Skip to content

feat(ai): add secret shield for outbound credential redaction - #59571

Open
GiacomoSaccaggi wants to merge 9 commits into
anomalyco:devfrom
GiacomoSaccaggi:secret-shield-v2
Open

GiacomoSaccaggi wants to merge 9 commits into
anomalyco:devfrom
GiacomoSaccaggi:secret-shield-v2

Conversation

@GiacomoSaccaggi

@GiacomoSaccaggi GiacomoSaccaggi commented Oct 27, 2026 •

Copy link
Copy Markdown

Issue for this PR

Closes #50904

Type of change

  • Bug fix
  • New feature
  • Refactor / code improvement
  • Documentation

What does this PR do?

Adds an opt-in credential scanning/redaction layer that intercepts outbound LLM requests before they reach providers. The shield scans system prompts, user messages, tool-result outputs (bash/read), tool-call inputs, reasoning parts, and tool descriptions for credential patterns.

Detection: 16 API key prefix families (OpenAI, Anthropic, GitHub, AWS, Stripe, Slack, etc.), JSON sensitive field names (password, token, api_key, etc.), HTTP auth headers, connection string URIs, and JWT tokens. Prefix patterns are left-anchored to avoid false positives inside identifiers. Auth scheme and header patterns require token-like values (mixed character classes) so prose like "Basic authentication" and type declarations like authorization: string are not flagged. Empty JSON values ("token": "") are skipped.

Structured value handling: Tool-call inputs and JSON tool results are walked recursively — only string leaves are redacted, preserving object shapes so providers receive valid structured data. Values under sensitive keys (password, token, etc.) are masked unconditionally.

Four modes (via experimental.secret_shield config key or OPENCODE_SECRET_SHIELD env var):

  • off (default) — no scanning
  • warn — scan and log detections, send unmodified
  • mask — replace detected credentials with [REDACTED]
  • block — reject the request with a visible synthetic message explaining how to recover

Config lives under experimental while detection rules settle. Mode resolution lives in core (config first, env var fallback). The ai package takes an explicit SecretShieldMode argument with no side effects.

Known limitations:

  • Mask mode risk: masked tool output (e.g. [REDACTED]) may be written back into files by the model. Users should be aware of this trade-off.
  • Coverage: only the main runner step is scanned. Title generation, compaction summaries and other direct model calls bypass the shield. Moving to the shared LLMClient layer is planned as a follow-up.

Files changed:

  • packages/ai/src/secret-shield.ts — redaction engine with recursive structured-value walker
  • packages/ai/src/index.ts — public exports
  • packages/schema/src/config/experimental.ts — secret_shield config field
  • packages/core/src/session/runner/llm.ts — shield wired after request preparation, before steps.attempt(); block publishes visible synthetic message
  • packages/ai/test/secret-shield.test.ts — 27 tests

Port of #50905 to the V2 architecture. Previous PRs: #58410 (auto-closed for missing template).

How did you verify your code works?

  • 27 tests covering: pattern families, false-positive boundaries (prose like "Basic authentication", type declarations, empty JSON values), tool-result text/json/sensitive-key redaction, structure preservation for tool-call inputs, reasoning scanning, and all four mode behaviours including fail-closed.
  • bun typecheck clean on packages/ai, packages/schema, packages/core.
  • Verified directly that redact("Use Basic authentication for the admin panel") reports 0 detections, redact("OAuth integration") reports 0, redact("authorization: string") reports 0, redact('"token": ""') reports 0.

Screenshots / recordings

N/A — no UI changes.

Checklist

  • I have tested my changes locally
  • I have not included unrelated changes in this PR

Added by the opencode bot (AI-generated).

Public API: adds the experimental.secret_shield config key, the OPENCODE_SECRET_SHIELD env var and secret-shield exports from @opencode/ai/effect (backwards compatible)

@opencode-agent opencode-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Requesting changes. The shield runs as described, but it flags a lot of ordinary text as credentials, and that makes block and mask risky to turn on.

