forge: read the API token only from the token file #178

Merged
vnprc merged 2 commits from agent/forge-token-file-only into master 2026-09-09 23:37:36 +01:00
Member

What changes: forge no longer reads the API token from the FORGEJO_TOKEN environment variable. The token comes only from the token file, ~/.config/git/forgejo-token by default or the path in FORGE_TOKEN_FILE, and that file must be mode 0600.
Why: an environment variable is inherited by every child process an agent spawns, so a token there reaches every build, test, and helper and can leak through env dumps, logs, and transcripts. A mode-0600 file is read only by code that deliberately opens it.
Fail loud, not silent: if FORGEJO_TOKEN is set and non-empty, every forge command except help output exits 1 before making any request or starting any child process, and prints one sentence saying the variable is no longer read and the token belongs in a mode-0600 file named by FORGE_TOKEN_FILE. An empty variable is ignored as before. Nobody can believe the variable is in use when it is not.
The 0600 promise is enforced: a token file with any group or other permission bit is refused before it is read, with one sentence naming the path and chmod 600. Symlinks are judged by their target. Any deployment whose token file is group- or world-readable will get that refusal on its first forge call; the fix is the chmod the message names.
What stays identical on this machine: nothing sets FORGEJO_TOKEN here (not the shell environment, not /etc, not the home-manager files), and ~/.config/git/forgejo-token is the agenix-delivered symlink whose target is -rw------- allod:users, so every forge call keeps working exactly as today. forge auth status now always names the token file as the source.
What proves it: the Go tests inject tokens only through a temp file behind FORGE_TOKEN_FILE. New tests pin that a set FORGEJO_TOKEN is refused with the exact message on auth status, label list with and without -R, token verify, and an unknown command, with zero requests recorded and the repo-inference seam failing the test if git would have been spawned; that help still prints; that 0640, 0604, 0644 and 0620 token files are refused and 0600 and 0400 accepted, through a symlink too; and that the file path alone authenticates with the file contents in the Authorization header. Both new checks were shown to fail when removed. gofmt, go vet, go test (both build tags), and nix flake check are all green.
If it is wrong: revert this PR. Anyone who really did rely on the variable, or has a loose token file, sees the one-line refusal on the first forge call, moves the token into a 0600 file, and points FORGE_TOKEN_FILE at it.

Closes allod/tools#57

This is option 3 from the issue: keep an environment override, but only for the file path, never for the token value.

Scope

cmd/forge/main.go run calls refuseEnvToken once after parseGlobalArgs and before dispatch, exempting only help requests, so the refusal is ahead of token verify (which never loads the configured credential) and of requireRepo (which spawns git to infer the repository before any command asks for a credential). cmd/forge/helpers.go loadToken reads the file as before but first stats it (following symlinks) and refuses any group or other permission bit; the no-token message now points at FORGE_TOKEN_FILE instead of the variable. cmd/forge/authcmd.go drops the env-var source label. Usage text (cmd/forge/usage.go) and docs/forge.md (Config table, token paragraph, auth status paragraph) describe the file-only model, and the main.go header no longer claims the retired bash script is at the repository root. README.md never mentioned the variable, so it is unchanged.

The token's path through the process is unchanged: internal/forgeapi/client.go states in its package comment that the token is set on an in-process http.Request Authorization header, is never logged, never wrapped into an error, and never leaves that file except as the outgoing header. This PR only narrows where the token comes from; where it goes is untouched.

Other repos: grepping /home/allod/work/allod (archetypes, nexus, vm, deploy, profiles, memory, inventory, secrets, strategy, .profile) for FORGEJO_TOKEN finds no consumer that sets or exports it. Every hit is inside allod/tools: the forge sources and tests changed here, the pr-explain environment scrub deny-list (which removes the variable from runner environments, and stays), and two shell scripts, pm/render and pm/integrity-check, that read FORGEJO_TOKEN for their own curl calls and do not invoke forge. Those two need nothing here: PR allod/tools#179 deletes the pm tree entirely.

