Keep secrets out of podman's argv, add env.default, --dry-run and a test suite #2

Merged
claudiomaradonna merged 2 commits from feat/env-name-only-dry-run-tests into main 2026-07-28 14:34:42 +02:00

Ports four improvements from claude-jail,
adapted to this repo's sessionless, single-shared-state model.

Secrets no longer appear in the podman command line

Container-level variables were forwarded as -e KEY=value. Process arguments
are world-readable on Linux — any local user can run ps aux or read
/proc/<pid>/cmdline — so every secret in .env.codex, OPENAI_API_KEY
included, leaked straight off the boundary this tool exists to enforce.

They are now passed by name only (-e KEY). Podman reads each value from
the environment it inherits from the wrapper, which is readable only by you and
root.

Two consequences worth reviewing:

  • A forwarded name must be a real exported variable. A key colliding with a
    script-internal name (workspace, network, max_memory, …) would
    otherwise hand podman the wrapper's own value, so those are skipped with a
    warning on stderr.
  • Where a key is set in both the shell and .env.codex, the container now
    receives the shell value. Previously the file's value leaked through in
    argv even though the wrapper itself used the shell's. The new behaviour
    matches the documented precedence.

Six variables stay inline as KEY=valueSSH_AUTH_SOCK, CODEX_SSH,
CODEX_WORKSPACE, TERM, COLUMNS, LINES. Their container value differs
from the host's by design and none is secret.

Shared defaults: ~/.codex-jail/env.default

Env loading moves into _load_env_file(), called for .env.codex then
env.default, first definition winning. Cross-project settings live in one
place; each workspace keeps only its deltas. Precedence, highest first:

CLI flag > host env > .env.codex > env.default > built-in default

This needed a change claude-jail does not have. There, only
sessions/<id>/config is mounted. Here the whole of ~/.codex-jail/ is
bind-mounted read-write as /home/codex/.codex, so a plain env.default
would be rewritable from inside the jail: Codex appends
CODEX_JAIL_MOUNTS=/:/host and escapes on the next run, in any workspace.

The wrapper therefore mounts /dev/null read-only over env.default inside
the container, immediately after the jail mount. Unconditionally — masking only
when the file exists would let the agent create one a later run would honour.
The file is seeded with a commented header on first use so both mount endpoints
exist and podman never creates the destination itself inside the bind-mounted
host directory. The host file is untouched.

--dry-run

Prints the assembled podman run command to stdout and exits, touching no
filesystem state. Diagnostics move to stderr so stdout is exactly the command.
Documented as inspection-only: pasted -e KEY arguments resolve against the
shell you paste into, which has not loaded .env.codex.

Tests

37 tests across 6 files, make test, nothing beyond bash. A stub podman
earlier on PATH records the argv and inherited environment the real one would
have received, then exits without starting a container — which is what makes
the property above directly assertable: the secret must be present in podman's
environment and absent from its argv.

Verified against the unmodified script first: 22 failures in exactly the
expected places, 0 after.

Incidental

  • The .codexignore diagnostic counted array elements rather than paths, so it
    always reported double (hiding 2 path(s) for a single directory).
  • tests/ and docs/ excluded from the Docker build context.

Not ported: .ai/prompt.md, and claude-jail's install.sh helper ordering,
which moved info/error below the HOME validation that calls them.

Design notes: docs/superpowers/specs/2026-07-28-env-name-only-dry-run-tests-design.md

https://claude.ai/code/session_0134mePQ5EAQxnitf8Yst3ny

