Skip to content

feat: add configurable secret scanner mode to fix false positives#324

Open
VinhTruong2001 wants to merge 3 commits intospacedriveapp:mainfrom
VinhTruong2001:fix/secret-scanner-false-positives
Open

feat: add configurable secret scanner mode to fix false positives#324
VinhTruong2001 wants to merge 3 commits intospacedriveapp:mainfrom
VinhTruong2001:fix/secret-scanner-false-positives

Conversation

@VinhTruong2001
Copy link

Summary

Fixes #45 — Secret scanner erroneously flagging intentionally-public keys (e.g. Algolia search keys embedded in scraped pages) as credentials, killing the worker before returning results.

Adds a configurable SecretScanMode with three modes:

Mode Layer 1 (exact-match stored secrets) Layer 2 (regex pattern detection)
strict (default) ✅ Active ✅ Active
own_secrets_only ✅ Active ❌ Skipped
disabled ❌ Skipped ❌ Skipped

own_secrets_only is the recommended mode for agents that scrape public websites — it still redacts stored secrets but stops flagging third-party API keys found in scraped content.

Configuration

Per-agent via sandbox.toml:

[sandbox]
secret_scanner = "own_secrets_only"

Changes

  • src/secrets/scrub.rsSecretScanMode enum + maybe_scrub_leaks() helper
  • src/sandbox.rssecret_scanner field on SandboxConfig
  • src/lib.rssecret_scan_mode() accessor on AgentDeps
  • src/hooks/spacebot.rs — Mode-aware scan_for_leaks() + 3 tests
  • src/tools/reply.rs, src/tools/set_status.rs — Respect scan mode on output scrubbing
  • src/opencode/worker.rs — Mode-aware leak scanning
  • src/agent/{channel,channel_dispatch,branch,cortex,worker,compactor,cortex_chat,ingestion}.rs — Thread scan mode through agent pipeline

16 files changed, 268 insertions, 56 deletions.

Test Plan

  • test_secret_scan_mode_deserialization — TOML deserializes all 3 variants
  • test_scan_for_leaks_strict_mode — Regex scanning fires in Strict
  • test_scan_for_leaks_own_secrets_only_mode — Regex scanning skipped in OwnSecretsOnly
  • test_scan_for_leaks_disabled_mode — Scanning skipped in Disabled
  • CI: cargo test --lib + cargo clippy

…acedriveapp#45)

Add SecretScanMode enum (strict/own_secrets_only/disabled) to control
regex-based leak detection. When set to own_secrets_only, only exact-match
scrubbing of stored secrets runs — regex patterns are skipped to eliminate
false positives from public API keys found in scraped web content.

Configurable per-agent via [agents.sandbox] secret_scanner in TOML config.
Default remains strict to preserve existing behavior.
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Mar 5, 2026

Walkthrough

Adds a configurable SecretScanMode (Strict, OwnSecretsOnly, Disabled), wires it through AgentDeps and SandboxConfig, extends scrubbing APIs, and propagates mode-aware scrubbing and leak-detection gating across hooks, workers, channels, tools, and dispatch logic.

Changes

Cohort / File(s) Summary
Core secret-scan primitives
src/secrets/scrub.rs, src/lib.rs, src/sandbox.rs
Introduce SecretScanMode (Strict/OwnSecretsOnly/Disabled) with apply/maybe scrubbing helpers, add AgentDeps::secret_scan_mode(), and add SandboxConfig.secret_scanner (serde default).
Spacebot hook
src/hooks/spacebot.rs
Add secret_scan_mode and secrets_snapshot fields and builder methods; make leak detection and scrubbing respect the configured mode and optional secrets store; update guard/tool result paths and tests.
Agent modules (hook wiring)
src/agent/branch.rs, src/agent/channel.rs, src/agent/compactor.rs, src/agent/cortex.rs, src/agent/cortex_chat.rs, src/agent/ingestion.rs, src/agent/worker.rs
Chain .with_secret_scan_mode(deps.secret_scan_mode()) (and secrets snapshot where applicable) when constructing SpacebotHook; replace direct scrub_leaks calls with mode-aware scrubbing calls.
Dispatch & worker spawn
src/agent/channel_dispatch.rs, src/opencode/worker.rs
Add spawn_worker_task_with_scan_mode(...), propagate scan_mode to worker construction and tasks, add secret_scan_mode to OpenCodeWorker, and make scrubbing conditional on mode.
Tools & tool wiring
src/tools.rs, src/tools/reply.rs, src/tools/set_status.rs
Add secret_scan_mode (and per-tool secret pairs) to ReplyTool and SetStatusTool with builders; apply mode-aware scrubbing before leak checks and wire mode into tool initialization.
Channel leak gating
src/agent/channel.rs (specific helper)
Introduce a helper that only reports leaks in Strict mode and replace previous unconditional scan_for_leaks calls with mode-gated checks.
Misc / tests
various small diffs
Unit tests and small call-site adjustments to use new APIs; minor builder chaining updates across multiple modules.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Suggested reviewers

  • jamiepine
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the main change: adding a configurable secret scanner mode to address false positives.
Description check ✅ Passed The description is well-structured, explaining the problem (false positives from public API keys), the solution (configurable SecretScanMode with three modes), configuration details, and test plan.
Linked Issues check ✅ Passed The PR fully addresses issue #45 by implementing configurable SecretScanMode that prevents false positives for public third-party API keys while preserving stored secret redaction.
Out of Scope Changes check ✅ Passed All changes are directly related to implementing configurable secret scanner modes. No unrelated modifications detected beyond the stated objectives.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

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 and usage tips.

