Skip to content

fix(danger): harden the command classifier and approval gate - #302

Open
jkyberneees wants to merge 72 commits into
mainfrom
fix/danger-classifier-red-bugs
Open

jkyberneees wants to merge 72 commits into
mainfrom
fix/danger-classifier-red-bugs

Conversation

@jkyberneees

@jkyberneees jkyberneees commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Hardening pass over internal/danger, the shell-command risk classifier and approval gate. Every change was driven by a failing test first; roughly 280 new tests, four invariant fuzz targets and a golden option-grammar corpus were added, and the branch closed with an adversarial review whose findings are fixed here too.

Classifier correctness

  • Normalization is quote-aware and quote-safe: ANSI-C decoding, backslash handling, nested substitutions, brace groups and sequences, arithmetic, heredocs, comments, line continuations. A substitution glued to a word stays in that word. Unterminated quotes fail closed.
  • Shell compound commands (for/while/if/case, subshells, groups, functions, [[ ]], (( ))) are parsed and every simple command inside is classified; unpaired constructs fail closed while inner commands still count.
  • Wrappers and tool adapters share one option-spec grammar, so fused flags, --opt=value and abbreviations are handled uniformly across timeout, env, xargs, sudo, watch, tar, sed, git, ssh/scp/rsync, kubectl/helm/hugo, docker and more.
  • Path rules cover more destination spellings, directory targets, other users' homes, ~user, setuid modes and persistence targets; the current user's home outranks the /root system prefix.
  • Static shell-state tracking is sound across && chains, rebinding builtins, unquoted expansions and loops, and is linear in command size.

Policy surface

  • New network_upload risk class (default prompt): file-backed or piped request bodies, credentials on the command line, local-to-remote transfers, listeners and tunnels. Plain fetches stay network_egress. Wired through config validation, sub-agent caps, scheduler, Web UI and docs.
  • gh verb adapter: reads stay egress, remote mutations prompt, deletes are destructive, credential-exposing and code-loading verbs are gated.
  • Repository-aware git escalation: ordinary git verbs prompt only when the targeted repository is actually armed (hooks, hooksPath, fsmonitor, filters, drivers, editors), failing closed when state is unknowable.
  • Denylist entries match as token prefixes at every command position (segments, pipes, wrappers, payloads, substitutions, compounds), with tool global options stripped and static variables resolved.
  • Secret-shaped environment variable references and credential files (by name, extension or directory) classify as system_write.

Read ledger, scanner, approvers

  • Scripts delivered through pipes, substitutions, eval, find -exec, program-file options, globs and versioned interpreters are gated; a same-command rewrite revokes a license; run-time program paths and decoded pipes fail closed; ledgers are bounded and dropped when sessions end.
  • Injection scanner handles more phrasings, decomposed accents, format characters and styled letters.
  • Approval prompts and denial messages on all surfaces escape control and bidi characters; the read-only carve-out is keyed on native tool names only.

Robustness

  • Invariant fuzzing (a dangerous suffix can never be hidden by any prefix; harmless prefixes never lower a verdict; bounded time), a 64 KiB input cap, a token budget and bounded scans.

Docs

  • SECURITY, CONFIG, CLI, CHEATSHEET, MIGRATION, SUBAGENTS, SCHEDULES, TELEGRAM, WEBUI, SANDBOXING, DEVELOPMENT, EVALS, API, EXTENSIONS, SESSIONS, the Docker guides, AGENTS.md and the package doc comment are reconciled with the new behaviour; security_report_validation_test.go pins the new mitigations.

Verification

  • internal/danger: green plain, under -race, shuffled, and on the fuzz targets.
  • cmd/odek, internal/config, internal/loop, internal/telegram: green.
  • Everyday command verdicts compared against main: only tightening of program-loading options and intentional new prompts (uploads, secret reads); ordinary git verbs, loops, conditionals, heredocs and command -v no longer prompt or deny.

🤖 Generated with Claude Code

https://claude.ai/code/session_014CfdqWSpArax9E8mF1aspK

claude added 15 commits October 8, 2026 12:40
Adds 72 failing tests across six areas of the danger classifier
(normalization, wrappers, paths, exec adapters, analysis layer, read
ledger and injection scanner). Each test documents a confirmed
misclassification, crash, hang or false positive and will turn green
with the fixes that follow.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014CfdqWSpArax9E8mF1aspK
Read ledger: fingerprintFile, fileHasShebang and executableTextFile stat the
path and require a regular file before any open (non-blocking open, handle
re-checked), so FIFOs and devices can no longer hang classification or
RecordRead. Interpreter detection strips version suffixes (python3.12,
python2, lua5.4) and also consults the shell and stdin-executing interpreter
sets (ash, ipython, luajit). stageExecutionFiles now treats only the program
operand and helper-option values as executed files: redirect operators and
targets and data arguments after the script are skipped (the `bash < x.sh`
stdin form is kept), fused -lc style payload clusters stop the scan, glob and
brace operands gate every match, and an unmatched glob is reported as its own
pattern. A path written earlier in the same command (redirects, curl -o,
wget -O, sed -i, tee, cp/mv/install into a file or directory) and then
executed is reported unread even if it was read before or does not exist yet.