tests/pr-explain/command.sh keeps exporting FORGEJO_TOKEN as a must-not-leak canary. That suite only ever drives the mock forge script behind ALLOD_PR_EXPLAIN_FORGE, which never reads the variable, so the canary still proves the scrub and never trips the new refusal; a comment now says so.

Risk

Low. Behaviour changes only for a caller that sets FORGEJO_TOKEN or whose token file is group- or world-readable, and that caller gets an immediate, explicit exit 1 rather than a silent fallback. On this deployment the variable is not set anywhere and the token file target is 0600. A script elsewhere that relied on FORGEJO_TOKEN=... forge ... must write the token to a 0600 file and set FORGE_TOKEN_FILE. pr-explain in production calls the real forge, so an operator with a stale FORGEJO_TOKEN export would see the refusal there too; that is the intended loud failure.

Validation

All run from the repo devShell in the worktree, on the committed tree at bbf9fbc (the review fix) and earlier at d3858a2.

  • nix develop -c gofmt -l . : empty
  • nix develop -c go vet ./... : ok
  • nix develop -c go test ./... : ok for all six packages (cmd/allod, cmd/flake-update-cascade, cmd/forge, internal/flakelock, internal/forgeapi, internal/gitremote)
  • nix develop -c go vet -tags site ./... : ok
  • nix develop -c go test -tags site ./... : ok for all six packages
  • nix flake check : all 7 checks passed (go-checks, allod-parity-tests including the pr-explain suites, flake-status-suite, flake-update-cascade-suites, and the three package builds)
  • Sabotage: with the refuseEnvToken() call in run commented out, go test ./cmd/forge -run TestForgejoTokenEnvRefused fails all five refusal subtests; with the permission condition in loadToken made unreachable, -run TestTokenFilePermissions fails all four loose-mode subtests and the symlink subtest; restored, both pass.
  • Real binary (go build ./cmd/forge) from /, a non-git directory: echo x | FORGEJO_TOKEN=x forge token verify and FORGEJO_TOKEN=x forge label list both print the refusal and exit 1 with FORGE_URL pointed at a closed port; forge auth status against a 0644 token file prints the chmod refusal and exits 1; FORGEJO_TOKEN=x forge --help prints usage and exits 0.
  • env | grep FORGEJO_TOKEN and a grep of /etc, ~/.bashrc, ~/.profile, ~/.config on this VM find no setting of the variable; stat -L ~/.config/git/forgejo-token reports -rw------- allod:users.
