feat(claude.sh): headless operation and host-controlled --policy for broker jobs #30

Merged
claudiomaradonna merged 14 commits from feat/headless-no-tty into main 2026-08-27 00:30:21 +02:00

Two related changes for running claude.sh unattended, plus a Critical fix
found while reviewing them.

1. Headless operation (--no-tty)

podman run was hardcoded -it, so a job with no terminal got a pseudo-TTY it
could not use and Claude Code painted escape sequences into whatever collected
the output. Now -i is unconditional and -t is added only when stdin and
stdout are both terminals. --no-tty / CLAUDE_JAIL_NO_TTY=1 forces it off for
a caller that has a terminal but wants clean logs.

COLUMNS/LINES are gated on the same condition — with no terminal, tput
reports nothing and the 80x24 fallback pinned a fictional width on output that
has none. TERM stays unconditional: Claude Code suppresses colour on its own
once stdout is a pipe.

Interactive use is unchanged, byte for byte.

2. Host-controlled policy files (--policy <path>)

The workspace is bind-mounted read-write, and claude.sh loads
${workspace}/.env.claude into its own environment. That file may define
CLAUDE_JAIL_* keys, which configure the launch. So an agent that writes

CLAUDE_JAIL_MOUNTS=/:/host

into its own workspace gets host root mounted into the next session.

For interactive use this is a feature — you own both the workspace and the
host. For a job broker running untrusted repos it is an escalation path.
--policy moves every file-sourced setting to a host-controlled file:

claude --policy /etc/claude-jail/jobs/1234.env --no-tty /srv/jobs/1234/repo

Under --policy:

  • ${workspace}/.env.claude is ignored, silently — a broker running many repos
    should not log a line per job for a file most repos legitimately carry.
  • ~/.claude-jail/env.default is not read either, so one file fully determines
    a job and can be audited on its own.
  • The host environment still outranks the policy file — the supported way to
    inject a per-job secret without writing it to disk.
  • ${workspace}/.claudeignore is still honoured, unioned with any
    --ignore-file. Every pattern only ever hides a path, so a project can add to
    what is hidden but never unhide what the policy hid. Dropping it would have
    made broker jobs see more than manual runs.
  • A missing policy file, or one reachable by a path under the workspace, is a
    fatal error rather than a fallback.

--policy is CLI-only. There is deliberately no CLAUDE_JAIL_POLICY env var:
the knob that selects a trust boundary must not be reachable from a file the
workspace controls.

  1. Critical: host command execution from workspace content

Found by the final review of this branch, and introduced by its own headless
commit — with a twin that predates it.

$var -eq 1 forces bash arithmetic evaluation, which expands array
subscripts, including command substitution. Both no_tty (CLAUDE_JAIL_NO_TTY)
and with_ssh (CLAUDE_JAIL_USE_SSH) are workspace-sourceable:

.env.claude, written by an agent during job N

CLAUDE_JAIL_NO_TTY=HOME[$(id -un > /tmp/PWNED)]

id -un ran on the host as the invoking user, and claude.sh exited 0.
Quoting does not help — "$v" -eq 1 still executes. Both sites now use
string comparison. Every other -eq in the file was swept; the remaining 18
compare parser-set integers.

Consequence: CLAUDE_JAIL_NO_TTY=01 or =1 no longer enable the flag. 1
exactly, as --help documents.

Two further trust holes closed at the same time: a workspace .env.claude
could forge the Policy: audit line by shadowing the script's own
policy_file variable, and .ai/prompt.md documented a --workspace
flag with no parser case — which, combined with --policy, pointed the
containment check at the wrong directory.

Testing

102 → 123 tests, all passing. tests/test_policy.sh and tests/test_tty.sh
are new.

The load-bearing test is a matched pair: the same .env.claude fixture must
produce the mount without --policy and not produce it with it. The
control half proves the vulnerability is real, so the closure claim is not
self-referential. Reviewers verified by mutation that each new test fails when
its fix is reverted.

The interactive -t branch is covered via a real pty from script(1), with an
honest skip (never a silent pass) where that is unavailable.

Known limitations

  • Containment checks paths, not inodes: it cannot detect a hardlink to a
    file inside the workspace, or a bind mount of the workspace elsewhere. Store
    policies on a filesystem the container never receives. Documented in the spec.
  • Ignore-derived mount arguments do not escape , or : in filenames, so a
    crafted directory name can make a pattern silently fail to hide, or abort the
    run. Pre-existing; no host-path bind mount is achievable through it. Deferred
    deliberately — a correct fix restructures mount emission and needs its own
    tests.

Spec: docs/superpowers/specs/2026-08-26-broker-policy-file-design.md
Plan: docs/superpowers/plans/2026-08-26-broker-policy-file.md