I ran the new tests (24 pass) and type-checked packages/ai and packages/core (clean). I called redact() on ordinary text and drove the TUI with the simulated model and OPENCODE_SECRET_SHIELD=block. The prompt "Add Basic authentication to the admin page" was blocked: the session went idle with no reply and no error shown to the user.

Public API (all additions, backwards compatible):

  • New config key secret_shield: "off" | "warn" | "mask" | "block"
  • New env var OPENCODE_SECRET_SHIELD
  • New exports from @opencode/ai: applySecretShield, redact, scan, redactMessages, redactSystem, redactTools, isSecretShieldMode, REDACTED, plus the types SecretShieldMode, ShieldReport and ShieldResult

Concerns, most important first (details inline):

  1. False positives. AUTH_SCHEME and HEADER match prose and source code: "Basic authentication", "OAuth integration", authorization: string in a type, Authorization: `Bearer ${apiKey}` . An empty "token": "" also counts as a detection.
  2. Block mode is silent and permanent. The user sees no error, and because history is never changed, one flagged string blocks every later step in the session.
  3. Mask mode can corrupt files. Masked read/bash output leads the model to write [REDACTED] back into the user's files through edit/write.
  4. Coverage. Only the main runner step is scanned. Title generation, compaction and other direct model calls are not, even though #50904 names title generation as a gap.
  5. Config key. secret_shield is the only snake_case key in Config.Info; it's new public config surface, so worth settling before it ships.

Comment thread packages/ai/src/secret-shield.ts Resolved
Comment thread packages/core/src/session/runner/llm.ts Resolved
Comment thread packages/ai/src/secret-shield.ts Resolved
Comment thread packages/core/src/session/runner/llm.ts Resolved
Comment thread packages/schema/src/config.ts Resolved
@GiacomoSaccaggi
GiacomoSaccaggi force-pushed the secret-shield-v2 branch 3 times, most recently from b3a7371 to 1688b17 Compare October 27, 2026 10:30

@opencode-agent opencode-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new commits fix part of what I raised. The block notice still doesn't reach the user, and two of the false positives remain.

Addressed: the config key moved to experimental.secret_shield. "Basic authentication" and an empty "token": "" are no longer flagged.

Still open:

  1. Block mode. In the TUI (simulated model, OPENCODE_SECRET_SHIELD=block), a prompt containing a ghp_… key ended the turn with nothing shown. A follow-up "Never mind, just say hello" was silently blocked too. Details inline.
  2. False positives. authorization: string; and "Bearer re-authentication" are still redacted. Details inline.
  3. Known limitations. Mask mode can still write [REDACTED] into files, and only the main runner step is scanned. Both are now documented in the description, not fixed.

Tests (27) pass; packages/core type-checks.

Public API (all additions, backwards compatible):

  • New config key experimental.secret_shield: "off" | "warn" | "mask" | "block"
  • New env var OPENCODE_SECRET_SHIELD
  • New exports from @opencode/ai: applySecretShield, redact, scan, redactMessages, redactSystem, redactTools, isSecretShieldMode, REDACTED, plus the types SecretShieldMode, ShieldReport and ShieldResult

Comment thread packages/core/src/session/runner/llm.ts Resolved
Comment thread packages/ai/src/secret-shield.ts Resolved

@opencode-agent opencode-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new commits fix the false positives and the setting name in the block message. Block mode still fails silently, and the rebase deleted three existing comments.

Addressed:

  • authorization: string;, "Bearer re-authentication" and Authorization: `Bearer ${apiKey}` are no longer redacted. Real values like Authorization: Basic dXNl…== still are.
  • The block message now names experimental.secret_shield and OPENCODE_SECRET_SHIELD.

Still open:

  1. Block mode. Block is back to raising StepFailedError, as in the first version. In the TUI (simulated model, OPENCODE_SECRET_SHIELD=block), a prompt containing a ghp_… key ended with nothing on screen, and a follow-up "Never mind, just say hello" was silently blocked too. The user needs a visible error and a way to recover the session.
  2. Mask mode and coverage are documented as known limitations, not fixed.

New: the rebase deletes three comments that dev keeps in llm.ts (details inline).

Tests (27) pass; packages/core type-checks.

Public API (all additions, backwards compatible):

  • New config key experimental.secret_shield: "off" | "warn" | "mask" | "block"

  • New env var OPENCODE_SECRET_SHIELD

  • New exports from @opencode/ai/effect: applySecretShield, isSecretShieldMode, plus the types SecretShieldMode, ShieldResult and ShieldReport

  • packages/core/src/session/runner/llm.ts:251: The rebase deleted three comments that dev keeps here, and they aren't related to this feature: - the note on toolChoice about keeping tool definitions on the final step to preserve the provider's cached prefix - the note on the prepared mapping explaining why only these fields are kept (memory held by the hook event while slow tools run) - the note on executeTool about tool calls arriving during a plugin reload Please restore them so the diff only contains the shield changes.

GiacomoSaccaggi and others added 2 commits October 27, 2026 11:50
Scan LLM requests for credential patterns before sending to providers.
Four modes: off (default), warn, mask, block. Detects 16 API key prefix
families, JSON sensitive fields, HTTP auth headers, connection string URIs,
and JWT tokens. Scans text, tool-result, reasoning, and tool-call parts.

Auth scheme and header patterns require a digit in the value to avoid
matching prose and type declarations. JSON sensitive fields skip empty
values. Structured values walked recursively preserving object shapes.
Config under experimental.secret_shield while rules settle.
27 tests covering pattern families, false-positive boundaries (prose,
type declarations, template literals, empty values), tool-result and
reasoning scanning, structure preservation, and all four modes. Runner:
mode from experimental config with env var fallback; block publishes
Step.Started + Step.Failed for TUI visibility with recovery instructions.

@opencode-agent opencode-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both points from my last review are fixed. A blocked request now shows a visible error on the turn, with instructions to continue, and the three deleted comments in llm.ts are restored. In the TUI (simulated model, OPENCODE_SECRET_SHIELD=block), a prompt containing a ghp_… key showed the Secret Shield error. A follow-up message showed the same error, since the key is still in the history. Tests (27) pass; packages/core type-checks.

Nothing new to flag. Mask mode and coverage remain the known limitations listed in the description.

Public API (all additions, backwards compatible):

  • New config key experimental.secret_shield: "off" | "warn" | "mask" | "block"
  • New env var OPENCODE_SECRET_SHIELD
  • New exports from @opencode/ai/effect: applySecretShield, isSecretShieldMode, plus the types SecretShieldMode, ShieldResult and ShieldReport

After

@GiacomoSaccaggi

GiacomoSaccaggi commented Oct 27, 2026 •

Copy link
Copy Markdown
Author

All review feedback has been addressed across 6 iterations:

  • Config moved to experimental.secret_shield while detection rules settle
  • Auth/header patterns require a digit to avoid false positives on prose and type declarations
  • Tool-result, reasoning and tool-call content scanned; structured values walked recursively preserving object shapes
  • Sensitive keys (password, token, etc.) masked unconditionally in structured values
  • Block mode publishes Step.Started + Step.Failed for inline TUI visibility with recovery instructions
  • packages/ai has no side effects; mode resolved in core from config + env var
  • Three unrelated comments restored after rebase

Known limitations documented in the description: mask mode can write [REDACTED] back into files, and only the main runner step is scanned (title/compaction planned for a follow-up at the LLMClient layer).

Tests (27) pass, typecheck clean on ai/schema/core.

@opencode-agent opencode-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The only new commit merges dev into the branch. The PR's own diff is unchanged since my last review, and none of the merged commits touch the files this PR changes. Tests (27) still pass and packages/core still type-checks. Nothing new to flag.

Public API (all additions, backwards compatible):

  • New config key experimental.secret_shield: "off" | "warn" | "mask" | "block"
  • New env var OPENCODE_SECRET_SHIELD
  • New exports from @opencode/ai/effect: applySecretShield, isSecretShieldMode, plus the types SecretShieldMode, ShieldResult and ShieldReport

This branch has not been deployed

No deployments
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.

[FEATURE]: Opt-in credential redaction before outbound LLM requests

2 participants