What changes: `forge` no longer reads the API token from the `FORGEJO_TOKEN` environment variable. The token comes only from the token file, `~/.config/git/forgejo-token` by default or the path in `FORGE_TOKEN_FILE`, and that file must be mode 0600. Why: an environment variable is inherited by every child process an agent spawns, so a token there reaches every build, test, and helper and can leak through env dumps, logs, and transcripts. A mode-0600 file is read only by code that deliberately opens it. Fail loud, not silent: if `FORGEJO_TOKEN` is set and non-empty, every `forge` command except help output exits 1 before making any request or starting any child process, and prints one sentence saying the variable is no longer read and the token belongs in a mode-0600 file named by `FORGE_TOKEN_FILE`. An empty variable is ignored as before. Nobody can believe the variable is in use when it is not. The 0600 promise is enforced: a token file with any group or other permission bit is refused before it is read, with one sentence naming the path and `chmod 600`. Symlinks are judged by their target. Any deployment whose token file is group- or world-readable will get that refusal on its first `forge` call; the fix is the chmod the message names. What stays identical on this machine: nothing sets `FORGEJO_TOKEN` here (not the shell environment, not `/etc`, not the home-manager files), and `~/.config/git/forgejo-token` is the agenix-delivered symlink whose target is `-rw------- allod:users`, so every `forge` call keeps working exactly as today. `forge auth status` now always names the token file as the source. What proves it: the Go tests inject tokens only through a temp file behind `FORGE_TOKEN_FILE`. New tests pin that a set `FORGEJO_TOKEN` is refused with the exact message on `auth status`, `label list` with and without `-R`, `token verify`, and an unknown command, with zero requests recorded and the repo-inference seam failing the test if git would have been spawned; that help still prints; that 0640, 0604, 0644 and 0620 token files are refused and 0600 and 0400 accepted, through a symlink too; and that the file path alone authenticates with the file contents in the `Authorization` header. Both new checks were shown to fail when removed. `gofmt`, `go vet`, `go test` (both build tags), and `nix flake check` are all green. If it is wrong: revert this PR. Anyone who really did rely on the variable, or has a loose token file, sees the one-line refusal on the first `forge` call, moves the token into a 0600 file, and points `FORGE_TOKEN_FILE` at it. Closes allod/tools#57 This is option 3 from the issue: keep an environment override, but only for the file path, never for the token value. ## Scope `cmd/forge/main.go` `run` calls `refuseEnvToken` once after `parseGlobalArgs` and before `dispatch`, exempting only help requests, so the refusal is ahead of `token verify` (which never loads the configured credential) and of `requireRepo` (which spawns git to infer the repository before any command asks for a credential). `cmd/forge/helpers.go` `loadToken` reads the file as before but first stats it (following symlinks) and refuses any group or other permission bit; the no-token message now points at `FORGE_TOKEN_FILE` instead of the variable. `cmd/forge/authcmd.go` drops the env-var source label. Usage text (`cmd/forge/usage.go`) and `docs/forge.md` (Config table, token paragraph, `auth status` paragraph) describe the file-only model, and the `main.go` header no longer claims the retired bash script is at the repository root. `README.md` never mentioned the variable, so it is unchanged. The token's path through the process is unchanged: `internal/forgeapi/client.go` states in its package comment that the token is set on an in-process `http.Request` `Authorization` header, is never logged, never wrapped into an error, and never leaves that file except as the outgoing header. This PR only narrows where the token comes from; where it goes is untouched. Other repos: grepping `/home/allod/work/allod` (archetypes, nexus, vm, deploy, profiles, memory, inventory, secrets, strategy, .profile) for `FORGEJO_TOKEN` finds no consumer that sets or exports it. Every hit is inside `allod/tools`: the `forge` sources and tests changed here, the `pr-explain` environment scrub deny-list (which removes the variable from runner environments, and stays), and two shell scripts, `pm/render` and `pm/integrity-check`, that read `FORGEJO_TOKEN` for their own `curl` calls and do not invoke `forge`. Those two need nothing here: PR allod/tools#179 deletes the `pm` tree entirely. `tests/pr-explain/command.sh` keeps exporting `FORGEJO_TOKEN` as a must-not-leak canary. That suite only ever drives the mock forge script behind `ALLOD_PR_EXPLAIN_FORGE`, which never reads the variable, so the canary still proves the scrub and never trips the new refusal; a comment now says so. ## Risk Low. Behaviour changes only for a caller that sets `FORGEJO_TOKEN` or whose token file is group- or world-readable, and that caller gets an immediate, explicit exit 1 rather than a silent fallback. On this deployment the variable is not set anywhere and the token file target is 0600. A script elsewhere that relied on `FORGEJO_TOKEN=... forge ...` must write the token to a 0600 file and set `FORGE_TOKEN_FILE`. `pr-explain` in production calls the real `forge`, so an operator with a stale `FORGEJO_TOKEN` export would see the refusal there too; that is the intended loud failure. ## Validation All run from the repo devShell in the worktree, on the committed tree at `bbf9fbc` (the review fix) and earlier at `d3858a2`. - `nix develop -c gofmt -l .` : empty - `nix develop -c go vet ./...` : ok - `nix develop -c go test ./...` : ok for all six packages (cmd/allod, cmd/flake-update-cascade, cmd/forge, internal/flakelock, internal/forgeapi, internal/gitremote) - `nix develop -c go vet -tags site ./...` : ok - `nix develop -c go test -tags site ./...` : ok for all six packages - `nix flake check` : all 7 checks passed (go-checks, allod-parity-tests including the pr-explain suites, flake-status-suite, flake-update-cascade-suites, and the three package builds) - Sabotage: with the `refuseEnvToken()` call in `run` commented out, `go test ./cmd/forge -run TestForgejoTokenEnvRefused` fails all five refusal subtests; with the permission condition in `loadToken` made unreachable, `-run TestTokenFilePermissions` fails all four loose-mode subtests and the symlink subtest; restored, both pass. - Real binary (`go build ./cmd/forge`) from `/`, a non-git directory: `echo x | FORGEJO_TOKEN=x forge token verify` and `FORGEJO_TOKEN=x forge label list` both print the refusal and exit 1 with `FORGE_URL` pointed at a closed port; `forge auth status` against a 0644 token file prints the chmod refusal and exits 1; `FORGEJO_TOKEN=x forge --help` prints usage and exits 0. - `env | grep FORGEJO_TOKEN` and a grep of `/etc`, `~/.bashrc`, `~/.profile`, `~/.config` on this VM find no setting of the variable; `stat -L ~/.config/git/forgejo-token` reports `-rw------- allod:users`.
FORGEJO_TOKEN is no longer a credential source. A set, non-empty
FORGEJO_TOKEN now makes forge exit 1 before any request with one
sentence saying the variable is no longer read and the token belongs
in a mode-0600 file named by FORGE_TOKEN_FILE. An empty variable is
ignored as before. auth status names the token file as the source.