Keep both our scan_mode capture and upstream's process_control_registry
+ runtime_config additions.
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/agent/cortex.rs (1)

2431-2443: ⚠️ Potential issue | 🟠 Major

disabled mode is not fully honored in detached task output scrubbing.

On Line 2438, scrub_with_store is still applied unconditionally, so Line 2443 cannot make disabled skip all scanning/scrubbing behavior as specified by this PR’s mode contract.

Suggested fix
-        let scrub = |text: String| -> String {
-            let scrubbed = if let Some(store) = secrets_snapshot.as_ref() {
-                crate::secrets::scrub::scrub_with_store(&text, store)
-            } else {
-                text
-            };
-            scan_mode.maybe_scrub_leaks(scrubbed)
-        };
+        let scrub = |text: String| -> String {
+            if matches!(scan_mode, crate::secrets::scrub::SecretScanMode::Disabled) {
+                return text;
+            }
+            let scrubbed = if let Some(store) = secrets_snapshot.as_ref() {
+                crate::secrets::scrub::scrub_with_store(&text, store)
+            } else {
+                text
+            };
+            scan_mode.maybe_scrub_leaks(scrubbed)
+        };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/agent/cortex.rs` around lines 2431 - 2443, The detached-task scrub
closure currently always applies crate::secrets::scrub::scrub_with_store even
when scan_mode should be disabled; change the closure so it first checks the
scan_mode disabled state (e.g., via a method like scan_mode.is_disabled() or
equivalent) and immediately returns the original text when disabled, otherwise
proceed to optionally call scrub_with_store when secrets_snapshot is Some and
then pass that result into scan_mode.maybe_scrub_leaks; update the closure that
captures scan_mode and secrets_snapshot to enforce this short-circuit behavior
so disabled truly skips all scrubbing.
🧹 Nitpick comments (4)
src/agent/channel.rs (1)

1937-1937: Consider extracting the repeated strict-mode leak gate into one helper.

The same Strict && scan_for_leaks(...) condition appears in three fallback branches. A shared helper would reduce drift and make future mode changes safer.

Also applies to: 2002-2002, 2060-2060

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/agent/channel.rs` at line 1937, Extract the repeated gate into a single
helper like is_strict_mode_and_leak(&self, text: &str) that returns true when
self.deps.secret_scan_mode() == crate::secrets::scrub::SecretScanMode::Strict &&
crate::secrets::scrub::scan_for_leaks(text). Replace the three occurrences
(current line with self.deps.secret_scan_mode() == ... && let Some(leak) =
crate::secrets::scrub::scan_for_leaks(text) and the blocks at the other two
locations) with that helper and, if needed, have the helper return the detected
leak (Option<_>) or a boolean depending on how the callers use the leak value so
callers can act on the result without duplicating the condition.
src/agent/channel_dispatch.rs (3)

508-513: Snapshot secret_scan_mode once per worker spawn.

state.deps.secret_scan_mode() is read multiple times in one spawn flow. If config reloads in between, worker internals and completion scrubbing can run with different modes for the same worker.

Suggested patch
+    let scan_mode = state.deps.secret_scan_mode();
@@
-        worker.with_secret_scan_mode(state.deps.secret_scan_mode())
+        worker.with_secret_scan_mode(scan_mode)
@@
-        worker.with_secret_scan_mode(state.deps.secret_scan_mode())
+        worker.with_secret_scan_mode(scan_mode)
@@
-        state.deps.secret_scan_mode(),
+        scan_mode,
Based on learnings: "For changes in async/stateful paths (worker lifecycle, cancellation, retrigger, recall cache behavior), include explicit race/terminal-state reasoning in the PR summary and run targeted tests in addition to `just gate-pr`."

Also applies to: 522-527, 538-545

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/agent/channel_dispatch.rs` around lines 508 - 513, Snapshot
secret_scan_mode once at worker spawn: read state.deps.secret_scan_mode() into a
local let secret_scan_mode variable before modifying/returning worker so the
same value is passed to worker.with_secret_scan_mode(...) and reused wherever
the mode is needed (e.g., building internals and completion-scrub paths that
currently call state.deps.secret_scan_mode() again). Update usages around
oc_secrets_store handling and the other spots noted (the blocks using
worker.with_secret_scan_mode and any subsequent calls that re-query
state.deps.secret_scan_mode()) to use the captured secret_scan_mode variable so
a single consistent mode is applied for the entire worker lifecycle.

609-657: Add mode-specific tests for the new worker completion helper.

spawn_worker_task_with_scan_mode changed core completion sanitization logic, but tests currently cover only cancellation classification. Please add strict/own_secrets_only/disabled assertions for both success and failure message paths.

Based on learnings: "For changes in async/stateful paths (worker lifecycle, cancellation, retrigger, recall cache behavior), include explicit race/terminal-state reasoning in the PR summary and run targeted tests in addition to just gate-pr."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/agent/channel_dispatch.rs` around lines 609 - 657, Add unit tests
exercising spawn_worker_task_with_scan_mode for each SecretScanMode variant
(Strict, OwnSecretsOnly, Disabled) covering both successful futures (Future ->
Ok(text)) and failing futures (Future -> Err(spacebot Error ->
WorkerCompletionError::Failed message)); assert that returned success texts and
failure messages are scrubbed according to the mode (exact-store redaction via
secrets_store + regex-based scan_mode.maybe_scrub_leaks behavior). Use the same
helper types referenced in the diff (spawn_worker_task_with_scan_mode,
SecretScanMode, WorkerCompletionError, secrets::scrub::scrub_with_store) to
locate code paths, simulate a secrets_store with known secrets, and keep
existing cancellation tests unchanged while adding explicit assertions for each
mode on both success and failure paths. Ensure tests run under tokio runtime and
include strict/own_secrets_only/disabled expectations for both outcomes.

271-276: Scrub policy is split and can drift across modes/paths.

This file always applies scrub_with_store(...) before mode-gated regex scrubbing. With mode logic also handled differently in other paths, behavior can diverge by event type. Consider centralizing exact-match + regex decisions behind one mode-aware helper and using it everywhere.

Also applies to: 643-657

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/agent/channel_dispatch.rs` around lines 271 - 276, The scrub logic
currently applies exact-store scrubbing
(crate::secrets::scrub::scrub_with_store) before mode-gated regex scrubbing
(scan_mode.maybe_scrub_leaks), which can diverge from other callers; extract and
centralize the exact-match + regex decisions into a single mode-aware helper
(e.g., in crate::secrets::scrub) that accepts (raw, Option<store>, scan_mode)
and internally decides whether to call scrub_with_store and/or maybe_scrub_leaks
and in which order, then replace the current inline sequence in
channel_dispatch.rs (the code using secrets_snapshot, scrub_with_store, and
scan_mode.maybe_scrub_leaks) and the other duplicated path (around lines
643-657) to call that helper so all event types share identical scrubbing
behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/hooks/spacebot.rs`:
- Around line 575-580: The current branch treats any non-Strict mode as
passthrough so OwnSecretsOnly skips redaction for ProcessEvent::ToolCompleted;
update the logic around self.secret_scan_mode (used in the ToolCompleted
handling) to explicitly handle SecretScanMode::OwnSecretsOnly: when
OwnSecretsOnly is set call a function that performs exact-match-only redaction
(e.g., a scrub_exact_matches-style helper in crate::secrets::scrub) on result,
keep Strict using scrub_leaks(result), and only return result.to_string() for
modes that truly mean no redaction; reference secret_scan_mode,
SecretScanMode::OwnSecretsOnly, SecretScanMode::Strict, and the ToolCompleted
path to locate where to add the new branch or helper and implement exact-match
redaction.

In `@src/secrets/scrub.rs`:
- Around line 44-53: The SecretScanMode currently only gates regex redaction via
maybe_scrub_leaks, leaving exact-match scrubbing to callers which causes
Disabled vs OwnSecretsOnly semantics to drift; update SecretScanMode to
centralize end-to-end semantics by adding/renaming a method (e.g.,
apply_scrubbing or maybe_scrub_all) that takes the original text and the
exact-match scrubber (or calls the existing exact-match logic) and returns the
final text such that: Strict performs regex (scrub_leaks) then exact-match
scrubbing, OwnSecretsOnly performs only exact-match scrubbing, and Disabled
returns the input unchanged; replace callers of maybe_scrub_leaks to use this
new centralized helper (referencing SecretScanMode::maybe_scrub_leaks,
scrub_leaks, and the new apply_scrubbing method) so mode behavior is enforced
consistently.

In `@src/tools/reply.rs`:
- Around line 389-400: The reply path only runs regex leak detection when
SecretScanMode::Strict via crate::secrets::scrub::scan_for_leaks, so exact-match
redaction for own_secrets_only is missing and replies can leak protected
secrets; update the reply flow in reply.rs to perform an exact-match secret
redaction step (use the same secret set used elsewhere) before returning
user-visible content in all non-disabled modes, and ensure when ReplyTool is
constructed in src/tools.rs it is passed the secrets via with_tool_secrets(...)
so ReplyTool has the own-secret set available; specifically add a pre-send
exact-match check/redact using the tool’s secret set (in addition to existing
scan_for_leaks branch) and wire with_tool_secrets into ReplyTool construction.

---

Outside diff comments:
In `@src/agent/cortex.rs`:
- Around line 2431-2443: The detached-task scrub closure currently always
applies crate::secrets::scrub::scrub_with_store even when scan_mode should be
disabled; change the closure so it first checks the scan_mode disabled state
(e.g., via a method like scan_mode.is_disabled() or equivalent) and immediately
returns the original text when disabled, otherwise proceed to optionally call
scrub_with_store when secrets_snapshot is Some and then pass that result into
scan_mode.maybe_scrub_leaks; update the closure that captures scan_mode and
secrets_snapshot to enforce this short-circuit behavior so disabled truly skips
all scrubbing.

---

Nitpick comments:
In `@src/agent/channel_dispatch.rs`:
- Around line 508-513: Snapshot secret_scan_mode once at worker spawn: read
state.deps.secret_scan_mode() into a local let secret_scan_mode variable before
modifying/returning worker so the same value is passed to
worker.with_secret_scan_mode(...) and reused wherever the mode is needed (e.g.,
building internals and completion-scrub paths that currently call
state.deps.secret_scan_mode() again). Update usages around oc_secrets_store
handling and the other spots noted (the blocks using
worker.with_secret_scan_mode and any subsequent calls that re-query
state.deps.secret_scan_mode()) to use the captured secret_scan_mode variable so
a single consistent mode is applied for the entire worker lifecycle.
- Around line 609-657: Add unit tests exercising
spawn_worker_task_with_scan_mode for each SecretScanMode variant (Strict,
OwnSecretsOnly, Disabled) covering both successful futures (Future -> Ok(text))
and failing futures (Future -> Err(spacebot Error ->
WorkerCompletionError::Failed message)); assert that returned success texts and
failure messages are scrubbed according to the mode (exact-store redaction via
secrets_store + regex-based scan_mode.maybe_scrub_leaks behavior). Use the same
helper types referenced in the diff (spawn_worker_task_with_scan_mode,
SecretScanMode, WorkerCompletionError, secrets::scrub::scrub_with_store) to
locate code paths, simulate a secrets_store with known secrets, and keep
existing cancellation tests unchanged while adding explicit assertions for each
mode on both success and failure paths. Ensure tests run under tokio runtime and
include strict/own_secrets_only/disabled expectations for both outcomes.
- Around line 271-276: The scrub logic currently applies exact-store scrubbing
(crate::secrets::scrub::scrub_with_store) before mode-gated regex scrubbing
(scan_mode.maybe_scrub_leaks), which can diverge from other callers; extract and
centralize the exact-match + regex decisions into a single mode-aware helper
(e.g., in crate::secrets::scrub) that accepts (raw, Option<store>, scan_mode)
and internally decides whether to call scrub_with_store and/or maybe_scrub_leaks
and in which order, then replace the current inline sequence in
channel_dispatch.rs (the code using secrets_snapshot, scrub_with_store, and
scan_mode.maybe_scrub_leaks) and the other duplicated path (around lines
643-657) to call that helper so all event types share identical scrubbing
behavior.

In `@src/agent/channel.rs`:
- Line 1937: Extract the repeated gate into a single helper like
is_strict_mode_and_leak(&self, text: &str) that returns true when
self.deps.secret_scan_mode() == crate::secrets::scrub::SecretScanMode::Strict &&
crate::secrets::scrub::scan_for_leaks(text). Replace the three occurrences
(current line with self.deps.secret_scan_mode() == ... && let Some(leak) =
crate::secrets::scrub::scan_for_leaks(text) and the blocks at the other two
locations) with that helper and, if needed, have the helper return the detected
leak (Option<_>) or a boolean depending on how the callers use the leak value so
callers can act on the result without duplicating the condition.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 0c9f3a80-aa28-4f83-8abf-5b38db2cf471

📥 Commits

Reviewing files that changed from the base of the PR and between 7f22fcb and cad6aa8.

📒 Files selected for processing (16)
  • src/agent/branch.rs
  • src/agent/channel.rs
  • src/agent/channel_dispatch.rs
  • src/agent/compactor.rs
  • src/agent/cortex.rs
  • src/agent/cortex_chat.rs
  • src/agent/ingestion.rs
  • src/agent/worker.rs
  • src/hooks/spacebot.rs
  • src/lib.rs
  • src/opencode/worker.rs
  • src/sandbox.rs
  • src/secrets/scrub.rs
  • src/tools.rs
  • src/tools/reply.rs
  • src/tools/set_status.rs

Comment on lines +44 to +53
impl SecretScanMode {
/// Apply regex-based leak scrubbing only when in `Strict` mode.
/// Returns the scrubbed text in Strict mode, or the input unchanged otherwise.
pub fn maybe_scrub_leaks(&self, text: String) -> String {
match self {
Self::Strict => scrub_leaks(&text),
Self::OwnSecretsOnly | Self::Disabled => text,
}
}
}
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

SecretScanMode API does not enforce a distinct disabled behavior end-to-end.

maybe_scrub_leaks gates only regex redaction, and both non-strict modes return the input unchanged. Since exact-match scrubbing is done externally, disabled vs own_secrets_only semantics are left to each caller and can drift from the intended contract.

🧩 Suggested refactor to centralize mode semantics
 impl SecretScanMode {
+    pub fn scrub_text_with_pairs(self, text: &str, tool_secrets: &[(String, String)]) -> String {
+        let layer1 = match self {
+            Self::Strict | Self::OwnSecretsOnly => scrub_secrets(text, tool_secrets),
+            Self::Disabled => text.to_string(),
+        };
+        match self {
+            Self::Strict => scrub_leaks(&layer1),
+            Self::OwnSecretsOnly | Self::Disabled => layer1,
+        }
+    }
+
     pub fn maybe_scrub_leaks(&self, text: String) -> String {
         match self {
             Self::Strict => scrub_leaks(&text),
             Self::OwnSecretsOnly | Self::Disabled => text,
         }
     }
 }

Then have egress call sites use the centralized helper instead of manually composing layer behavior.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/secrets/scrub.rs` around lines 44 - 53, The SecretScanMode currently only
gates regex redaction via maybe_scrub_leaks, leaving exact-match scrubbing to
callers which causes Disabled vs OwnSecretsOnly semantics to drift; update
SecretScanMode to centralize end-to-end semantics by adding/renaming a method
(e.g., apply_scrubbing or maybe_scrub_all) that takes the original text and the
exact-match scrubber (or calls the existing exact-match logic) and returns the
final text such that: Strict performs regex (scrub_leaks) then exact-match
scrubbing, OwnSecretsOnly performs only exact-match scrubbing, and Disabled
returns the input unchanged; replace callers of maybe_scrub_leaks to use this
new centralized helper (referencing SecretScanMode::maybe_scrub_leaks,
scrub_leaks, and the new apply_scrubbing method) so mode behavior is enforced
consistently.

Comment on lines +389 to 400
if self.secret_scan_mode == crate::secrets::scrub::SecretScanMode::Strict {
if let Some(leak) = crate::secrets::scrub::scan_for_leaks(&converted_content) {
tracing::error!(
conversation_id = %self.conversation_id,
leak_prefix = %&leak[..leak.len().min(8)],
"reply tool blocked content matching secret pattern"
);
return Err(ReplyError(
"blocked reply content: potential secret detected".into(),
));
}
}
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

own_secrets_only mode currently drops own-secret protection in reply output.

This block only performs regex leak detection in Strict, and ReplyTool still has no exact-match secret redaction step before sending user-visible content. That makes own_secrets_only behave like disabled for replies, which breaks the intended mode contract.

🔧 Suggested direction
 pub struct ReplyTool {
     response_tx: mpsc::Sender<OutboundResponse>,
@@
     agent_display_name: String,
+    tool_secret_pairs: Vec<(String, String)>,
     secret_scan_mode: crate::secrets::scrub::SecretScanMode,
 }

 impl ReplyTool {
@@
         Self {
@@
+            tool_secret_pairs: Vec::new(),
             secret_scan_mode: crate::secrets::scrub::SecretScanMode::default(),
         }
     }

+    pub fn with_tool_secrets(mut self, pairs: Vec<(String, String)>) -> Self {
+        self.tool_secret_pairs = pairs;
+        self
+    }
@@
-        let converted_content = convert_mentions(...).await;
+        let converted_content = convert_mentions(...).await;
+        let converted_content = match self.secret_scan_mode {
+            crate::secrets::scrub::SecretScanMode::Strict
+            | crate::secrets::scrub::SecretScanMode::OwnSecretsOnly => {
+                crate::secrets::scrub::scrub_secrets(&converted_content, &self.tool_secret_pairs)
+            }
+            crate::secrets::scrub::SecretScanMode::Disabled => converted_content,
+        };

         if self.secret_scan_mode == crate::secrets::scrub::SecretScanMode::Strict {
             if let Some(leak) = crate::secrets::scrub::scan_for_leaks(&converted_content) {

Also wire with_tool_secrets(...) where ReplyTool is constructed in src/tools.rs.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/tools/reply.rs` around lines 389 - 400, The reply path only runs regex
leak detection when SecretScanMode::Strict via
crate::secrets::scrub::scan_for_leaks, so exact-match redaction for
own_secrets_only is missing and replies can leak protected secrets; update the
reply flow in reply.rs to perform an exact-match secret redaction step (use the
same secret set used elsewhere) before returning user-visible content in all
non-disabled modes, and ensure when ReplyTool is constructed in src/tools.rs it
is passed the secrets via with_tool_secrets(...) so ReplyTool has the own-secret
set available; specifically add a pre-send exact-match check/redact using the
tool’s secret set (in addition to existing scan_for_leaks branch) and wire
with_tool_secrets into ReplyTool construction.

Resolves CodeRabbit review findings on PR spacedriveapp#324. The core issue was that
SecretScanMode only gated regex-based scanning (layer 2), while
exact-match scrubbing (layer 1) was handled ad-hoc by callers — causing
OwnSecretsOnly to behave like Disabled in several egress paths.

- Add apply_scrubbing_with_store() and apply_scrubbing_with_pairs() to
  SecretScanMode for centralized layer 1 + layer 2 enforcement
- Add secrets_snapshot to SpacebotHook for ToolCompleted exact-match
  scrubbing on event payloads
- Add tool_secret_pairs to ReplyTool for pre-send exact-match redaction
- Fix cortex.rs detached task scrub to fully honor Disabled mode
- Snapshot secret_scan_mode once per OpenCode worker spawn
- Extract strict_mode_leak() helper in channel.rs (DRY, 3 call sites)
- Migrate all scrub call sites to centralized methods
- Add tests for all three modes on apply_scrubbing_with_pairs
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
src/agent/channel_dispatch.rs (1)

650-659: ⚠️ Potential issue | 🟠 Major

Scrub panic payloads before emitting worker failure text.

The panic branch bypasses scan_mode.apply_scrubbing_with_store, so panic messages can leak secrets through WorkerComplete.result.

🔧 Suggested fix
             Err(panic_payload) => {
                 let panic_message = crate::agent::panic_payload_to_string(&*panic_payload);
+                let store_ref = secrets_store.as_ref().map(|s| s.as_ref());
+                let scrubbed_panic_message =
+                    scan_mode.apply_scrubbing_with_store(&panic_message, store_ref);
                 tracing::error!(
                     worker_id = %worker_id,
-                    panic_message = %panic_message,
+                    panic_message = %scrubbed_panic_message,
                     "worker task panicked"
                 );
                 Err(WorkerCompletionError::failed(format!(
-                    "worker task panicked: {panic_message}"
+                    "worker task panicked: {scrubbed_panic_message}"
                 )))
             }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/agent/channel_dispatch.rs` around lines 650 - 659, The panic branch
currently converts the payload with panic_payload_to_string and logs/returns it
directly, which can leak secrets; instead pass the panic_message through
scan_mode.apply_scrubbing_with_store (using the same store used elsewhere in
this scope) to produce a scrubbed_message, then use that scrubbed_message in the
tracing::error payload and in the WorkerCompletionError::failed text so no raw
panic payload is emitted; update the branch around panic_payload_to_string,
tracing::error, and WorkerCompletionError::failed to use the scrubbed result.
src/tools/set_status.rs (1)

103-115: ⚠️ Potential issue | 🟠 Major

Scrub before truncation to avoid partial secret leakage.

Right now, truncation happens first. If a secret is cut at the boundary, exact-match redaction won’t fire and a secret fragment can be emitted.

🔐 Suggested fix
-        let status = if args.status.len() > 256 {
-            let end = args.status.floor_char_boundary(256);
-            let boundary = args.status[..end].rfind(char::is_whitespace).unwrap_or(end);
-            format!("{}...", &args.status[..boundary])
-        } else {
-            args.status
-        };
-
-        // Apply centralized scrubbing: exact-match (layer 1) + regex (layer 2) per mode.
-        let status = self
+        // Scrub first so truncation cannot split a secret and bypass exact-match redaction.
+        let scrubbed_status = self
             .secret_scan_mode
-            .apply_scrubbing_with_pairs(&status, &self.tool_secret_pairs);
+            .apply_scrubbing_with_pairs(&args.status, &self.tool_secret_pairs);
+
+        let status = if scrubbed_status.len() > 256 {
+            let end = scrubbed_status.floor_char_boundary(256);
+            let boundary = scrubbed_status[..end]
+                .rfind(char::is_whitespace)
+                .unwrap_or(end);
+            format!("{}...", &scrubbed_status[..boundary])
+        } else {
+            scrubbed_status
+        };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/tools/set_status.rs` around lines 103 - 115, The code currently truncates
args.status before scrubbing which can leave partial secrets; instead call
self.secret_scan_mode.apply_scrubbing_with_pairs on the full args.status (using
&args.status and &self.tool_secret_pairs) to produce a scrubbed string, then
apply the existing truncation logic (floor_char_boundary,
rfind(char::is_whitespace), adding "..." when truncated) to that scrubbed result
and assign to status; update references to use the scrubbed full string when
computing end and boundary so no secret fragments survive truncation.
♻️ Duplicate comments (1)
src/hooks/spacebot.rs (1)

587-590: ⚠️ Potential issue | 🟠 Major

Prevent fail-open redaction when OwnSecretsOnly has no secrets snapshot.

This call becomes passthrough in OwnSecretsOnly if self.secrets_snapshot is None. A provided cross-file snippet (src/agent/ingestion.rs:495-502) shows a hook construction path without with_secrets_snapshot(...), so tool output can be emitted unsanitized.

🛡️ Suggested hardening in this path
-            let scrubbed = self.secret_scan_mode.apply_scrubbing_with_store(
-                result,
-                self.secrets_snapshot.as_ref().map(|arc| arc.as_ref()),
-            );
+            let store_ref = self.secrets_snapshot.as_ref().map(|arc| arc.as_ref());
+            let scrubbed = match (self.secret_scan_mode, store_ref) {
+                (crate::secrets::scrub::SecretScanMode::OwnSecretsOnly, None) => {
+                    tracing::warn!(
+                        process_id = %self.process_id,
+                        "OwnSecretsOnly enabled without secrets snapshot; applying regex-only fallback scrub"
+                    );
+                    crate::secrets::scrub::scrub_leaks(result)
+                }
+                _ => self.secret_scan_mode.apply_scrubbing_with_store(result, store_ref),
+            };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/hooks/spacebot.rs` around lines 587 - 590, The call to
self.secret_scan_mode.apply_scrubbing_with_store(...) can become a passthrough
when self.secrets_snapshot is None and the mode is OwnSecretsOnly; detect when
self.secret_scan_mode is OwnSecretsOnly and self.secrets_snapshot.is_none() and
instead pass a safe empty snapshot (or otherwise force full redaction) into
apply_scrubbing_with_store so tool output cannot be emitted unsanitized. Locate
the use of secret_scan_mode.apply_scrubbing_with_store, inspect
self.secrets_snapshot and the SecretScanMode::OwnSecretsOnly variant, and if
missing create/derive an empty snapshot (or a safe stub) and pass that to
apply_scrubbing_with_store rather than None.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/secrets/scrub.rs`:
- Around line 39-41: Update the documentation for the Disabled enum variant to
reflect the actual behavior: it performs no scrubbing and returns the input
unchanged (remove the note about "Exact-match scrubbing of stored secrets (Layer
1) still runs"). Locate the enum variant Disabled in the scrub.rs declaration
and change its doc comment to state that Disabled disables all scrubbing,
matching the existing implementation paths that return the original input
unchanged.

---

Outside diff comments:
In `@src/agent/channel_dispatch.rs`:
- Around line 650-659: The panic branch currently converts the payload with
panic_payload_to_string and logs/returns it directly, which can leak secrets;
instead pass the panic_message through scan_mode.apply_scrubbing_with_store
(using the same store used elsewhere in this scope) to produce a
scrubbed_message, then use that scrubbed_message in the tracing::error payload
and in the WorkerCompletionError::failed text so no raw panic payload is
emitted; update the branch around panic_payload_to_string, tracing::error, and
WorkerCompletionError::failed to use the scrubbed result.

In `@src/tools/set_status.rs`:
- Around line 103-115: The code currently truncates args.status before scrubbing
which can leave partial secrets; instead call
self.secret_scan_mode.apply_scrubbing_with_pairs on the full args.status (using
&args.status and &self.tool_secret_pairs) to produce a scrubbed string, then
apply the existing truncation logic (floor_char_boundary,
rfind(char::is_whitespace), adding "..." when truncated) to that scrubbed result
and assign to status; update references to use the scrubbed full string when
computing end and boundary so no secret fragments survive truncation.

---

Duplicate comments:
In `@src/hooks/spacebot.rs`:
- Around line 587-590: The call to
self.secret_scan_mode.apply_scrubbing_with_store(...) can become a passthrough
when self.secrets_snapshot is None and the mode is OwnSecretsOnly; detect when
self.secret_scan_mode is OwnSecretsOnly and self.secrets_snapshot.is_none() and
instead pass a safe empty snapshot (or otherwise force full redaction) into
apply_scrubbing_with_store so tool output cannot be emitted unsanitized. Locate
the use of secret_scan_mode.apply_scrubbing_with_store, inspect
self.secrets_snapshot and the SecretScanMode::OwnSecretsOnly variant, and if
missing create/derive an empty snapshot (or a safe stub) and pass that to
apply_scrubbing_with_store rather than None.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 942206a2-813e-4716-847a-b72109a4da4e

📥 Commits

Reviewing files that changed from the base of the PR and between cad6aa8 and bf14735.

📒 Files selected for processing (10)
  • src/agent/branch.rs
  • src/agent/channel.rs
  • src/agent/channel_dispatch.rs
  • src/agent/cortex.rs
  • src/agent/worker.rs
  • src/hooks/spacebot.rs
  • src/secrets/scrub.rs
  • src/tools.rs
  • src/tools/reply.rs
  • src/tools/set_status.rs
🚧 Files skipped from review as they are similar to previous changes (4)
  • src/tools.rs
  • src/agent/worker.rs
  • src/agent/branch.rs
  • src/agent/channel.rs

Comment on lines +39 to +41
/// No regex-based leak detection. Exact-match scrubbing of stored
/// secrets (Layer 1) still runs. Use with caution.
Disabled,
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Correct Disabled mode docs to match implementation.

The doc says Disabled still runs layer-1 exact-match scrubbing, but Line 66 and Line 95 return input unchanged. This mismatch can cause misconfiguration expectations.

📝 Suggested doc fix
-    /// No regex-based leak detection. Exact-match scrubbing of stored
-    /// secrets (Layer 1) still runs. Use with caution.
+    /// No scrubbing or leak detection. Returns input unchanged.
+    /// Use with caution.
     Disabled,
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/secrets/scrub.rs` around lines 39 - 41, Update the documentation for the
Disabled enum variant to reflect the actual behavior: it performs no scrubbing
and returns the input unchanged (remove the note about "Exact-match scrubbing of
stored secrets (Layer 1) still runs"). Locate the enum variant Disabled in the
scrub.rs declaration and change its doc comment to state that Disabled disables
all scrubbing, matching the existing implementation paths that return the
original input unchanged.

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.

Secret Scanner Erroneously Flagging Intentionally Public Keys

1 participant