Skip to content

safety-classifier: first-word allow paths let chained writes through #2292

Description

@bowlax

Summary

install/hooks/lib/safety-classifier.ts classifyCommand decides allow from the first word on several paths (SEARCH_TOOLS, loopback curl, the for/while/until loop allow, DEV_BINARIES, READ_ONLY_COMMAND_PATTERNS, and bashTargetsTrustedPath, which tests the whole command string). Later segments of a compound command (;, &&, |) and output redirections are not checked, so a write that does not match a named shape is allowed. Safety.hook.ts emits allow, which skips the native prompt for the whole command.

Your own comment above the destructive-shape scan says the same thing: these paths "only look at the head of the command". The shape denies (mutating-pipe-consumer, dangerous-shape) catch some chains but not the rest.

Evidence

Replayed 7 Oct 2026 against LifeOS/install/hooks/lib/safety-classifier.ts at 5e2f2e8 (bun, classifyCommand({toolName:"Bash",command})):

"cat a | tee /etc/hosts"                          -> allow (read-only-command)
"ls > ~/.zshrc"                                   -> allow (read-only-command)
"git status; touch ~/zz"                          -> allow (read-only-command)
"for i in 1; do rm ~/x; done"                     -> allow (shell-loop-data-iteration)
"curl http://localhost:31337/x -o ~/.zshrc"       -> allow (loopback-http)
"rm ~/Documents/zz; ls /tmp"                      -> allow (trusted-workspace-command)

For contrast, ls; rm -rf ~/Documents/zz, curl http://localhost:31337/n; rm ~/x and npm test; rm ~/Documents/zz already come back neutral, so the shape denies cover some of this.

Impact

Allow-to-prompt only. This is a hardening report, not a hard hole: the worst case of fixing it is extra prompts. The cost of leaving it is that the allow removes the native prompt for writes the classifier never looked at.

Fix direction

Split the command on top-level ;, &&, ||, |, |&, & and newlines, respecting quotes, backslashes, $(…) and backticks, and treat an unsplittable command as not allowed. Then:

  • the first-word allows fire only when every segment is itself read-only;
  • an output redirect to anything other than /dev/null or a file descriptor makes a segment non-read-only;
  • the loop allow requires a read-only body, segment by segment;
  • loopback curl requires every segment to be a loopback fetch or read-only, and refuses -o, -O, -T, --output;
  • the trusted-path allow applies per segment, so a /tmp mention in one segment does not trust the rest.

I have this working in a private fork (splitter, segment check and tests, 112 passing) and can share the approach or the test cases if useful.

Activity

  1. bowlax commented on Oct 7, 2026

    @bowlax
    Author

    Follow-up from the same fork: three more first-word gaps of the same family. All are allow-to-prompt only, and the replay was against the same module and commit as the report above.

    "ls $(rm ~/zz)"            -> allow (read-only-command)
    "find ~/zz -delete"        -> allow (read-only-command)
    "env rm -rf ~/zz"          -> allow (read-only-command)
    
    • splitCompoundCommand keeps $(…), backticks and <(…) inside their segment, so the segment is judged by its first word and the substitution runs unchecked.
    • find is in SEARCH_TOOLS, and the only find deny shape needs -exec rm -r, so -delete, -ok and -fprint* pass. fd -x/--exec and yq -i pass the same way.
    • env matches a READ_ONLY_COMMAND_PATTERNS entry, so env <anything> allows.

    Fix direction that has worked here:

    • A segment counts as read-only only if every command inside its substitutions is read-only too (recursive check). If a marker cannot be extracted, treat it as not read-only. $((…)) arithmetic is not a substitution.
    • Treat find -delete/-exec/-execdir/-ok/-okdir/-fprint*/-fls, fd -x/-X/--exec/--exec-batch and yq -i/--inplace as non-read-only.
    • Allow only bare env (/^env\s*$/), not env <cmd>.

    Separate small one: CREDENTIAL_PATHS is only applied to Bash command text, never to tc.filePath, so an Edit or Read of a .env file under a trusted prefix returns allow and removes a prompt that an ask rule was meant to raise. .aws/credentials is also named as a single file, so cat ~/.aws/config allows. Running the patterns over filePath before the read-only-tool and trusted-path allows, and widening to the whole .aws/ and .kube/ directories, fixes both.

    Tests for all of the above pass in the fork; happy to share them.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions