Skip to content

feat(security): deny irreversible destructive commands in every posture - #23

Open
JordanTheJet wants to merge 314 commits into
masterfrom
fix/command-policy-hard-deny
Open

feat(security): deny irreversible destructive commands in every posture#23
JordanTheJet wants to merge 314 commits into
masterfrom
fix/command-policy-hard-deny

Conversation

@JordanTheJet

Copy link
Copy Markdown
Owner

Summary

  • Base branch: master
  • What changed and why: is_command_allowed returns true immediately when allowed_commands contains * and block_high_risk_commands is false. That is the documented operator opt-out from command screening — but today it also opts out of the handful of operations that leave no system to recover on. This adds a deny tier that no configuration can disable.
  • Scope is deliberately tiny. Only operations that are immediate, irreversible, and never legitimate from an agent shell: mkfs (and mkfs.<fstype>), dd writing to a raw block device, rm naming the filesystem root, and the classic fork bomb.
  • Evaluated per chain segment and ahead of the wildcard opt-out, so ls && mkfs.ext4 /dev/sdb1 is caught. Reports a distinct error rather than the generic policy refusal, so an operator can tell "you turned screening off but this is still denied" from "your allowlist rejected this".
  • Scope boundary: No change to the allowlist, to command_risk_level, to approval flow, or to what block_high_risk_commands does for everything else. This does not attempt general shell-syntax normalization — the existing guards already reject backticks, $(), and process substitution outside the wildcard path.
  • Blast radius: Every caller of SecurityPolicy::is_command_allowed / validate_command_execution, i.e. the shell tool and anything reusing the policy gate. Behavior changes only for the four patterns above; ordinary work is unaffected and covered by a negative test.
  • Linked issue(s): N/A
  • Labels: type:feat, risk:medium, size:S

Testing (required)

How you can test

  • Reviewer testing requested? Yes
  • Interface(s) exercised: surface = cli (shell tool via the security policy)
  • Setup / preconditions: A config with allowed_commands = ["*"], block_high_risk_commands = false, autonomy = "full" — the most permissive posture available.
  • Steps to run: Ask the agent to run mkfs.ext4 /dev/sdb1 (use a device that does not exist if you would rather not find out).
  • Expected on this branch (after): Refused with "irreversible destructive operation, denied in every posture".
  • Prior behavior on master (before): The policy permits it and the command reaches the shell.

How I tested

Four tests written before the implementation. At red, three failed and catastrophic_deny_does_not_capture_ordinary_work already passed — establishing that the negative cases were allowed before the change and still are after it.

  • catastrophic_commands_are_denied_in_the_most_permissive_posture — nine variants, asserting both the is_command_allowed refusal and the distinct error text
  • catastrophic_deny_does_not_capture_ordinary_workdd if=input.iso of=output.img, dd of=./disk.img, rm -rf ./build, rm -rf /tmp/scratch, echo ':(){ :|:& };:', grep -r mkfs docs/
  • catastrophic_deny_survives_a_high_risk_command_being_explicitly_listed — listing dd unlocks dd if=a.img of=b.img but not of=/dev/sda
  • catastrophic_deny_applies_to_every_segment_of_a_chain
cargo test -p zeroclaw-config
# test result: ok. 1245 passed; 0 failed  (+ 90, 8, 5, 1, 1 in the other targets)

cargo test -p zeroclaw-tools --lib shell
# test result: ok. 4 passed; 0 failed

cargo fmt --all -- --check                                       # clean
cargo clippy -p zeroclaw-config --all-targets -- -D warnings     # clean
bash scripts/ci/comment_hygiene_gate.sh                          # passed
  • CI checks relied on and why they cover this change: Rust quality gate over zeroclaw-config, which owns the policy gate; the full 1245-test policy suite is the regression surface for a shared predicate.
  • Known CI coverage gap, if any: Windows-specific naming is only partly exercised — strip_windows_exe_suffix is a no-op off Windows, so mkfs matching is validated on Unix naming only. The four patterns are Unix-shaped by nature.
  • Beyond CI, what did you manually verify? I checked that quoted text is not captured (the fork-bomb matcher strips quoted spans first, so echo ':(){ :|:& };:' stays allowed) and that /dev/null and /dev/zero are excluded from the raw-block-device prefixes so output suppression keeps working. I did NOT run any of these commands against real hardware.