Scanner: every Unicode Cf character plus the Hangul fillers is invisible. The
ignore/disregard patterns accept determiners (all/any/of/your/the/my). French
and German patterns match both precomposed and stripped accents. The identity
replacement pattern is bounded to 40 characters and stops at sentence
punctuation.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014CfdqWSpArax9E8mF1aspK
…xec-class tools

tar: flag -F/--info-script/--new-volume-script/--rsh-command/--rmt-command,
GNU unique-prefix long options, and I/F letters inside dash-bundled and
old-style option clusters.

sed: the standalone `e` command is now recognised behind any GNU address
form (numeric, $, /re/ with I/M, first~step, addr,+N, addr,~N, addr,addr,
trailing !).

git: --config/--config= treated like -c; credential.<url>.helper,
remote.<name>.uploadpack/receivepack, core.askpass, core.gitproxy and
related program-valued keys flagged; --upload-pack, clone -u,
--receive-pack, --exec and --template flagged on network subcommands;
`bisect run` and `hook run` are code execution; push --mirror/--delete/-d/
--prune/--force-if-includes and +/: refspecs are data loss; `maintenance
start|register` is persistence; --output=FILE on log/show/diff/archive is
a write target.

Other tools: sort --compress-program, sdiff --diff-program, rg
--hostname-bin, ssh/scp/sftp -F, -S/-D and -o ProxyCommand/LocalCommand
(all spellings), and rsync -e/--rsh are code execution.

Infra CLIs: kubectl/helm value-taking global flags and hugo/compose flags
no longer have their value read as the verb; helm --post-renderer is code
execution; terraform state rm|mv|push|replace-provider and kubectl auth
reconcile are system_write.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014CfdqWSpArax9E8mF1aspK
…nv vars

- Shell inline scripts are extracted from any short-flag cluster containing
  `c` (-lc, -ec, -xc), honouring `--` and value-taking options (-o/-O,
  --rcfile); classifyStage, isCodeExecution and the analysis payload walk
  share the helper.
- `env -S/--split-string` (spaced, fused, `=` and abbreviated forms) is
  classified as a command line and wins over the env-dump heuristic; env's
  value-taking option grammar is parsed in one place.
- Environment dumps behind wrappers or assignment prefixes (`FOO=1 env`,
  `timeout 5 env`, `env env`, `FOO=1 export -p`) and bare
  `export`/`declare`/`typeset` are system_write like bare `env`.
- NAME=value operands of `export` / `declare -x` / `typeset -x` go through
  the same environment-assignment risk check as leading assignments.
- xargs/parallel inner verbs are unwrapped before the fail-closed verb check
  so `xargs nohup rm -rf` is treated like `xargs rm -rf`.
- printf/echo static pipe payloads are decoded (escapes, printf directives)
  before being composed onto xargs or a piped shell; undecodable directives
  report not-static.
- GIT_CONFIG_COUNT/KEY_n/VALUE_n, JAVA_TOOL_OPTIONS, _JAVA_OPTIONS,
  JDK_JAVA_OPTIONS, LESSOPEN, LESSCLOSE, SSH_ASKPASS(_REQUIRE) and
  GCONV_PATH assignments escalate to system_write.
- `man -P/--pager` and `-H/--html` are code_execution.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014CfdqWSpArax9E8mF1aspK
Analysis state:
- Mutations behind `&&` (assignments, cd/pushd) are carried only inside the
  chain; once the chain ends the variables and cwd they touched are unknown.
  Assignments inside `||`/`&` commands drop the old static value instead of
  keeping it.
- read, mapfile, getopts, printf -v, unset, export, declare, typeset, local,
  readonly and let drop the static value of the variables they bind
  (declare -n drops all of them).
- Variable expansion scans each token once and looks names up in the map, so
  it is linear in the command length. An unquoted reference whose value has
  whitespace, glob characters or IFS separators fails closed to the dynamic
  marker; quoted references are substituted as before.
- cd/pushd operand selection skips redirect operators, their targets and fd
  digits, -L/-P/-e/-@ and `--`; unknown options, stack rotation and extra
  operands make the cwd unknown, and a relative step from an unknown cwd stays
  unknown.
- wrapperDirectory skips the options and numeric operands of timeout, nice,
  ionice, stdbuf, xargs, watch and strace so an `env -C` behind them is seen.

Write targets:
- Unambiguous abbreviations of long output options (--out, --target,
  --output-doc) are matched, as are abbreviated --output-dir/--directory-prefix.
- Fused short clusters keep the wget -P directory (-qP dir, -qP/dir) and the
  curl -O default name (-sO, -sSLO).

