DocsSupervision (zirv ctx)
Safety and permissions
Low-noise interactive, fail-closed unattended#
"Headless" here is a launch mode — whether a human is present to answer a
permission prompt — not a spawn topology. zirv chat, zirv ctx wrap and a
pane a human spawned from the dashboard's own overlay are interactive;
zirv ctx exec, zirv ctx loop, zirv agent and any pane spawned by a
request nobody vouched for are headless, however visible they are on screen.
The permission rule is simple: everyday and unknown commands run silently in
an interactive session; a short list of genuinely dangerous commands prompts;
a shorter irreversible, credential-exfiltrating, or zirv-self-destructive list
is refused outright. Headless launches stay fail-closed because nobody is
present to answer. This is a launch-flag/hook layer, not injected instruction
text, so --simple does not remove it—only --no-supervise (pure passthrough)
or the explicit opt-out below do.
-
Claude interactive:
--permission-mode default, native workspace/tool scoping, and aPreToolUsesafety hook (coveringBash/PowerShellamong the other tool names its own consolidated matcher names) attested on every Zirv launch through fingerprinted settings and immutable policy snapshots under~/.zirv/runtime/as the sole per-command gate. The hook evaluates both the launch snapshot and the policy resolved now, keeps the stricter verdict, and fails closed on a missing or tampered attestation. It emits an explicitallowfor everyday and unclassified commands,askonly for the closed dangerous list, anddenyfor the shorter refusal list. Zirv ships conservative Design B: no blanket nativeBash(*)allow. On macOS, Linux, and WSL2 the same launch layer requests Claude's OS sandbox in auto-allow mode to block common credential paths, and scrubs cloud credentials from subprocesses. On Linux the OS sandbox requires bubblewrap (bwrap) andsocat; install them withsudo apt install bubblewrap socaton Debian/Ubuntu to enable it. When they are missing, Zirv still launches Claude Code, which prints "Sandbox disabled" and runs Bash without OS sandboxing; Zirv's permission mode, allowed/disallowed tools and the safety check running inside thePreToolUsehook (zirv ctx hook pretool) remain in force. macOS uses the built-insandbox-exec. Native Windows has no OS sandbox and receives the hook and credential rules. The interactiveEdit(./**)/Read(./**)scope also covers Claude Code's own agent-worktree convention (.claude/worktrees/**) and any--add-dirgrant this launch passes (a linked git worktree of the launch repo, issue #329), so a native subagent's Edit/Write inside a delegation worktree never falls through to a prompt. -
Claude headless:
--permission-mode dontAsk; ordinary allow rules are pre-approved and both deny and ask rules are disallowed, so no prompt can stall automation. -
chat.claude_permission_mode(issue #504, operator-only, see Trust boundary): overrides the INTERACTIVE launch's own--permission-modeto"acceptEdits"or"bypassPermissions"instead of the shipped"default". Claude Code's own--permission-modeflag outrankspermissions.defaultModein the operator's~/.claude/ settings.json, so before this key existed there was no way to quiet the interactive prompt volume from config at all — an operator running several native subagents delegating into worktrees got prompted for every Edit/Write and every unlisted compound command, withbypassPermissionsreachable only via the live/permissionsslash command each session. Headless is untouched either way, and the mode change never suppresses or widens the--allowedTools/--disallowedToolslists above — only the flag itself changes:[chat] claude_permission_mode = "acceptEdits" # or "bypassPermissions"or
ZIRV_CTX_CHAT_CLAUDE_PERMISSION_MODE=acceptEdits. Unset (the default) reproduces"default"exactly.REPO_FORBIDDEN: a repository checkout must not be able to widen its own session's permission posture. -
Codex interactive:
--sandbox workspace-write --ask-for-approval on-requestwhen the installed CLI's own bounded capability probe documents it, otherwisenever. When that CLI also advertises--approve-for-me, Zirv enables Codex's native security reviewer for boundary requests; older versions retain plainon-request. No zirv[safety]rule is projected per command. -
Codex headless:
--sandbox workspace-write --ask-for-approval never.
Zirv-launched Codex workspace-write sessions also get zirv's own state subdirectories (mail, memory, sessions, logs, tasks, groups, workflows, verification, artifacts, workflow telemetry, adoption, intake, frontend, status snapshots) as writable roots so zirv commands do not escalate; the bare state root, policy snapshots, the decision log and ~/.zirv operator config stay unwritable.
adapters::SHIPPED_POSTURE_ALLOW/_ASK/_DENY are the shared source for the
built-in classifier and Claude projection. Plain curl/wget, dependency
installation, builds, commits, in-repo writes, read utilities, and commands
zirv has never seen are not prompt-worthy merely because they mutate or are
unknown. Force-push, hard reset, local ref/stash/reflog/worktree loss, recursive deletion, process termination,
registry mutation, remote HTTP mutations, infrastructure destruction and
device/partition tools ask interactively. Generated-directory cleanup,
downloads, loopback requests and dry runs stay silent. Irreversible package or
release publication/deletion, credential-store access, secret-file uploads, privilege
escalation, download-to-shell pipelines, and attacks on zirv itself are denied.
Local reads or copies of project secret files (.env, .env.*, *.pem, *.key)
ask; .env.example, .env.sample, .env.template, .env.dist, and .env.test
templates stay silent.
The shipped allow set also covers zirv report bug|feature, absolute-path
find, ordinary git merge/pull/push/branch, and the macOS SSH-agent
setup export SSH_AUTH_SOCK=$(launchctl getenv SSH_AUTH_SOCK). Only that
specific launchctl environment key is allowed. The matching
unsandboxed-retry rules keep those commands silent when the OS sandbox cannot
reach the required repository, remote, user path, or agent socket. Destructive
variants such as force/delete pushes, hard resets, and forced branch deletion
still use the ask rules above.
The structural/semantic result is identical for Unix, cmd.exe, and
PowerShell spellings (including .exe/.cmd wrappers). This classifier is a
tripwire layered with the harness sandbox, not a claim that finite command
analysis can contain an arbitrary-code interpreter.
An operator's own explicit --sandbox/--ask-for-approval/--permission-mode/
--disallowedTools (passed after --, or via worker.claude/worker.codex's
own trailing flags) always wins outright — zirv prepends nothing when the
launch already pins one of these.
To restore the pre-2026-08-22 behaviour (no zirv-applied flags at all; a launch's approval/sandbox posture comes entirely from the harness's own native config), set:
[sandbox]
enabled = falseor ZIRV_CTX_SANDBOX=false. sandbox.enabled is REPO_FORBIDDEN (see
Trust boundary above): a checkout cannot turn its own
sandboxing off, only the operator can.
Worker secret scrub#
Delegated workers (zirv agent, zirv ctx exec, zirv ctx loop) start without
the operator's secret-shaped environment variables. A name is secret-shaped when,
case-insensitively, it contains TOKEN, SECRET, PASSWORD, PASSWD, API_KEY,
APIKEY, ACCESS_KEY, PRIVATE_KEY or CREDENTIAL, has AUTH as a whole
_-separated word (SSH_AUTH_SOCK, not GIT_AUTHOR_NAME), or is a well-known
exact name (AWS_ACCESS_KEY_ID, NPM_TOKEN, VAULT_TOKEN, ...). ZIRV_*,
PATH, HOME, USERPROFILE, TERM, LANG and LC_* are never removed, nor is
anything the launch itself sets. The harness's own credentials are kept: Claude Code's
ANTHROPIC_*/CLAUDE_* login variables (plus its cloud-provider credentials while a
CLAUDE_CODE_USE_* switch is set), codex's OPENAI_API_KEY/CODEX_API_KEY and any
[endpoint.codex] credential_env, Gemini's GEMINI_API_KEY/GOOGLE_API_KEY/
GOOGLE_APPLICATION_CREDENTIALS, and Copilot's GH_TOKEN/GITHUB_TOKEN/
COPILOT_GITHUB_TOKEN. A harness that declares no credentials is left unscrubbed.
The stripped names (never values) are written to the decision log as scrub-env.
The interactive seat you launch yourself is unchanged.
[sandbox]
scrub_worker_secrets = falseor ZIRV_CTX_SANDBOX_SCRUB_WORKER_SECRETS=false. The key is REPO_FORBIDDEN, and
is separate from scrub_subprocess_env, which configures Claude Code's own subprocess scrub.
Command safety policy (issue #83)#
[safety] is zirv's harness-neutral shell-command classifier. Claude projects
it through its native rule lists and Bash|PowerShell hook; codex currently has no verified
per-command channel and relies on its sandbox/approval boundary instead. gh
and glab (GitHub's and GitLab's CLIs) are both subcommand-based CLIs with the
same <tool> <resource> <verb> shape; this classifier's own read-only carve-out
recognizes a mutating gh api call specifically (a non-GET --method/-X,
or a body flag such as -f/-F/--input) and classifies it the same way a
destructive git or publish command is. zirv ctx permissions' separate
classifier checks the equivalent mutating-vs-read shape for both gh api and
glab api, using its own flag list (-f/--field/--input) — see
Permission auditing and safe-list
proposals below for turning
repeated prompts on these two CLIs into either a standing operator allow or a
proposed policy change:
[safety]
deny = ["terraform destroy*"] # additional deny patterns
ask = ["kubectl delete*"] # additional ask patterns
allow = ["just test*"] # operator-only, REPO-FORBIDDEN
default = "ask" # operator-only, REPO-FORBIDDEN
interactive_default = "allow" # operator-only, REPO-FORBIDDEN
sql = "on" # operator-only, REPO-FORBIDDENRules are glob patterns (* matches any run of characters); a command is
matched deny-first, then ask, then allow, first match wins within a
category. Unmatched commands use interactive_default for an interactive
launch and default for a headless one. The Claude hook treats every non-empty
permission mode except dontAsk as interactive, including auto and
bypassPermissions; an empty or missing mode is headless.
ZIRV_CTX_LAUNCH_MODE=interactive overrides either case to interactive.
With SQL classification on, one
provably read-only SELECT/EXPLAIN/SHOW through a recognized client runs
silently; write-shaped, multi-statement, stdin/script-fed, malformed, or CTE
input asks conservatively.
With the default interactive_default = "allow", the hook answers "allow" for commands no rule matches and suppresses the harness's own permission prompt, so enabling the hook widens what runs without a prompt; set [safety] interactive_default = "ask" in ~/.zirv/ctx.toml to keep the harness's prompt for unmatched commands.
Zirv's own commands never prompt (issue #845). The launch layer's native
Bash(zirv ...) allow rules are derived from the clap command schema
(zirv commands): every built-in is allowed except a short exclusion list --
commands that run a caller-supplied command or start a nested harness session
(ctx exec/wrap/run/loop/chat/agent/resume/handover,
ctx usage tee) and commands that write operator or harness configuration
(setup, the mutating ctx config verbs, ctx hook install, ctx provider login/credential). Repo-authored zirv <script> is never covered. Because
Claude Code matches every compound segment on its own and never matches a
path-qualified binary, an env-prefixed command or a command substitution, the
PermissionRequest hook (zirv ctx hook permission) additionally prints an
allow decision when it is about to prompt for a Bash command that zirv's own
safety verdict allows and whose every segment is a prompt-free built-in zirv
invocation (zirv or the running binary's own path; only ZIRV_* env prefixes,
and only on ctx), a literal cd, or a read-only filter, with each
$(...)/backtick body held to the same rule. It never prints deny or ask, so
every refusal and a matching deny rule still win.
Same-command literal variable assignments are resolved when judging scratchpad-confined writes and redirects under the hook payload's cwd on an unsandboxed retry. A variable must have a single literal assignment in a simple command list; functions, traps, shell-variable mutations and ambiguous expansions disable resolution. Unresolved targets still require approval on a retry. A CLAUDE_CODE_TMPDIR already ending in claude-<uid> is used as-is; on macOS, scratchpad roots include both /tmp/... and /private/tmp/... spellings.
Elasticsearch GET/POST query endpoints (/_search, /_msearch, /_count, /_field_caps, /_explain and /_explain/<id>, /_validate/query, /_sql, /_eql/search, /_search/template, /_render/template) are read-only for the network classifier and retry screen when every request URL matches a query route and any body is inline. File/stdin uploads, dynamic bodies, client config files, unknown query options and non-query routes disqualify this exception; explicit ask/deny rules still apply.
The analyzer evaluates the most restrictive result across quote-aware compound
segments (;, &, &&, ||, pipes and newlines), nested
sh/bash/zsh/cmd/PowerShell inline wrappers, $() and backtick command
substitutions, and every semantic candidate it finds. The semantic layer
recognizes SQL writes, remote network mutations, credential-file access,
recursive deletion, infrastructure/service destruction, and irreversible
package/release operations using case-folded executable basenames, so native
Windows and Unix wrapper spellings reach the same verdict. Quoted
command-looking text remains data. This tripwire is bounded against hostile
input and deliberately does not decode obfuscation, expand variables, or read
dynamically sourced scripts; the harness sandbox is the containment boundary
beneath it.
Each supervised Claude decision appends a privacy-preserving audit record under
the platform state directory's logs/safety-decisions/ UTC-day bucket. Records
contain the verdict, a bounded command family (program plus a subcommand only
for known dispatcher CLIs), matched rule/origin, launch/current policy
fingerprints, attestation status and SHA-256 of the command—never the raw
command, remaining arguments, source, paths, tokens, or shell secrets.
deny/ask may be extended by a repo checkout (narrowing is always safe—both
are checked before allow); allow, both defaults, and sql may not. A
checkout cannot grant itself approval, loosen either launch posture, or turn
off the conservative SQL narrowing.
A recursive delete whose every target sits inside the OS temp root (or
/tmp, /var/tmp) is allowed by the hook even under the shipped rm -rf
ask rule, so an agent can clear its own scratch directory. A headless launch
still projects the ask set into --disallowedTools, which Claude Code
checks before the hook, so there the delete stays blocked.
Three verbs work with the resolved policy directly:
zirv ctx safety check --mode interactive -- rm -rf / # exits 0/1/2 for allow/ask/deny
zirv ctx safety list # the effective merged policy, with each rule's origin
zirv ctx safety explain --mode headless -- git push --force # rule plus launch consequencezirv ctx safety check (with no trailing command) is the hook-mode entrypoint
hook::run_pretool calls in-process for Bash/PowerShell calls (issue
#769: zirv setup apply no longer wires it in as its own separate
PreToolUse registration — see Hook registration),
so the same evaluator zirv's own CLI uses is what claude consults before
running a command — see Context Management.
[jev] approve/approve_allow (issue #781, both operator-only and off by
default) add an optional Jev risk check on top of this deterministic policy,
strictly after every other rule above has already produced its verdict. The
request sends bounded local facts only — program class, subcommand class,
write/delete/network/privilege-escalation flags (folded over every
executable candidate normalize_segments finds, not just the command's own
leading token), a path-scope class (worktree/repo/home-or-root-wide/
credential path), pipe/redirect/command-substitution counts, a
secret-placeholder count from the same detector [obfuscate] uses (issue
#466), and a shell/eval/inline-interpreter wrapper flag — never the command
text, paths, arguments, env values, or file contents; the request must pass
jev::safe_metadata_request like every other [jev]-gated site. Neither key
makes a call under dontAsk (every headless launch): the hook emits nothing
there for allow or a non-operator ask, so the answer could not change the
decision. approve may only ESCALATE a deterministic allow to ask, on a decisive
answer; widening what may be escalated is always safe, so this direction has
no further restriction — except one operator-decided carve-out: approve
makes no Jev call at all for a command that is read-only and confined to the
worktree/scratchpad (a fixed allowlist of inspection programs/subcommands —
grep/rg/cat/head/tail/wc/ls/find without -exec/-delete/
-ok, read-only git subcommands including branch --list, pwd, echo,
and pipes ONLY between such programs — with no writes/deletes/network/
privilege flags, no eval/shell wrapper, no code-bearing argument, no command
substitution, and no redirection to a file; anything it cannot positively
confirm still reaches Jev). approve_allow (effective only when approve is
also on) may only LOWER an ask to allow, on a decisive safe answer
clearing a HIGH margin and confidence floor (2026-09-18 probe: 8/10 correct,
both misses cautious, n=10 — too small to gate on at a normal bar), and only
for a simple unmatched-default ask that clears every one of these
checks:
- No matched rule at all; exactly one segment; no pipe/redirect/command-or-
process-substitution/backtick/heredoc; no leading
VAR=valueprefix. - The command's own program is not a shell/
eval/exec/xargs/env/sudo/doas/dot-source, an interpreter given inline code (-c/-e), a fixed list of always-opaque wrappers (builtin, PowerShell's owniex/Invoke-Expression/Start-Process/Invoke-Command,trap,alias, remote/relay executionssh/scp/nc/ncat/socat/telnet, orosascript/lua/deno/bun/php/tclsh/awk/gawk/expect), or a fixed list of destructive/service-altering programs zirv's own deterministic policy does not otherwise classify (shred,srm,truncate,diskutil,crontab,launchctl,systemctl,wipefs,cipher,schtasks,kill,mkfs*,rsyncwith a--delete*flag,reg delete). - No argument token is code-bearing: none contains whitespace (a quoted
multi-word string, already dequoted),
(,),{,}, or@, and none is itself-e/-c//cimmediately followed by another token. - Computed over the command itself: not destructive, not a network program, not privilege-escalating, with a path-scope no wider than the repo.
- Structural launcher check: for every
iin1..tokens.len()(the full, unbounded remainder — an earlier bounded window still missed a real program past its cap), the SAME deterministic path the hook itself uses (evaluate, no Jev) is re-run on the suffixtokens[i..], and every check above (matched rule,deny, destructive/network/privilege- escalating, the refused-program list, a code-bearing argument) is re-applied to that suffix too. A launcher prefix (nohup,timeout N,nice/nice -n N,command,time,stdbuf ...,setsid, or any other launcher this module does not name) shifts the real program past every check that looks at the command's own leading token alone; this re-evaluates every plausible starting position instead of naming launchers, so it generalizes to one this list does not yet know about. - The RAW command may not contain
\,?,*,[,],$,',",~,`, or a newline, checked before any other test runs: every check above reasons about dequoted tokens, and a backslash-split program name, a glob standing in for it, or an unexpanded$IFSstanding in for whitespace all compare unequal to the plain-string program/argument names those checks look for while a real shell still executes them identically.
A matched-rule check on the WHOLE command is not enough by itself: the
deterministic fold (evaluate_candidates) keeps the FIRST candidate at a
tied verdict rank, so a compound like foo-unknown; rm -rf ~ reports ask
with no matched rule at all even though its second segment is the
shipped rm -rf * ask family. The simple-command restriction above,
including the structural launcher check, is what actually keeps rm -rf,
force-push, sh -c "...", eval "$(...)", nohup rm -rf ...,
timeout 5 git reset --hard, and every other SHIPPED_POSTURE_DENY/
SHIPPED_POSTURE_ASK family, dangerous wrapper, or launcher-prefixed
dangerous shape out of reach — not any single check by itself, and this is
necessarily a bounded, reviewed list rather than a claim of catching every
possible obfuscation. An unmatched, simple, single-program command the
deterministic policy simply has no opinion on at all — e.g.
chmod 000 /Users/x/keep, ln -sf /dev/null /Users/x/.zshrc,
./script.sh, certutil -urlcache — clears every check above and so
relies on Jev's own confidence/margin floors alone, a documented trade-off
of this off-by-default opt-in rather than an oversight. Any Jev error,
timeout, or an answer that misses either floor
falls back to the deterministic verdict unchanged. Decisions are recorded
on site approve with outcome escalated/lowered/unchanged/fallback.
Permission auditing and safe-list proposals (issue #178)#
zirv ctx permissions turns recent transcripts into a report on which
commands kept needing a human, so a policy decision can be made about the
family of command instead of clicking through the same prompt every session:
zirv ctx permissions audit --agent codex --sessions 5 # read-only report
zirv ctx permissions audit --agent claude --json
zirv ctx permissions compile --agent codex --dry-run # preview eligible allows; writes nothing
zirv ctx permissions propose --agent claude --dry-run # preview proposed issues; files/comments nothingauditis strictly read-only. It extracts every escalated/denied permission request from the sampled transcripts (codex:require_escalatedexec requests; claude: headlessdontAskdenials and interactive sandbox-escape asks), groups them by a normalized command family (gh pr,cargo publish, ...), and reports each group's sample command, cause, and a reusability verdict: whether a saved approval for this family would plausibly match the next equivalent invocation, or whether it collapses to a one-off (a long literal payload, or a pipe intojq/grep/awk/sedwhose own argument is what varies).compileruns the same audit, then writes eligible families as standing[safety] allowentries in the operator's own~/.zirv/ctx.toml. A family is eligible only when it is reusable, has at least two normalized tokens (a bare program name is too coarse — it would authorize whatever a future invocation is told to run), and is not a protected family: destructive git (git push --force,git reset --hard,git rebase, ...), a global binary/config install, a credential/secret command, a publish/release action, an interpreter/shell/remote-exec program, or a mutatinggh api/glab apicall always stay prompting regardless of reusability.--agent codexcompiles are still written as real[safety]entries — claude reads them too — but a printed caveat makes clear this changes nothing about codex's own launch posture, since codex has no per-command approval hook yet for zirv to pin against.proposeis the mirror image: instead of escalated/denied requests, it looks at operator-approved prompts and classifies which are so clearly safe they should never have prompted at all — today, only a documentedgh/glabcollaboration verb (SAFE_COLLABORATION_VERBS: creating or updating a PR/issue, or commenting — never merge, close/reopen, delete, release, auth, or an arbitrary API call), matched at the exact(program, resource, verb)triple, unchained and unpiped. Evidence is grouped by family; a family with no open proposal issue yet files one, and a family whose evidence has changed since the last run gets one updated comment on its existing issue — a family already reported with unchanged evidence is skipped, so a re-run over an overlapping transcript window never re-comments the same evidence.
propose is disabled by default — it auto-files issues on a public GitHub
repository. Enable it explicitly, operator-side only:
# ~/.zirv/.settings.toml
[permissions]
propose_enabled = trueAbove a threshold of 5 total requests in one audit, zirv ctx optimize's own
friction pass surfaces the same summary as a finding, well before a session
reaches the volume that originally motivated this feature.