From 62de092a02932e5457aa8d8b34902d0ae600ef24 Mon Sep 17 00:00:00 2001 From: using-system Date: Sat, 19 Sep 2026 23:04:11 +0200 Subject: [PATCH] fix(odd-status): keep the job token out of the checkout and say what --secret-env-vars does Closes #43 Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci.yml | 21 ++++++++++++++--- odd-status/README.md | 40 ++++++++++++++++++++++++--------- odd-status/action.yml | 9 +++++--- scripts/run-copilot.sh | 10 ++++++--- setup-claude/README.md | 7 +++++- setup-copilot/README.md | 18 ++++++++++----- setup-opencode/README.md | 7 +++++- tests/launch_cases.py | 2 +- tests/odd-status/test_launch.py | 6 ++--- 9 files changed, 89 insertions(+), 31 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8d1ba90..7abdf66 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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: @@ -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 diff --git a/odd-status/README.md b/odd-status/README.md index fee94d3..e0beeda 100644 --- a/odd-status/README.md +++ b/odd-status/README.md @@ -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 @@ -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//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 @@ -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 @@ -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 diff --git a/odd-status/action.yml b/odd-status/action.yml index 9c965d5..70c1098 100644 --- a/odd-status/action.yml +++ b/odd-status/action.yml @@ -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 }} diff --git a/scripts/run-copilot.sh b/scripts/run-copilot.sh index 0b790e3..5f85fcc 100755 --- a/scripts/run-copilot.sh +++ b/scripts/run-copilot.sh @@ -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 \ diff --git a/setup-claude/README.md b/setup-claude/README.md index 8409604..0d475a5 100644 --- a/setup-claude/README.md +++ b/setup-claude/README.md @@ -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 @@ -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 diff --git a/setup-copilot/README.md b/setup-copilot/README.md index 8a566fe..4555d23 100644 --- a/setup-copilot/README.md +++ b/setup-copilot/README.md @@ -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 @@ -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 diff --git a/setup-opencode/README.md b/setup-opencode/README.md index 92b19cc..7fe9fc7 100644 --- a/setup-opencode/README.md +++ b/setup-opencode/README.md @@ -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 }} @@ -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 diff --git a/tests/launch_cases.py b/tests/launch_cases.py index 5a3f1a2..f7d097c 100644 --- a/tests/launch_cases.py +++ b/tests/launch_cases.py @@ -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( diff --git a/tests/odd-status/test_launch.py b/tests/odd-status/test_launch.py index 95f9bb4..276fdb9 100644 --- a/tests/odd-status/test_launch.py +++ b/tests/odd-status/test_launch.py @@ -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, @@ -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 )