Ports four improvements from [claude-jail](https://git.unitoo.it/claudiomaradonna/claude-jail), adapted to this repo's sessionless, single-shared-state model. ## Secrets no longer appear in the podman command line Container-level variables were forwarded as `-e KEY=value`. Process arguments are world-readable on Linux — any local user can run `ps aux` or read `/proc/<pid>/cmdline` — so every secret in `.env.codex`, `OPENAI_API_KEY` included, leaked straight off the boundary this tool exists to enforce. They are now passed **by name only** (`-e KEY`). Podman reads each value from the environment it inherits from the wrapper, which is readable only by you and root. Two consequences worth reviewing: - A forwarded name must be a real *exported* variable. A key colliding with a script-internal name (`workspace`, `network`, `max_memory`, …) would otherwise hand podman the wrapper's own value, so those are skipped with a warning on stderr. - Where a key is set in both the shell and `.env.codex`, the container now receives the **shell** value. Previously the file's value leaked through in argv even though the wrapper itself used the shell's. The new behaviour matches the documented precedence. Six variables stay inline as `KEY=value` — `SSH_AUTH_SOCK`, `CODEX_SSH`, `CODEX_WORKSPACE`, `TERM`, `COLUMNS`, `LINES`. Their container value differs from the host's by design and none is secret. ## Shared defaults: `~/.codex-jail/env.default` Env loading moves into `_load_env_file()`, called for `.env.codex` then `env.default`, first definition winning. Cross-project settings live in one place; each workspace keeps only its deltas. Precedence, highest first: CLI flag > host env > .env.codex > env.default > built-in default **This needed a change claude-jail does not have.** There, only `sessions/<id>/config` is mounted. Here the whole of `~/.codex-jail/` is bind-mounted read-write as `/home/codex/.codex`, so a plain `env.default` would be rewritable from inside the jail: Codex appends `CODEX_JAIL_MOUNTS=/:/host` and escapes on the next run, in any workspace. The wrapper therefore mounts `/dev/null` read-only over `env.default` inside the container, immediately after the jail mount. Unconditionally — masking only when the file exists would let the agent create one a later run would honour. The file is seeded with a commented header on first use so both mount endpoints exist and podman never creates the destination itself inside the bind-mounted host directory. The host file is untouched. ## `--dry-run` Prints the assembled `podman run` command to stdout and exits, touching no filesystem state. Diagnostics move to stderr so stdout is exactly the command. Documented as inspection-only: pasted `-e KEY` arguments resolve against the shell you paste into, which has not loaded `.env.codex`. ## Tests 37 tests across 6 files, `make test`, nothing beyond bash. A stub `podman` earlier on `PATH` records the argv and inherited environment the real one would have received, then exits without starting a container — which is what makes the property above directly assertable: the secret must be present in podman's *environment* and absent from its *argv*. Verified against the unmodified script first: 22 failures in exactly the expected places, 0 after. ## Incidental - The `.codexignore` diagnostic counted array elements rather than paths, so it always reported double (`hiding 2 path(s)` for a single directory). - `tests/` and `docs/` excluded from the Docker build context. Not ported: `.ai/prompt.md`, and claude-jail's `install.sh` helper ordering, which moved `info`/`error` below the `HOME` validation that calls them. Design notes: `docs/superpowers/specs/2026-07-28-env-name-only-dry-run-tests-design.md` https://claude.ai/code/session_0134mePQ5EAQxnitf8Yst3ny
Claude-Session: https://claude.ai/code/session_0134mePQ5EAQxnitf8Yst3ny
Ported from claude-jail and adapted to this repo's sessionless model.

Container-level variables now reach podman as `-e KEY` instead of
`-e KEY=value`. Process arguments are world-readable on Linux, so every
secret in .env.codex was visible to any local user via `ps` or
/proc/<pid>/cmdline. Podman reads the values from the environment it
inherits from the wrapper instead. A key that is not a real exported
variable — because it collides with a script-internal name like
`workspace` or `network` — is skipped with a warning rather than
forwarded with the wrong value. Where a key is set in both the shell and
.env.codex the container now receives the shell value, matching the
documented precedence.

Env loading moves into _load_env_file(), called for .env.codex then
~/.codex-jail/env.default, first definition winning. Since the whole jail
directory is bind-mounted read-write, env.default is masked inside the
container with a read-only /dev/null: Codex could otherwise append
CODEX_JAIL_MOUNTS=/:/host and escape on the next run in any workspace.
The mask is unconditional, and the file is seeded with a commented header
on first use so both mount endpoints exist.

--dry-run prints the assembled podman command and exits without running
it or touching the filesystem. The .codexignore diagnostic moves to
stderr so dry-run stdout is exactly the command; its path count was also
reporting double, counting array elements rather than paths.

tests/ runs against a stub podman that records argv and inherited env,
so the argv/environment split above is directly asserted. 37 tests, no
dependencies beyond bash: `make test`.

Claude-Session: https://claude.ai/code/session_0134mePQ5EAQxnitf8Yst3ny
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
claudiomaradonna/codex-jail!2
No description provided.