Security & Privacy Impact (required)

  • New permissions, capabilities, or file system access scope? No — this removes capability
  • New external network calls? No
  • Secrets / tokens / credentials handling changed? No
  • PII, real identities, or personal data in diff, tests, fixtures, or docs? No
  • Prompt injection or untrusted model-visible text introduced/changed? No
  • If any Yes, describe the risk and mitigation: N/A. The intended risk trade-off is stated plainly: an operator who deliberately set ["*"] loses the ability to run four specific patterns. That is the point of the tier, and the error message says so rather than failing as a generic policy refusal.

JordanTheJet and others added 30 commits July 18, 2026 20:38
…eroclaw-labs#9014)

- e530dd4 ci(release): notarize + staple the macOS .dmg for offline validation
- da1be7e fix(ci): publish the notarized macOS DMG deterministically
- 649a013 test(ci): keep desktop release guards mergeable
- 40bca67 fix(ci): keep zero-DMG failure portable
- be2fd5c Merge branch 'master' into ci-staple-macos-dmg
- 4c8b791 docs(governance): define accepted RFC dispositions
- 9c9d22b Merge branch 'master' into codex/fnd003-rfc-dispositions
- 22417ab feat(zerocode): add agent rename dashboard flow
- ec964a8 chore(zerocode): refresh agent rename branch
- 35ec476 Merge branch 'master' into codex/issue-7790-zerocode-agent-rename
…zeroclaw-labs#9133)

- b3d021f docs(architecture): record channel plugin and gateway process targets
- c8c7fd6 Merge branch 'master' into codex/adr-006-007-targets
…law-labs#9130)

- cec0fb1 fix(config): restore generated descriptions and code contracts
- e0e0a7e fix(gateway): restore pending reload contract description
- 13ebdba Merge origin/master into codex/restore-8901-contracts
- a39f4ad Merge branch 'master' into codex/restore-8901-contracts
…claw-labs#9026)

- b0a7138 feat(gateway/acp): select session agent via ?agent= query param
- d163820 fix(gateway/acp): reject disabled agents at shared alias boundary
- 28aba77 docs(acp): replace prose em-dashes in ?agent= section
- 306178a fix(gateway/acp): keep ?agent= out of session restore rebind
- bfcde2f Merge branch 'master' into feat/acp-agent-query-param
…9146)

The hardware peripherals design doc linked to a non-existent GitHub repository. Replace it with the canonical kevinmehall/nusb repository verified through crates.io.
…8s (zeroclaw-labs#9147)

Replace the nonexistent zeroclaw-templates repository link with the in-repo deploy-k8s sample manifests and correct the Helm wording.
…labs#9148)

Remove optional angle brackets from two CommonMark link destinations to match the surrounding documentation style.
…9150)

Remove the trailing space inside the Mattermost voice-message code span so markdownlint MD038 passes.
…rt (zeroclaw-labs#9151)

Escape the line-leading zeroclaw-labs#6960 reference so markdownlint does not parse it as an ATX heading.
…zeroclaw-labs#8622)

- 595c61b feat(channels): emit structured login lifecycle events for QR pairing
- 5032f0e fix(log): keep QR pairing credentials off the persisted JSONL trace
- 480dbd7 fix(log): flush writer before reading persisted trace in ephemeral-attrs test
- 2f355cd fix(log,gateway): keep QR pairing credentials off verbose stderr and unauthenticated SSE
- a6a0792 Merge upstream/master into feat/channel-login-events
- 13a6205 fix(gateway,log,runtime): hold the pairing-credential boundary across every broadcast consumer
- 497a22c Merge upstream/master into feat/channel-login-events
- 0a2f53b style(gateway,log,runtime): rustfmt the credential-boundary test helpers
- 9b9fe0d Merge upstream/master into feat/channel-login-events
…9122)

