mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-07 17:09:13 -10:00
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:
1 parent
1043cf3d23
commit
72a5eaf3b2
3 files changed
+130
-54
No files matched your search
+108
-40
@@ -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)]
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user