feat(claude.sh): headless operation and host-controlled --policy for broker jobs #30
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "feat/headless-no-tty"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Two related changes for running
claude.shunattended, plus a Critical fixfound while reviewing them.
1. Headless operation (
--no-tty)podman runwas hardcoded-it, so a job with no terminal got a pseudo-TTY itcould not use and Claude Code painted escape sequences into whatever collected
the output. Now
-iis unconditional and-tis added only when stdin andstdout are both terminals.
--no-tty/CLAUDE_JAIL_NO_TTY=1forces it off fora caller that has a terminal but wants clean logs.
COLUMNS/LINESare gated on the same condition — with no terminal,tputreports nothing and the 80x24 fallback pinned a fictional width on output that
has none.
TERMstays unconditional: Claude Code suppresses colour on its ownonce 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.shloads${workspace}/.env.claudeinto its own environment. That file may defineCLAUDE_JAIL_*keys, which configure the launch. So an agent that writesCLAUDE_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.
--policymoves every file-sourced setting to a host-controlled file:Under --policy:
should not log a line per job for a file most repos legitimately carry.
a job and can be audited on its own.
inject a per-job secret without writing it to disk.
--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.
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.
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
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.
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
`[[ $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_019hxPDty6dpzzohpDg6ubGzpolicy_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