- ee12041 refactor(plugins): establish canonical instance scope
- 1f22dc3 fix(plugins): require adapter opt-in for HTTP
- 150c82a fix(plugins): preserve callable tool attribution
- caed50f docs(plugins): require grant and adapter opt-in
- 03e7153 Merge branch 'master' into feat/plugin-instance-context
- 6b44540 fix(cli): apply config-dir before locale detection
- c06956e fix(cli): preserve clap argument ownership
- 78e4317 Merge origin/master into benchmark-memory-boot-metrics
…zeroclaw-labs#9094)

- 18b4c32 fix(zerocode): separate the literal Ctrl label from the key on darwin
- 576fbc6 Merge branch 'master' into fix/darwin-ctrl-chord-display-assertion
…#9161)

- c7dc0bc docs(ci): correct stale Rust-CodeQL note in codeql.yml
- dfc5088 Merge branch 'master' into chore/codeql-comment-rust-split
…bs#9176)

- 6275e56 docs(reference): correct operator-facing contract claims
- 139610a docs(reference): narrow operator contract claims
…oclaw-labs#9165)

The 'Determine version tag' step in docs-deploy.yml expanded
${{ github.event.inputs.tag }} directly into the run: script, one line
before the regex that validates it. GitHub performs template
interpolation before the shell executes, so a crafted workflow_dispatch
tag input (e.g. '"; <cmd>; "') would run arbitrary commands before the
validation could reject it — an Actions script-injection (CWE-94) in a
job that holds contents: write.

Pass github.event_name / event.inputs.tag / github.ref / github.ref_name
through env vars and reference them as shell variables, so template
values reach the shell as data and can never be interpreted as code. The
validation regex and all downstream behavior are unchanged.

Exploitability is low (workflow_dispatch requires repo write access and
the regex bounds valid values), but this is the canonical Actions
injection anti-pattern and the fix is free.
…eroclaw-labs#9152)

resolve_sops_dir() only handled the None/empty case relative to the
workspace; an explicit relative sops_dir (the shape shown in the
documented <workspace>/sops layout) fell through to PathBuf::from(),
which resolves against whatever directory the process happens to be
running in rather than the workspace. Path::join already replaces the
base when the joined path is absolute, so workspace_dir.join(expanded)
is a strict superset: absolute and ~-prefixed values behave exactly as
before, relative values now resolve against the workspace as documented.
…s#9144)

- f26c269 fix(ci): publish Docker variants before scheduled scans
- ecd5fc8 fix(ci): avoid inheriting release secrets
- ab5f592 test(ci): lock container release invariants
…#9132)

- 17d68d3 docs(architecture): document background work lifecycle
- ddb6ff2 docs(architecture): clarify background wait boundaries
- 2aca605 fix(ci): make comment hygiene gate language-aware
- 4468acb test(ci): cover comment scanner fatal path
- 4367404 fix(i18n): localize agent-scope rejection
- fe7447a Merge origin/master into codex/issue-6548-agent-scope-rejection-locales
…claw-labs#9043)

- 33e3a05 docs(install): align first-run guidance with installer behavior
- 41e27d4 docs(install): qualify supported prebuilt targets
- 60b366 docs(install): qualify Cargo bin destination
@JordanTheJet

Copy link
Copy Markdown
Owner Author

Note on the diff size. This branch is based on upstream/master (e91a7a0c6), but this fork's master is 313 commits behind upstream, so GitHub renders the base gap as ~1000 changed files. The real change is one file:

crates/zeroclaw-config/src/policy.rs | 201 +++++++++++++++++++++

Review with git diff upstream/master...fix/command-policy-hard-deny. Same fork-sync caveat as #22.

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