Two related changes for running `claude.sh` unattended, plus a Critical fix found while reviewing them. ## 1. Headless operation (`--no-tty`) `podman run` was hardcoded `-it`, so a job with no terminal got a pseudo-TTY it could not use and Claude Code painted escape sequences into whatever collected the output. Now `-i` is unconditional and `-t` is added only when stdin **and** stdout are both terminals. `--no-tty` / `CLAUDE_JAIL_NO_TTY=1` forces it off for a caller that has a terminal but wants clean logs. `COLUMNS`/`LINES` are gated on the same condition — with no terminal, `tput` reports nothing and the 80x24 fallback pinned a fictional width on output that has none. `TERM` stays unconditional: Claude Code suppresses colour on its own once stdout is a pipe. Interactive use is unchanged, byte for byte. ## 2. Host-controlled policy files (`--policy <path>`) The workspace is bind-mounted read-write, and `claude.sh` loads `${workspace}/.env.claude` into its own environment. That file may define `CLAUDE_JAIL_*` keys, which configure the launch. So an agent that writes CLAUDE_JAIL_MOUNTS=/:/host into its own workspace gets host root mounted into the **next** session. For interactive use this is a feature — you own both the workspace and the host. For a job broker running untrusted repos it is an escalation path. `--policy` moves every file-sourced setting to a host-controlled file: ```bash claude --policy /etc/claude-jail/jobs/1234.env --no-tty /srv/jobs/1234/repo ``` Under --policy: - ${workspace}/.env.claude is ignored, silently — a broker running many repos should not log a line per job for a file most repos legitimately carry. - ~/.claude-jail/env.default is not read either, so one file fully determines a job and can be audited on its own. - The host environment still outranks the policy file — the supported way to inject a per-job secret without writing it to disk. - ${workspace}/.claudeignore is still honoured, unioned with any --ignore-file. Every pattern only ever hides a path, so a project can add to what is hidden but never unhide what the policy hid. Dropping it would have made broker jobs see more than manual runs. - A missing policy file, or one reachable by a path under the workspace, is a fatal error rather than a fallback. --policy is CLI-only. There is deliberately no CLAUDE_JAIL_POLICY env var: the knob that selects a trust boundary must not be reachable from a file the workspace controls. 3. Critical: host command execution from workspace content Found by the final review of this branch, and introduced by its own headless commit — with a twin that predates it. [[ $var -eq 1 ]] forces bash arithmetic evaluation, which expands array subscripts, including command substitution. Both no_tty (CLAUDE_JAIL_NO_TTY) and with_ssh (CLAUDE_JAIL_USE_SSH) are workspace-sourceable: # .env.claude, written by an agent during job N CLAUDE_JAIL_NO_TTY=HOME[$(id -un > /tmp/PWNED)] id -un ran on the host as the invoking user, and claude.sh exited 0. Quoting does not help — [[ "$v" -eq 1 ]] still executes. Both sites now use string comparison. Every other -eq in the file was swept; the remaining 18 compare parser-set integers. Consequence: CLAUDE_JAIL_NO_TTY=01 or =1 no longer enable the flag. 1 exactly, as --help documents. Two further trust holes closed at the same time: a workspace .env.claude could forge the Policy: audit line by shadowing the script's own policy_file variable, and .ai/prompt.md documented a --workspace <path> flag with no parser case — which, combined with --policy, pointed the containment check at the wrong directory. Testing 102 → 123 tests, all passing. tests/test_policy.sh and tests/test_tty.sh are new. The load-bearing test is a matched pair: the same .env.claude fixture must produce the mount without --policy and not produce it with it. The control half proves the vulnerability is real, so the closure claim is not self-referential. Reviewers verified by mutation that each new test fails when its fix is reverted. The interactive -t branch is covered via a real pty from script(1), with an honest skip (never a silent pass) where that is unavailable. Known limitations - Containment checks paths, not inodes: it cannot detect a hardlink to a file inside the workspace, or a bind mount of the workspace elsewhere. Store policies on a filesystem the container never receives. Documented in the spec. - Ignore-derived mount arguments do not escape , or : in filenames, so a crafted directory name can make a pattern silently fail to hide, or abort the run. Pre-existing; no host-path bind mount is achievable through it. Deferred deliberately — a correct fix restructures mount emission and needs its own tests. Spec: docs/superpowers/specs/2026-08-26-broker-policy-file-design.md Plan: docs/superpowers/plans/2026-08-26-broker-policy-file.md ```
podman run was hardcoded to -it, so a job with no terminal got a pseudo-TTY
it could not use and claude painted escape sequences into whatever collected
the output. Allocate one only when stdin and stdout are both terminals, and
let --no-tty / CLAUDE_JAIL_NO_TTY force it off for a caller that has a
terminal but wants clean logs.

Geometry follows the same gate: with no terminal, tput reports nothing and
the 80x24 fallback pinned a fictional width on output that has none. TERM
stays unconditional -- claude suppresses color on its own once stdout is a
pipe.

The COLUMNS/LINES inline-shape assertion moves from test_env_passthrough.sh
to the new pty-backed test, where the condition it asserts now holds.

