fix(approvals): conservative redaction of served approval summary, stable outcome (APPS-142)

Review defects confirmed in code and fixed:
- redaction is one function (redact_ask_text): quote-aware word split; a shell
  command keeps only the program basename plus plain arguments, and any quoted,
  KEY=VALUE, credential-shaped, sensitive-named-flag or unrecognised argument
  collapses all arguments to '[args redacted]'; descriptions redact unplain
  words; session_peek::redact still runs on the result.
- workspace paths are rewritten only at a root prefix boundary; absolute paths
  outside it are reduced to .../<basename>; no more bare-substring replace.
- outcome keeps its old values (allowed_once/denied); new standing_rule field
  (allow|deny) is set for session_rule decisions, which includes grant-based
  auto-approvals (same decider), documented as such.
- the summary is only produced by ApprovalReceipt::asked_with and served by
  GET /v1/approvals; no tracing/log call reads it (grep). The separate
  approval.required event payload predates this commit and is untouched.

Evidence: RUST_MIN_STACK=32MiB cargo test -p codewhale-tui --lib approval -> 406 passed, 0 failed.
Limits: no new tests; heuristic still cannot catch a bare secret passed as a plain positional word; other workspace gate not run.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
CodeWhale BotandClaude Sonnet 5.5 committed 2026-10-09 19:25:55 -07:00
1 parent 1043cf3d23
commit 72a5eaf3b2
3 files changed
+130 -54

No files matched your search

