Repository navigation
fix(danger): harden the command classifier and approval gate - #302
Open
jkyberneees wants to merge 72 commits into
Open
jkyberneees wants to merge 72 commits into
jkyberneees wants to merge 72 commits into
Conversation
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
# Conflicts: # internal/danger/analysis.go
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
Deploying with
|
| 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 |
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
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
…rands 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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
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.--opt=valueand abbreviations are handled uniformly acrosstimeout,env,xargs,sudo,watch, tar, sed, git, ssh/scp/rsync, kubectl/helm/hugo, docker and more.~user, setuid modes and persistence targets; the current user's home outranks the/rootsystem prefix.&&chains, rebinding builtins, unquoted expansions and loops, and is linear in command size.Policy surface
network_uploadrisk class (defaultprompt): file-backed or piped request bodies, credentials on the command line, local-to-remote transfers, listeners and tunnels. Plain fetches staynetwork_egress. Wired through config validation, sub-agent caps, scheduler, Web UI and docs.ghverb adapter: reads stay egress, remote mutations prompt, deletes are destructive, credential-exposing and code-loading verbs are gated.system_write.Read ledger, scanner, approvers
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.Robustness
Docs
security_report_validation_test.gopins 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.main: only tightening of program-loading options and intentional new prompts (uploads, secret reads); ordinary git verbs, loops, conditionals, heredocs andcommand -vno longer prompt or deny.🤖 Generated with Claude Code
https://claude.ai/code/session_014CfdqWSpArax9E8mF1aspK