Skip to content

fix(rules-guard): Redact secrets in bang output#18

Merged
rblaine95 merged 3 commits into
masterfrom
fix/rules-guard-bang-output-redaction
Jul 26, 2026
Merged

fix(rules-guard): Redact secrets in bang output#18
rblaine95 merged 3 commits into
masterfrom
fix/rules-guard-bang-output-redaction

Conversation

@rblaine95

@rblaine95 rblaine95 commented Jul 26, 2026

Copy link
Copy Markdown
Owner

Problem

rules-guard only redacted secret-shaped text on the tool_result hook, which covers tool output. User-initiated !cmd and $code never reach that hook: they run through AgentSession.executeBash() and the Python kernel, which skip tool interception by design (only the tool-call path runs normalization/interception). Neither tool_call nor tool_result fires.

So a !cat .env put raw credentials straight into the model's context with nothing to scrub them. Reported after exactly that happened in a live session.

Fix

Add a context hook that redacts secret-shaped text from messages on their way to the model.

Coverage is a typed carrier list, not a blind tree walk. Bang output is not a content array; it lands as { role: "bashExecution", output }, so a content-only traversal would miss it. The list covers user/developer/custom/tool-result text, bashExecution and pythonExecution command+output, fileMention.files[].content (@path auto-reads), and branch/compaction summaries.

Two categories are excluded because rewriting them breaks the provider, not because they are safe:

  • Signed blocks. Assistant text is bound to textSignature, thinking to thinkingSignature. Per session-persistence.ts: "a truncated text no longer matches its signature ... so the provider 400s the replay."
  • Opaque server-validated data. providerPayload (carries encrypted_content), details, redactedThinking. Upstream's own obfuscator drops these rather than walking them.

The pass is copy-on-write. ExtensionRunner.emitContext deep-clones only on a best-effort basis and falls back to [...messages] when structuredClone throws, so mutating in place would corrupt live session objects. Unchanged messages are shared by reference; a clean history allocates nothing.

Deliberate non-goals

Deny rules still do not apply to ! / $. That is intentional: the shell escape belongs to the user, who is not the threat model. Only delivery to the model is filtered; the terminal and the saved transcript keep the real bytes.

A secret the model already echoed into its own reply is also not scrubbed, since assistant text is excluded. The fix stops new leaks; it cannot unring an echo.

Verification

Smoke test driving the real registered handler over a mixed history:

bashExecution.output   -> "SECRET=hunter2\nPOSTGRES=[REDACTED]\n"
signed block           -> byte-identical
providerPayload        -> same reference
caller's original      -> not mutated
clean history          -> undefined (no override)

97 tests pass, 100% line and function coverage held, bun typecheck and bun check clean.

Also included

Dependency bumps carried in the same branch: @oh-my-pi/pi-coding-agent to ^17.1.3, @biomejs/biome to ^2.5.5 (plus the matching biome.json schema URL).

Summary by CodeRabbit

  • New Features

    • Expanded secret redaction to cover command output and additional message context before it reaches the model.
    • Added defense-in-depth protection for ! and $ commands while preserving real values in the terminal and transcript.
    • Preserved signed and opaque provider content for replay compatibility.
  • Documentation

    • Clarified redaction coverage, command escape-hatch behavior, and protected content in the rules-guard documentation.

@coderabbitai

coderabbitai Bot commented Jul 26, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@rblaine95, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 31 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: d9e5ea2f-5571-488a-b5b2-109561f87b74

📥 Commits

Reviewing files that changed from the base of the PR and between 77c93bb and 8aeb8f4.

