Skip to content

feat(init): chain tirith shell-hook install with fail-open detection - #2

Merged
thehoff merged 1 commit into
developfrom
feat/init-chains-tirith
May 14, 2026
Merged

thehoff merged 1 commit into
developfrom
feat/init-chains-tirith

Conversation

@thehoff

@thehoff thehoff commented May 14, 2026

Copy link
Copy Markdown
Owner

Summary

`contextcrawler init -g` is the documented one-shot install. Tirith's shell hook used to be a separate manual step (`eval "$(tirith init --shell ...)"` in the user's shell rc), which is easy to miss and leaves the defense-in-depth gate disabled at the interactive shell layer.

This PR chains the Tirith hook installation into `init -g` with fail-open semantics — if the `tirith` binary is missing, init prints a hint and continues without it.

Behavior

Tirith on PATH? What `contextcrawler init -g` now does
Yes Detects shell from `$SHELL`, writes a sentinel-wrapped `eval "$(tirith init --shell )"` block to the user's rc file. Idempotent — re-runs detect the sentinel and skip.
No Prints a SKIPPED notice with the install hint; ContextCrawler auto-rewrite continues to work.

Supported shells: bash, zsh, fish. Unrecognized shells (nu, ksh, etc.) get a SKIPPED notice with the detected `$SHELL` value — no rc file is touched.

Sample output

With Tirith installed:
```
Tirith shell hook: appended to /home/thehoff/.bashrc (restart your shell to activate)
```

Without Tirith:
```
Tirith shell hook: SKIPPED (binary not on PATH — fail-open)
Install with: cargo install tirith
Then re-run: contextcrawler init -g
```

Re-run (idempotent):
```
Tirith shell hook: already present in /home/thehoff/.bashrc
```

Design

  • Wrapped in `// ===== contextzip-downstream =====` sentinel comments to keep upstream-rtk rebases confined to one insertion in `run_default_mode` plus the new function.
  • Pure helpers (`detect_shell`, `shell_rc_path`, `rc_has_tirith_sentinel`, `build_tirith_block`) are unit-testable in isolation; `install_tirith_shell_hook` orchestrates them and does the filesystem I/O.
  • All I/O failures (read, create_dir_all, append) are silenced unless `-v` is passed — init must not break because of an unwritable rc file.

Test plan

  • `cargo test hooks::init::tests` — 75 passed / 0 failed
  • 6 new tests covering all four pure helpers + sentinel-roundtrip idempotency check
  • Devel-test on Framework after install:
    • First-time install (Tirith present): rc file gets sentinel-wrapped block
    • Second `init -g`: prints "already present", rc file unchanged
    • With Tirith uninstalled: prints SKIPPED notice, no rc touch

Independence from PR #1

This branch is off `develop` (clean, doesn't include #1's rebrand). No conflicts expected when both merge — they touch disjoint surfaces.

🤖 Generated with Claude Code