Approval:
- The read_only non-interactive carve-out honours only the PromptOperation
  tool name, never the description passed to PromptCommand.
- NonInteractiveAction and CheckOperation are nil-receiver safe.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014CfdqWSpArax9E8mF1aspK
Raw devices: dd of= values are cleaned and symlink-resolved before the
block-device check, the prefix list covers md/mapper/rdisk/mem/etc., and any
non-stdio node under /dev is destructive (`/dev/./sda`, `/dev/s?a`).
dd of=<file> is now a semantic write target (local_write); of=$VAR fails
closed as unknown.

Destinations: persistence directory markers match a directory destination
(`cp x .git/hooks/`); cp/mv/install/ln/rsync destinations are resolved
(-t/--target-directory in every spelling, trailing slash, existing dir,
`dir/.`, rsync `dir/`) and each source basename is joined onto a directory
destination, so `mv .bashrc ~/` is a rc write. rsync escalates only when its
final local operand classifies above local_write (`rsync -av /src/ /dst/`
stays safe). tar -C/--directory, unzip -d, 7z -o, git archive -o/--output and
pandoc -o/--output destinations are checked, including attached values.

tar list detection parses only the mode cluster up to the first
value-taking letter, so `-C/etc` and `-cftest.tar` are no longer listings.

Tilde expansion follows the shell: `~name` resolves through os/user (unknown
names fail closed to /home/<name>), `~+`/`~-` expand to the working
directories. Other accounts' homes (/home/<u>, /Users/<u>, /root) get the
same rc-file, credential-directory, odek-anchor and systemd-user rules as
the current home. More startup files (.xinitrc, .xprofile, .xsession,
.mkshrc, ~/.local/share/systemd/user/), CI definitions (.circleci,
.buildkite, azure-pipelines, bitbucket-pipelines, appveyor), .git/config and
submodule hooks are persistence targets.

rm wipe targets accept trailing slashes, $PWD/ forms and ~name; chmod treats
a dash-leading symbolic mode (`-x,u+s`) as the mode; install/mkdir/mknod -m
setuid/setgid values escalate; kill parses pid operands numerically so
`01`, `+1` and `-01` name init and the broadcast pid.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014CfdqWSpArax9E8mF1aspK
The exec and paths fixes each added a git entry to semanticWriteTargets;
fold the archive -o/--output flags into the single git case so the
switch compiles and both spellings stay covered.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014CfdqWSpArax9E8mF1aspK
The normalization phases each tracked (or ignored) quote state on their own,
so several rewrites left text that later phases parsed differently from the
shell: collapsed backslashes turned escaped quotes into quote openers,
decoded ANSI-C quote characters opened spans, and `'$'` was read as an
ANSI-C opener. Commands after such a desync were hidden from classification.

- Add a shared lexical tracker (quotes, ANSI-C spans, comments, nested
  $(...)/backtick/arithmetic contexts) used by the new phases.
- collapseUnquotedBackslashes keeps escaped quote/backslash pairs intact
  (also inside double quotes) and tokenize turns them into literal characters.
- decodeANSIC only opens on a live `$'`, decodes \a \b \e \f \v \cX \uHHHH
  \UHHHHHHHH and 1-2 digit \x, and emits values containing syntax characters
  as a single-quoted literal.
- Backslash-newline is joined first (not inside single quotes or comments);
  comments are stripped so an apostrophe in a comment cannot swallow the
  following lines.
- Here-document bodies fed to data-only programs (cat, tee, ...) are consumed;
  substitutions in unquoted-delimiter bodies are still extracted. Bodies for
  interpreters, piped heredocs and anything ambiguous stay classified.
- expandBraces distributes preamble/postscript, expands nested groups, leaves
  quoted braces and ${...} alone, and caps work; overflow emits an unknown
  command so oversized expansions are denied.
- extractSubstitutions unescapes nested backtick bodies, treats $((...)) as
  arithmetic (nested substitutions still extracted), splices empty
  $N/${N}/$@/$* out of words, and no longer pads empty substitutions.
- `command -v/-V` is a lookup, not a wrapper; `command -p/--` options are
  skipped when stripping the wrapper.

decodeANSIC whitebox expectations now reflect quoted output for non-plain
values and bash's real \a/\b control characters.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014CfdqWSpArax9E8mF1aspK
The normalize and wrappers fixes each introduced isHexDigit; keep the
one in classifier.go.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014CfdqWSpArax9E8mF1aspK
rsync -e/--rsh now escalates to code execution only when the value
names a path or program other than ssh, or an ssh invocation that itself
loads a program (ProxyCommand, -F). The everyday `-e ssh` and
`-e 'ssh -p N'` spellings stay egress instead of prompting.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014CfdqWSpArax9E8mF1aspK
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Oct 8, 2026 •

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Preview URL Updated (UTC)
✅ Deployment successful!
View logs
odek de6f857 Commit Preview URL