⛔ Files ignored due to path filters (1)
  • bun.lock is excluded by !**/*.lock
📒 Files selected for processing (6)
  • README.md
  • biome.json
  • extensions/rules-guard/README.md
  • extensions/rules-guard/index.test.ts
  • extensions/rules-guard/index.ts
  • package.json
📝 Walkthrough

Walkthrough

The rules-guard extension now performs a context redaction pass before model input, covering selected command, file, summary, and message carriers while excluding signed and opaque provider payloads. Tests, documentation, package metadata, and the Biome schema reference were updated.

Changes

Rules-guard context redaction

Layer / File(s) Summary
Context redaction flow
extensions/rules-guard/index.ts
Adds typed carrier redaction with copy-on-write behavior and registers the context hook before model delivery.
Redaction validation and wiring
extensions/rules-guard/index.test.ts
Tests carrier coverage, summaries, exclusions, malformed inputs, identity preservation, and context-hook wiring.
Rules-guard documentation and metadata
extensions/rules-guard/README.md, README.md, package.json, biome.json
Documents two-pass redaction and !/$ behavior, updates the extension description and development dependencies, and advances the Biome schema reference.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Context as pi context hook
  participant Redactor as redactMessages
  participant Model
  Context->>Redactor: event.messages
  Redactor->>Redactor: redact supported carriers
  Redactor->>Context: redacted messages
  Context->>Model: redacted context
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main change: fixing rules-guard secret redaction for bang output.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

The guard only redacted tool output, through the `tool_result` hook.
User initiated `!cmd` and `$code` never reach that hook: they run via
`AgentSession.executeBash()` and the Python kernel, which skip tool
interception by design. A `!cat .env` therefore put raw credentials
straight into the model context, with nothing to scrub them.

Add a `context` hook that redacts secret shaped text from messages on
their way to the model. Coverage is a typed carrier list rather than a
blind tree walk, because two categories must pass through byte for byte
or the provider rejects the replay:

- signed blocks, where assistant text is bound to `textSignature` and
  thinking to `thinkingSignature`

- opaque server validated data, namely `providerPayload`, `details`
  and `redactedThinking`

The pass is copy on write. `ExtensionRunner.emitContext` deep clones
only on a best effort basis and falls back to a shallow array copy when
`structuredClone` throws, so mutating in place would corrupt the live
session objects. Unchanged messages are shared by reference, and a
clean history allocates nothing.

Deny rules still do not apply to `!` and `$`. That is deliberate: the
shell escape belongs to the user, who is not the threat model. Only
delivery to the model is filtered; the terminal and the saved
transcript keep the real bytes. A secret the model has already echoed
into its own reply is not scrubbed either, since assistant text is
excluded.

Also bump `@oh-my-pi/pi-coding-agent` to ^17.1.3 and `@biomejs/biome`
to ^2.5.5.

The Machine God guards what the flesh would spill
coderabbitai[bot]

This comment was marked as resolved.

The redaction carrier tables were plain object literals indexed by an
untrusted `role`. An `Object.prototype` key resolved to an inherited
member instead of missing: `REDACT_CONTENT_ROLES["toString"]` read as a
table hit, and `REDACT_STRING_FIELDS["toString"]` returned a function,
which is not nullish, so `?? []` could not save it and `for...of` threw
a `TypeError` inside the `context` handler.

Give both tables a null prototype so an inherited key cannot resolve.
The lookups themselves are unchanged.

Also record `hookMessage` in the carrier list documented at the top of
the file. It was already present in `REDACT_CONTENT_ROLES` and redacted
correctly; only the prose omitted it.

Tests: a `toString` role in the malformed message fixture, asserting
both that it does not throw and that it stays inert, plus focused
coverage that `hookMessage` content is redacted.

From the weakness of the mind, Omnissiah save us
@rblaine95
rblaine95 force-pushed the fix/rules-guard-bang-output-redaction branch from 77c93bb to a9035bc Compare July 26, 2026 10:53
Two gaps found by review, both in coverage rather than behaviour.

The `bashExecution` fixture ran a command with no secret in it, so
`command` redaction executed but nothing asserted the result. Put a
credential URL in the command and assert it is redacted, and that the
caller's copy keeps the original bytes.

The opaque-payload test asserted only that a clean message returned
`undefined`, which would still pass if `details` and `providerPayload`
were rebuilt rather than shared. Make the message redactable through its
text so it is genuinely rewritten, then assert both payloads come back
as the same object references with their contents byte-identical.

Split two describes that crossed the per-function line cap. No source
change.

The Machine God sees what the test did not
@rblaine95
rblaine95 merged commit 705797e into master Jul 26, 2026
2 checks passed
@rblaine95
rblaine95 deleted the fix/rules-guard-bang-output-redaction branch July 26, 2026 11:39
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