Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 18 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -71,9 +71,19 @@ jobs:
# still does, with a placeholder key no step of the setup spends, so
# every cell reports its required check (a job-level condition would
# collapse the matrix into one unexpanded check the ruleset never
# sees). opencode has no --no-custom-instructions: the checkout's
# AGENTS.md reaches that run; accepted, since it runs on this
# repository's own branches only.
# sees). The head-repository gate on the step is the workflow's
# half; the host's is that a public repository's fork pull_request
# run gets no write scope whatever `permissions` asks - read from
# GitHub's workflow-syntax documentation, not from a fork run's log:
# the key can add or remove read permissions for a fork's run, and
# the one exception that grants write exists for private
# repositories only - so the copilot-requests: write below is not
# minted for a fork's run, and the fork-approval policy (all
# external contributors) keeps a fork's workflow from running before
# a maintainer approves it.
# opencode has no --no-custom-instructions: the checkout's AGENTS.md
# reaches that run; accepted, since it runs on this repository's own
# branches only.
strategy:
fail-fast: false
matrix:
Expand All @@ -92,6 +102,11 @@ jobs:
ACTION: ${{ matrix.action }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
# The job token stays out of the checkout's .git/config: every
# launch below runs an auto-approved shell in this directory,
# and nothing here uses git with the token.
persist-credentials: false
- name: Install uv
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
- name: The action's tests, off the runner
Expand Down
40 changes: 29 additions & 11 deletions odd-status/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ installed, and turns its verdict into outputs a workflow can gate on:
| --- | --- | --- | --- |
| `prompt` | no | | What the status is about, in the caller's words: a service, a stack, a question (`the checkout service on prod`). Passed to the packaged command as its arguments; empty for the whole loop; never starting with a dash. |
| `fail-on` | no | `none` | Fail the step when the status reaches this level: `warning` (warning or error fail), `error` (error fails), `none` (the outputs carry the verdict; the step still fails when the run produced no answer). |
| `token` | no | `${{ github.token }}` | The token the Copilot run authenticates with, set as `GITHUB_TOKEN` on the Copilot launch step and nowhere else; the opencode and Claude Code runs never receive it. The workflow's own token by default, under the job permission `copilot-requests: write`; a user token (`${{ secrets.COPILOT_USER_TOKEN }}`) when the run must be billed to a user. Passed as given. |
| `token` | no | `${{ github.token }}` | The token the Copilot run authenticates with, set as `GITHUB_TOKEN` on the Copilot launch step and nowhere else; the opencode and Claude Code steps receive none from this action (the checkout's `persist-credentials: false` keeps the job token out of the checkout's `.git/config`, see "What this grants"). The workflow's own token by default, under the job permission `copilot-requests: write`; a user token (`${{ secrets.COPILOT_USER_TOKEN }}`) when the run must be billed to a user, readable by the model's shell as the Copilot bullet below says. Passed as given. |

## Outputs

Expand Down Expand Up @@ -53,8 +53,16 @@ documents:
the default, the workflow's own token, and that permission is the
only thing the caller sets. The launch line grants a shell, file
access to the skills only, no instructions from the checkout, no
built-in GitHub MCP server, the token stripped from the shells the
run opens. What the CLI accepts is GitHub's: its Actions
built-in GitHub MCP server, and keeps the token out of the
environment of the shells and MCP servers the run opens and out of
the output (`--secret-env-vars GITHUB_TOKEN`, as the CLI's help
states it). That flag is a filter, not a boundary: the CLI process
itself holds the token, and a shell running as the runner's user can
read a process's environment (`/proc/<pid>/environ` on Linux), so
the model's shell can reach it the way it can reach opencode's key
file and Claude Code's credential file. A user token passed as
`token` is exposed the same way: scope it to Copilot requests alone
and keep it short-lived. What the CLI accepts is GitHub's: its Actions
documentation names the workflow's `GITHUB_TOKEN` under
`copilot-requests: write`, and the CLI's own help says only that the
variable holds "an authentication token"; the action passes the
Expand Down Expand Up @@ -106,6 +114,8 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: using-system/oddyssey-actions/setup-copilot@v1
- id: status
uses: using-system/oddyssey-actions/odd-status@v1
Expand All @@ -120,19 +130,27 @@ jobs:
With opencode or Claude Code, replace the setup step by
`setup-opencode` with its `openai-api-key`, or `setup-claude` with its
`claude-oauth-token` or `anthropic-api-key`, and drop the
`copilot-requests` permission: those steps receive no token. To bill the Copilot run to a user,
add `token: ${{ secrets.COPILOT_USER_TOKEN }}` under `with:`.
`copilot-requests` permission: those steps receive no token from this
action. To bill the Copilot run to a user, add
`token: ${{ secrets.COPILOT_USER_TOKEN }}` under `with:`.

## What this grants

The run is the setup's launch line: the model runs any shell command the
runner allows, reads and writes the checkout and the package's
directory, and reaches the network. Keep the job at `contents: read`
(plus `copilot-requests: write` for Copilot), never put the step on a
trigger that carries untrusted input (`issue_comment`,
`pull_request_target`, a fork's `pull_request`), and treat the outputs
as the model's text before a later step acts on them - a `todo` is a
list to read, not a command to run.
directory, and reaches the network. The checkout is part of that: with
`actions/checkout` at its defaults the job token is persisted in the
checkout's `.git/config`, readable by the model's shell on every CLI,
which is why the examples set `persist-credentials: false` - nothing
in these actions uses git with the token. Keep the job at
`contents: read` (plus `copilot-requests: write` for Copilot), never
put the step on a trigger that carries untrusted input
(`issue_comment`, `pull_request_target`, a fork's `pull_request`), and
mind a same-repository `pull_request` too: its author writes the
checkout's `.odd/`, which the run reads on every CLI, and the
instruction files opencode and Claude Code read. Treat the outputs as
the model's text before a later step
acts on them - a `todo` is a list to read, not a command to run.

## Pinning

Expand Down
9 changes: 6 additions & 3 deletions odd-status/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,12 @@ inputs:
description: >-
The token the Copilot run authenticates with, set as `GITHUB_TOKEN`
on the Copilot launch step and nowhere else; the opencode and Claude
Code runs never receive it. Defaults to the workflow's own token, which needs the
job permission `copilot-requests: write`; a user token when the run
must be billed to a user. The action passes it as given.
Code steps receive none from this action (a job token
`actions/checkout` persisted in the checkout is another matter: the
README's examples set `persist-credentials: false`). Defaults to the
workflow's own token, which needs the job permission
`copilot-requests: write`; a user token when the run must be billed
to a user. The action passes it as given.
required: false
default: ${{ github.token }}

Expand Down
10 changes: 7 additions & 3 deletions scripts/run-copilot.sh
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,13 @@ arguments="$(cat "$ARGUMENTS_FILE")"
# the model as written and the model routes it to the packaged
# skill. Scoped: tools auto-approved (non-interactive mode requires
# it), file access to the skills only, no instructions from the
# checkout, the built-in GitHub MCP server off, the token stripped
# from the shells the run opens, the installed version and nothing
# newer.
# checkout, the built-in GitHub MCP server off, the token kept out of
# the environment of the shells and MCP servers the run opens and
# redacted from the output (--secret-env-vars, as the CLI's help
# states it - the CLI process itself holds the token, and a shell
# running as the same user can read a process's environment, so the
# flag is a filter, not a boundary), the installed version and
# nothing newer.
copilot -p "/${COMMAND} ${arguments}" --model "$ODDYSSEY_MODEL" \
--allow-all-tools --add-dir "$HOME/.agents/skills" \
--no-ask-user --no-custom-instructions --disable-builtin-mcps \
Expand Down
7 changes: 6 additions & 1 deletion setup-claude/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,8 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: using-system/oddyssey-actions/setup-claude@v1
with:
model: haiku # optional; claude-sonnet-5 without it
Expand All @@ -120,7 +122,10 @@ A later step that launches Claude Code with
requires) lets the model run any shell command the runner allows, read
and write the checkout and the package's directory, reach the network,
and read the credential file through a shell, since the runner's user
owns it. The actions of this repository add `--setting-sources user`
owns it (as it can read the job token `actions/checkout` persists in
`.git/config` at its defaults: the example sets
`persist-credentials: false`, since nothing here uses git with it).
The actions of this repository add `--setting-sources user`
(nothing from the checkout's `.claude/`: no project settings, no
hooks), `--no-session-persistence` and `DISABLE_AUTOUPDATER=1`; the
checkout's `CLAUDE.md` still reaches the run, since the CLI reads it
Expand Down
18 changes: 13 additions & 5 deletions setup-copilot/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,8 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: using-system/oddyssey-actions/setup-copilot@v1
with:
model: claude-sonnet-5 # optional; gpt-5.6-luna without it
Expand All @@ -97,11 +99,17 @@ A step that launches the CLI (`--allow-all-tools`, which
non-interactive mode requires, grants a shell; the actions of this
repository add `--add-dir "$HOME/.agents/skills"` in place of
`--allow-all-paths`, `--no-custom-instructions`,
`--disable-builtin-mcps`, `--secret-env-vars GITHUB_TOKEN` and
`--no-auto-update`) lets the model run any shell command the runner
allows, read and write the checkout and the skills' directory, reach
the network, and read whatever the prompt and the skills put in front
of it. Keep the job at `contents: read` and `copilot-requests: write`,
`--disable-builtin-mcps`, `--secret-env-vars GITHUB_TOKEN` - the token
kept out of the shells' and MCP servers' environment and redacted from
the output, while the CLI process still holds it, readable by a shell
running as the same user - and `--no-auto-update`) lets the model run
any shell command the runner allows, read and write the checkout and
the skills' directory, reach the network, read the token through the
CLI's process environment, and read whatever the prompt and the skills
put in front of it. With `actions/checkout` at its defaults the job
token also sits in the checkout's `.git/config`: the example sets
`persist-credentials: false`, since nothing here uses git with it.
Keep the job at `contents: read` and `copilot-requests: write`,
never put the step on a trigger that carries untrusted input
(`issue_comment`, `pull_request_target`, a fork's `pull_request`), and
treat the answer as untrusted text before it reaches a place that acts
Expand Down
7 changes: 6 additions & 1 deletion setup-opencode/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,8 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: using-system/oddyssey-actions/setup-opencode@v1
with:
openai-api-key: ${{ secrets.OPENROUTER_API_KEY }}
Expand All @@ -101,7 +103,10 @@ turns the answer into outputs a workflow can gate on.
A later step that launches opencode with `--auto` lets the model run any
shell command the runner allows, read and write the checkout and the
package's directory, reach the network, and read the key file through
the provider (and through a shell, since the runner's user owns it).
the provider (and through a shell, since the runner's user owns it -
as it can read the job token `actions/checkout` persists in
`.git/config` at its defaults: the example sets
`persist-credentials: false`, since nothing here uses git with it).
Keep the job at `contents: read`, never put such a step on a trigger
that carries untrusted input (`issue_comment`, `pull_request_target`, a
fork's `pull_request`), and treat the answer as untrusted text before it
Expand Down
2 changes: 1 addition & 1 deletion tests/launch_cases.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ def check_unset_command_fails_before_the_cli(script, fake_cli, tmp_path, action,
assert not argv.called


def check_copilot_launches_scoped_with_the_token_stripped(
def check_copilot_launches_scoped_with_secret_env_vars(
script, fake_cli, tmp_path, action, command
):
argv = fake_cli(
Expand Down
6 changes: 3 additions & 3 deletions tests/odd-status/test_launch.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
check_claude_reads_an_api_key_into_its_own_variable,
check_copilot_fails_on_an_empty_token,
check_copilot_fails_when_the_cli_would_prefer_another_token,
check_copilot_launches_scoped_with_the_token_stripped,
check_copilot_launches_scoped_with_secret_env_vars,
check_invalid_command_fails_before_the_cli,
check_opencode_launches_the_packaged_command_without_a_token,
check_unset_command_fails_before_the_cli,
Expand All @@ -32,8 +32,8 @@ def test_unset_command_fails_before_the_cli(script, fake_cli, tmp_path, cli):
check_unset_command_fails_before_the_cli(script, fake_cli, tmp_path, ACTION, cli)


def test_copilot_launches_scoped_with_the_token_stripped(script, fake_cli, tmp_path):
check_copilot_launches_scoped_with_the_token_stripped(
def test_copilot_launches_scoped_with_secret_env_vars(script, fake_cli, tmp_path):
check_copilot_launches_scoped_with_secret_env_vars(
script, fake_cli, tmp_path, ACTION, COMMAND
)

Expand Down
Loading