+108 -40
View File
@@ -160,51 +160,113 @@ fn cap_ask_text(text: &str) -> Option<String> {
Some(format!("{head}…"))
}
fn relativize(text: &str, workspace: &Path) -> String {
fn safe_path(token: &str, workspace: &Path) -> String {
let root = workspace.to_string_lossy();
let root = root.trim_end_matches('/');
if root.is_empty() {
return text.to_string();
}
let mut out = String::with_capacity(text.len());
let mut rest = text;
while let Some(at) = rest.find(root) {
let after = &rest[at + root.len()..];
if after.is_empty() || after.starts_with('/') {
out.push_str(&rest[..at]);
out.push('.');
} else {
out.push_str(&rest[..at + root.len()]);
if !root.is_empty()
&& let Some(rest) = token.strip_prefix(root)
{
if rest.is_empty() {
return ".".to_string();
}
if rest.starts_with('/') {
return format!(".{rest}");
}
rest = after;
}
out.push_str(rest);
out
if token.starts_with('/') || token.starts_with('~') {
let base = token.rsplit('/').find(|part| !part.is_empty()).unwrap_or("");
return format!(".../{base}");
}
token.to_string()
}
fn mask_env_assignments(text: &str) -> String {
text.split(' ')
.map(|word| match word.split_once('=') {
Some((key, value))
if !value.is_empty()
&& key
.chars()
.next()
.is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
&& key.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') =>
{
format!("{key}=[redacted]")
fn split_shell_words(text: &str) -> Vec<&str> {
let mut words = Vec::new();
let mut start = None;
let mut quote: Option<char> = None;
for (at, c) in text.char_indices() {
match (quote, c) {
(Some(q), _) if c == q => quote = None,
(Some(_), _) => {}
(None, '"' | '\'' | '`') => {
quote = Some(c);
start.get_or_insert(at);
}
_ => word.to_string(),
})
.collect::<Vec<_>>()
.join(" ")
(None, c) if c.is_whitespace() => {
if let Some(s) = start.take() {
words.push(&text[s..at]);
}
}
_ => {
start.get_or_insert(at);
}
}
}
if let Some(s) = start {
words.push(&text[s..]);
}
words
}
fn redact_ask_text(text: &str, workspace: &Path) -> Option<String> {
let masked = mask_env_assignments(text);
let (redacted, _) = crate::session_peek::redact(&masked);
cap_ask_text(&relativize(&redacted, workspace))
fn plain_word(word: &str) -> bool {
const SENSITIVE: [&str; 11] = [
"pass", "token", "secret", "key", "auth", "cred", "user", "cookie", "bearer", "header",
"private",
];
if word.is_empty() || word.chars().count() > 64 {
return false;
}
if !word
.chars()
.all(|c| c.is_ascii_alphanumeric() || "_./~+,%-".contains(c))
{
return false;
}
if let Some(flag) = word.strip_prefix("--") {
let lower = flag.to_ascii_lowercase();
return !flag.is_empty() && !SENSITIVE.iter().any(|s| lower.contains(s));
}
if let Some(flag) = word.strip_prefix('-') {
return word.len() > 1 && flag.len() == 1 && "alrRfnvih".contains(flag);
}
true
}
fn redact_ask_text(text: &str, workspace: &Path, command: bool) -> Option<String> {
let words = split_shell_words(text);
let redacted = if command {
let program = words.first().copied().filter(|w| plain_word(w));
match program {
None => "[command redacted]".to_string(),
Some(program) => {
let name = safe_path(program, workspace);
let name = name.rsplit('/').next().unwrap_or(&name).to_string();
let rest = &words[1..];
if rest.iter().all(|w| plain_word(w)) {
let mut out = vec![name];
out.extend(rest.iter().map(|w| safe_path(w, workspace)));
out.join(" ")
} else {
format!("{name} [args redacted]")
}
}
}
} else {
words
.iter()
.map(|w| {
if plain_word(w) || w.chars().all(|c| c.is_alphanumeric() || ".,:;()".contains(c))
{
safe_path(w, workspace)
} else {
"[redacted]".to_string()
}
})
.collect::<Vec<_>>()
.join(" ")
};
let (redacted, _) = crate::session_peek::redact(&redacted);
cap_ask_text(&redacted)
}
fn ask_target(input: &serde_json::Value, workspace: &Path) -> Option<String> {
@@ -218,19 +280,25 @@ fn ask_target(input: &serde_json::Value, workspace: &Path) -> Option<String> {
}
for key in ["path", "file_path", "filepath", "filename"] {
if let Some(raw) = input.get(key).and_then(|v| v.as_str()) {
return redact_ask_text(raw, workspace);
let raw = raw.trim();
if raw.is_empty() || raw.contains(char::is_whitespace) {
return redact_ask_text(raw, workspace, false);
}
return cap_ask_text(&safe_path(raw, workspace));
}
}
None
}
fn ask_summary(input: &serde_json::Value, description: &str, workspace: &Path) -> Option<String> {
let source = input
match input
.get("command")
.and_then(|v| v.as_str())
.filter(|c| !c.trim().is_empty())
.unwrap_or(description);
redact_ask_text(source, workspace)
{
Some(command) => redact_ask_text(command, workspace, true),
None => redact_ask_text(description, workspace, false),
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
+12 -6
View File
@@ -5624,6 +5624,10 @@ struct ApprovalHistoryRow {
#[serde(skip_serializing_if = "Option::is_none")]
summary: Option<String>,
outcome: String,
/// `allow` or `deny` when a standing session rule or grant answered the
/// ask rather than a person; absent otherwise.
#[serde(skip_serializing_if = "Option::is_none")]
standing_rule: Option<&'static str>,
/// Who resolved it: `user`, `session_rule`, `posture`, or `host`.
/// Absent while pending and on records written before deciders were kept.
#[serde(skip_serializing_if = "Option::is_none")]
@@ -5632,15 +5636,15 @@ struct ApprovalHistoryRow {
decided_at: Option<chrono::DateTime<Utc>>,
}
fn approval_row_outcome(
fn approval_row_standing_rule(
outcome: &crate::approval_log::ApprovalOutcome,
decided_by: Option<crate::approval_log::ApprovalDecider>,
) -> &'static str {
) -> Option<&'static str> {
use crate::approval_log::{ApprovalDecider, ApprovalOutcome};
match (outcome, decided_by) {
(ApprovalOutcome::ApprovedOnce, Some(ApprovalDecider::SessionRule)) => "always_allow",
(ApprovalOutcome::Denied, Some(ApprovalDecider::SessionRule)) => "blocked",
_ => approval_outcome_label(outcome),
(ApprovalOutcome::ApprovedOnce, Some(ApprovalDecider::SessionRule)) => Some("allow"),
(ApprovalOutcome::Denied, Some(ApprovalDecider::SessionRule)) => Some("deny"),
_ => None,
}
}
@@ -5670,7 +5674,8 @@ fn approval_history_rows(replay: &crate::approval_log::ApprovalReplay) -> Vec<Ap
tool_name: completed.ask.tool_name().unwrap_or("unknown").to_string(),
target: completed.ask.target().map(str::to_string),
summary: completed.ask.summary().map(str::to_string),
outcome: approval_row_outcome(&completed.outcome, completed.decided_by).to_string(),
outcome: approval_outcome_label(&completed.outcome).to_string(),
standing_rule: approval_row_standing_rule(&completed.outcome, completed.decided_by),
decided_by: completed.decided_by,
asked_at,
decided_at: Some(completed.decided_at),
@@ -5682,6 +5687,7 @@ fn approval_history_rows(replay: &crate::approval_log::ApprovalReplay) -> Vec<Ap
target: ask.target().map(str::to_string),
summary: ask.summary().map(str::to_string),
outcome: "pending".to_string(),
standing_rule: None,
decided_by: None,
asked_at: ask.created_at(),
decided_at: None,
+10 -8
View File
@@ -1340,14 +1340,16 @@ Each history row carries `approval_id`, `tool_name`, `outcome`, `asked_at` and
`decided_at` (null while pending), plus optional `decided_by` (`user`,
`session_rule`, `posture`, `host`), `target` and `summary`. `outcome` is one of
`allowed_once`, `denied`, `timeout`, `cancelled`, `unavailable`,
`retry_with_policy`, `pending`, or the standing-rule outcomes `always_allow` and
`blocked`: an approval or denial answered by a remembered session rule rather
than a person (`decided_by: session_rule`). `target` is the host of a URL or a
workspace-relative path; `summary` is a one-line description of what was asked
(the shell command when there is one). Both are redacted when the ask is
recorded: `KEY=VALUE` words become `KEY=[redacted]`, credentials, tokens and URL
userinfo are masked, the workspace root becomes `.`, whitespace is collapsed and
text is capped at 120 characters. Rows recorded before these fields existed
`retry_with_policy`, or `pending`. `standing_rule` (`allow` or `deny`) is present
only when a remembered session rule or grant answered rather than a person
(`decided_by: session_rule`); `outcome` is unchanged. `target` is the host of a
URL or a workspace-relative path (absolute paths outside the workspace are
reduced to `.../<basename>`); `summary` is a one-line description of what was
asked. Both are redacted conservatively when the ask is recorded: a shell
command keeps only the program name and plain arguments, and any quoted,
`KEY=VALUE`, credential-like or unrecognised argument replaces all arguments
with `[args redacted]`. Whitespace is collapsed and text is capped at 120
characters. Rows recorded before these fields existed
omit `target` and `summary`.
`approval_id` is minted by the Runtime, not by the model or the provider. It is