zirvDocs

DocsSupervision (zirv ctx)

Safety and permissions

README.md, lines 7259–7663View source

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 a PreToolUse safety hook (covering Bash/PowerShell among 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 explicit allow for everyday and unclassified commands, ask only for the closed dangerous list, and deny for the shorter refusal list. Zirv ships conservative Design B: no blanket native Bash(*) 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) and socat; install them with sudo apt install bubblewrap socat on 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 the PreToolUse hook (zirv ctx hook pretool) remain in force. macOS uses the built-in sandbox-exec. Native Windows has no OS sandbox and receives the hook and credential rules. The interactive Edit(./**)/ Read(./**) scope also covers Claude Code's own agent-worktree convention (.claude/worktrees/**) and any --add-dir grant 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-mode to "acceptEdits" or "bypassPermissions" instead of the shipped "default". Claude Code's own --permission-mode flag outranks permissions.defaultMode in 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, with bypassPermissions reachable only via the live /permissions slash command each session. Headless is untouched either way, and the mode change never suppresses or widens the --allowedTools/--disallowedTools lists above — only the flag itself changes:

    toml
    [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-request when the installed CLI's own bounded capability probe documents it, otherwise never. When that CLI also advertises --approve-for-me, Zirv enables Codex's native security reviewer for boundary requests; older versions retain plain on-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:

toml
[sandbox]
enabled = false

or 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.

toml
[sandbox]
scrub_worker_secrets = false

or 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:

toml
[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-FORBIDDEN

Rules 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:

sh
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 consequence

zirv 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=value prefix.
  • 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 own iex/Invoke-Expression/Start-Process/Invoke-Command, trap, alias, remote/relay execution ssh/scp/nc/ncat/socat/telnet, or osascript/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*, rsync with 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//c immediately 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 i in 1..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 suffix tokens[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 $IFS standing 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:

bash
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 nothing
  • audit is strictly read-only. It extracts every escalated/denied permission request from the sampled transcripts (codex: require_escalated exec requests; claude: headless dontAsk denials 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 into jq/grep/awk/sed whose own argument is what varies).
  • compile runs the same audit, then writes eligible families as standing [safety] allow entries 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 mutating gh api/glab api call always stay prompting regardless of reusability. --agent codex compiles 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.
  • propose is 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 documented gh/glab collaboration 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:

toml
# ~/.zirv/.settings.toml
[permissions]
propose_enabled = true

Above 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.