Claude-Session: https://claude.ai/code/session_019hxPDty6dpzzohpDg6ubGz
.env.claude lives in the read-write workspace and can define CLAUDE_JAIL_*
keys, so an agent can choose what the next session mounts. Correct for
interactive use, an escalation path for a broker running untrusted repos.

Specifies --policy <path>: host-controlled file config, fail-closed on a
missing or workspace-resident policy, and a union rather than a replacement
for .claudeignore, since every ignore pattern only ever hides.

Claude-Session: https://claude.ai/code/session_019hxPDty6dpzzohpDg6ubGz
Five TDD tasks. The load-bearing test is a matched pair: the same
.env.claude fixture must produce the mount without --policy and not
produce it with it, so the suite proves the vector exists before
proving it is closed.

Claude-Session: https://claude.ai/code/session_019hxPDty6dpzzohpDg6ubGz
Under --policy the workspace's .env.claude and the shared env.default are
no longer consulted; the policy file is the sole file layer. Closes the
escalation where an agent writes CLAUDE_JAIL_* keys into its own
workspace's .env.claude to configure the next session's launch (extra
mounts, image, network). Host env vars still take precedence over the
policy file, matching existing precedence semantics.

Also adds the missing refute_stderr_matches helper (mirrors
refute_stdout_matches; needed by the new declined-silently test) and
unsets POLICY_VAR in setup() to stop it leaking between tests.
assert_env_has alone guards the "don't overwrite an already-exported host
value" rule inside _load_env_file, but not whether the wrapper's -e
forwarding loop actually names the key at all -- a regression dropping
POLICY_VAR from that loop would still pass, since the test's own export
reaches the podman stub's environment by ordinary child-process
inheritance regardless of forwarding. Add assert_argv_has "POLICY_VAR" to
close that gap.
Under --policy, the workspace .claudeignore keeps applying additively:
every pattern only hides a path (tmpfs or /dev/null overlay), so a
project can add to what is hidden but never unhide what the policy
hid. ignore_files (array, union of sources) is new; ignore_file
(scalar) stays as-is so the explicit --ignore-file validation keeps
its current meaning.
`[[ $v -eq 1 ]]` evaluates both operands as arithmetic, and the arithmetic
evaluator expands array subscripts -- including a command substitution hidden
in one. Both CLAUDE_JAIL_NO_TTY and CLAUDE_JAIL_USE_SSH can be set from the
workspace's .env.claude, so a value of `HOME[$(cmd)]` ran cmd on the host as
the invoking user, and the wrapper still exited 0. Quoting does not help: the
operand of -eq is evaluated arithmetically either way. `==` is a string
comparison and never enters that evaluator.

Every other -eq/-gt in the file compares a script-set integer -- parser flags,
loop counters, ${#array[@]} -- and none of them can take an env-file value.

Claude-Session: https://claude.ai/code/session_019hxPDty6dpzzohpDg6ubGz
policy_file is assigned empty before the env files load, and _load_env_file
exports any key whose current value is empty -- so a workspace .env.claude
setting `policy_file=...` reached the `Policy:` diagnostic and made a job log
claim policy mode was on when it was not. _load_env_file now skips any key
that is already a declared-but-not-exported shell variable, which is exactly
the script's own internals; an inherited environment variable is declared *and*
exported, so TERM/HOME/PATH forwarding is untouched. The Phase 8 guard stays:
it catches the Phase 5 names (network, max_memory) that are not yet declared
when the env files load.

The containment check also resolved only where the policy file *is*, not how
the path reached it. With ${workspace}/esc a symlink the agent controls,
--policy ${workspace}/esc/p.env was accepted -- the agent could not author the
content but did choose which host file the broker read. Each directory
component of the path as written is now resolved in turn and refused if it
lands in the workspace.

Finally the prefix is built with ${_workspace_real%/}/ so a workspace of "/"
yields "/" and not "//", which matched nothing and accepted every host path.

Claude-Session: https://claude.ai/code/session_019hxPDty6dpzzohpDg6ubGz
`grep -c -- '--workspace)' claude.sh` returns 0: there is no parser case, so
the flag falls through to the unrecognized branch, is forwarded to claude, and
the workspace silently becomes $(pwd) -- which under --policy makes the
containment check compare against the wrong directory. README and --help are
already correct; .ai/prompt.md is the file agents are told is authoritative,
so a phantom flag there is the harmful copy.

Claude-Session: https://claude.ai/code/session_019hxPDty6dpzzohpDg6ubGz
The rule checks paths, not inodes. It cannot see a hardlink to a file inside
the workspace, a bind mount of the workspace elsewhere, or a policy stored
somewhere the policy itself mounts read-write into the container. Saying so
beats letting a broker operator infer a stronger property than the code has.

Also records the component-walk added in 54c62de: a path that merely
traverses agent-writable ground is refused, not just one that ends there.

Claude-Session: https://claude.ai/code/session_019hxPDty6dpzzohpDg6ubGz
claudiomaradonna deleted branch feat/headless-no-tty 2026-08-27 00:30:24 +02:00
Commenting is not possible because the repository is archived.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
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/claude-jail!30
No description provided.