Branch Preview URL
Oct 08 2026, 04:51 PM

claude added 14 commits October 8, 2026 13:24
DangerousConfig.ActionForCommand matched denylist entries as a literal
prefix of the whole command line, so a chain, wrapper, git global option,
absolute program path, grouping, shell -c payload or substitution hid a
denied command (`true && git push`, `git -C . push`, `env git push`,
`bash -c 'git push'`).

Entries are now matched as a token prefix against each segment and pipe
stage, the wrapper-stripped command, the program basename, git with its
global options removed, grouping and keyword prefixes, shell -c / eval
payloads, find -exec commands and substitution bodies. `git push` still
matches `git push origin` but no longer matches `git push-notes`.
Documented in docs/CONFIG.md and the compose guide.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014CfdqWSpArax9E8mF1aspK
timeout -s/-k, stdbuf -o/-i/-e, nice -n, ionice -c/-n, sudo/doas -u/-g/...,
chrt, taskset and flock now have their option values and fixed operands
consumed before the wrapped command is read, so the inner command (not a
signal name or CPU list) is what gets classified. One option grammar table
(wrapper_grammar.go) now drives unwrapWrappers, the argv-composer chain walk
and the wrapper working-directory tracker, replacing three diverging copies.

script -c, flock -c, nix-shell --run/--command and watch with a shell command
line hand a command string to a shell: the string is analyzed as a command
and the stage carries a code_execution floor. nix run/shell/develop, mise/rtx
exec, direnv exec carry the same floor; asdf exec and arch run their argument
as an ordinary wrapper.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014CfdqWSpArax9E8mF1aspK
{1..3}, {a..c}, {01..10}, {1..10..2} and {c..c} expand in bash, so
/et{c..c}/shadow names /etc/shadow. expandBraces now expands sequence groups
(integers with zero-padding and negative ends, single characters, optional
increment, either direction) alongside comma groups, under the same word and
byte caps. A long numeric range is represented by its leading elements and
its last, since elements differ only in digits and cannot change a word's
class; this keeps loops over large ranges from tripping the overflow denial.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014CfdqWSpArax9E8mF1aspK
Reading a credential into the model's context was only gated for
home-anchored paths and whole-environment dumps. `echo $GITHUB_TOKEN`,
`printenv AWS_SECRET_ACCESS_KEY`, `cat .env`, `cat config/credentials.json`
or `cat server.pem` in the workspace all classified safe.

- Any reference to a secret-bearing variable (suffixes such as _TOKEN,
  _SECRET, _API_KEY, _PASSWORD, _PRIVATE_KEY, _ACCESS_KEY, _CREDENTIALS and
  well-known names), as $NAME, ${NAME...}, printenv/declare NAME,
  os.environ/ENVIRON/env.NAME accessors or an unquoted here-document body,
  is system_write, including through echo and printf.
- A credential file basename or extension (.env except example/sample/
  template, credentials.json, service-account*.json, *.pem, *.key, id_rsa
  family, .netrc, .npmrc, .pypirc, .git-credentials, kubeconfig,
  *.tfstate, *.tfvars, secrets.<data ext>, keystores) as any argument,
  `if=`/`--opt=` value or redirect source is system_write; redirect and
  write targets are never below system_write.
- ls/stat/test/wc/du/file, grep-style pattern operands and find -name
  operands do not count as reads, keeping routine inspection quiet.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014CfdqWSpArax9E8mF1aspK
Every gh subcommand except help/completion/version used to classify as
network_egress (allowed by default), so `gh repo delete`, `gh pr merge`,
`gh auth token` and `gh api -X DELETE` ran without a prompt.

gh now has a verb adapter (gh_adapter.go):
- reads of the GitHub API stay network_egress;
- remote mutation (pr/issue/release/workflow/secret/... verbs, gh api with a
  non-GET method or body flag, GraphQL mutations) is system_write;
- irreversible deletion (repo/release/gist/issue/run/cache/project/label/
  secret/variable/key/codespace delete, gh api -X DELETE) is destructive;
- gh auth token, auth status --show-token and the login/logout/refresh/
  setup-git/switch verbs are system_write;
- verbs that run local programs (extension install/exec, alias set with a
  ! expansion or --shell, alias import, codespace ssh/cp/ports forward,
  copilot, config set editor/pager/browser, git flags after -- on clone)
  are code_execution;
- run/release download and repo/gist clone route their destination (-D, -O,
  clone directory, or the working directory) through the write-target rules;
- only -R/--repo/--hostname (any spelling) may precede the verb; an
  unrecognised command or verb, or any other option before the verb, is
  unknown (deny); help, version and completion stay safe.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014CfdqWSpArax9E8mF1aspK
…ns and program-file options

An interpreter that executes its stdin now gates the files the upstream
readers emit (cat x.sh | bash, sed ... x.sh | sh -s), and a command or
process substitution that eval, source or an interpreter executes gates the
files its reader stages emit (bash <(cat x.sh), eval "$(cat x.sh)",
sh <<< "$(cat x.sh)"). find -exec/-execdir/-ok/-okdir gate the program
named by the action's command. Program-file options of awk -f/--file,
sed -f, emacs --script/-l, vim/nvim -S/-u/-s/-l, make -f, gdb -x and
lldb -s are extracted, along with source commands inside gdb -ex, lldb -o
and vim -c/+cmd strings. Option matching accepts separate, =-joined, fused,
short-cluster and unambiguous long-prefix spellings.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014CfdqWSpArax9E8mF1aspK
With HOME=/root (the sandbox runs as root) every ordinary write to the
agent's own home classified system_write, because /root is a system path:
`echo x > ~/notes.txt` and `mv evil ~/.local/bin/git` prompted, and
TestClassify_ShellRCTargets failed under that HOME.

classifyPathLexical now decides the current user's home after the
protected-path rules (rc files, credential directories, odek anchors still
escalate) and before the system-prefix list, so everything else under it is
local_write. isSystemPath no longer reports paths under the user's home, so
a service account with HOME=/var/lib/svc is treated the same way. A
degenerate HOME (/, a bare system directory) never gets the precedence.
Other users' homes, including /root for a non-root user, keep their rules,
and `rm -rf /root/x` carries the same effects as `rm -rf ~/x`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014CfdqWSpArax9E8mF1aspK
…ocal and readonly

The analysis dropped every variable named by a declaring builtin, so
export S=x.sh; bash $S lost S while the plain S=x.sh form resolved it. A
literal NAME=value operand now records its value (single-stage, unconditional
statements only, like a plain assignment); a bare NAME keeps the known value.
Values from substitutions, array literals and attribute flags that retype or
re-case the value (-i, -l, -u, -a, -A, -n) are still dropped.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014CfdqWSpArax9E8mF1aspK
The per-session read ledgers grew without limit. Each session now keeps at
most 4096 paths (the oldest reads are evicted first, in batches) and at most
1024 session keys exist at once (least recently used evicted; the default key
is never evicted). Eviction only removes a licence, so an evicted script gates
again until re-read; WasReadFresh semantics are unchanged.

