Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions approval-gate/src/functions/gate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ use crate::denial::{gate_unavailable_envelope, human_only_denial, permissions_de
use crate::error::ApprovalError;
use crate::pending;
use crate::permissions::Decision;
use crate::redact::redact;
use crate::redact::redact_for;
use crate::session;
use crate::settings;
use crate::types::{
Expand Down Expand Up @@ -125,7 +125,7 @@ async fn hold(deps: &Deps, input: &HookInput, call: &HookCall) -> HookOutput {
turn_id: input.turn_id.clone(),
function_call_id: call.id.clone(),
function_id: call.function_id.clone(),
arguments_excerpt: redact(&call.arguments),
arguments_excerpt: redact_for(&call.function_id, &call.arguments),
pending_at,
session_title,
session_description,
Expand Down
81 changes: 74 additions & 7 deletions approval-gate/src/redact.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,28 +53,62 @@ pub fn clip(s: &str) -> String {
}
}

/// Coder write functions whose arguments must reach approval UIs intact —
/// a clipped patch or content op can't render as a diff preview. Secret-key
/// redaction still applies; `FULL_PAYLOAD_BUDGET_BYTES` guards notification
/// channels against unbounded records.
const FULL_PAYLOAD_FUNCTIONS: [&str; 5] = [
"coder::update-file",
"coder::create-file",
"coder::apply-patch",
"coder::move-file",
"coder::delete-file",
];

pub const FULL_PAYLOAD_BUDGET_BYTES: usize = 64 * 1024;

/// Function-aware excerpt: coder writes keep their payload unclipped (up to
/// the budget) so approval cards can show real diffs; everything else gets
/// the classic clipped excerpt.
pub fn redact_for(function_id: &str, value: &Value) -> Value {
if FULL_PAYLOAD_FUNCTIONS.contains(&function_id) {
let full = redact_at(value, 0, false);
let within_budget = serde_json::to_string(&full)
.map(|s| s.len() <= FULL_PAYLOAD_BUDGET_BYTES)
.unwrap_or(false);
if within_budget {
return full;
}
}
redact(value)
}

/// Walk the value tree, redacting secret-keyed values and clipping long
/// strings. Returns a brand-new tree.
pub fn redact(value: &Value) -> Value {
redact_at(value, 0)
redact_at(value, 0, true)
}

fn redact_at(value: &Value, depth: usize) -> Value {
fn redact_at(value: &Value, depth: usize, clip_strings: bool) -> Value {
match value {
Value::String(s) => Value::String(clip(s)),
Value::String(s) if clip_strings => Value::String(clip(s)),
Value::String(s) => Value::String(s.clone()),
Value::Array(_) | Value::Object(_) if depth >= MAX_REDACT_DEPTH => {
Value::String(MAX_DEPTH_SENTINEL.to_string())
}
Value::Array(items) => {
Value::Array(items.iter().map(|v| redact_at(v, depth + 1)).collect())
}
Value::Array(items) => Value::Array(
items
.iter()
.map(|v| redact_at(v, depth + 1, clip_strings))
.collect(),
),
Value::Object(map) => Value::Object(
map.iter()
.map(|(k, v)| {
let redacted = if is_secret_key(k) {
Value::String(REDACTED.to_string())
} else {
redact_at(v, depth + 1)
redact_at(v, depth + 1, clip_strings)
};
(k.clone(), redacted)
})
Expand Down Expand Up @@ -161,6 +195,39 @@ mod tests {
assert_eq!(depth, MAX_REDACT_DEPTH);
}

#[test]
fn coder_writes_keep_payload_unclipped() {
let patch = "x".repeat(5000);
let out = redact_for("coder::apply-patch", &json!({ "patch": patch }));
assert_eq!(out["patch"].as_str().unwrap().len(), 5000);
// Secrets still redact in full mode.
let out = redact_for(
"coder::create-file",
&json!({ "content": "c".repeat(400), "api_key": "sk" }),
);
assert_eq!(out["api_key"], json!("<redacted>"));
assert_eq!(out["content"].as_str().unwrap().len(), 400);
}

#[test]
fn non_coder_functions_still_clip() {
let out = redact_for("shell::exec", &json!({ "cmd": "y".repeat(300) }));
assert_eq!(
out["cmd"].as_str().unwrap().chars().count(),
ARGS_EXCERPT_LEN_CAP + 1
);
}

#[test]
fn full_payload_budget_falls_back_to_clipping() {
let huge = "z".repeat(FULL_PAYLOAD_BUDGET_BYTES + 1024);
let out = redact_for("coder::apply-patch", &json!({ "patch": huge }));
assert_eq!(
out["patch"].as_str().unwrap().chars().count(),
ARGS_EXCERPT_LEN_CAP + 1
);
}

#[test]
fn never_mutates_input() {
let input = json!({ "password": "x", "nested": { "api_key": "y" } });
Expand Down
Loading