`contextcrawler init -g` is the documented one-shot install path. Tirith's
shell hook was a separate manual step (`eval "$(tirith init --shell …)"`
in the user's shell rc), which is easy to miss and leaves the defense-
in-depth gate disabled at the interactive shell layer.

This change chains Tirith hook installation into `init -g`:

1. Probe `which("tirith")`.
2. If found: detect shell from `$SHELL`, write a sentinel-wrapped
   `eval "$(tirith init --shell <shell>)"` block to the user's rc file
   (idempotent — re-runs detect the sentinel and skip).
3. If absent: print a fail-open notice with the install hint; the
   ContextCrawler auto-rewrite path continues to work unchanged.

Design notes:

- Wrapped in `// ===== contextzip-downstream =====` sentinel comments so
  upstream-rtk rebases stay confined to the import block plus this
  single insertion in `run_default_mode`.
- Pure helpers (`detect_shell`, `shell_rc_path`, `rc_has_tirith_sentinel`,
  `build_tirith_block`) are individually unit-testable; the integration
  function `install_tirith_shell_hook` orchestrates them and does the
  filesystem I/O behind a fail-open boundary.
- Supported shells: bash, zsh, fish. Unrecognized shells (nu, ksh, etc.)
  print a SKIPPED notice with the user's $SHELL value.
- All I/O failures (read, create_dir_all, append) are silenced unless
  -v is passed — init must not break because of an unwritable rc file.

Tests added (5):

* test_shell_rc_path_known_shells           — bash/zsh/fish → expected paths
* test_shell_rc_path_unsupported_shell_is_none — nu/ksh/"" → None
* test_detect_shell_never_returns_empty     — $SHELL contract
* test_rc_has_tirith_sentinel_detects_managed_block
* test_rc_has_tirith_sentinel_ignores_unrelated_content
* test_build_tirith_block_round_trips_through_sentinel_check
  (re-running init detects its own sentinel and is a no-op)

Verified: cargo test hooks::init::tests → 75 passed / 0 failed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@thehoff
thehoff merged commit c918ad2 into develop May 14, 2026
4 checks passed
@thehoff
thehoff deleted the feat/init-chains-tirith branch May 14, 2026 13:04
noogalabs pushed a commit to noogalabs/contextcrawler that referenced this pull request Jun 4, 2026
…path

G1 audit finding thehoff#2 of thehoff#100: the Tirith pre-execution gate and the
supply-chain gate only ran on the legacy `contextcrawler rewrite`
subcommand (hooks/rewrite_cmd.rs). The live Claude Code PreToolUse path
(`contextcrawler hook claude` -> process_claude_payload) did permissions
+ rewrite but never invoked either gate, so the defence-in-depth layer
was effectively dark for every real agent session.

Wire both gates into process_claude_payload_with, after the existing
PermissionVerdict::Deny handling and before get_rewritten:

  * New pure helper gate_decision(tirith, supply_chain) -> GateDecision
    { Proceed, Ask, Deny } maps the two gate verdicts. supply-chain
    Block -> Deny; supply-chain Unavailable -> Ask (fail closed);
    tirith should_downgrade -> Ask; both clean -> Proceed. Block
    outranks Ask.
  * supply-chain Block reuses the thehoff#102 deny machinery -- emits a
    PayloadAction::Deny (audit_tag deny:supply_chain_block) routed
    through the existing emit_claude_deny. No new PayloadAction variant.
  * A gate Ask sets gate_ask, which suppresses the permissionDecision
    "allow" so Claude Code prompts -- a gate Ask wins over a
    permissions Allow.
  * A gate-flagged command with no rewrite (e.g. `pip install foo`)
    cannot express "ask" in the PreToolUse protocol, so it fails CLOSED
    to a deny rather than a silent Skip.

Both gates stay opt-in: Tirith via CONTEXTCRAWLER_TIRITH_REQUIRED,
supply-chain via supply_chain.enabled. When disabled the gates are
no-ops and the hook behaves byte-for-byte as before -- covered by a
no-regression test asserting `git status` still rewrites with no deny.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
noogalabs pushed a commit to noogalabs/contextcrawler that referenced this pull request Jun 4, 2026
…fixes (thehoff#111 G5)

grep_cmd.rs (CRITICAL): the rg/grep argv placed pattern and path with no
`--` separator, so a pattern shaped like `--pre=<cmd>` reached rg unscanned
and ran an arbitrary preprocessor (RCE, thehoff#32). Restructure both the rg path
and the plain-grep fallback as: fixed flags, then forbidden-checked
extra_args, then `--`, then pattern, then path. Mirrors the thehoff#100 G5#1/thehoff#2
boundary already in ls.rs/tree.rs.

tree.rs: confirmed `-o filename` is genuine native tree (v2.x) output-file
flag — kept in VALUE_FLAGS with a clarifying comment; extracted the
flag/path splitter into a testable helper.

find_cmd.rs: a trailing slash on the search root made strip_prefix fail and
emit absolute paths, breaking the relative-path contract. Normalise the
walk root (strip trailing slash) before walking and stripping.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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.

1 participant