An environment variable is inherited by every child process an agent
spawns, so a token there reaches every build, test, and helper and can
leak through env dumps, logs, and transcripts; a 0600 file is read
only by code that deliberately opens it. Option 3 of allod/tools#57.

The test harness now injects tokens through a temp token file behind
FORGE_TOKEN_FILE. New tests pin the refusal (no HTTP request is made)
and the file path. The pr-explain suite keeps FORGEJO_TOKEN as a leak
canary; it only ever drives the mock forge, which never reads it.

Refs allod/tools#57
Review of PR 178 found the refusal lived in the lazy loadToken, so
`token verify` (which never loads the configured credential) and any
command without -R (which spawns git to infer the repo first) could
proceed or start a child process with the variable still set. The
check now runs once in run() after parseGlobalArgs, exempting only
help output, so "set and non-empty FORGEJO_TOKEN exits 1 before any
request or child process" holds on every path. Tests cover token
verify, a command without -R (the repo-inference seam fails the test
if it runs), an unknown command, and the help exemption.

The messages promised a mode-0600 file but nothing checked it. loadToken
now stats the file (following symlinks, which is how the agenix-delivered
file is reached) and refuses with a chmod 600 hint when any group or
other bit is set. Tests cover four loose modes, 0600 and 0400, and a
symlink judged by its target.

Usage text now says "set, non-empty" like the docs and code, and the
main.go header no longer claims the retired bash script is at the
repository root.

Refs allod/tools#57
Author
Member