ForgetReadLedger(sessionKey) drops a finished session's receipts. It is called
when a serve session is deleted, when a Telegram chat is reset with /new (the
next session of the chat reuses the same ledger key, so the archived
conversation's reads no longer license it), and after each scheduled run.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014CfdqWSpArax9E8mF1aspK
The markdown-header injection pattern was anchored to the start of the whole
text, but NormalizeForScan flattens newlines, so a header after the first
line never matched. Headers are now matched per line of the original text
after the same normalization and folding.

FoldHomoglyphs now folds the enclosed alphanumerics (circled and
parenthesized letters), the mathematical alphanumeric blocks by offset
(bold, italic, script, fraktur, double-struck, sans, monospace letters and
digits), the letterlike symbols, superscript/subscript letters, roman
numerals, dotless i, script g, Cyrillic shha and izhitsa, and treats Greek
nu as v. The scan also tries the n reading of nu, because a capital Nu is
lower-cased to it before folding. No new module dependency is needed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014CfdqWSpArax9E8mF1aspK
Add the network_upload risk class (default prompt, ranked between
network_egress and code_execution). Every upload also carries
network_egress, so the two policy keys stay independent: denying either
denies the command.

An invocation is an upload when its request body comes from a file, stdin
(redirect, here-string, or a pipe from a non-literal producer) or a runtime
substitution/variable; when it carries credentials or a client certificate;
when it uses a mutating method; when it is a local-source to remote-
destination transfer; or when it opens a listener or tunnel. Inline literal
bodies, downloads, plain fetches and `ssh host cmd` stay network_egress.

Covered: curl (file/stdin bodies, -T, -F @/<, -n, -u, -E/--key, -X, smtp/
telnet/dict/gopher/ldap, file:// read classification), wget (post/body
file, method, cookies, credentials, -e as config injection), scp/rsync/
sftp/rclone/aws s3/gsutil/gcloud storage/az storage direction, gh gist and
release upload, ssh stdin/tunnel/forwarding options, nc/ncat/socat/telnet/
ftp/tftp/openssl s_client stdin and listeners, /dev/tcp. nc -e/-c and
socat EXEC:/SYSTEM: are now code_execution. DNS lookups with a runtime-built
name are unknown.

Wired through config validation, sub-agent lockdown and max_risk caps, the
scheduler, the Web UI approval labels, restricted docker config and docs.
Rank values shift by one above network_egress; existing pins for scp/rsync
uploads, curl -X POST bodies, nc -l and socat EXEC were updated to the new
classes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014CfdqWSpArax9E8mF1aspK
…d ledger bounds

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014CfdqWSpArax9E8mF1aspK
gh forms that push local content carry both the network_upload effect
and the adapter's system_write mutation; assert the effect and a
summary of at least network_upload instead of an exact class.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014CfdqWSpArax9E8mF1aspK
claude added 30 commits October 8, 2026 15:10
git status/add/commit/diff/merge/checkout/switch/stash/gc/rebase/worktree/
submodule/cherry-pick/am/restore were code_execution unconditionally because
they might run hooks, an fsmonitor, filters, textconv/external diff, merge
drivers or an editor. That prompted on every ordinary git command.

The escalation now resolves the repository the command targets (the tracked
cwd so `cd dir && git status` resolves dir, `git -C`, `--git-dir`, walking up
to the nearest .git directory or gitdir: file, including linked worktrees via
commondir and cloned submodules) and escalates only when something there can
make the verb spawn a program: an executable real hook script (not *.sample),
core.hooksPath / hook.*.command, core.fsmonitor naming a program, filter
clean/smudge/process commands (plain git-lfs excepted), diff.external and
diff.*.command/textconv, merge.*.driver, interactive.diffFilter, a
submodule.*.update shell command, and core.editor / sequence.editor for verbs
that open an editor. Repo, global, system, XDG and GIT_CONFIG_* environment
config is parsed minimally with no includes followed; an include section, an
uncertain cwd, a missing or unreadable repository, hooks directory or config,
an oversized or non-regular config file, or GIT_DIR-style environment
overrides fail closed to code_execution. Which kinds apply depends on the
verb (status/add/diff/restore do not run hooks; log/show only consult
textconv and never escalate outside a repository).

Command-line escalations stay unconditional: -c/--config-env exec keys (now
also include.*, hook.*.command, interactive.diffFilter, submodule update
shell commands), --ext-diff/--textconv and their abbreviations, --paginate,
rebase --exec, external merge strategies, difftool/mergetool, bisect run,
hook run, submodule foreach. `git worktree add/move` destinations are now
write targets since the code_execution escalation no longer covers them.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014CfdqWSpArax9E8mF1aspK
…mmands

Loops, conditionals, case, groups, subshells, functions and test/arithmetic
commands were denied as unknown because their keywords were read as command
names, which pushed operators toward blanket allow policies. The classifier
now reads the grammar and judges every simple command inside, failing closed
on anything it cannot pair.

- compound.go parses the token stream into lists, pipelines and compound
  nodes: for/select (word list or ((;;)) header), while/until, if/elif/else,
  case (;; ;& ;;&), { }, ( ), name() / function name, [[ ]], (( )), time,
  !, coproc, and trailing redirections. Keywords count only at command
  position; quoted parentheses are words. Nesting is capped at 32 levels.
- A static for list (up to 64 words) is unrolled with the loop variable
  bound to each element and judged as an assignment; a glob, $VAR, $(...),
  "$@" or a longer list binds the dynamic marker, so dangerous verbs fail
  closed. Glob lists are also judged once per pattern so scripts they run
  stay gated by the read ledger.
- Branch and loop state is joined with the state before it and iterated to
  a fixed point; subshells and pipeline stages restore the caller's
  variables and directory; substitution bodies never see a variable an
  unrolled loop rebinds.
- Functions are judged where called (arguments bound to $1..$9, "$@", "$*")
  and, when never called, at the end of the command; recursion and a name
  not defined in the same command line stay unknown.
- Unterminated or stray constructs classify unknown while their contents are
  still judged. [[ ]] and (( )) are data, but each clause that would be a
  command were the bracket only a word is classified too, so an escaped
  bracket cannot hide one. For lists, case words and patterns are data
  scanned as resource tokens and never commands.
- Tokenizer: >| (noclobber), <& fd duplication, ;; ;& ;;& case terminators
  (a blank line is still two separators), ( and ) as operators outside words,
  and one token for a whole arithmetic command. break and continue are safe
  builtins; shift and set invalidate known positional parameters.
- The denylist and the substitution reader scan see commands inside compounds
  (case arms, function bodies, loops).

Fuzz seeds and the harmless-wrapper table in monotonicity_fuzz_test.go now
include compound shapes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014CfdqWSpArax9E8mF1aspK
# Conflicts:
#	internal/danger/analysis.go
#	internal/danger/denylist.go
Add a golden corpus of command lines that exercise the option grammars
parsed by the classifier adapters, with the effects Analyze reports for
each. It is the differential check for unifying the option parsers.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014CfdqWSpArax9E8mF1aspK
…mars

Introduce optSpec, a per-tool option grammar (value-taking short letters,
long options with their value flag, GNU abbreviations, pflag-style
=value, early end of options) with a single parser that reads fused short
clusters, --opt=value, --opt value, abbreviations and the -- terminator.
The curl, wget, scp, rsync, sftp, rclone, ssh, nc and gh transfer
classifiers now read their arguments through it; the private cliSyntax
parser is gone. Verdicts are unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014CfdqWSpArax9E8mF1aspK
Replace the private pflag-style parser used by the gh adapter (api, auth,
config, alias, run/release download, clone) with optSpec. Verdicts are
unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014CfdqWSpArax9E8mF1aspK
wrapperSpec, env's private option reader and the xargs value-flag table are
replaced by optSpec entries, so wrappers, env and xargs/parallel share one
reading of clusters, abbreviations and --opt=value. xargs now reads fused
clusters (-0n 1, -0I {}) and abbreviated long options (--max-a 1), which
used to leave the option's value to be read as the inner command, and its
optional-value options (-i, -e, -l, --replace) take only a fused value so a
trailing letter cannot swallow the command.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014CfdqWSpArax9E8mF1aspK
…d hugo uses optSpec

The skip-the-global-options scans in gitSubcommandAndArgs, hugo,
infraVerbs and the container verb path now read their options through
optSpec, and the whole-word value-flag tables the deny list compares
against are derived from the same specs. Fused clusters ending in a
value-taking letter (kubectl -An ns, docker -Dl debug) no longer leave
their value to be read as the verb. hugo keeps short letters
case-sensitive (long names fold): -D and -E are flags, so
'hugo -D server' is read as the server it runs instead of as a build.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014CfdqWSpArax9E8mF1aspK
…work

- A substitution glued to word characters now joins the surrounding
  word (git p$(echo ush) is git push; "$(echo rm)" is rm), so a glued
  spelling can neither hide a verb nor bypass a denylist entry.
- Process substitutions carry their own marker: the read-ledger gate
  tells a stream apart from a run-time file path.
- An interpreter whose program operand only exists at run time (a
  command substitution, an unexpanded variable, an argv placeholder)
  fails closed instead of running an unreviewed script behind a plain
  code_execution prompt; inline code flags and process substitutions
  are not operands.
- An unquoted variable holding a glob expands as that glob rather than
  a dynamic marker, so loop scripts stay gated and wipe targets keep
  their class; pure glob loops run their patterns inside the fixpoint
  pass so state carried between iterations is judged.
- A hook, config or attributes file written earlier in the same
  command makes the git repository state unknowable, keeping the
  code-execution escalation for the git verb that follows.
- A dynamic GraphQL query is treated as a possible mutation.
- Indirect expansion of a variable naming a secret-bearing variable
  is a secret read.
- history is a benign builtin.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014CfdqWSpArax9E8mF1aspK
…tSpec

tarRunsCommand, tarListsOnly, chmodSetsSUIDGID, modeOptionSetsSUIDGID and
the sed in-place, script and shell-code predicates now share optSpec
instead of five hand-rolled scans. Spellings the real tools accept that
were missed are now read: chmod --ref/--reference after the mode
operand, install/mkdir/mknod --m and -Zm4755 (Z is a flag), sed --in,
--fil and --expr abbreviations, and a tar option value that looks like a
mode flag (-tf -x lists an archive named -x).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014CfdqWSpArax9E8mF1aspK
…optSpec

transferClientRunsProgram reads ssh, scp, sftp and rsync arguments through
the same specs the transfer classifier uses, and executionFileTargets and
fdExecutionFiles build a spec from the options they look for; the
optionValue matcher is gone. scp -O is the legacy-protocol flag rather
than an option with a value, so 'scp -O -F cfg' is now seen to load a
config file; a script file fused into a short cluster ('sed -nfscript.sed')
is now found, and -I in a tar cluster takes the rest of the word as getopt
does.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014CfdqWSpArax9E8mF1aspK
semanticWriteTargets finds the paths written by curl, wget, sort, gcc, gpg,
find, cp/mv/install, tar, unzip, 7z, pandoc and git through one
per-tool option table instead of a flag map, a cluster scanner and
three abbreviation helpers; shortOptionTakesValue, longOptionAbbreviation,
shortClusterValue and curlRemoteName are gone. Value-taking options now
consume their value wherever it sits, so curl -P (ftp-port) is no longer a
download directory, curl -H -o x names no output file, wget -qO- and
--output-doc=FILE no longer also write a file named after the URL, and
git archive --output is reported once.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014CfdqWSpArax9E8mF1aspK
…ifier

AGENTS.md: danger/ source layout lists each file and what it holds; the
Danger classifier, Approval friction and Resource bounds security bullets
cover compound commands, the shared wrapper grammar, network_upload, the gh
adapter, the repo-aware git rule, denylist position matching, secret reads,
the bounded read ledger and ForgetReadLedger, display sanitization and the
input and token caps; Testing gains the four classifier fuzz commands, the
non-root HOME requirement and the golangci-lint caveat.

internal/danger: the package doc comment now describes the full layered
design (quote-aware normalisation, compound parser, wrapper grammar, resource
scanning, tool adapters, read ledger, denylist, bounds) and current
limitations; file-level comments added for denylist.go, wrapper_grammar.go and
normalize_phases.go; ActionForCommand doc states the token-prefix denylist.

docs/DEVELOPMENT.md, docs/EVALS.md: classifier probe style, fuzz targets, HOME
requirement and security_report_validation_test.go as the regression bar.
docs/EXTENSIONS.md: args_summary class values include network_upload.
docs/SESSIONS.md: read-ledger lifetime. docs/API.md: decisions record the
sanitized approval command.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014CfdqWSpArax9E8mF1aspK
README, CONFIG, CLI, CHEATSHEET, SUBAGENTS, SCHEDULES, TELEGRAM, WEBUI,
SANDBOXING, DAILY-WORKER, docker/README, DOCKER_COMPOSE_USER_GUIDE and
MIGRATION now describe the severity-ordered risk classes (including
network_upload and unread_exec), token-prefix denylist matching, whole-line
allowlist matching, the read_only non-interactive carve-out, sub-agent
max_risk ordering, scheduler denied classes, trust-shortcut rules, the
sanitized approval prompt, the user's-own-home rule, and the new MIGRATION
section. Stale defaults (network_egress shown as prompt, force-push as
destructive) are corrected.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014CfdqWSpArax9E8mF1aspK
awkRunsShellCode shares optSpec with the sed predicates. A --source=TEXT
option no longer ends the scan before a later -f, and gawk's --exec/-E
(a program read from a file) is treated like -f.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014CfdqWSpArax9E8mF1aspK
git accepts '--attr-source TREE' ahead of the subcommand; the global option
table did not list it, so TREE was read as the subcommand and a following
'push --force' or 'reset --hard' classified as safe.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014CfdqWSpArax9E8mF1aspK
The sub-agent max_risk clamp now lists unread_exec beside the other
classes, and the Web UI approval card has badges for persistence and
unread_exec instead of the generic fallback.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014CfdqWSpArax9E8mF1aspK
…ression bar

docs/SECURITY.md: risk-class list now follows danger.Rank (network_upload
between network_egress and code_execution) with the cap implications; default
posture no longer implies uploads run unprompted; quote-aware normalization,
brace groups, comments, here-documents and empty positional parameters, shared
wrapper grammar and launchers, exec-controlling exports, helper-program
options, destination spellings and the home-versus-/root rule, the analysis
state rules, credential directories and wildcard operands are described once
in the classifier list; the duplicate env-dump, watch/strace and display
sanitization bullets are merged or moved to Tool-call approval; the gh api
read rule, dd/blocked wording, persistence targets, unread_exec licence
revocation and decoded pipes are corrected; the allowlist/denylist section
describes token-sequence matching at every command position; twelve rows added
to the attack-vector table.

cmd/odek/security_report_validation_test.go: compact pins for network_upload
default action and rank, gh verb classes, per-position denylist matching,
secret-read gating, compound commands, repository-aware git on an unarmed temp
repository, display escaping, and the MaxCommandBytes denial.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014CfdqWSpArax9E8mF1aspK
git push with no arguments pushes the current branch to its upstream,
so it classifies network_egress like the explicit forms (two old pins
that called it a harmless no-op are updated). Also corrects the risk
class count in DEVELOPMENT.md, the trust-shortcut list in the docs FAQ,
and the Web UI badge description.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014CfdqWSpArax9E8mF1aspK
Guard the option-spec call sites that slice off the program name so an
empty token list yields no options instead of a slice-bounds panic.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014CfdqWSpArax9E8mF1aspK
… space

GNU sed writes `w/tmp/x` exactly like `w /tmp/x`; the file-IO pattern
now accepts a path-shaped operand (/, ., ~) glued to the command letter
while a plain word starting with r or w stays an ordinary operand. Two
option-grammar golden lines move to the egress verdict of bare git push.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014CfdqWSpArax9E8mF1aspK
… the refactors

Removes classifyPipeline, flagArg and stageExecutionFiles, whose callers
moved to the context-aware variants, and rewrites a few negated boolean
expressions and one if-chain the way staticcheck asks.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014CfdqWSpArax9E8mF1aspK
parseBrowserIP builds the 32-bit address from its bounded parts and
splits it into octets with encoding/binary, so no ParseUint result is
narrowed to a byte; the per-part range checks are unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014CfdqWSpArax9E8mF1aspK
… home tests when unreadable

CI runs the suite with -race and atomic coverage, which slows the
classifier by an order of magnitude; the bounds guard against
algorithmic blowups, so they now scale by 10 under the race build tag.
The HOME=/root tests skip when the process cannot read /root, since
path resolution then fails closed to system_write by design.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014CfdqWSpArax9E8mF1aspK
…fter the call returns

The watchdog's stop now waits for the goroutine to exit and a tick that
became ready together with the stop request is dropped, which removes
the race TestToolHeartbeat_LongRunningToolEmitsSignals hit under load.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014CfdqWSpArax9E8mF1aspK

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants