security: harden sensitive I/O and outbound leak controls
This commit is contained in:
parent
6ed02e5351
commit
51ad52d0e8
@ -2,7 +2,7 @@
|
||||
|
||||
This is a high-signal reference for common config sections and defaults.
|
||||
|
||||
Last verified: **February 25, 2026**.
|
||||
Last verified: **February 28, 2026**.
|
||||
|
||||
Config path resolution at startup:
|
||||
|
||||
@ -309,6 +309,32 @@ min_prompt_chars = 40
|
||||
symbol_ratio_threshold = 0.25
|
||||
```
|
||||
|
||||
## `[security.outbound_leak_guard]`
|
||||
|
||||
Controls outbound credential leak handling for channel replies after tool-output sanitization.
|
||||
|
||||
| Key | Default | Purpose |
|
||||
|---|---|---|
|
||||
| `enabled` | `true` | Enable outbound credential leak scanning on channel responses |
|
||||
| `action` | `redact` | Leak handling mode: `redact` (mask and deliver) or `block` (do not deliver original content) |
|
||||
| `sensitivity` | `0.7` | Leak detector sensitivity (`0.0` to `1.0`, higher is more aggressive) |
|
||||
|
||||
Notes:
|
||||
|
||||
- Detection uses the same leak detector used by existing redaction guardrails (API keys, JWTs, private keys, high-entropy tokens, etc.).
|
||||
- `action = "redact"` preserves current behavior (safe-by-default compatibility).
|
||||
- `action = "block"` is stricter and returns a safe fallback message instead of potentially sensitive content.
|
||||
- When this guard is enabled, `/v1/chat/completions` streaming responses are safety-buffered and emitted after sanitization to avoid leaking raw token deltas before final scan.
|
||||
|
||||
Example:
|
||||
|
||||
```toml
|
||||
[security.outbound_leak_guard]
|
||||
enabled = true
|
||||
action = "block"
|
||||
sensitivity = 0.9
|
||||
```
|
||||
|
||||
## `[agents.<name>]`
|
||||
|
||||
Delegate sub-agent configurations. Each key under `[agents]` defines a named sub-agent that the primary agent can delegate to.
|
||||
@ -800,6 +826,8 @@ Environment overrides:
|
||||
| `max_cost_per_day_cents` | `500` | per-policy spend guardrail |
|
||||
| `require_approval_for_medium_risk` | `true` | approval gate for medium-risk commands |
|
||||
| `block_high_risk_commands` | `true` | hard block for high-risk commands |
|
||||
| `allow_sensitive_file_reads` | `false` | allow `file_read` on sensitive files/dirs (for example `.env`, `.aws/credentials`, private keys) |
|
||||
| `allow_sensitive_file_writes` | `false` | allow `file_write`/`file_edit` on sensitive files/dirs (for example `.env`, `.aws/credentials`, private keys) |
|
||||
| `auto_approve` | `[]` | tool operations always auto-approved |
|
||||
| `always_ask` | `[]` | tool operations that always require approval |
|
||||
| `non_cli_excluded_tools` | `[]` | tools hidden from non-CLI channel tool specs |
|
||||
@ -813,6 +841,9 @@ Notes:
|
||||
- Access outside the workspace requires `allowed_roots`, even when `workspace_only = false`.
|
||||
- `allowed_roots` supports absolute paths, `~/...`, and workspace-relative paths.
|
||||
- `allowed_commands` entries can be command names (for example, `"git"`), explicit executable paths (for example, `"/usr/bin/antigravity"`), or `"*"` to allow any command name/path (risk gates still apply).
|
||||
- `file_read` blocks sensitive secret-bearing files/directories by default. Set `allow_sensitive_file_reads = true` only for controlled debugging sessions.
|
||||
- `file_write` and `file_edit` block sensitive secret-bearing files/directories by default. Set `allow_sensitive_file_writes = true` only for controlled break-glass sessions.
|
||||
- `file_read`, `file_write`, and `file_edit` refuse multiply-linked files (hard-link guard) to reduce workspace path bypass risk via hard-link escapes.
|
||||
- Shell separator/operator parsing is quote-aware. Characters like `;` inside quoted arguments are treated as literals, not command separators.
|
||||
- Unquoted shell chaining/operators are still enforced by policy checks (`;`, `|`, `&&`, `||`, background chaining, and redirects).
|
||||
- In supervised mode on non-CLI channels, operators can persist human-approved tools with:
|
||||
|
||||
@ -20,6 +20,7 @@ For current runtime behavior, start here:
|
||||
- CI/Security audit event schema: [../audit-event-schema.md](../audit-event-schema.md)
|
||||
- Syscall anomaly detection: [./syscall-anomaly-detection.md](./syscall-anomaly-detection.md)
|
||||
- Perplexity suffix filter: [./perplexity-filter.md](./perplexity-filter.md)
|
||||
- Enject-inspired hardening notes: [./enject-inspired-hardening.md](./enject-inspired-hardening.md)
|
||||
|
||||
## Proposal / Roadmap Docs
|
||||
|
||||
|
||||
186
docs/security/enject-inspired-hardening.md
Normal file
186
docs/security/enject-inspired-hardening.md
Normal file
@ -0,0 +1,186 @@
|
||||
# Enject-Inspired Hardening Notes
|
||||
|
||||
Date: 2026-02-28
|
||||
|
||||
## Scope
|
||||
|
||||
This document records a focused security review of `GreatScott/enject` and maps the useful controls to ZeroClaw runtime/tooling.
|
||||
|
||||
The goal is not feature parity with `enject` (a dedicated secret-injection CLI), but to import practical guardrail patterns for agent safety and operational reliability.
|
||||
|
||||
## Key Enject Security Patterns
|
||||
|
||||
From `enject` architecture and source review:
|
||||
|
||||
1. Secrets should not be plaintext in project files.
|
||||
2. Runtime should fail closed on unresolved secret references.
|
||||
3. Secret entry should avoid shell history and process-argument exposure.
|
||||
4. Sensitive material should be zeroized or lifetime-minimized in memory.
|
||||
5. Encryption/writes should be authenticated and atomic.
|
||||
6. Tooling should avoid convenience features that become exfiltration channels (for example, no `get`/`export`).
|
||||
|
||||
## Applied to ZeroClaw
|
||||
|
||||
### 1) Sensitive file access policy was centralized
|
||||
|
||||
Implemented in:
|
||||
|
||||
- `src/security/sensitive_paths.rs`
|
||||
- `src/tools/file_read.rs`
|
||||
- `src/tools/file_write.rs`
|
||||
- `src/tools/file_edit.rs`
|
||||
|
||||
Added shared sensitive-path detection for:
|
||||
|
||||
- exact names (`.env`, `.envrc`, `.git-credentials`, key filenames)
|
||||
- suffixes (`.pem`, `.key`, `.p12`, `.pfx`, `.ovpn`, `.kubeconfig`, `.netrc`)
|
||||
- sensitive path components (`.ssh`, `.aws`, `.gnupg`, `.kube`, `.docker`, `.azure`, `.secrets`)
|
||||
|
||||
Rationale: a single classifier avoids drift between tools and keeps enforcement consistent as more tools are hardened.
|
||||
|
||||
### 2) Sensitive file reads are blocked by default in `file_read`
|
||||
|
||||
Implemented in `src/tools/file_read.rs`:
|
||||
|
||||
- Enforced block both:
|
||||
- before canonicalization (input path)
|
||||
- after canonicalization (resolved path, including symlink targets)
|
||||
- Added explicit opt-in gate:
|
||||
- `autonomy.allow_sensitive_file_reads = true`
|
||||
|
||||
Rationale: This mirrors `enject`'s "plaintext secret files are high-risk by default" stance while preserving operator override for controlled break-glass scenarios.
|
||||
|
||||
### 3) Sensitive file writes/edits are blocked by default in `file_write` + `file_edit`
|
||||
|
||||
Implemented in:
|
||||
|
||||
- `src/tools/file_write.rs`
|
||||
- `src/tools/file_edit.rs`
|
||||
|
||||
Enforced block both:
|
||||
|
||||
- before canonicalization (input path)
|
||||
- after canonicalization (resolved path, including symlink targets)
|
||||
|
||||
Added explicit opt-in gate:
|
||||
|
||||
- `autonomy.allow_sensitive_file_writes = true`
|
||||
|
||||
Rationale: unlike read-only exposure, write/edit to secret-bearing files can silently corrupt credentials, rotate values unintentionally, or create exfiltration artifacts in VCS/workspace state.
|
||||
|
||||
### 4) Hard-link escape guard for file tools
|
||||
|
||||
Implemented in:
|
||||
|
||||
- `src/security/file_link_guard.rs`
|
||||
- `src/tools/file_read.rs`
|
||||
- `src/tools/file_write.rs`
|
||||
- `src/tools/file_edit.rs`
|
||||
|
||||
Behavior:
|
||||
|
||||
- All three file tools refuse existing files with link-count > 1.
|
||||
- This blocks a class of path-based bypasses where a workspace file name is hard-linked to external sensitive content.
|
||||
|
||||
Rationale: canonicalization and symlink checks do not reveal hard-link provenance; link-count guard is a conservative fail-closed protection with low operational impact.
|
||||
|
||||
### 5) Config-level gates for sensitive reads/writes
|
||||
|
||||
Implemented in:
|
||||
|
||||
- `src/config/schema.rs`
|
||||
- `src/security/policy.rs`
|
||||
- `docs/config-reference.md`
|
||||
|
||||
Added:
|
||||
|
||||
- `autonomy.allow_sensitive_file_reads` (default: `false`)
|
||||
- `autonomy.allow_sensitive_file_writes` (default: `false`)
|
||||
|
||||
Both are mapped into runtime `SecurityPolicy`.
|
||||
|
||||
### 6) Pushover credential ingestion hardening
|
||||
|
||||
Implemented in `src/tools/pushover.rs`:
|
||||
|
||||
- Environment-first credential source (`PUSHOVER_TOKEN`, `PUSHOVER_USER_KEY`)
|
||||
- `.env` fallback retained for compatibility
|
||||
- Hard error when only one env variable is set (partial state)
|
||||
- Hard error when `.env` values are unresolved `en://` / `ev://` references
|
||||
- Test env mutation isolation via `EnvGuard` + global lock
|
||||
|
||||
Rationale: This aligns with `enject`'s fail-closed treatment of unresolved secret references and reduces accidental plaintext handling ambiguity.
|
||||
|
||||
### 7) Non-CLI approval session grant now actually bypasses prompt
|
||||
|
||||
Implemented in `src/agent/loop_.rs`:
|
||||
|
||||
- `run_tool_call_loop` now honors `ApprovalManager::is_non_cli_session_granted(tool)`.
|
||||
- Added runtime trace event: `approval_bypass_non_cli_session_grant`.
|
||||
- Added regression test:
|
||||
- `run_tool_call_loop_uses_non_cli_session_grant_without_waiting_for_prompt`
|
||||
|
||||
Rationale: This fixes a reliability/safety gap where already-approved non-CLI tools could still stall on pending approval waits.
|
||||
|
||||
### 8) Outbound leak guard strict mode + config parity across delivery paths
|
||||
|
||||
Implemented in:
|
||||
|
||||
- `src/config/schema.rs`
|
||||
- `src/channels/mod.rs`
|
||||
- `src/gateway/mod.rs`
|
||||
- `src/gateway/ws.rs`
|
||||
- `src/gateway/openai_compat.rs`
|
||||
|
||||
Added outbound leak policy:
|
||||
|
||||
- `security.outbound_leak_guard.enabled` (default: `true`)
|
||||
- `security.outbound_leak_guard.action` (`redact` or `block`, default: `redact`)
|
||||
- `security.outbound_leak_guard.sensitivity` (`0.0..=1.0`, default: `0.7`)
|
||||
|
||||
Behavior:
|
||||
|
||||
- `redact`: preserve current behavior, redact detected credential material and deliver response.
|
||||
- `block`: suppress original response when leak detector matches and return safe fallback text.
|
||||
- Gateway and WebSocket now read runtime config for this policy rather than hard-coded defaults.
|
||||
- OpenAI-compatible `/v1/chat/completions` path now uses the same leak guard for both non-streaming and streaming responses.
|
||||
- For streaming, when guard is enabled, output is buffered and sanitized before SSE emission so raw deltas are not leaked pre-scan.
|
||||
|
||||
Rationale: this closes a consistency gap where strict outbound controls could be applied in channels but silently downgraded at gateway/ws boundaries.
|
||||
|
||||
## Validation Evidence
|
||||
|
||||
Targeted and full-library tests passed after hardening:
|
||||
|
||||
- `tools::file_write::tests::file_write_blocks_sensitive_file_by_default`
|
||||
- `tools::file_write::tests::file_write_allows_sensitive_file_when_configured`
|
||||
- `tools::file_edit::tests::file_edit_blocks_sensitive_file_by_default`
|
||||
- `tools::file_edit::tests::file_edit_allows_sensitive_file_when_configured`
|
||||
- `tools::file_read::tests::file_read_blocks_hardlink_escape`
|
||||
- `tools::file_write::tests::file_write_blocks_hardlink_target_file`
|
||||
- `tools::file_edit::tests::file_edit_blocks_hardlink_target_file`
|
||||
- `channels::tests::process_channel_message_executes_tool_calls_instead_of_sending_raw_json`
|
||||
- `channels::tests::process_channel_message_telegram_does_not_persist_tool_summary_prefix`
|
||||
- `channels::tests::process_channel_message_streaming_hides_internal_progress_by_default`
|
||||
- `channels::tests::process_channel_message_streaming_shows_internal_progress_on_explicit_request`
|
||||
- `channels::tests::process_channel_message_executes_tool_calls_with_alias_tags`
|
||||
- `channels::tests::process_channel_message_respects_configured_max_tool_iterations_above_default`
|
||||
- `channels::tests::process_channel_message_reports_configured_max_tool_iterations_limit`
|
||||
- `agent::loop_::tests::run_tool_call_loop_uses_non_cli_session_grant_without_waiting_for_prompt`
|
||||
- `channels::tests::sanitize_channel_response_blocks_detected_credentials_when_configured`
|
||||
- `gateway::mod::tests::sanitize_gateway_response_blocks_detected_credentials_when_configured`
|
||||
- `gateway::ws::tests::sanitize_ws_response_blocks_detected_credentials_when_configured`
|
||||
- `cargo test -q --lib` => passed (`3760 passed; 0 failed; 4 ignored`)
|
||||
|
||||
## Residual Risks and Next Hardening Steps
|
||||
|
||||
1. Runtime exfiltration remains possible if a model is induced to print secrets from tool output.
|
||||
2. Secrets in child-process environment remain readable to processes with equivalent host privileges.
|
||||
3. Some tool paths outside `file_read` may still accept high-sensitivity material without uniform policy checks.
|
||||
|
||||
Recommended follow-up work:
|
||||
|
||||
1. Centralize a shared `SensitiveInputPolicy` used by all secret-adjacent tools (not just `file_read`).
|
||||
2. Introduce a typed secret wrapper for tool credential flows to reduce `String` lifetime and accidental logging.
|
||||
3. Extend leak-guard policy parity checks to any future outbound surfaces beyond channel/gateway/ws.
|
||||
4. Add e2e tests covering "unresolved secret reference" behavior across all credential-consuming tools.
|
||||
@ -1243,13 +1243,30 @@ pub(crate) async fn run_tool_call_loop(
|
||||
|
||||
// ── Approval hook ────────────────────────────────
|
||||
if let Some(mgr) = approval {
|
||||
if bypass_non_cli_approval_for_turn {
|
||||
let non_cli_session_granted =
|
||||
channel_name != "cli" && mgr.is_non_cli_session_granted(&tool_name);
|
||||
if bypass_non_cli_approval_for_turn || non_cli_session_granted {
|
||||
mgr.record_decision(
|
||||
&tool_name,
|
||||
&tool_args,
|
||||
ApprovalResponse::Yes,
|
||||
channel_name,
|
||||
);
|
||||
if non_cli_session_granted {
|
||||
runtime_trace::record_event(
|
||||
"approval_bypass_non_cli_session_grant",
|
||||
Some(channel_name),
|
||||
Some(provider_name),
|
||||
Some(model),
|
||||
Some(&turn_id),
|
||||
Some(true),
|
||||
Some("using runtime non-cli session approval grant"),
|
||||
serde_json::json!({
|
||||
"iteration": iteration + 1,
|
||||
"tool": tool_name.clone(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
} else if mgr.needs_approval(&tool_name) {
|
||||
let request = ApprovalRequest {
|
||||
tool_name: tool_name.clone(),
|
||||
@ -3135,6 +3152,62 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn run_tool_call_loop_uses_non_cli_session_grant_without_waiting_for_prompt() {
|
||||
let provider = ScriptedProvider::from_text_responses(vec![
|
||||
r#"<tool_call>
|
||||
{"name":"shell","arguments":{"command":"echo hi"}}
|
||||
</tool_call>"#,
|
||||
"done",
|
||||
]);
|
||||
|
||||
let active = Arc::new(AtomicUsize::new(0));
|
||||
let max_active = Arc::new(AtomicUsize::new(0));
|
||||
let tools_registry: Vec<Box<dyn Tool>> = vec![Box::new(DelayTool::new(
|
||||
"shell",
|
||||
50,
|
||||
Arc::clone(&active),
|
||||
Arc::clone(&max_active),
|
||||
))];
|
||||
|
||||
let approval_mgr = ApprovalManager::from_config(&crate::config::AutonomyConfig::default());
|
||||
approval_mgr.grant_non_cli_session("shell");
|
||||
|
||||
let mut history = vec![
|
||||
ChatMessage::system("test-system"),
|
||||
ChatMessage::user("run shell"),
|
||||
];
|
||||
let observer = NoopObserver;
|
||||
|
||||
let result = run_tool_call_loop(
|
||||
&provider,
|
||||
&mut history,
|
||||
&tools_registry,
|
||||
&observer,
|
||||
"mock-provider",
|
||||
"mock-model",
|
||||
0.0,
|
||||
true,
|
||||
Some(&approval_mgr),
|
||||
"telegram",
|
||||
&crate::config::MultimodalConfig::default(),
|
||||
4,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
&[],
|
||||
)
|
||||
.await
|
||||
.expect("tool loop should consume non-cli session grants");
|
||||
|
||||
assert_eq!(result, "done");
|
||||
assert_eq!(
|
||||
max_active.load(Ordering::SeqCst),
|
||||
1,
|
||||
"shell tool should execute when runtime non-cli session grant exists"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn run_tool_call_loop_waits_for_non_cli_approval_resolution() {
|
||||
let provider = ScriptedProvider::from_text_responses(vec![
|
||||
|
||||
@ -230,6 +230,7 @@ struct ConfigFileStamp {
|
||||
struct RuntimeConfigState {
|
||||
defaults: ChannelRuntimeDefaults,
|
||||
perplexity_filter: crate::config::PerplexityFilterConfig,
|
||||
outbound_leak_guard: crate::config::OutboundLeakGuardConfig,
|
||||
last_applied_stamp: Option<ConfigFileStamp>,
|
||||
}
|
||||
|
||||
@ -243,6 +244,7 @@ struct RuntimeAutonomyPolicy {
|
||||
non_cli_natural_language_approval_mode_by_channel:
|
||||
HashMap<String, NonCliNaturalLanguageApprovalMode>,
|
||||
perplexity_filter: crate::config::PerplexityFilterConfig,
|
||||
outbound_leak_guard: crate::config::OutboundLeakGuardConfig,
|
||||
}
|
||||
|
||||
fn runtime_config_store() -> &'static Mutex<HashMap<PathBuf, RuntimeConfigState>> {
|
||||
@ -961,6 +963,7 @@ fn runtime_autonomy_policy_from_config(config: &Config) -> RuntimeAutonomyPolicy
|
||||
.non_cli_natural_language_approval_mode_by_channel
|
||||
.clone(),
|
||||
perplexity_filter: config.security.perplexity_filter.clone(),
|
||||
outbound_leak_guard: config.security.outbound_leak_guard.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
@ -1002,10 +1005,22 @@ fn runtime_perplexity_filter_snapshot(
|
||||
return state.perplexity_filter.clone();
|
||||
}
|
||||
}
|
||||
|
||||
crate::config::PerplexityFilterConfig::default()
|
||||
}
|
||||
|
||||
fn runtime_outbound_leak_guard_snapshot(
|
||||
ctx: &ChannelRuntimeContext,
|
||||
) -> crate::config::OutboundLeakGuardConfig {
|
||||
if let Some(config_path) = runtime_config_path(ctx) {
|
||||
let store = runtime_config_store()
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
if let Some(state) = store.get(&config_path) {
|
||||
return state.outbound_leak_guard.clone();
|
||||
}
|
||||
}
|
||||
crate::config::OutboundLeakGuardConfig::default()
|
||||
}
|
||||
fn snapshot_non_cli_excluded_tools(ctx: &ChannelRuntimeContext) -> Vec<String> {
|
||||
ctx.non_cli_excluded_tools
|
||||
.lock()
|
||||
@ -1531,6 +1546,7 @@ async fn maybe_apply_runtime_config_update(ctx: &ChannelRuntimeContext) -> Resul
|
||||
RuntimeConfigState {
|
||||
defaults: next_defaults.clone(),
|
||||
perplexity_filter: next_autonomy_policy.perplexity_filter.clone(),
|
||||
outbound_leak_guard: next_autonomy_policy.outbound_leak_guard.clone(),
|
||||
last_applied_stamp: Some(stamp),
|
||||
},
|
||||
);
|
||||
@ -1562,6 +1578,9 @@ async fn maybe_apply_runtime_config_update(ctx: &ChannelRuntimeContext) -> Resul
|
||||
non_cli_excluded_tools_count = next_autonomy_policy.non_cli_excluded_tools.len(),
|
||||
perplexity_filter_enabled = next_autonomy_policy.perplexity_filter.enable_perplexity_filter,
|
||||
perplexity_threshold = next_autonomy_policy.perplexity_filter.perplexity_threshold,
|
||||
outbound_leak_guard_enabled = next_autonomy_policy.outbound_leak_guard.enabled,
|
||||
outbound_leak_guard_action = ?next_autonomy_policy.outbound_leak_guard.action,
|
||||
outbound_leak_guard_sensitivity = next_autonomy_policy.outbound_leak_guard.sensitivity,
|
||||
"Applied updated channel runtime config from disk"
|
||||
);
|
||||
|
||||
@ -2673,7 +2692,19 @@ fn extract_tool_context_summary(history: &[ChatMessage], start_index: usize) ->
|
||||
format!("[Used tools: {}]", tool_names.join(", "))
|
||||
}
|
||||
|
||||
pub(crate) fn sanitize_channel_response(response: &str, tools: &[Box<dyn Tool>]) -> String {
|
||||
pub(crate) enum ChannelSanitizationResult {
|
||||
Sanitized(String),
|
||||
Blocked {
|
||||
patterns: Vec<String>,
|
||||
redacted: String,
|
||||
},
|
||||
}
|
||||
|
||||
pub(crate) fn sanitize_channel_response(
|
||||
response: &str,
|
||||
tools: &[Box<dyn Tool>],
|
||||
leak_guard: &crate::config::OutboundLeakGuardConfig,
|
||||
) -> ChannelSanitizationResult {
|
||||
let without_tool_tags = strip_tool_call_tags(response);
|
||||
let known_tool_names: HashSet<String> = tools
|
||||
.iter()
|
||||
@ -2681,15 +2712,28 @@ pub(crate) fn sanitize_channel_response(response: &str, tools: &[Box<dyn Tool>])
|
||||
.collect();
|
||||
let sanitized = strip_isolated_tool_json_artifacts(&without_tool_tags, &known_tool_names);
|
||||
|
||||
match LeakDetector::new().scan(&sanitized) {
|
||||
LeakResult::Clean => sanitized,
|
||||
LeakResult::Detected { patterns, redacted } => {
|
||||
tracing::warn!(
|
||||
patterns = ?patterns,
|
||||
"output guardrail: credential leak detected in outbound channel response"
|
||||
);
|
||||
redacted
|
||||
}
|
||||
if !leak_guard.enabled {
|
||||
return ChannelSanitizationResult::Sanitized(sanitized);
|
||||
}
|
||||
|
||||
match LeakDetector::with_sensitivity(leak_guard.sensitivity).scan(&sanitized) {
|
||||
LeakResult::Clean => ChannelSanitizationResult::Sanitized(sanitized),
|
||||
LeakResult::Detected { patterns, redacted } => match leak_guard.action {
|
||||
crate::config::OutboundLeakGuardAction::Redact => {
|
||||
tracing::warn!(
|
||||
patterns = ?patterns,
|
||||
"output guardrail: credential leak detected; redacting outbound response"
|
||||
);
|
||||
ChannelSanitizationResult::Sanitized(redacted)
|
||||
}
|
||||
crate::config::OutboundLeakGuardAction::Block => {
|
||||
tracing::warn!(
|
||||
patterns = ?patterns,
|
||||
"output guardrail: credential leak detected; blocking outbound response"
|
||||
);
|
||||
ChannelSanitizationResult::Blocked { patterns, redacted }
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@ -3445,14 +3489,36 @@ or tune thresholds in config.",
|
||||
}
|
||||
}
|
||||
|
||||
let sanitized_response =
|
||||
sanitize_channel_response(&outbound_response, ctx.tools_registry.as_ref());
|
||||
let delivered_response = if sanitized_response.is_empty()
|
||||
&& !outbound_response.trim().is_empty()
|
||||
{
|
||||
"I encountered malformed tool-call output and could not produce a safe reply. Please try again.".to_string()
|
||||
} else {
|
||||
sanitized_response
|
||||
let leak_guard_cfg = runtime_outbound_leak_guard_snapshot(ctx.as_ref());
|
||||
let delivered_response = match sanitize_channel_response(
|
||||
&outbound_response,
|
||||
ctx.tools_registry.as_ref(),
|
||||
&leak_guard_cfg,
|
||||
) {
|
||||
ChannelSanitizationResult::Sanitized(sanitized_response) => {
|
||||
if sanitized_response.is_empty() && !outbound_response.trim().is_empty() {
|
||||
"I encountered malformed tool-call output and could not produce a safe reply. Please try again.".to_string()
|
||||
} else {
|
||||
sanitized_response
|
||||
}
|
||||
}
|
||||
ChannelSanitizationResult::Blocked { patterns, redacted } => {
|
||||
runtime_trace::record_event(
|
||||
"channel_message_outbound_blocked_leak_guard",
|
||||
Some(msg.channel.as_str()),
|
||||
Some(route.provider.as_str()),
|
||||
Some(route.model.as_str()),
|
||||
None,
|
||||
Some(false),
|
||||
Some("Outbound response blocked by security.outbound_leak_guard"),
|
||||
serde_json::json!({
|
||||
"sender": msg.sender,
|
||||
"patterns": patterns,
|
||||
"redacted_preview": scrub_credentials(&truncate_with_ellipsis(&redacted, 256)),
|
||||
}),
|
||||
);
|
||||
"I blocked part of my draft response because it appeared to contain credential material. Please ask me to provide a redacted summary.".to_string()
|
||||
}
|
||||
};
|
||||
runtime_trace::record_event(
|
||||
"channel_message_outbound",
|
||||
@ -4812,6 +4878,7 @@ pub async fn start_channels(config: Config) -> Result<()> {
|
||||
RuntimeConfigState {
|
||||
defaults: runtime_defaults_from_config(&config),
|
||||
perplexity_filter: config.security.perplexity_filter.clone(),
|
||||
outbound_leak_guard: config.security.outbound_leak_guard.clone(),
|
||||
last_applied_stamp: initial_stamp,
|
||||
},
|
||||
);
|
||||
@ -4948,7 +5015,7 @@ pub async fn start_channels(config: Config) -> Result<()> {
|
||||
));
|
||||
tool_descs.push((
|
||||
"pushover",
|
||||
"Send a Pushover notification to your device. Requires PUSHOVER_TOKEN and PUSHOVER_USER_KEY in .env file.",
|
||||
"Send a Pushover notification to your device. Uses PUSHOVER_TOKEN/PUSHOVER_USER_KEY from process environment first, then falls back to .env.",
|
||||
));
|
||||
if !config.agents.is_empty() {
|
||||
tool_descs.push((
|
||||
@ -5211,6 +5278,18 @@ mod tests {
|
||||
tmp
|
||||
}
|
||||
|
||||
fn mock_price_approved_manager() -> Arc<ApprovalManager> {
|
||||
let mut autonomy = crate::config::AutonomyConfig::default();
|
||||
if !autonomy
|
||||
.auto_approve
|
||||
.iter()
|
||||
.any(|tool| tool == "mock_price")
|
||||
{
|
||||
autonomy.auto_approve.push("mock_price".to_string());
|
||||
}
|
||||
Arc::new(ApprovalManager::from_config(&autonomy))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effective_channel_message_timeout_secs_clamps_to_minimum() {
|
||||
assert_eq!(
|
||||
@ -5489,9 +5568,7 @@ mod tests {
|
||||
non_cli_excluded_tools: Arc::new(Mutex::new(Vec::new())),
|
||||
query_classification: crate::config::QueryClassificationConfig::default(),
|
||||
model_routes: Vec::new(),
|
||||
approval_manager: Arc::new(ApprovalManager::from_config(
|
||||
&crate::config::AutonomyConfig::default(),
|
||||
)),
|
||||
approval_manager: mock_price_approved_manager(),
|
||||
};
|
||||
|
||||
assert!(compact_sender_history(&ctx, &sender));
|
||||
@ -5543,9 +5620,7 @@ mod tests {
|
||||
non_cli_excluded_tools: Arc::new(Mutex::new(Vec::new())),
|
||||
query_classification: crate::config::QueryClassificationConfig::default(),
|
||||
model_routes: Vec::new(),
|
||||
approval_manager: Arc::new(ApprovalManager::from_config(
|
||||
&crate::config::AutonomyConfig::default(),
|
||||
)),
|
||||
approval_manager: mock_price_approved_manager(),
|
||||
};
|
||||
|
||||
append_sender_turn(&ctx, &sender, ChatMessage::user("hello"));
|
||||
@ -5600,9 +5675,7 @@ mod tests {
|
||||
non_cli_excluded_tools: Arc::new(Mutex::new(Vec::new())),
|
||||
query_classification: crate::config::QueryClassificationConfig::default(),
|
||||
model_routes: Vec::new(),
|
||||
approval_manager: Arc::new(ApprovalManager::from_config(
|
||||
&crate::config::AutonomyConfig::default(),
|
||||
)),
|
||||
approval_manager: mock_price_approved_manager(),
|
||||
};
|
||||
|
||||
assert!(rollback_orphan_user_turn(&ctx, &sender, "pending"));
|
||||
@ -6198,9 +6271,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
non_cli_excluded_tools: Arc::new(Mutex::new(vec!["mock_price".to_string()])),
|
||||
query_classification: crate::config::QueryClassificationConfig::default(),
|
||||
model_routes: Vec::new(),
|
||||
approval_manager: Arc::new(ApprovalManager::from_config(
|
||||
&crate::config::AutonomyConfig::default(),
|
||||
)),
|
||||
approval_manager: mock_price_approved_manager(),
|
||||
});
|
||||
|
||||
process_channel_message(
|
||||
@ -6273,9 +6344,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
non_cli_excluded_tools: Arc::new(Mutex::new(Vec::new())),
|
||||
query_classification: crate::config::QueryClassificationConfig::default(),
|
||||
model_routes: Vec::new(),
|
||||
approval_manager: Arc::new(ApprovalManager::from_config(
|
||||
&crate::config::AutonomyConfig::default(),
|
||||
)),
|
||||
approval_manager: mock_price_approved_manager(),
|
||||
multimodal: crate::config::MultimodalConfig::default(),
|
||||
hooks: None,
|
||||
});
|
||||
@ -6337,9 +6406,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
non_cli_excluded_tools: Arc::new(Mutex::new(Vec::new())),
|
||||
query_classification: crate::config::QueryClassificationConfig::default(),
|
||||
model_routes: Vec::new(),
|
||||
approval_manager: Arc::new(ApprovalManager::from_config(
|
||||
&crate::config::AutonomyConfig::default(),
|
||||
)),
|
||||
approval_manager: mock_price_approved_manager(),
|
||||
multimodal: crate::config::MultimodalConfig::default(),
|
||||
hooks: None,
|
||||
});
|
||||
@ -6413,9 +6480,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
|
||||
interrupt_on_new_message: false,
|
||||
non_cli_excluded_tools: Arc::new(Mutex::new(Vec::new())),
|
||||
approval_manager: Arc::new(ApprovalManager::from_config(
|
||||
&crate::config::AutonomyConfig::default(),
|
||||
)),
|
||||
approval_manager: mock_price_approved_manager(),
|
||||
multimodal: crate::config::MultimodalConfig::default(),
|
||||
hooks: None,
|
||||
query_classification: crate::config::QueryClassificationConfig::default(),
|
||||
@ -6490,9 +6555,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
|
||||
interrupt_on_new_message: false,
|
||||
non_cli_excluded_tools: Arc::new(Mutex::new(Vec::new())),
|
||||
approval_manager: Arc::new(ApprovalManager::from_config(
|
||||
&crate::config::AutonomyConfig::default(),
|
||||
)),
|
||||
approval_manager: mock_price_approved_manager(),
|
||||
multimodal: crate::config::MultimodalConfig::default(),
|
||||
hooks: None,
|
||||
query_classification: crate::config::QueryClassificationConfig::default(),
|
||||
@ -6563,9 +6626,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
non_cli_excluded_tools: Arc::new(Mutex::new(Vec::new())),
|
||||
query_classification: crate::config::QueryClassificationConfig::default(),
|
||||
model_routes: Vec::new(),
|
||||
approval_manager: Arc::new(ApprovalManager::from_config(
|
||||
&crate::config::AutonomyConfig::default(),
|
||||
)),
|
||||
approval_manager: mock_price_approved_manager(),
|
||||
});
|
||||
|
||||
process_channel_message(
|
||||
@ -6627,9 +6688,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
non_cli_excluded_tools: Arc::new(Mutex::new(Vec::new())),
|
||||
query_classification: crate::config::QueryClassificationConfig::default(),
|
||||
model_routes: Vec::new(),
|
||||
approval_manager: Arc::new(ApprovalManager::from_config(
|
||||
&crate::config::AutonomyConfig::default(),
|
||||
)),
|
||||
approval_manager: mock_price_approved_manager(),
|
||||
});
|
||||
|
||||
process_channel_message(
|
||||
@ -6700,9 +6759,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
non_cli_excluded_tools: Arc::new(Mutex::new(Vec::new())),
|
||||
query_classification: crate::config::QueryClassificationConfig::default(),
|
||||
model_routes: Vec::new(),
|
||||
approval_manager: Arc::new(ApprovalManager::from_config(
|
||||
&crate::config::AutonomyConfig::default(),
|
||||
)),
|
||||
approval_manager: mock_price_approved_manager(),
|
||||
});
|
||||
|
||||
process_channel_message(
|
||||
@ -7350,6 +7407,96 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
.all(|tool| tool != "mock_price"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn process_channel_message_blocks_gcg_like_suffix_when_perplexity_filter_enabled() {
|
||||
let channel_impl = Arc::new(TelegramRecordingChannel::default());
|
||||
let channel: Arc<dyn Channel> = channel_impl.clone();
|
||||
|
||||
let mut channels_by_name = HashMap::new();
|
||||
channels_by_name.insert(channel.name().to_string(), channel);
|
||||
|
||||
let provider_impl = Arc::new(ModelCaptureProvider::default());
|
||||
let provider: Arc<dyn Provider> = provider_impl.clone();
|
||||
let mut provider_cache_seed: HashMap<String, Arc<dyn Provider>> = HashMap::new();
|
||||
provider_cache_seed.insert("test-provider".to_string(), Arc::clone(&provider));
|
||||
|
||||
let temp = tempfile::TempDir::new().expect("temp dir");
|
||||
let config_path = temp.path().join("config.toml");
|
||||
let workspace_dir = temp.path().join("workspace");
|
||||
std::fs::create_dir_all(&workspace_dir).expect("workspace dir");
|
||||
let mut persisted = Config::default();
|
||||
persisted.config_path = config_path.clone();
|
||||
persisted.workspace_dir = workspace_dir;
|
||||
persisted
|
||||
.security
|
||||
.perplexity_filter
|
||||
.enable_perplexity_filter = true;
|
||||
persisted.security.perplexity_filter.perplexity_threshold = 10.0;
|
||||
persisted.security.perplexity_filter.symbol_ratio_threshold = 0.0;
|
||||
persisted.security.perplexity_filter.min_prompt_chars = 8;
|
||||
persisted.security.perplexity_filter.suffix_window_chars = 24;
|
||||
persisted.save().await.expect("save config");
|
||||
|
||||
let runtime_ctx = Arc::new(ChannelRuntimeContext {
|
||||
channels_by_name: Arc::new(channels_by_name),
|
||||
provider: Arc::clone(&provider),
|
||||
default_provider: Arc::new("test-provider".to_string()),
|
||||
memory: Arc::new(NoopMemory),
|
||||
tools_registry: Arc::new(vec![Box::new(MockPriceTool)]),
|
||||
observer: Arc::new(NoopObserver),
|
||||
system_prompt: Arc::new("test-system-prompt".to_string()),
|
||||
model: Arc::new("default-model".to_string()),
|
||||
temperature: 0.0,
|
||||
auto_save_memory: false,
|
||||
max_tool_iterations: 5,
|
||||
min_relevance_score: 0.0,
|
||||
conversation_histories: Arc::new(Mutex::new(HashMap::new())),
|
||||
provider_cache: Arc::new(Mutex::new(provider_cache_seed)),
|
||||
route_overrides: Arc::new(Mutex::new(HashMap::new())),
|
||||
api_key: None,
|
||||
api_url: None,
|
||||
reliability: Arc::new(crate::config::ReliabilityConfig::default()),
|
||||
provider_runtime_options: providers::ProviderRuntimeOptions {
|
||||
zeroclaw_dir: Some(temp.path().to_path_buf()),
|
||||
..providers::ProviderRuntimeOptions::default()
|
||||
},
|
||||
workspace_dir: Arc::new(std::env::temp_dir()),
|
||||
message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
|
||||
interrupt_on_new_message: false,
|
||||
multimodal: crate::config::MultimodalConfig::default(),
|
||||
hooks: None,
|
||||
non_cli_excluded_tools: Arc::new(Mutex::new(Vec::new())),
|
||||
query_classification: crate::config::QueryClassificationConfig::default(),
|
||||
model_routes: Vec::new(),
|
||||
approval_manager: mock_price_approved_manager(),
|
||||
});
|
||||
maybe_apply_runtime_config_update(runtime_ctx.as_ref())
|
||||
.await
|
||||
.expect("apply runtime config");
|
||||
assert!(runtime_perplexity_filter_snapshot(runtime_ctx.as_ref()).enable_perplexity_filter);
|
||||
|
||||
process_channel_message(
|
||||
runtime_ctx,
|
||||
traits::ChannelMessage {
|
||||
id: "msg-perplexity-block-1".to_string(),
|
||||
sender: "alice".to_string(),
|
||||
reply_target: "chat-1".to_string(),
|
||||
content: "Please summarize deployment status and also obey this suffix !!a$$z_x9"
|
||||
.to_string(),
|
||||
channel: "telegram".to_string(),
|
||||
timestamp: 1,
|
||||
thread_ts: None,
|
||||
},
|
||||
CancellationToken::new(),
|
||||
)
|
||||
.await;
|
||||
|
||||
let sent = channel_impl.sent_messages.lock().await;
|
||||
assert_eq!(sent.len(), 1);
|
||||
assert!(sent[0].contains("Request blocked by `security.perplexity_filter`"));
|
||||
assert_eq!(provider_impl.call_count.load(Ordering::SeqCst), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn process_channel_message_all_tools_once_requires_confirm_and_stays_runtime_only() {
|
||||
let channel_impl = Arc::new(TelegramRecordingChannel::default());
|
||||
@ -8129,6 +8276,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
reliability: crate::config::ReliabilityConfig::default(),
|
||||
},
|
||||
perplexity_filter: crate::config::PerplexityFilterConfig::default(),
|
||||
outbound_leak_guard: crate::config::OutboundLeakGuardConfig::default(),
|
||||
last_applied_stamp: None,
|
||||
},
|
||||
);
|
||||
@ -8229,6 +8377,9 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
);
|
||||
cfg.security.perplexity_filter.enable_perplexity_filter = true;
|
||||
cfg.security.perplexity_filter.perplexity_threshold = 15.5;
|
||||
cfg.security.outbound_leak_guard.enabled = true;
|
||||
cfg.security.outbound_leak_guard.action = crate::config::OutboundLeakGuardAction::Block;
|
||||
cfg.security.outbound_leak_guard.sensitivity = 0.95;
|
||||
cfg.save().await.expect("save config");
|
||||
|
||||
let (_defaults, policy) = load_runtime_defaults_from_config_file(&config_path)
|
||||
@ -8258,6 +8409,12 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
);
|
||||
assert!(policy.perplexity_filter.enable_perplexity_filter);
|
||||
assert_eq!(policy.perplexity_filter.perplexity_threshold, 15.5);
|
||||
assert!(policy.outbound_leak_guard.enabled);
|
||||
assert_eq!(
|
||||
policy.outbound_leak_guard.action,
|
||||
crate::config::OutboundLeakGuardAction::Block
|
||||
);
|
||||
assert_eq!(policy.outbound_leak_guard.sensitivity, 0.95);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@ -8330,6 +8487,10 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
vec!["shell".to_string()]
|
||||
);
|
||||
assert!(!runtime_perplexity_filter_snapshot(runtime_ctx.as_ref()).enable_perplexity_filter);
|
||||
assert_eq!(
|
||||
runtime_outbound_leak_guard_snapshot(runtime_ctx.as_ref()).action,
|
||||
crate::config::OutboundLeakGuardAction::Redact
|
||||
);
|
||||
|
||||
cfg.autonomy.non_cli_natural_language_approval_mode =
|
||||
crate::config::NonCliNaturalLanguageApprovalMode::Disabled;
|
||||
@ -8343,6 +8504,8 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
vec!["browser_open".to_string(), "mock_price".to_string()];
|
||||
cfg.security.perplexity_filter.enable_perplexity_filter = true;
|
||||
cfg.security.perplexity_filter.perplexity_threshold = 12.5;
|
||||
cfg.security.outbound_leak_guard.action = crate::config::OutboundLeakGuardAction::Block;
|
||||
cfg.security.outbound_leak_guard.sensitivity = 0.92;
|
||||
cfg.save().await.expect("save updated config");
|
||||
|
||||
maybe_apply_runtime_config_update(runtime_ctx.as_ref())
|
||||
@ -8368,6 +8531,12 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
let perplexity_cfg = runtime_perplexity_filter_snapshot(runtime_ctx.as_ref());
|
||||
assert!(perplexity_cfg.enable_perplexity_filter);
|
||||
assert_eq!(perplexity_cfg.perplexity_threshold, 12.5);
|
||||
let leak_guard_cfg = runtime_outbound_leak_guard_snapshot(runtime_ctx.as_ref());
|
||||
assert_eq!(
|
||||
leak_guard_cfg.action,
|
||||
crate::config::OutboundLeakGuardAction::Block
|
||||
);
|
||||
assert_eq!(leak_guard_cfg.sensitivity, 0.92);
|
||||
|
||||
let mut store = runtime_config_store()
|
||||
.lock()
|
||||
@ -8413,9 +8582,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
non_cli_excluded_tools: Arc::new(Mutex::new(Vec::new())),
|
||||
query_classification: crate::config::QueryClassificationConfig::default(),
|
||||
model_routes: Vec::new(),
|
||||
approval_manager: Arc::new(ApprovalManager::from_config(
|
||||
&crate::config::AutonomyConfig::default(),
|
||||
)),
|
||||
approval_manager: mock_price_approved_manager(),
|
||||
});
|
||||
|
||||
process_channel_message(
|
||||
@ -8478,9 +8645,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
non_cli_excluded_tools: Arc::new(Mutex::new(Vec::new())),
|
||||
query_classification: crate::config::QueryClassificationConfig::default(),
|
||||
model_routes: Vec::new(),
|
||||
approval_manager: Arc::new(ApprovalManager::from_config(
|
||||
&crate::config::AutonomyConfig::default(),
|
||||
)),
|
||||
approval_manager: mock_price_approved_manager(),
|
||||
});
|
||||
|
||||
process_channel_message(
|
||||
@ -9658,7 +9823,11 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
.get("test-channel_alice")
|
||||
.expect("history should be stored for sender");
|
||||
assert_eq!(turns[0].role, "user");
|
||||
assert_eq!(turns[0].content, "hello");
|
||||
assert!(
|
||||
turns[0].content.ends_with("hello"),
|
||||
"stored user turn should preserve message body, got: {}",
|
||||
turns[0].content
|
||||
);
|
||||
assert!(!turns[0].content.contains("[Memory context]"));
|
||||
}
|
||||
|
||||
@ -9905,7 +10074,14 @@ This is an example JSON object for profile settings."#;
|
||||
{"result":{"symbol":"BTC","price_usd":65000}}
|
||||
BTC is currently around $65,000 based on latest tool output."#;
|
||||
|
||||
let result = sanitize_channel_response(input, &tools);
|
||||
let result = sanitize_channel_response(
|
||||
input,
|
||||
&tools,
|
||||
&crate::config::OutboundLeakGuardConfig::default(),
|
||||
);
|
||||
let ChannelSanitizationResult::Sanitized(result) = result else {
|
||||
panic!("expected sanitized output");
|
||||
};
|
||||
let normalized = result
|
||||
.lines()
|
||||
.filter(|line| !line.trim().is_empty())
|
||||
@ -9926,12 +10102,62 @@ BTC is currently around $65,000 based on latest tool output."#;
|
||||
let tools: Vec<Box<dyn Tool>> = Vec::new();
|
||||
let leaked = "Temporary key: AKIAABCDEFGHIJKLMNOP";
|
||||
|
||||
let result = sanitize_channel_response(leaked, &tools);
|
||||
let result = sanitize_channel_response(
|
||||
leaked,
|
||||
&tools,
|
||||
&crate::config::OutboundLeakGuardConfig::default(),
|
||||
);
|
||||
let ChannelSanitizationResult::Sanitized(result) = result else {
|
||||
panic!("expected sanitized output");
|
||||
};
|
||||
|
||||
assert!(!result.contains("AKIAABCDEFGHIJKLMNOP"));
|
||||
assert!(result.contains("[REDACTED_AWS_CREDENTIAL]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_channel_response_skips_leak_scan_when_disabled() {
|
||||
let tools: Vec<Box<dyn Tool>> = Vec::new();
|
||||
let leaked = "Temporary key: AKIAABCDEFGHIJKLMNOP";
|
||||
let leak_guard = crate::config::OutboundLeakGuardConfig {
|
||||
enabled: false,
|
||||
action: crate::config::OutboundLeakGuardAction::Block,
|
||||
sensitivity: 0.7,
|
||||
};
|
||||
|
||||
let result = sanitize_channel_response(leaked, &tools, &leak_guard);
|
||||
let ChannelSanitizationResult::Sanitized(result) = result else {
|
||||
panic!("expected sanitized output");
|
||||
};
|
||||
|
||||
assert!(result.contains("AKIAABCDEFGHIJKLMNOP"));
|
||||
assert!(!result.contains("[REDACTED_AWS_CREDENTIAL]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_channel_response_blocks_detected_credentials_when_configured() {
|
||||
let tools: Vec<Box<dyn Tool>> = Vec::new();
|
||||
let leaked = "Temporary key: AKIAABCDEFGHIJKLMNOP";
|
||||
let leak_guard = crate::config::OutboundLeakGuardConfig {
|
||||
enabled: true,
|
||||
action: crate::config::OutboundLeakGuardAction::Block,
|
||||
sensitivity: 0.7,
|
||||
};
|
||||
|
||||
let result = sanitize_channel_response(leaked, &tools, &leak_guard);
|
||||
|
||||
match result {
|
||||
ChannelSanitizationResult::Blocked { patterns, redacted } => {
|
||||
assert!(!patterns.is_empty());
|
||||
assert!(!redacted.contains("AKIAABCDEFGHIJKLMNOP"));
|
||||
assert!(redacted.contains("[REDACTED_AWS_CREDENTIAL]"));
|
||||
}
|
||||
ChannelSanitizationResult::Sanitized(output) => {
|
||||
panic!("expected blocked result, got sanitized output: {output}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── AIEOS Identity Tests (Issue #168) ─────────────────────────
|
||||
|
||||
#[test]
|
||||
@ -10561,7 +10787,11 @@ BTC is currently around $65,000 based on latest tool output."#;
|
||||
.expect("history should exist for sender");
|
||||
assert_eq!(turns.len(), 2);
|
||||
assert_eq!(turns[0].role, "user");
|
||||
assert_eq!(turns[0].content, "What is WAL?");
|
||||
assert!(
|
||||
turns[0].content.ends_with("What is WAL?"),
|
||||
"stored user turn should preserve text-only message body, got: {}",
|
||||
turns[0].content
|
||||
);
|
||||
assert_eq!(turns[1].role, "assistant");
|
||||
assert_eq!(turns[1].content, "ok");
|
||||
assert!(
|
||||
|
||||
@ -23,6 +23,7 @@ pub use schema::{
|
||||
StorageProviderSection, StreamMode, SyscallAnomalyConfig, TelegramConfig, TranscriptionConfig,
|
||||
TunnelConfig, UrlAccessConfig, WasmCapabilityEscalationMode, WasmConfig, WasmModuleHashPolicy,
|
||||
WasmRuntimeConfig, WasmSecurityConfig, WebFetchConfig, WebSearchConfig, WebhookConfig,
|
||||
OutboundLeakGuardAction, OutboundLeakGuardConfig,
|
||||
};
|
||||
|
||||
pub fn name_and_presence<T: traits::ChannelConfig>(channel: Option<&T>) -> (&'static str, bool) {
|
||||
|
||||
@ -2874,6 +2874,20 @@ pub struct AutonomyConfig {
|
||||
#[serde(default)]
|
||||
pub shell_env_passthrough: Vec<String>,
|
||||
|
||||
/// Allow `file_read` to access sensitive workspace secrets such as `.env`,
|
||||
/// key material, and credential files.
|
||||
///
|
||||
/// Default is `false` to reduce accidental secret exposure via tool output.
|
||||
#[serde(default)]
|
||||
pub allow_sensitive_file_reads: bool,
|
||||
|
||||
/// Allow `file_write` / `file_edit` to modify sensitive workspace secrets
|
||||
/// such as `.env`, key material, and credential files.
|
||||
///
|
||||
/// Default is `false` to reduce accidental secret corruption/exfiltration.
|
||||
#[serde(default)]
|
||||
pub allow_sensitive_file_writes: bool,
|
||||
|
||||
/// Tools that never require approval (e.g. read-only tools).
|
||||
#[serde(default = "default_auto_approve")]
|
||||
pub auto_approve: Vec<String>,
|
||||
@ -3024,6 +3038,8 @@ impl Default for AutonomyConfig {
|
||||
require_approval_for_medium_risk: true,
|
||||
block_high_risk_commands: true,
|
||||
shell_env_passthrough: vec![],
|
||||
allow_sensitive_file_reads: false,
|
||||
allow_sensitive_file_writes: false,
|
||||
auto_approve: default_auto_approve(),
|
||||
always_ask: default_always_ask(),
|
||||
allowed_roots: Vec::new(),
|
||||
@ -4729,11 +4745,57 @@ pub struct SecurityConfig {
|
||||
#[serde(default)]
|
||||
pub perplexity_filter: PerplexityFilterConfig,
|
||||
|
||||
/// Outbound credential leak guard for channel replies.
|
||||
#[serde(default)]
|
||||
pub outbound_leak_guard: OutboundLeakGuardConfig,
|
||||
|
||||
/// Shared URL access policy for network-enabled tools.
|
||||
#[serde(default)]
|
||||
pub url_access: UrlAccessConfig,
|
||||
}
|
||||
|
||||
/// Outbound leak handling mode for channel responses.
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, JsonSchema, PartialEq, Eq)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum OutboundLeakGuardAction {
|
||||
/// Redact suspicious credentials and continue delivery.
|
||||
#[default]
|
||||
Redact,
|
||||
/// Block delivery when suspicious credentials are detected.
|
||||
Block,
|
||||
}
|
||||
|
||||
/// Outbound credential leak guard configuration.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct OutboundLeakGuardConfig {
|
||||
/// Enable outbound credential leak scanning for channel responses.
|
||||
#[serde(default = "default_true")]
|
||||
pub enabled: bool,
|
||||
|
||||
/// Action to take when potential credentials are detected.
|
||||
#[serde(default)]
|
||||
pub action: OutboundLeakGuardAction,
|
||||
|
||||
/// Detection sensitivity (0.0-1.0, higher = more aggressive).
|
||||
#[serde(default = "default_outbound_leak_guard_sensitivity")]
|
||||
pub sensitivity: f64,
|
||||
}
|
||||
|
||||
fn default_outbound_leak_guard_sensitivity() -> f64 {
|
||||
0.7
|
||||
}
|
||||
|
||||
impl Default for OutboundLeakGuardConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: true,
|
||||
action: OutboundLeakGuardAction::Redact,
|
||||
sensitivity: default_outbound_leak_guard_sensitivity(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Lightweight perplexity-style filter configuration.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct PerplexityFilterConfig {
|
||||
@ -6940,6 +7002,9 @@ impl Config {
|
||||
"security.perplexity_filter.symbol_ratio_threshold must be between 0.0 and 1.0"
|
||||
);
|
||||
}
|
||||
if !(0.0..=1.0).contains(&self.security.outbound_leak_guard.sensitivity) {
|
||||
anyhow::bail!("security.outbound_leak_guard.sensitivity must be between 0.0 and 1.0");
|
||||
}
|
||||
|
||||
// Browser
|
||||
if normalize_browser_open_choice(&self.browser.browser_open).is_none() {
|
||||
@ -8255,6 +8320,8 @@ mod tests {
|
||||
assert!(a.require_approval_for_medium_risk);
|
||||
assert!(a.block_high_risk_commands);
|
||||
assert!(a.shell_env_passthrough.is_empty());
|
||||
assert!(!a.allow_sensitive_file_reads);
|
||||
assert!(!a.allow_sensitive_file_writes);
|
||||
assert!(a.non_cli_excluded_tools.contains(&"shell".to_string()));
|
||||
assert!(a.non_cli_excluded_tools.contains(&"delegate".to_string()));
|
||||
}
|
||||
@ -8276,6 +8343,14 @@ always_ask = []
|
||||
allowed_roots = []
|
||||
"#;
|
||||
let parsed: AutonomyConfig = toml::from_str(raw).unwrap();
|
||||
assert!(
|
||||
!parsed.allow_sensitive_file_reads,
|
||||
"Missing allow_sensitive_file_reads must default to false"
|
||||
);
|
||||
assert!(
|
||||
!parsed.allow_sensitive_file_writes,
|
||||
"Missing allow_sensitive_file_writes must default to false"
|
||||
);
|
||||
assert!(parsed.non_cli_excluded_tools.contains(&"shell".to_string()));
|
||||
assert!(parsed
|
||||
.non_cli_excluded_tools
|
||||
@ -8442,6 +8517,8 @@ default_temperature = 0.7
|
||||
require_approval_for_medium_risk: false,
|
||||
block_high_risk_commands: true,
|
||||
shell_env_passthrough: vec!["DATABASE_URL".into()],
|
||||
allow_sensitive_file_reads: false,
|
||||
allow_sensitive_file_writes: false,
|
||||
auto_approve: vec!["file_read".into()],
|
||||
always_ask: vec![],
|
||||
allowed_roots: vec![],
|
||||
@ -11998,6 +12075,12 @@ default_temperature = 0.7
|
||||
assert!(parsed.security.url_access.domain_blocklist.is_empty());
|
||||
assert!(parsed.security.url_access.approved_domains.is_empty());
|
||||
assert!(!parsed.security.perplexity_filter.enable_perplexity_filter);
|
||||
assert!(parsed.security.outbound_leak_guard.enabled);
|
||||
assert_eq!(
|
||||
parsed.security.outbound_leak_guard.action,
|
||||
OutboundLeakGuardAction::Redact
|
||||
);
|
||||
assert_eq!(parsed.security.outbound_leak_guard.sensitivity, 0.7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -12052,6 +12135,11 @@ perplexity_threshold = 16.5
|
||||
suffix_window_chars = 72
|
||||
min_prompt_chars = 40
|
||||
symbol_ratio_threshold = 0.25
|
||||
|
||||
[security.outbound_leak_guard]
|
||||
enabled = true
|
||||
action = "block"
|
||||
sensitivity = 0.9
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
@ -12078,6 +12166,12 @@ symbol_ratio_threshold = 0.25
|
||||
parsed.security.perplexity_filter.symbol_ratio_threshold,
|
||||
0.25
|
||||
);
|
||||
assert!(parsed.security.outbound_leak_guard.enabled);
|
||||
assert_eq!(
|
||||
parsed.security.outbound_leak_guard.action,
|
||||
OutboundLeakGuardAction::Block
|
||||
);
|
||||
assert_eq!(parsed.security.outbound_leak_guard.sensitivity, 0.9);
|
||||
assert_eq!(parsed.security.otp.gated_actions.len(), 2);
|
||||
assert_eq!(parsed.security.otp.gated_domains.len(), 2);
|
||||
assert_eq!(
|
||||
@ -12367,6 +12461,19 @@ symbol_ratio_threshold = 0.25
|
||||
assert!(err.to_string().contains("symbol_ratio_threshold"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
async fn security_validation_rejects_invalid_outbound_leak_guard_sensitivity() {
|
||||
let mut config = Config::default();
|
||||
config.security.outbound_leak_guard.sensitivity = 1.2;
|
||||
|
||||
let err = config
|
||||
.validate()
|
||||
.expect_err("expected outbound leak guard sensitivity validation failure");
|
||||
assert!(err
|
||||
.to_string()
|
||||
.contains("security.outbound_leak_guard.sensitivity"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
async fn coordination_config_defaults() {
|
||||
let config = Config::default();
|
||||
|
||||
@ -986,13 +986,30 @@ pub(super) async fn run_gateway_chat_with_tools(
|
||||
crate::agent::process_message(config, message).await
|
||||
}
|
||||
|
||||
fn sanitize_gateway_response(response: &str, tools: &[Box<dyn Tool>]) -> String {
|
||||
let sanitized = crate::channels::sanitize_channel_response(response, tools);
|
||||
if sanitized.is_empty() && !response.trim().is_empty() {
|
||||
"I encountered malformed tool-call output and could not produce a safe reply. Please try again."
|
||||
.to_string()
|
||||
} else {
|
||||
sanitized
|
||||
fn gateway_outbound_leak_guard_snapshot(
|
||||
state: &AppState,
|
||||
) -> crate::config::OutboundLeakGuardConfig {
|
||||
state.config.lock().security.outbound_leak_guard.clone()
|
||||
}
|
||||
|
||||
fn sanitize_gateway_response(
|
||||
response: &str,
|
||||
tools: &[Box<dyn Tool>],
|
||||
leak_guard: &crate::config::OutboundLeakGuardConfig,
|
||||
) -> String {
|
||||
match crate::channels::sanitize_channel_response(response, tools, leak_guard) {
|
||||
crate::channels::ChannelSanitizationResult::Sanitized(sanitized) => {
|
||||
if sanitized.is_empty() && !response.trim().is_empty() {
|
||||
"I encountered malformed tool-call output and could not produce a safe reply. Please try again."
|
||||
.to_string()
|
||||
} else {
|
||||
sanitized
|
||||
}
|
||||
}
|
||||
crate::channels::ChannelSanitizationResult::Blocked { .. } => {
|
||||
"I blocked a draft response because it appeared to contain credential material. Please ask for a redacted summary."
|
||||
.to_string()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1227,9 +1244,11 @@ fn handle_webhook_streaming(
|
||||
.await
|
||||
{
|
||||
Ok(response) => {
|
||||
let leak_guard_cfg = gateway_outbound_leak_guard_snapshot(&state_for_call);
|
||||
let safe_response = sanitize_gateway_response(
|
||||
&response,
|
||||
state_for_call.tools_registry_exec.as_ref(),
|
||||
&leak_guard_cfg,
|
||||
);
|
||||
let duration = started_at.elapsed();
|
||||
state_for_call.observer.record_event(
|
||||
@ -1608,8 +1627,12 @@ async fn handle_webhook(
|
||||
|
||||
match run_gateway_chat_simple(&state, message).await {
|
||||
Ok(response) => {
|
||||
let safe_response =
|
||||
sanitize_gateway_response(&response, state.tools_registry_exec.as_ref());
|
||||
let leak_guard_cfg = gateway_outbound_leak_guard_snapshot(&state);
|
||||
let safe_response = sanitize_gateway_response(
|
||||
&response,
|
||||
state.tools_registry_exec.as_ref(),
|
||||
&leak_guard_cfg,
|
||||
);
|
||||
let duration = started_at.elapsed();
|
||||
state
|
||||
.observer
|
||||
@ -1814,8 +1837,12 @@ async fn handle_whatsapp_message(
|
||||
|
||||
match run_gateway_chat_with_tools(&state, &msg.content).await {
|
||||
Ok(response) => {
|
||||
let safe_response =
|
||||
sanitize_gateway_response(&response, state.tools_registry_exec.as_ref());
|
||||
let leak_guard_cfg = gateway_outbound_leak_guard_snapshot(&state);
|
||||
let safe_response = sanitize_gateway_response(
|
||||
&response,
|
||||
state.tools_registry_exec.as_ref(),
|
||||
&leak_guard_cfg,
|
||||
);
|
||||
// Send reply via WhatsApp
|
||||
if let Err(e) = wa
|
||||
.send(&SendMessage::new(safe_response, &msg.reply_target))
|
||||
@ -1933,8 +1960,12 @@ async fn handle_linq_webhook(
|
||||
// Call the LLM
|
||||
match run_gateway_chat_with_tools(&state, &msg.content).await {
|
||||
Ok(response) => {
|
||||
let safe_response =
|
||||
sanitize_gateway_response(&response, state.tools_registry_exec.as_ref());
|
||||
let leak_guard_cfg = gateway_outbound_leak_guard_snapshot(&state);
|
||||
let safe_response = sanitize_gateway_response(
|
||||
&response,
|
||||
state.tools_registry_exec.as_ref(),
|
||||
&leak_guard_cfg,
|
||||
);
|
||||
// Send reply via Linq
|
||||
if let Err(e) = linq
|
||||
.send(&SendMessage::new(safe_response, &msg.reply_target))
|
||||
@ -2027,8 +2058,12 @@ async fn handle_wati_webhook(State(state): State<AppState>, body: Bytes) -> impl
|
||||
// Call the LLM
|
||||
match run_gateway_chat_with_tools(&state, &msg.content).await {
|
||||
Ok(response) => {
|
||||
let safe_response =
|
||||
sanitize_gateway_response(&response, state.tools_registry_exec.as_ref());
|
||||
let leak_guard_cfg = gateway_outbound_leak_guard_snapshot(&state);
|
||||
let safe_response = sanitize_gateway_response(
|
||||
&response,
|
||||
state.tools_registry_exec.as_ref(),
|
||||
&leak_guard_cfg,
|
||||
);
|
||||
// Send reply via WATI
|
||||
if let Err(e) = wati
|
||||
.send(&SendMessage::new(safe_response, &msg.reply_target))
|
||||
@ -2133,8 +2168,12 @@ async fn handle_nextcloud_talk_webhook(
|
||||
|
||||
match run_gateway_chat_with_tools(&state, &msg.content).await {
|
||||
Ok(response) => {
|
||||
let safe_response =
|
||||
sanitize_gateway_response(&response, state.tools_registry_exec.as_ref());
|
||||
let leak_guard_cfg = gateway_outbound_leak_guard_snapshot(&state);
|
||||
let safe_response = sanitize_gateway_response(
|
||||
&response,
|
||||
state.tools_registry_exec.as_ref(),
|
||||
&leak_guard_cfg,
|
||||
);
|
||||
if let Err(e) = nextcloud_talk
|
||||
.send(&SendMessage::new(safe_response, &msg.reply_target))
|
||||
.await
|
||||
@ -2224,8 +2263,12 @@ async fn handle_qq_webhook(
|
||||
|
||||
match run_gateway_chat_with_tools(&state, &msg.content).await {
|
||||
Ok(response) => {
|
||||
let safe_response =
|
||||
sanitize_gateway_response(&response, state.tools_registry_exec.as_ref());
|
||||
let leak_guard_cfg = gateway_outbound_leak_guard_snapshot(&state);
|
||||
let safe_response = sanitize_gateway_response(
|
||||
&response,
|
||||
state.tools_registry_exec.as_ref(),
|
||||
&leak_guard_cfg,
|
||||
);
|
||||
if let Err(e) = qq
|
||||
.send(
|
||||
&SendMessage::new(safe_response, &msg.reply_target)
|
||||
@ -2787,7 +2830,8 @@ mod tests {
|
||||
</tool_call>
|
||||
After"#;
|
||||
|
||||
let result = sanitize_gateway_response(input, &[]);
|
||||
let leak_guard = crate::config::OutboundLeakGuardConfig::default();
|
||||
let result = sanitize_gateway_response(input, &[], &leak_guard);
|
||||
let normalized = result
|
||||
.lines()
|
||||
.filter(|line| !line.trim().is_empty())
|
||||
@ -2805,12 +2849,27 @@ After"#;
|
||||
{"result":{"status":"scheduled"}}
|
||||
Reminder set successfully."#;
|
||||
|
||||
let result = sanitize_gateway_response(input, &tools);
|
||||
let leak_guard = crate::config::OutboundLeakGuardConfig::default();
|
||||
let result = sanitize_gateway_response(input, &tools, &leak_guard);
|
||||
assert_eq!(result, "Reminder set successfully.");
|
||||
assert!(!result.contains("\"name\":\"schedule\""));
|
||||
assert!(!result.contains("\"result\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_gateway_response_blocks_detected_credentials_when_configured() {
|
||||
let tools: Vec<Box<dyn Tool>> = Vec::new();
|
||||
let leak_guard = crate::config::OutboundLeakGuardConfig {
|
||||
enabled: true,
|
||||
action: crate::config::OutboundLeakGuardAction::Block,
|
||||
sensitivity: 0.7,
|
||||
};
|
||||
|
||||
let result =
|
||||
sanitize_gateway_response("Temporary key: AKIAABCDEFGHIJKLMNOP", &tools, &leak_guard);
|
||||
assert!(result.contains("blocked a draft response"));
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct MockMemory;
|
||||
|
||||
|
||||
@ -275,11 +275,17 @@ async fn handle_non_streaming(
|
||||
.await
|
||||
{
|
||||
Ok(response_text) => {
|
||||
let leak_guard_cfg = state.config.lock().security.outbound_leak_guard.clone();
|
||||
let safe_response = sanitize_openai_compat_response(
|
||||
&response_text,
|
||||
state.tools_registry_exec.as_ref(),
|
||||
&leak_guard_cfg,
|
||||
);
|
||||
let duration = started_at.elapsed();
|
||||
record_success(&state, &provider_label, &model, duration);
|
||||
|
||||
#[allow(clippy::cast_possible_truncation)]
|
||||
let completion_tokens = (response_text.len() / 4) as u32;
|
||||
let completion_tokens = (safe_response.len() / 4) as u32;
|
||||
#[allow(clippy::cast_possible_truncation)]
|
||||
let prompt_tokens = messages.iter().map(|m| m.content.len() / 4).sum::<usize>() as u32;
|
||||
|
||||
@ -292,7 +298,7 @@ async fn handle_non_streaming(
|
||||
index: 0,
|
||||
message: ChatCompletionsResponseMessage {
|
||||
role: "assistant",
|
||||
content: response_text,
|
||||
content: safe_response,
|
||||
},
|
||||
finish_reason: "stop",
|
||||
}],
|
||||
@ -338,6 +344,71 @@ fn handle_streaming(
|
||||
) -> impl IntoResponse {
|
||||
let request_id = format!("chatcmpl-{}", Uuid::new_v4());
|
||||
let created = unix_timestamp();
|
||||
let leak_guard_cfg = state.config.lock().security.outbound_leak_guard.clone();
|
||||
|
||||
// Security-first behavior: when outbound leak guard is enabled, do not emit live
|
||||
// unvetted deltas. Buffer full provider output, sanitize once, then send SSE.
|
||||
if leak_guard_cfg.enabled {
|
||||
let model_clone = model.clone();
|
||||
let id = request_id.clone();
|
||||
let tools_registry = state.tools_registry_exec.clone();
|
||||
let leak_guard = leak_guard_cfg.clone();
|
||||
|
||||
let stream = futures_util::stream::once(async move {
|
||||
match state
|
||||
.provider
|
||||
.chat_with_history(&messages, &model_clone, temperature)
|
||||
.await
|
||||
{
|
||||
Ok(text) => {
|
||||
let safe_text = sanitize_openai_compat_response(
|
||||
&text,
|
||||
tools_registry.as_ref(),
|
||||
&leak_guard,
|
||||
);
|
||||
let duration = started_at.elapsed();
|
||||
record_success(&state, &provider_label, &model_clone, duration);
|
||||
|
||||
let chunk = ChatCompletionsChunk {
|
||||
id: id.clone(),
|
||||
object: "chat.completion.chunk",
|
||||
created,
|
||||
model: model_clone,
|
||||
choices: vec![ChunkChoice {
|
||||
index: 0,
|
||||
delta: ChunkDelta {
|
||||
role: Some("assistant"),
|
||||
content: Some(safe_text),
|
||||
},
|
||||
finish_reason: Some("stop"),
|
||||
}],
|
||||
};
|
||||
let json = serde_json::to_string(&chunk).unwrap_or_else(|_| "{}".to_string());
|
||||
let mut output = format!("data: {json}\n\n");
|
||||
output.push_str("data: [DONE]\n\n");
|
||||
Ok::<_, std::io::Error>(axum::body::Bytes::from(output))
|
||||
}
|
||||
Err(e) => {
|
||||
let duration = started_at.elapsed();
|
||||
let sanitized = crate::providers::sanitize_api_error(&e.to_string());
|
||||
record_failure(&state, &provider_label, &model_clone, duration, &sanitized);
|
||||
|
||||
let error_json = serde_json::json!({"error": sanitized});
|
||||
let output = format!("data: {error_json}\n\ndata: [DONE]\n\n");
|
||||
Ok(axum::body::Bytes::from(output))
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return axum::response::Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(header::CONTENT_TYPE, "text/event-stream")
|
||||
.header(header::CACHE_CONTROL, "no-cache")
|
||||
.header(header::CONNECTION, "keep-alive")
|
||||
.body(Body::from_stream(stream))
|
||||
.unwrap()
|
||||
.into_response();
|
||||
}
|
||||
|
||||
if !state.provider.supports_streaming() {
|
||||
// Provider doesn't support streaming — fall back to a single-chunk response
|
||||
@ -579,6 +650,27 @@ fn record_failure(
|
||||
});
|
||||
}
|
||||
|
||||
fn sanitize_openai_compat_response(
|
||||
response: &str,
|
||||
tools: &[Box<dyn crate::tools::Tool>],
|
||||
leak_guard: &crate::config::OutboundLeakGuardConfig,
|
||||
) -> String {
|
||||
match crate::channels::sanitize_channel_response(response, tools, leak_guard) {
|
||||
crate::channels::ChannelSanitizationResult::Sanitized(sanitized) => {
|
||||
if sanitized.is_empty() && !response.trim().is_empty() {
|
||||
"I encountered malformed tool-call output and could not produce a safe reply. Please try again."
|
||||
.to_string()
|
||||
} else {
|
||||
sanitized
|
||||
}
|
||||
}
|
||||
crate::channels::ChannelSanitizationResult::Blocked { .. } => {
|
||||
"I blocked a draft response because it appeared to contain credential material. Please ask for a redacted summary."
|
||||
.to_string()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
// TESTS
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
@ -586,6 +678,7 @@ fn record_failure(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::tools::Tool;
|
||||
|
||||
#[test]
|
||||
fn chat_completions_request_deserializes_minimal() {
|
||||
@ -717,4 +810,49 @@ mod tests {
|
||||
fn body_size_limit_is_512kb() {
|
||||
assert_eq!(CHAT_COMPLETIONS_MAX_BODY_SIZE, 524_288);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_openai_compat_response_redacts_detected_credentials() {
|
||||
let tools: Vec<Box<dyn Tool>> = Vec::new();
|
||||
let leak_guard = crate::config::OutboundLeakGuardConfig::default();
|
||||
let output = sanitize_openai_compat_response(
|
||||
"Temporary key: AKIAABCDEFGHIJKLMNOP",
|
||||
&tools,
|
||||
&leak_guard,
|
||||
);
|
||||
assert!(!output.contains("AKIAABCDEFGHIJKLMNOP"));
|
||||
assert!(output.contains("[REDACTED_AWS_CREDENTIAL]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_openai_compat_response_blocks_detected_credentials_when_configured() {
|
||||
let tools: Vec<Box<dyn Tool>> = Vec::new();
|
||||
let leak_guard = crate::config::OutboundLeakGuardConfig {
|
||||
enabled: true,
|
||||
action: crate::config::OutboundLeakGuardAction::Block,
|
||||
sensitivity: 0.7,
|
||||
};
|
||||
let output = sanitize_openai_compat_response(
|
||||
"Temporary key: AKIAABCDEFGHIJKLMNOP",
|
||||
&tools,
|
||||
&leak_guard,
|
||||
);
|
||||
assert!(output.contains("blocked a draft response"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_openai_compat_response_skips_scan_when_disabled() {
|
||||
let tools: Vec<Box<dyn Tool>> = Vec::new();
|
||||
let leak_guard = crate::config::OutboundLeakGuardConfig {
|
||||
enabled: false,
|
||||
action: crate::config::OutboundLeakGuardAction::Block,
|
||||
sensitivity: 0.7,
|
||||
};
|
||||
let output = sanitize_openai_compat_response(
|
||||
"Temporary key: AKIAABCDEFGHIJKLMNOP",
|
||||
&tools,
|
||||
&leak_guard,
|
||||
);
|
||||
assert!(output.contains("AKIAABCDEFGHIJKLMNOP"));
|
||||
}
|
||||
}
|
||||
|
||||
@ -188,8 +188,12 @@ pub async fn handle_api_chat(
|
||||
// ── Run the full agent loop ──
|
||||
match run_gateway_chat_with_tools(&state, &enriched_message).await {
|
||||
Ok(response) => {
|
||||
let safe_response =
|
||||
sanitize_gateway_response(&response, state.tools_registry_exec.as_ref());
|
||||
let leak_guard_cfg = state.config.lock().security.outbound_leak_guard.clone();
|
||||
let safe_response = sanitize_gateway_response(
|
||||
&response,
|
||||
state.tools_registry_exec.as_ref(),
|
||||
&leak_guard_cfg,
|
||||
);
|
||||
let duration = started_at.elapsed();
|
||||
|
||||
state
|
||||
@ -560,7 +564,12 @@ pub async fn handle_v1_chat_completions_with_tools(
|
||||
// ── Run the full agent loop ──
|
||||
let reply = match run_gateway_chat_with_tools(&state, &enriched_message).await {
|
||||
Ok(response) => {
|
||||
let safe = sanitize_gateway_response(&response, state.tools_registry_exec.as_ref());
|
||||
let leak_guard_cfg = state.config.lock().security.outbound_leak_guard.clone();
|
||||
let safe = sanitize_gateway_response(
|
||||
&response,
|
||||
state.tools_registry_exec.as_ref(),
|
||||
&leak_guard_cfg,
|
||||
);
|
||||
let duration = started_at.elapsed();
|
||||
|
||||
state
|
||||
|
||||
@ -24,13 +24,24 @@ use axum::{
|
||||
const EMPTY_WS_RESPONSE_FALLBACK: &str =
|
||||
"Tool execution completed, but the model returned no final text response. Please ask me to summarize the result.";
|
||||
|
||||
fn sanitize_ws_response(response: &str, tools: &[Box<dyn crate::tools::Tool>]) -> String {
|
||||
let sanitized = crate::channels::sanitize_channel_response(response, tools);
|
||||
if sanitized.is_empty() && !response.trim().is_empty() {
|
||||
"I encountered malformed tool-call output and could not produce a safe reply. Please try again."
|
||||
.to_string()
|
||||
} else {
|
||||
sanitized
|
||||
fn sanitize_ws_response(
|
||||
response: &str,
|
||||
tools: &[Box<dyn crate::tools::Tool>],
|
||||
leak_guard: &crate::config::OutboundLeakGuardConfig,
|
||||
) -> String {
|
||||
match crate::channels::sanitize_channel_response(response, tools, leak_guard) {
|
||||
crate::channels::ChannelSanitizationResult::Sanitized(sanitized) => {
|
||||
if sanitized.is_empty() && !response.trim().is_empty() {
|
||||
"I encountered malformed tool-call output and could not produce a safe reply. Please try again."
|
||||
.to_string()
|
||||
} else {
|
||||
sanitized
|
||||
}
|
||||
}
|
||||
crate::channels::ChannelSanitizationResult::Blocked { .. } => {
|
||||
"I blocked a draft response because it appeared to contain credential material. Please ask for a redacted summary."
|
||||
.to_string()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -94,8 +105,9 @@ fn finalize_ws_response(
|
||||
response: &str,
|
||||
history: &[ChatMessage],
|
||||
tools: &[Box<dyn crate::tools::Tool>],
|
||||
leak_guard: &crate::config::OutboundLeakGuardConfig,
|
||||
) -> String {
|
||||
let sanitized = sanitize_ws_response(response, tools);
|
||||
let sanitized = sanitize_ws_response(response, tools, leak_guard);
|
||||
if !sanitized.trim().is_empty() {
|
||||
return sanitized;
|
||||
}
|
||||
@ -257,8 +269,13 @@ async fn handle_socket(mut socket: WebSocket, state: AppState) {
|
||||
// Full agentic loop with tools (includes WASM skills, shell, memory, etc.)
|
||||
match super::run_gateway_chat_with_tools(&state, &content).await {
|
||||
Ok(response) => {
|
||||
let safe_response =
|
||||
finalize_ws_response(&response, &history, state.tools_registry_exec.as_ref());
|
||||
let leak_guard_cfg = { state.config.lock().security.outbound_leak_guard.clone() };
|
||||
let safe_response = finalize_ws_response(
|
||||
&response,
|
||||
&history,
|
||||
state.tools_registry_exec.as_ref(),
|
||||
&leak_guard_cfg,
|
||||
);
|
||||
// Add assistant response to history
|
||||
history.push(ChatMessage::assistant(&safe_response));
|
||||
|
||||
@ -465,7 +482,8 @@ mod tests {
|
||||
</tool_call>
|
||||
After"#;
|
||||
|
||||
let result = sanitize_ws_response(input, &[]);
|
||||
let leak_guard = crate::config::OutboundLeakGuardConfig::default();
|
||||
let result = sanitize_ws_response(input, &[], &leak_guard);
|
||||
let normalized = result
|
||||
.lines()
|
||||
.filter(|line| !line.trim().is_empty())
|
||||
@ -483,12 +501,27 @@ After"#;
|
||||
{"result":{"status":"scheduled"}}
|
||||
Reminder set successfully."#;
|
||||
|
||||
let result = sanitize_ws_response(input, &tools);
|
||||
let leak_guard = crate::config::OutboundLeakGuardConfig::default();
|
||||
let result = sanitize_ws_response(input, &tools, &leak_guard);
|
||||
assert_eq!(result, "Reminder set successfully.");
|
||||
assert!(!result.contains("\"name\":\"schedule\""));
|
||||
assert!(!result.contains("\"result\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_ws_response_blocks_detected_credentials_when_configured() {
|
||||
let tools: Vec<Box<dyn Tool>> = Vec::new();
|
||||
let leak_guard = crate::config::OutboundLeakGuardConfig {
|
||||
enabled: true,
|
||||
action: crate::config::OutboundLeakGuardAction::Block,
|
||||
sensitivity: 0.7,
|
||||
};
|
||||
|
||||
let result =
|
||||
sanitize_ws_response("Temporary key: AKIAABCDEFGHIJKLMNOP", &tools, &leak_guard);
|
||||
assert!(result.contains("blocked a draft response"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_ws_system_prompt_includes_tool_protocol_for_prompt_mode() {
|
||||
let config = crate::config::Config::default();
|
||||
@ -523,7 +556,8 @@ Reminder set successfully."#;
|
||||
),
|
||||
];
|
||||
|
||||
let result = finalize_ws_response("", &history, &tools);
|
||||
let leak_guard = crate::config::OutboundLeakGuardConfig::default();
|
||||
let result = finalize_ws_response("", &history, &tools, &leak_guard);
|
||||
assert!(result.contains("Latest tool output:"));
|
||||
assert!(result.contains("Disk usage: 72%"));
|
||||
assert!(!result.contains("<tool_result"));
|
||||
@ -538,7 +572,8 @@ Reminder set successfully."#;
|
||||
.to_string(),
|
||||
}];
|
||||
|
||||
let result = finalize_ws_response("", &history, &tools);
|
||||
let leak_guard = crate::config::OutboundLeakGuardConfig::default();
|
||||
let result = finalize_ws_response("", &history, &tools, &leak_guard);
|
||||
assert!(result.contains("Latest tool output:"));
|
||||
assert!(result.contains("/dev/disk3s1"));
|
||||
}
|
||||
@ -548,7 +583,8 @@ Reminder set successfully."#;
|
||||
let tools: Vec<Box<dyn Tool>> = vec![Box::new(MockScheduleTool)];
|
||||
let history = vec![ChatMessage::system("sys")];
|
||||
|
||||
let result = finalize_ws_response("", &history, &tools);
|
||||
let leak_guard = crate::config::OutboundLeakGuardConfig::default();
|
||||
let result = finalize_ws_response("", &history, &tools, &leak_guard);
|
||||
assert_eq!(result, EMPTY_WS_RESPONSE_FALLBACK);
|
||||
}
|
||||
}
|
||||
|
||||
56
src/security/file_link_guard.rs
Normal file
56
src/security/file_link_guard.rs
Normal file
@ -0,0 +1,56 @@
|
||||
use std::fs::Metadata;
|
||||
|
||||
/// Returns true when a file has multiple hard links.
|
||||
///
|
||||
/// Multiple links can allow path-based workspace guards to be bypassed by
|
||||
/// linking a workspace path to external sensitive content.
|
||||
pub fn has_multiple_hard_links(metadata: &Metadata) -> bool {
|
||||
link_count(metadata) > 1
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn link_count(metadata: &Metadata) -> u64 {
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
metadata.nlink()
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn link_count(metadata: &Metadata) -> u64 {
|
||||
use std::os::windows::fs::MetadataExt;
|
||||
u64::from(metadata.number_of_links())
|
||||
}
|
||||
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
fn link_count(_metadata: &Metadata) -> u64 {
|
||||
1
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn single_link_file_is_not_flagged() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let file = dir.path().join("single.txt");
|
||||
std::fs::write(&file, "hello").unwrap();
|
||||
let meta = std::fs::metadata(&file).unwrap();
|
||||
assert!(!has_multiple_hard_links(&meta));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hard_link_file_is_flagged_when_supported() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let original = dir.path().join("original.txt");
|
||||
let linked = dir.path().join("linked.txt");
|
||||
std::fs::write(&original, "hello").unwrap();
|
||||
|
||||
if std::fs::hard_link(&original, &linked).is_err() {
|
||||
// Some filesystems may disable hard links; treat as unsupported.
|
||||
return;
|
||||
}
|
||||
|
||||
let meta = std::fs::metadata(&original).unwrap();
|
||||
assert!(has_multiple_hard_links(&meta));
|
||||
}
|
||||
}
|
||||
@ -455,7 +455,9 @@ MIIEowIBAAKCAQEA0ZPr5JeyVDonXsKhfq...
|
||||
#[test]
|
||||
fn low_sensitivity_skips_generic() {
|
||||
let detector = LeakDetector::with_sensitivity(0.3);
|
||||
let content = "secret=mygenericvalue123456";
|
||||
// Use low entropy so this test only exercises the generic rule gate and
|
||||
// does not trip the independent high-entropy detector.
|
||||
let content = "secret=aaaaaaaaaaaaaaaa";
|
||||
let result = detector.scan(content);
|
||||
// Low sensitivity should not flag generic secrets
|
||||
assert!(matches!(result, LeakResult::Clean));
|
||||
|
||||
@ -23,6 +23,7 @@ pub mod audit;
|
||||
pub mod bubblewrap;
|
||||
pub mod detect;
|
||||
pub mod docker;
|
||||
pub mod file_link_guard;
|
||||
|
||||
// Prompt injection defense (contributed from RustyClaw, MIT licensed)
|
||||
pub mod domain_matcher;
|
||||
@ -39,6 +40,7 @@ pub mod policy;
|
||||
pub mod prompt_guard;
|
||||
pub mod roles;
|
||||
pub mod secrets;
|
||||
pub mod sensitive_paths;
|
||||
pub mod syscall_anomaly;
|
||||
pub mod traits;
|
||||
|
||||
|
||||
@ -106,6 +106,8 @@ pub struct SecurityPolicy {
|
||||
pub require_approval_for_medium_risk: bool,
|
||||
pub block_high_risk_commands: bool,
|
||||
pub shell_env_passthrough: Vec<String>,
|
||||
pub allow_sensitive_file_reads: bool,
|
||||
pub allow_sensitive_file_writes: bool,
|
||||
pub tracker: ActionTracker,
|
||||
}
|
||||
|
||||
@ -158,6 +160,8 @@ impl Default for SecurityPolicy {
|
||||
require_approval_for_medium_risk: true,
|
||||
block_high_risk_commands: true,
|
||||
shell_env_passthrough: vec![],
|
||||
allow_sensitive_file_reads: false,
|
||||
allow_sensitive_file_writes: false,
|
||||
tracker: ActionTracker::new(),
|
||||
}
|
||||
}
|
||||
@ -1096,6 +1100,8 @@ impl SecurityPolicy {
|
||||
require_approval_for_medium_risk: autonomy_config.require_approval_for_medium_risk,
|
||||
block_high_risk_commands: autonomy_config.block_high_risk_commands,
|
||||
shell_env_passthrough: autonomy_config.shell_env_passthrough.clone(),
|
||||
allow_sensitive_file_reads: autonomy_config.allow_sensitive_file_reads,
|
||||
allow_sensitive_file_writes: autonomy_config.allow_sensitive_file_writes,
|
||||
tracker: ActionTracker::new(),
|
||||
}
|
||||
}
|
||||
@ -1459,6 +1465,8 @@ mod tests {
|
||||
require_approval_for_medium_risk: false,
|
||||
block_high_risk_commands: false,
|
||||
shell_env_passthrough: vec!["DATABASE_URL".into()],
|
||||
allow_sensitive_file_reads: true,
|
||||
allow_sensitive_file_writes: true,
|
||||
..crate::config::AutonomyConfig::default()
|
||||
};
|
||||
let workspace = PathBuf::from("/tmp/test-workspace");
|
||||
@ -1473,6 +1481,8 @@ mod tests {
|
||||
assert!(!policy.require_approval_for_medium_risk);
|
||||
assert!(!policy.block_high_risk_commands);
|
||||
assert_eq!(policy.shell_env_passthrough, vec!["DATABASE_URL"]);
|
||||
assert!(policy.allow_sensitive_file_reads);
|
||||
assert!(policy.allow_sensitive_file_writes);
|
||||
assert_eq!(policy.workspace_dir, PathBuf::from("/tmp/test-workspace"));
|
||||
}
|
||||
|
||||
|
||||
94
src/security/sensitive_paths.rs
Normal file
94
src/security/sensitive_paths.rs
Normal file
@ -0,0 +1,94 @@
|
||||
use std::path::Path;
|
||||
|
||||
const SENSITIVE_EXACT_FILENAMES: &[&str] = &[
|
||||
".env",
|
||||
".envrc",
|
||||
".secret_key",
|
||||
".npmrc",
|
||||
".pypirc",
|
||||
".git-credentials",
|
||||
"credentials",
|
||||
"credentials.json",
|
||||
"auth-profiles.json",
|
||||
"id_rsa",
|
||||
"id_dsa",
|
||||
"id_ecdsa",
|
||||
"id_ed25519",
|
||||
];
|
||||
|
||||
const SENSITIVE_SUFFIXES: &[&str] = &[
|
||||
".pem",
|
||||
".key",
|
||||
".p12",
|
||||
".pfx",
|
||||
".ovpn",
|
||||
".kubeconfig",
|
||||
".netrc",
|
||||
];
|
||||
|
||||
const SENSITIVE_PATH_COMPONENTS: &[&str] = &[
|
||||
".ssh", ".aws", ".gnupg", ".kube", ".docker", ".azure", ".secrets",
|
||||
];
|
||||
|
||||
/// Returns true when a path appears to target secret-bearing material.
|
||||
///
|
||||
/// This check is intentionally conservative and case-insensitive to reduce
|
||||
/// accidental credential exposure through tool I/O.
|
||||
pub fn is_sensitive_file_path(path: &Path) -> bool {
|
||||
for component in path.components() {
|
||||
let std::path::Component::Normal(name) = component else {
|
||||
continue;
|
||||
};
|
||||
let lower = name.to_string_lossy().to_ascii_lowercase();
|
||||
if SENSITIVE_PATH_COMPONENTS.iter().any(|v| lower == *v) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
|
||||
return false;
|
||||
};
|
||||
let lower_name = name.to_ascii_lowercase();
|
||||
|
||||
if SENSITIVE_EXACT_FILENAMES
|
||||
.iter()
|
||||
.any(|v| lower_name == v.to_ascii_lowercase())
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if lower_name.starts_with(".env.") {
|
||||
return true;
|
||||
}
|
||||
|
||||
SENSITIVE_SUFFIXES
|
||||
.iter()
|
||||
.any(|suffix| lower_name.ends_with(suffix))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn detects_sensitive_exact_filenames() {
|
||||
assert!(is_sensitive_file_path(Path::new(".env")));
|
||||
assert!(is_sensitive_file_path(Path::new("ID_RSA")));
|
||||
assert!(is_sensitive_file_path(Path::new("credentials.json")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_sensitive_suffixes_and_components() {
|
||||
assert!(is_sensitive_file_path(Path::new("tls/cert.pem")));
|
||||
assert!(is_sensitive_file_path(Path::new(".aws/credentials")));
|
||||
assert!(is_sensitive_file_path(Path::new(
|
||||
"ops/.secrets/runtime.txt"
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_regular_paths() {
|
||||
assert!(!is_sensitive_file_path(Path::new("src/main.rs")));
|
||||
assert!(!is_sensitive_file_path(Path::new("notes/readme.md")));
|
||||
}
|
||||
}
|
||||
@ -1,7 +1,10 @@
|
||||
use super::traits::{Tool, ToolResult};
|
||||
use crate::security::file_link_guard::has_multiple_hard_links;
|
||||
use crate::security::sensitive_paths::is_sensitive_file_path;
|
||||
use crate::security::SecurityPolicy;
|
||||
use async_trait::async_trait;
|
||||
use serde_json::json;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Edit a file by replacing an exact string match with new content.
|
||||
@ -20,6 +23,21 @@ impl FileEditTool {
|
||||
}
|
||||
}
|
||||
|
||||
fn sensitive_file_edit_block_message(path: &str) -> String {
|
||||
format!(
|
||||
"Editing sensitive file '{path}' is blocked by policy. \
|
||||
Set [autonomy].allow_sensitive_file_writes = true only when strictly necessary."
|
||||
)
|
||||
}
|
||||
|
||||
fn hard_link_edit_block_message(path: &Path) -> String {
|
||||
format!(
|
||||
"Editing multiply-linked file '{}' is blocked by policy \
|
||||
(potential hard-link escape).",
|
||||
path.display()
|
||||
)
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for FileEditTool {
|
||||
fn name(&self) -> &str {
|
||||
@ -27,7 +45,7 @@ impl Tool for FileEditTool {
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Edit a file by replacing an exact string match with new content"
|
||||
"Edit a file by replacing an exact string match with new content. Sensitive files (for example .env and key material) are blocked by default."
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> serde_json::Value {
|
||||
@ -103,6 +121,14 @@ impl Tool for FileEditTool {
|
||||
});
|
||||
}
|
||||
|
||||
if !self.security.allow_sensitive_file_writes && is_sensitive_file_path(Path::new(path)) {
|
||||
return Ok(ToolResult {
|
||||
success: false,
|
||||
output: String::new(),
|
||||
error: Some(sensitive_file_edit_block_message(path)),
|
||||
});
|
||||
}
|
||||
|
||||
let full_path = self.security.workspace_dir.join(path);
|
||||
|
||||
// ── 5. Canonicalize parent ─────────────────────────────────
|
||||
@ -147,6 +173,16 @@ impl Tool for FileEditTool {
|
||||
|
||||
let resolved_target = resolved_parent.join(file_name);
|
||||
|
||||
if !self.security.allow_sensitive_file_writes && is_sensitive_file_path(&resolved_target) {
|
||||
return Ok(ToolResult {
|
||||
success: false,
|
||||
output: String::new(),
|
||||
error: Some(sensitive_file_edit_block_message(
|
||||
&resolved_target.display().to_string(),
|
||||
)),
|
||||
});
|
||||
}
|
||||
|
||||
// ── 7. Symlink check ───────────────────────────────────────
|
||||
if let Ok(meta) = tokio::fs::symlink_metadata(&resolved_target).await {
|
||||
if meta.file_type().is_symlink() {
|
||||
@ -159,6 +195,14 @@ impl Tool for FileEditTool {
|
||||
)),
|
||||
});
|
||||
}
|
||||
|
||||
if has_multiple_hard_links(&meta) {
|
||||
return Ok(ToolResult {
|
||||
success: false,
|
||||
output: String::new(),
|
||||
error: Some(hard_link_edit_block_message(&resolved_target)),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── 8. Record action ───────────────────────────────────────
|
||||
@ -248,6 +292,18 @@ mod tests {
|
||||
})
|
||||
}
|
||||
|
||||
fn test_security_allow_sensitive_writes(
|
||||
workspace: std::path::PathBuf,
|
||||
allow_sensitive_file_writes: bool,
|
||||
) -> Arc<SecurityPolicy> {
|
||||
Arc::new(SecurityPolicy {
|
||||
autonomy: AutonomyLevel::Supervised,
|
||||
workspace_dir: workspace,
|
||||
allow_sensitive_file_writes,
|
||||
..SecurityPolicy::default()
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_edit_name() {
|
||||
let tool = FileEditTool::new(test_security(std::env::temp_dir()));
|
||||
@ -396,6 +452,69 @@ mod tests {
|
||||
let _ = tokio::fs::remove_dir_all(&dir).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn file_edit_blocks_sensitive_file_by_default() {
|
||||
let dir = std::env::temp_dir().join("zeroclaw_test_file_edit_sensitive_blocked");
|
||||
let _ = tokio::fs::remove_dir_all(&dir).await;
|
||||
tokio::fs::create_dir_all(&dir).await.unwrap();
|
||||
tokio::fs::write(dir.join(".env"), "API_KEY=old")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let tool = FileEditTool::new(test_security(dir.clone()));
|
||||
let result = tool
|
||||
.execute(json!({
|
||||
"path": ".env",
|
||||
"old_string": "old",
|
||||
"new_string": "new"
|
||||
}))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(!result.success);
|
||||
assert!(result
|
||||
.error
|
||||
.as_deref()
|
||||
.unwrap_or("")
|
||||
.contains("sensitive file"));
|
||||
|
||||
let content = tokio::fs::read_to_string(dir.join(".env")).await.unwrap();
|
||||
assert_eq!(content, "API_KEY=old");
|
||||
|
||||
let _ = tokio::fs::remove_dir_all(&dir).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn file_edit_allows_sensitive_file_when_configured() {
|
||||
let dir = std::env::temp_dir().join("zeroclaw_test_file_edit_sensitive_allowed");
|
||||
let _ = tokio::fs::remove_dir_all(&dir).await;
|
||||
tokio::fs::create_dir_all(&dir).await.unwrap();
|
||||
tokio::fs::write(dir.join(".env"), "API_KEY=old")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let tool = FileEditTool::new(test_security_allow_sensitive_writes(dir.clone(), true));
|
||||
let result = tool
|
||||
.execute(json!({
|
||||
"path": ".env",
|
||||
"old_string": "old",
|
||||
"new_string": "new"
|
||||
}))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(
|
||||
result.success,
|
||||
"sensitive edit should succeed when enabled: {:?}",
|
||||
result.error
|
||||
);
|
||||
|
||||
let content = tokio::fs::read_to_string(dir.join(".env")).await.unwrap();
|
||||
assert_eq!(content, "API_KEY=new");
|
||||
|
||||
let _ = tokio::fs::remove_dir_all(&dir).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn file_edit_missing_path_param() {
|
||||
let tool = FileEditTool::new(test_security(std::env::temp_dir()));
|
||||
@ -572,6 +691,47 @@ mod tests {
|
||||
let _ = tokio::fs::remove_dir_all(&root).await;
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn file_edit_blocks_hardlink_target_file() {
|
||||
let root = std::env::temp_dir().join("zeroclaw_test_file_edit_hardlink_target");
|
||||
let workspace = root.join("workspace");
|
||||
let outside = root.join("outside");
|
||||
|
||||
let _ = tokio::fs::remove_dir_all(&root).await;
|
||||
tokio::fs::create_dir_all(&workspace).await.unwrap();
|
||||
tokio::fs::create_dir_all(&outside).await.unwrap();
|
||||
|
||||
tokio::fs::write(outside.join("target.txt"), "original")
|
||||
.await
|
||||
.unwrap();
|
||||
std::fs::hard_link(outside.join("target.txt"), workspace.join("linked.txt")).unwrap();
|
||||
|
||||
let tool = FileEditTool::new(test_security(workspace.clone()));
|
||||
let result = tool
|
||||
.execute(json!({
|
||||
"path": "linked.txt",
|
||||
"old_string": "original",
|
||||
"new_string": "hacked"
|
||||
}))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(!result.success, "editing through hard link must be blocked");
|
||||
assert!(result
|
||||
.error
|
||||
.as_deref()
|
||||
.unwrap_or("")
|
||||
.contains("hard-link escape"));
|
||||
|
||||
let content = tokio::fs::read_to_string(outside.join("target.txt"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(content, "original", "original file must not be modified");
|
||||
|
||||
let _ = tokio::fs::remove_dir_all(&root).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn file_edit_blocks_readonly_mode() {
|
||||
let dir = std::env::temp_dir().join("zeroclaw_test_file_edit_readonly");
|
||||
|
||||
@ -1,11 +1,29 @@
|
||||
use super::traits::{Tool, ToolResult};
|
||||
use crate::security::file_link_guard::has_multiple_hard_links;
|
||||
use crate::security::sensitive_paths::is_sensitive_file_path;
|
||||
use crate::security::SecurityPolicy;
|
||||
use async_trait::async_trait;
|
||||
use serde_json::json;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
const MAX_FILE_SIZE_BYTES: u64 = 10 * 1024 * 1024;
|
||||
|
||||
fn sensitive_file_block_message(path: &str) -> String {
|
||||
format!(
|
||||
"Reading sensitive file '{path}' is blocked by policy. \
|
||||
Set [autonomy].allow_sensitive_file_reads = true only when strictly necessary."
|
||||
)
|
||||
}
|
||||
|
||||
fn hard_link_block_message(path: &Path) -> String {
|
||||
format!(
|
||||
"Reading multiply-linked file '{}' is blocked by policy \
|
||||
(potential hard-link escape).",
|
||||
path.display()
|
||||
)
|
||||
}
|
||||
|
||||
/// Read file contents with path sandboxing
|
||||
pub struct FileReadTool {
|
||||
security: Arc<SecurityPolicy>,
|
||||
@ -24,7 +42,7 @@ impl Tool for FileReadTool {
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Read file contents with line numbers. Supports partial reading via offset and limit. Extracts text from PDF; other binary files are read with lossy UTF-8 conversion."
|
||||
"Read file contents with line numbers. Supports partial reading via offset and limit. Extracts text from PDF; other binary files are read with lossy UTF-8 conversion. Sensitive files (for example .env and key material) are blocked by default."
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> serde_json::Value {
|
||||
@ -71,6 +89,14 @@ impl Tool for FileReadTool {
|
||||
});
|
||||
}
|
||||
|
||||
if !self.security.allow_sensitive_file_reads && is_sensitive_file_path(Path::new(path)) {
|
||||
return Ok(ToolResult {
|
||||
success: false,
|
||||
output: String::new(),
|
||||
error: Some(sensitive_file_block_message(path)),
|
||||
});
|
||||
}
|
||||
|
||||
// Record action BEFORE canonicalization so that every non-trivially-rejected
|
||||
// request consumes rate limit budget. This prevents attackers from probing
|
||||
// path existence (via canonicalize errors) without rate limit cost.
|
||||
@ -107,9 +133,27 @@ impl Tool for FileReadTool {
|
||||
});
|
||||
}
|
||||
|
||||
if !self.security.allow_sensitive_file_reads && is_sensitive_file_path(&resolved_path) {
|
||||
return Ok(ToolResult {
|
||||
success: false,
|
||||
output: String::new(),
|
||||
error: Some(sensitive_file_block_message(
|
||||
&resolved_path.display().to_string(),
|
||||
)),
|
||||
});
|
||||
}
|
||||
|
||||
// Check file size AFTER canonicalization to prevent TOCTOU symlink bypass
|
||||
match tokio::fs::metadata(&resolved_path).await {
|
||||
Ok(meta) => {
|
||||
if has_multiple_hard_links(&meta) {
|
||||
return Ok(ToolResult {
|
||||
success: false,
|
||||
output: String::new(),
|
||||
error: Some(hard_link_block_message(&resolved_path)),
|
||||
});
|
||||
}
|
||||
|
||||
if meta.len() > MAX_FILE_SIZE_BYTES {
|
||||
return Ok(ToolResult {
|
||||
success: false,
|
||||
@ -341,6 +385,124 @@ mod tests {
|
||||
assert!(result.error.as_ref().unwrap().contains("not allowed"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn file_read_blocks_sensitive_env_file_by_default() {
|
||||
let dir = std::env::temp_dir().join("zeroclaw_test_file_read_sensitive_env");
|
||||
let _ = tokio::fs::remove_dir_all(&dir).await;
|
||||
tokio::fs::create_dir_all(&dir).await.unwrap();
|
||||
tokio::fs::write(dir.join(".env"), "API_KEY=plaintext-secret")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let tool = FileReadTool::new(test_security(dir.clone()));
|
||||
let result = tool.execute(json!({"path": ".env"})).await.unwrap();
|
||||
assert!(!result.success);
|
||||
assert!(result
|
||||
.error
|
||||
.as_deref()
|
||||
.unwrap_or("")
|
||||
.contains("sensitive file"));
|
||||
|
||||
let _ = tokio::fs::remove_dir_all(&dir).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn file_read_blocks_sensitive_dotenv_variant_by_default() {
|
||||
let dir = std::env::temp_dir().join("zeroclaw_test_file_read_sensitive_env_variant");
|
||||
let _ = tokio::fs::remove_dir_all(&dir).await;
|
||||
tokio::fs::create_dir_all(&dir).await.unwrap();
|
||||
tokio::fs::write(dir.join(".env.production"), "API_KEY=plaintext-secret")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let tool = FileReadTool::new(test_security(dir.clone()));
|
||||
let result = tool
|
||||
.execute(json!({"path": ".env.production"}))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!result.success);
|
||||
assert!(result
|
||||
.error
|
||||
.as_deref()
|
||||
.unwrap_or("")
|
||||
.contains("sensitive file"));
|
||||
|
||||
let _ = tokio::fs::remove_dir_all(&dir).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn file_read_blocks_sensitive_directory_credentials_by_default() {
|
||||
let dir = std::env::temp_dir().join("zeroclaw_test_file_read_sensitive_aws");
|
||||
let _ = tokio::fs::remove_dir_all(&dir).await;
|
||||
tokio::fs::create_dir_all(dir.join(".aws")).await.unwrap();
|
||||
tokio::fs::write(dir.join(".aws/credentials"), "aws_access_key_id=abc")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let tool = FileReadTool::new(test_security(dir.clone()));
|
||||
let result = tool
|
||||
.execute(json!({"path": ".aws/credentials"}))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!result.success);
|
||||
assert!(result
|
||||
.error
|
||||
.as_deref()
|
||||
.unwrap_or("")
|
||||
.contains("sensitive file"));
|
||||
|
||||
let _ = tokio::fs::remove_dir_all(&dir).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn file_read_allows_sensitive_file_when_policy_enabled() {
|
||||
let dir = std::env::temp_dir().join("zeroclaw_test_file_read_sensitive_allowed");
|
||||
let _ = tokio::fs::remove_dir_all(&dir).await;
|
||||
tokio::fs::create_dir_all(&dir).await.unwrap();
|
||||
tokio::fs::write(dir.join(".env"), "SAFE=value")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let policy = Arc::new(SecurityPolicy {
|
||||
autonomy: AutonomyLevel::Supervised,
|
||||
workspace_dir: dir.clone(),
|
||||
allow_sensitive_file_reads: true,
|
||||
..SecurityPolicy::default()
|
||||
});
|
||||
let tool = FileReadTool::new(policy);
|
||||
let result = tool.execute(json!({"path": ".env"})).await.unwrap();
|
||||
assert!(result.success);
|
||||
assert!(result.output.contains("1: SAFE=value"));
|
||||
|
||||
let _ = tokio::fs::remove_dir_all(&dir).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn file_read_allows_sensitive_nested_path_when_policy_enabled() {
|
||||
let dir = std::env::temp_dir().join("zeroclaw_test_file_read_sensitive_nested_allowed");
|
||||
let _ = tokio::fs::remove_dir_all(&dir).await;
|
||||
tokio::fs::create_dir_all(dir.join(".aws")).await.unwrap();
|
||||
tokio::fs::write(dir.join(".aws/credentials"), "aws_access_key_id=allowed")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let policy = Arc::new(SecurityPolicy {
|
||||
autonomy: AutonomyLevel::Supervised,
|
||||
workspace_dir: dir.clone(),
|
||||
allow_sensitive_file_reads: true,
|
||||
..SecurityPolicy::default()
|
||||
});
|
||||
let tool = FileReadTool::new(policy);
|
||||
let result = tool
|
||||
.execute(json!({"path": ".aws/credentials"}))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(result.success);
|
||||
assert!(result.output.contains("1: aws_access_key_id=allowed"));
|
||||
|
||||
let _ = tokio::fs::remove_dir_all(&dir).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn file_read_blocks_when_rate_limited() {
|
||||
let dir = std::env::temp_dir().join("zeroclaw_test_file_read_rate_limited");
|
||||
@ -461,6 +623,35 @@ mod tests {
|
||||
let _ = tokio::fs::remove_dir_all(&root).await;
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn file_read_blocks_hardlink_escape() {
|
||||
let root = std::env::temp_dir().join("zeroclaw_test_file_read_hardlink_escape");
|
||||
let workspace = root.join("workspace");
|
||||
let outside = root.join("outside");
|
||||
|
||||
let _ = tokio::fs::remove_dir_all(&root).await;
|
||||
tokio::fs::create_dir_all(&workspace).await.unwrap();
|
||||
tokio::fs::create_dir_all(&outside).await.unwrap();
|
||||
|
||||
tokio::fs::write(outside.join("secret.txt"), "outside workspace")
|
||||
.await
|
||||
.unwrap();
|
||||
std::fs::hard_link(outside.join("secret.txt"), workspace.join("alias.txt")).unwrap();
|
||||
|
||||
let tool = FileReadTool::new(test_security(workspace.clone()));
|
||||
let result = tool.execute(json!({"path": "alias.txt"})).await.unwrap();
|
||||
|
||||
assert!(!result.success);
|
||||
assert!(result
|
||||
.error
|
||||
.as_deref()
|
||||
.unwrap_or("")
|
||||
.contains("hard-link escape"));
|
||||
|
||||
let _ = tokio::fs::remove_dir_all(&root).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn file_read_outside_workspace_allowed_when_workspace_only_disabled() {
|
||||
let root = std::env::temp_dir().join("zeroclaw_test_file_read_allowed_roots_hint");
|
||||
|
||||
@ -1,7 +1,10 @@
|
||||
use super::traits::{Tool, ToolResult};
|
||||
use crate::security::file_link_guard::has_multiple_hard_links;
|
||||
use crate::security::sensitive_paths::is_sensitive_file_path;
|
||||
use crate::security::SecurityPolicy;
|
||||
use async_trait::async_trait;
|
||||
use serde_json::json;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Write file contents with path sandboxing
|
||||
@ -15,6 +18,21 @@ impl FileWriteTool {
|
||||
}
|
||||
}
|
||||
|
||||
fn sensitive_file_write_block_message(path: &str) -> String {
|
||||
format!(
|
||||
"Writing sensitive file '{path}' is blocked by policy. \
|
||||
Set [autonomy].allow_sensitive_file_writes = true only when strictly necessary."
|
||||
)
|
||||
}
|
||||
|
||||
fn hard_link_write_block_message(path: &Path) -> String {
|
||||
format!(
|
||||
"Writing multiply-linked file '{}' is blocked by policy \
|
||||
(potential hard-link escape).",
|
||||
path.display()
|
||||
)
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for FileWriteTool {
|
||||
fn name(&self) -> &str {
|
||||
@ -22,7 +40,7 @@ impl Tool for FileWriteTool {
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Write contents to a file in the workspace"
|
||||
"Write contents to a file in the workspace. Sensitive files (for example .env and key material) are blocked by default."
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> serde_json::Value {
|
||||
@ -78,6 +96,14 @@ impl Tool for FileWriteTool {
|
||||
});
|
||||
}
|
||||
|
||||
if !self.security.allow_sensitive_file_writes && is_sensitive_file_path(Path::new(path)) {
|
||||
return Ok(ToolResult {
|
||||
success: false,
|
||||
output: String::new(),
|
||||
error: Some(sensitive_file_write_block_message(path)),
|
||||
});
|
||||
}
|
||||
|
||||
let full_path = self.security.workspace_dir.join(path);
|
||||
|
||||
let Some(parent) = full_path.parent() else {
|
||||
@ -124,6 +150,16 @@ impl Tool for FileWriteTool {
|
||||
|
||||
let resolved_target = resolved_parent.join(file_name);
|
||||
|
||||
if !self.security.allow_sensitive_file_writes && is_sensitive_file_path(&resolved_target) {
|
||||
return Ok(ToolResult {
|
||||
success: false,
|
||||
output: String::new(),
|
||||
error: Some(sensitive_file_write_block_message(
|
||||
&resolved_target.display().to_string(),
|
||||
)),
|
||||
});
|
||||
}
|
||||
|
||||
// If the target already exists and is a symlink, refuse to follow it
|
||||
if let Ok(meta) = tokio::fs::symlink_metadata(&resolved_target).await {
|
||||
if meta.file_type().is_symlink() {
|
||||
@ -136,6 +172,14 @@ impl Tool for FileWriteTool {
|
||||
)),
|
||||
});
|
||||
}
|
||||
|
||||
if has_multiple_hard_links(&meta) {
|
||||
return Ok(ToolResult {
|
||||
success: false,
|
||||
output: String::new(),
|
||||
error: Some(hard_link_write_block_message(&resolved_target)),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if !self.security.record_action() {
|
||||
@ -187,6 +231,18 @@ mod tests {
|
||||
})
|
||||
}
|
||||
|
||||
fn test_security_allow_sensitive_writes(
|
||||
workspace: std::path::PathBuf,
|
||||
allow_sensitive_file_writes: bool,
|
||||
) -> Arc<SecurityPolicy> {
|
||||
Arc::new(SecurityPolicy {
|
||||
autonomy: AutonomyLevel::Supervised,
|
||||
workspace_dir: workspace,
|
||||
allow_sensitive_file_writes,
|
||||
..SecurityPolicy::default()
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_write_name() {
|
||||
let tool = FileWriteTool::new(test_security(std::env::temp_dir()));
|
||||
@ -330,6 +386,52 @@ mod tests {
|
||||
let _ = tokio::fs::remove_dir_all(&dir).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn file_write_blocks_sensitive_file_by_default() {
|
||||
let dir = std::env::temp_dir().join("zeroclaw_test_file_write_sensitive_blocked");
|
||||
let _ = tokio::fs::remove_dir_all(&dir).await;
|
||||
tokio::fs::create_dir_all(&dir).await.unwrap();
|
||||
|
||||
let tool = FileWriteTool::new(test_security(dir.clone()));
|
||||
let result = tool
|
||||
.execute(json!({"path": ".env", "content": "API_KEY=123"}))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(!result.success);
|
||||
assert!(result
|
||||
.error
|
||||
.as_deref()
|
||||
.unwrap_or("")
|
||||
.contains("sensitive file"));
|
||||
assert!(!dir.join(".env").exists());
|
||||
|
||||
let _ = tokio::fs::remove_dir_all(&dir).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn file_write_allows_sensitive_file_when_configured() {
|
||||
let dir = std::env::temp_dir().join("zeroclaw_test_file_write_sensitive_allowed");
|
||||
let _ = tokio::fs::remove_dir_all(&dir).await;
|
||||
tokio::fs::create_dir_all(&dir).await.unwrap();
|
||||
|
||||
let tool = FileWriteTool::new(test_security_allow_sensitive_writes(dir.clone(), true));
|
||||
let result = tool
|
||||
.execute(json!({"path": ".env", "content": "API_KEY=123"}))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(
|
||||
result.success,
|
||||
"sensitive write should succeed when enabled: {:?}",
|
||||
result.error
|
||||
);
|
||||
let content = tokio::fs::read_to_string(dir.join(".env")).await.unwrap();
|
||||
assert_eq!(content, "API_KEY=123");
|
||||
|
||||
let _ = tokio::fs::remove_dir_all(&dir).await;
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn file_write_blocks_symlink_escape() {
|
||||
@ -450,6 +552,43 @@ mod tests {
|
||||
let _ = tokio::fs::remove_dir_all(&root).await;
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn file_write_blocks_hardlink_target_file() {
|
||||
let root = std::env::temp_dir().join("zeroclaw_test_file_write_hardlink_target");
|
||||
let workspace = root.join("workspace");
|
||||
let outside = root.join("outside");
|
||||
|
||||
let _ = tokio::fs::remove_dir_all(&root).await;
|
||||
tokio::fs::create_dir_all(&workspace).await.unwrap();
|
||||
tokio::fs::create_dir_all(&outside).await.unwrap();
|
||||
|
||||
tokio::fs::write(outside.join("target.txt"), "original")
|
||||
.await
|
||||
.unwrap();
|
||||
std::fs::hard_link(outside.join("target.txt"), workspace.join("linked.txt")).unwrap();
|
||||
|
||||
let tool = FileWriteTool::new(test_security(workspace.clone()));
|
||||
let result = tool
|
||||
.execute(json!({"path": "linked.txt", "content": "overwritten"}))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(!result.success, "writing through hard link must be blocked");
|
||||
assert!(result
|
||||
.error
|
||||
.as_deref()
|
||||
.unwrap_or("")
|
||||
.contains("hard-link escape"));
|
||||
|
||||
let content = tokio::fs::read_to_string(outside.join("target.txt"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(content, "original", "original file must not be modified");
|
||||
|
||||
let _ = tokio::fs::remove_dir_all(&root).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn file_write_blocks_null_byte_in_path() {
|
||||
let dir = std::env::temp_dir().join("zeroclaw_test_file_write_null");
|
||||
|
||||
@ -7,6 +7,8 @@ use std::sync::Arc;
|
||||
|
||||
const PUSHOVER_API_URL: &str = "https://api.pushover.net/1/messages.json";
|
||||
const PUSHOVER_REQUEST_TIMEOUT_SECS: u64 = 15;
|
||||
const PUSHOVER_TOKEN_ENV: &str = "PUSHOVER_TOKEN";
|
||||
const PUSHOVER_USER_KEY_ENV: &str = "PUSHOVER_USER_KEY";
|
||||
|
||||
pub struct PushoverTool {
|
||||
security: Arc<SecurityPolicy>,
|
||||
@ -41,7 +43,35 @@ impl PushoverTool {
|
||||
)
|
||||
}
|
||||
|
||||
fn looks_like_secret_reference(value: &str) -> bool {
|
||||
let trimmed = value.trim();
|
||||
trimmed.starts_with("en://") || trimmed.starts_with("ev://")
|
||||
}
|
||||
|
||||
fn parse_process_env_credentials() -> anyhow::Result<Option<(String, String)>> {
|
||||
let token = std::env::var(PUSHOVER_TOKEN_ENV)
|
||||
.ok()
|
||||
.map(|v| v.trim().to_string())
|
||||
.filter(|v| !v.is_empty());
|
||||
let user_key = std::env::var(PUSHOVER_USER_KEY_ENV)
|
||||
.ok()
|
||||
.map(|v| v.trim().to_string())
|
||||
.filter(|v| !v.is_empty());
|
||||
|
||||
match (token, user_key) {
|
||||
(Some(token), Some(user_key)) => Ok(Some((token, user_key))),
|
||||
(Some(_), None) | (None, Some(_)) => Err(anyhow::anyhow!(
|
||||
"Process environment has only one Pushover credential. Set both {PUSHOVER_TOKEN_ENV} and {PUSHOVER_USER_KEY_ENV}."
|
||||
)),
|
||||
(None, None) => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_credentials(&self) -> anyhow::Result<(String, String)> {
|
||||
if let Some(credentials) = Self::parse_process_env_credentials()? {
|
||||
return Ok(credentials);
|
||||
}
|
||||
|
||||
let env_path = self.workspace_dir.join(".env");
|
||||
let content = tokio::fs::read_to_string(&env_path)
|
||||
.await
|
||||
@ -60,17 +90,27 @@ impl PushoverTool {
|
||||
let key = key.trim();
|
||||
let value = Self::parse_env_value(value);
|
||||
|
||||
if key.eq_ignore_ascii_case("PUSHOVER_TOKEN") {
|
||||
if Self::looks_like_secret_reference(&value) {
|
||||
return Err(anyhow::anyhow!(
|
||||
"{} uses secret references ({value}) for {key}. \
|
||||
Provide resolved credentials via process env vars ({PUSHOVER_TOKEN_ENV}/{PUSHOVER_USER_KEY_ENV}), \
|
||||
for example by launching ZeroClaw with enject injection.",
|
||||
env_path.display()
|
||||
));
|
||||
}
|
||||
|
||||
if key.eq_ignore_ascii_case(PUSHOVER_TOKEN_ENV) {
|
||||
token = Some(value);
|
||||
} else if key.eq_ignore_ascii_case("PUSHOVER_USER_KEY") {
|
||||
} else if key.eq_ignore_ascii_case(PUSHOVER_USER_KEY_ENV) {
|
||||
user_key = Some(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let token = token.ok_or_else(|| anyhow::anyhow!("PUSHOVER_TOKEN not found in .env"))?;
|
||||
let token =
|
||||
token.ok_or_else(|| anyhow::anyhow!("{PUSHOVER_TOKEN_ENV} not found in .env"))?;
|
||||
let user_key =
|
||||
user_key.ok_or_else(|| anyhow::anyhow!("PUSHOVER_USER_KEY not found in .env"))?;
|
||||
user_key.ok_or_else(|| anyhow::anyhow!("{PUSHOVER_USER_KEY_ENV} not found in .env"))?;
|
||||
|
||||
Ok((token, user_key))
|
||||
}
|
||||
@ -83,7 +123,7 @@ impl Tool for PushoverTool {
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Send a Pushover notification to your device. Requires PUSHOVER_TOKEN and PUSHOVER_USER_KEY in .env file."
|
||||
"Send a Pushover notification to your device. Uses PUSHOVER_TOKEN/PUSHOVER_USER_KEY from process environment first, then falls back to .env."
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> serde_json::Value {
|
||||
@ -219,8 +259,11 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::security::AutonomyLevel;
|
||||
use std::fs;
|
||||
use std::sync::{LazyLock, Mutex, MutexGuard};
|
||||
use tempfile::TempDir;
|
||||
|
||||
static ENV_LOCK: LazyLock<Mutex<()>> = LazyLock::new(|| Mutex::new(()));
|
||||
|
||||
fn test_security(level: AutonomyLevel, max_actions_per_hour: u32) -> Arc<SecurityPolicy> {
|
||||
Arc::new(SecurityPolicy {
|
||||
autonomy: level,
|
||||
@ -230,6 +273,39 @@ mod tests {
|
||||
})
|
||||
}
|
||||
|
||||
fn lock_env() -> MutexGuard<'static, ()> {
|
||||
ENV_LOCK.lock().expect("env lock poisoned")
|
||||
}
|
||||
|
||||
struct EnvGuard {
|
||||
key: &'static str,
|
||||
original: Option<String>,
|
||||
}
|
||||
|
||||
impl EnvGuard {
|
||||
fn set(key: &'static str, value: &str) -> Self {
|
||||
let original = std::env::var(key).ok();
|
||||
std::env::set_var(key, value);
|
||||
Self { key, original }
|
||||
}
|
||||
|
||||
fn unset(key: &'static str) -> Self {
|
||||
let original = std::env::var(key).ok();
|
||||
std::env::remove_var(key);
|
||||
Self { key, original }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for EnvGuard {
|
||||
fn drop(&mut self) {
|
||||
if let Some(value) = &self.original {
|
||||
std::env::set_var(self.key, value);
|
||||
} else {
|
||||
std::env::remove_var(self.key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pushover_tool_name() {
|
||||
let tool = PushoverTool::new(
|
||||
@ -272,6 +348,9 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn credentials_parsed_from_env_file() {
|
||||
let _env_lock = lock_env();
|
||||
let _g1 = EnvGuard::unset(PUSHOVER_TOKEN_ENV);
|
||||
let _g2 = EnvGuard::unset(PUSHOVER_USER_KEY_ENV);
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let env_path = tmp.path().join(".env");
|
||||
fs::write(
|
||||
@ -294,6 +373,9 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn credentials_fail_without_env_file() {
|
||||
let _env_lock = lock_env();
|
||||
let _g1 = EnvGuard::unset(PUSHOVER_TOKEN_ENV);
|
||||
let _g2 = EnvGuard::unset(PUSHOVER_USER_KEY_ENV);
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let tool = PushoverTool::new(
|
||||
test_security(AutonomyLevel::Full, 100),
|
||||
@ -306,6 +388,9 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn credentials_fail_without_token() {
|
||||
let _env_lock = lock_env();
|
||||
let _g1 = EnvGuard::unset(PUSHOVER_TOKEN_ENV);
|
||||
let _g2 = EnvGuard::unset(PUSHOVER_USER_KEY_ENV);
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let env_path = tmp.path().join(".env");
|
||||
fs::write(&env_path, "PUSHOVER_USER_KEY=userkey456\n").unwrap();
|
||||
@ -321,6 +406,9 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn credentials_fail_without_user_key() {
|
||||
let _env_lock = lock_env();
|
||||
let _g1 = EnvGuard::unset(PUSHOVER_TOKEN_ENV);
|
||||
let _g2 = EnvGuard::unset(PUSHOVER_USER_KEY_ENV);
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let env_path = tmp.path().join(".env");
|
||||
fs::write(&env_path, "PUSHOVER_TOKEN=testtoken123\n").unwrap();
|
||||
@ -336,6 +424,9 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn credentials_ignore_comments() {
|
||||
let _env_lock = lock_env();
|
||||
let _g1 = EnvGuard::unset(PUSHOVER_TOKEN_ENV);
|
||||
let _g2 = EnvGuard::unset(PUSHOVER_USER_KEY_ENV);
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let env_path = tmp.path().join(".env");
|
||||
fs::write(&env_path, "# This is a comment\nPUSHOVER_TOKEN=realtoken\n# Another comment\nPUSHOVER_USER_KEY=realuser\n").unwrap();
|
||||
@ -374,6 +465,9 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn credentials_support_export_and_quoted_values() {
|
||||
let _env_lock = lock_env();
|
||||
let _g1 = EnvGuard::unset(PUSHOVER_TOKEN_ENV);
|
||||
let _g2 = EnvGuard::unset(PUSHOVER_USER_KEY_ENV);
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let env_path = tmp.path().join(".env");
|
||||
fs::write(
|
||||
@ -394,6 +488,72 @@ mod tests {
|
||||
assert_eq!(user_key, "quoteduser");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn credentials_use_process_env_without_env_file() {
|
||||
let _env_lock = lock_env();
|
||||
let _g1 = EnvGuard::set(PUSHOVER_TOKEN_ENV, "env-token-123");
|
||||
let _g2 = EnvGuard::set(PUSHOVER_USER_KEY_ENV, "env-user-456");
|
||||
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let tool = PushoverTool::new(
|
||||
test_security(AutonomyLevel::Full, 100),
|
||||
tmp.path().to_path_buf(),
|
||||
);
|
||||
let result = tool.get_credentials().await;
|
||||
|
||||
assert!(result.is_ok());
|
||||
let (token, user_key) = result.unwrap();
|
||||
assert_eq!(token, "env-token-123");
|
||||
assert_eq!(user_key, "env-user-456");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn credentials_fail_when_only_one_process_env_var_is_set() {
|
||||
let _env_lock = lock_env();
|
||||
let _g1 = EnvGuard::set(PUSHOVER_TOKEN_ENV, "only-token");
|
||||
let _g2 = EnvGuard::unset(PUSHOVER_USER_KEY_ENV);
|
||||
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let tool = PushoverTool::new(
|
||||
test_security(AutonomyLevel::Full, 100),
|
||||
tmp.path().to_path_buf(),
|
||||
);
|
||||
let result = tool.get_credentials().await;
|
||||
|
||||
assert!(result.is_err());
|
||||
assert!(result
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("only one Pushover credential"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn credentials_fail_on_secret_reference_values_in_dotenv() {
|
||||
let _env_lock = lock_env();
|
||||
let _g1 = EnvGuard::unset(PUSHOVER_TOKEN_ENV);
|
||||
let _g2 = EnvGuard::unset(PUSHOVER_USER_KEY_ENV);
|
||||
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let env_path = tmp.path().join(".env");
|
||||
fs::write(
|
||||
&env_path,
|
||||
"PUSHOVER_TOKEN=en://pushover_token\nPUSHOVER_USER_KEY=en://pushover_user\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let tool = PushoverTool::new(
|
||||
test_security(AutonomyLevel::Full, 100),
|
||||
tmp.path().to_path_buf(),
|
||||
);
|
||||
let result = tool.get_credentials().await;
|
||||
|
||||
assert!(result.is_err());
|
||||
assert!(result
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("secret references"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn execute_blocks_readonly_mode() {
|
||||
let tool = PushoverTool::new(
|
||||
|
||||
Loading…
Reference in New Issue
Block a user