Review findings, each answered in commit bbf9fbc (additive, no force-push):

  1. should-fix, refusal was lazy: refuseEnvToken now runs once in run() right after parseGlobalArgs, before dispatch, exempting only help requests (isHelpRequest). token verify and a command without -R from a non-git directory both hit the refusal first; TestForgejoTokenEnvRefused covers auth status, label list with and without -R, token verify, and an unknown command, asserting the exact message, zero recorded requests, and a repo-inference seam that fails the test if git would have been spawned, plus a help-exemption subtest. Confirmed on the real binary from /.
  2. nit, usage wording: usage.go now says "a set, non-empty FORGEJO_TOKEN is refused, never read", matching the docs and code.
  3. security, 0600 not enforced: loadToken stats the token file after the readability check (os.Stat, so symlinks are judged by their target) and refuses with forge: token file <path> is readable by group or others; run chmod 600 on it when any group or other bit is set, before reading the contents or making a request. TestTokenFilePermissions covers 0640, 0604, 0644, 0620 (refused), 0600 and 0400 (accepted), and a symlink whose target flips from 0600 to 0644. The PR body now states that a deployment with a loose token file gets this refusal on its first call and that the fix is the named chmod; this VM's target is -rw------- allod:users.
  4. nit, stale main.go header: now says the bash script is retired and that divergences are marked "diverges from bash".
  5. pm scripts: the PR body now says PR allod/tools#179 deletes the pm tree, instead of calling it a follow-up.

Validation rerun on bbf9fbc from the devShell: gofmt -l . empty; go vet ./... and go vet -tags site ./... ok; go test ./... and go test -tags site ./... ok for all six packages; nix flake check all 7 checks passed. Sabotage: commenting out the refuseEnvToken() call fails all five refusal subtests, and disabling the permission condition fails all five loose-mode and symlink subtests; restored, both pass. The PR body's summary, Scope, Risk, and Validation sections are updated to match.

Review findings, each answered in commit bbf9fbc (additive, no force-push): 1. should-fix, refusal was lazy: `refuseEnvToken` now runs once in `run()` right after `parseGlobalArgs`, before `dispatch`, exempting only help requests (`isHelpRequest`). `token verify` and a command without `-R` from a non-git directory both hit the refusal first; `TestForgejoTokenEnvRefused` covers `auth status`, `label list` with and without `-R`, `token verify`, and an unknown command, asserting the exact message, zero recorded requests, and a repo-inference seam that fails the test if git would have been spawned, plus a help-exemption subtest. Confirmed on the real binary from `/`. 2. nit, usage wording: `usage.go` now says "a set, non-empty FORGEJO_TOKEN is refused, never read", matching the docs and code. 3. security, 0600 not enforced: `loadToken` stats the token file after the readability check (`os.Stat`, so symlinks are judged by their target) and refuses with `forge: token file <path> is readable by group or others; run chmod 600 on it` when any group or other bit is set, before reading the contents or making a request. `TestTokenFilePermissions` covers 0640, 0604, 0644, 0620 (refused), 0600 and 0400 (accepted), and a symlink whose target flips from 0600 to 0644. The PR body now states that a deployment with a loose token file gets this refusal on its first call and that the fix is the named chmod; this VM's target is `-rw------- allod:users`. 4. nit, stale `main.go` header: now says the bash script is retired and that divergences are marked "diverges from bash". 5. pm scripts: the PR body now says PR allod/tools#179 deletes the `pm` tree, instead of calling it a follow-up. Validation rerun on bbf9fbc from the devShell: `gofmt -l .` empty; `go vet ./...` and `go vet -tags site ./...` ok; `go test ./...` and `go test -tags site ./...` ok for all six packages; `nix flake check` all 7 checks passed. Sabotage: commenting out the `refuseEnvToken()` call fails all five refusal subtests, and disabling the permission condition fails all five loose-mode and symlink subtests; restored, both pass. The PR body's summary, Scope, Risk, and Validation sections are updated to match.
vnprc force-pushed agent/forge-token-file-only from bbf9fbc467 to c86f577559 2026-09-09 23:29:49 +01:00 Compare
vnprc approved these changes 2026-09-09 23:37:30 +01:00
vnprc merged commit c86f577559 into master 2026-09-09 23:37:36 +01:00
vnprc deleted branch agent/forge-token-file-only 2026-09-09 23:37:36 +01:00
Sign in to join this conversation.
No description provided.