-
Notifications
You must be signed in to change notification settings - Fork 3.6k
Scope a session patch grant to the file it approved #6251
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -36,7 +36,6 @@ use std::fmt::Write as _; | |
| use serde_json::Value; | ||
| use sha2::{Digest, Sha256}; | ||
|
|
||
| use crate::tools::apply_patch::{NormalizedApplyPatchInput, normalize_apply_patch_input}; | ||
| use codewhale_execpolicy::command_safety::classify_command; | ||
|
|
||
| /// The fingerprint of a tool call — stable enough to match repeated | ||
|
|
@@ -129,35 +128,41 @@ fn command_prefix(input: &serde_json::Value) -> String { | |
| } | ||
|
|
||
| /// Hash the sorted set of file paths referenced by a patch input. | ||
| /// | ||
| /// The paths come from [`preflight_apply_patch`] — the same resolver the | ||
| /// executor, the permission path (`core/engine.rs`) and auto-review already | ||
| /// use — rather than from a second, weaker parser. That matters because this | ||
| /// string *is* the scope of an "approve for the session" grant: two patches | ||
| /// share a grant exactly when they share this key. | ||
| /// | ||
| /// The previous implementation read only `+++ b/` headers and the | ||
| /// `replace`/`changes` array, so it saw no paths at all for the documented | ||
| /// `apply_patch{path, patch}` override, for `--no-prefix` diffs, or for | ||
| /// delete-only diffs — and collapsed all of them to one shared constant. | ||
| /// Approving any one of those pre-approved every later one, to any file | ||
| /// (#6247). | ||
| /// | ||
| /// An input the resolver cannot parse gets a digest of the input itself, not | ||
| /// a shared constant: an unparseable patch is its own family and matches | ||
| /// nothing but a byte-identical repeat. | ||
| fn hash_patch_paths(input: &serde_json::Value) -> String { | ||
| use std::collections::hash_map::DefaultHasher; | ||
| use std::hash::{Hash, Hasher}; | ||
|
|
||
| let mut paths: Vec<&str> = Vec::new(); | ||
| let Ok(preflight) = crate::tools::apply_patch::preflight_apply_patch(input) else { | ||
| return format!("unparsed_{}", hash_json_value(input)); | ||
| }; | ||
|
|
||
| match normalize_apply_patch_input(input) { | ||
| Ok(NormalizedApplyPatchInput::Replacement { entries, .. }) => { | ||
| for change in entries { | ||
| if let Some(path) = change.get("path").and_then(|v| v.as_str()) { | ||
| paths.push(path); | ||
| } | ||
| } | ||
| } | ||
| Ok(NormalizedApplyPatchInput::Patch(patch_text)) => { | ||
| for line in patch_text.lines() { | ||
| if let Some(rest) = line.strip_prefix("+++ b/") { | ||
| paths.push(rest.trim()); | ||
| } | ||
| } | ||
| } | ||
| Err(_) => {} | ||
| } | ||
| let mut paths: Vec<&str> = preflight.touched_files.iter().map(String::as_str).collect(); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The fail-closed contract of both new arms rests on |
||
|
|
||
| paths.sort(); | ||
| paths.sort_unstable(); | ||
| paths.dedup(); | ||
|
|
||
| if paths.is_empty() { | ||
| return "no_files".to_string(); | ||
| // The resolver parsed the input but found no target. Fail closed for | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [INFO] Intentional UX change: path-less/unresolvable patches lose their family grant Previously any unparseable or path-less patch shared one session key, so one approval covered all of them (the bug). Now each distinct byte input gets its own key, so a session approval for such a patch is effectively one-shot until the exact input repeats. That is the right fail-closed behavior and it is documented in the rustdoc and both changelogs, but it is a user-visible increase in prompt frequency for the shapes that previously relied on the shared constant, so it is worth calling out in the PR body/release notes beyond the security framing. |
||
| // the same reason as the error arm above: a shared key here is a | ||
| // shared grant. | ||
| return format!("no_target_{}", hash_json_value(input)); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [WARNING] The
|
||
| } | ||
|
|
||
| let mut hasher = DefaultHasher::new(); | ||
|
|
@@ -337,6 +342,73 @@ mod tests { | |
| assert_ne!(key_a, key_b); | ||
| } | ||
|
|
||
| /// #6247. The `path` override is the documented way to patch without | ||
| /// diff headers (`apply_patch.rs` tells the model "Ensure the patch | ||
| /// includes ---/+++ headers or provide `path`"), and a bare hunk has no | ||
| /// `+++` line at all. Before the fix both of these produced the constant | ||
| /// `patch:no_files`, so one session grant covered every later one. | ||
| #[test] | ||
| fn grouping_key_scopes_a_path_override_to_its_own_file() { | ||
| let hunk = "@@ -1 +1 @@\n-old\n+new\n"; | ||
| let benign = build_approval_grouping_key( | ||
| "apply_patch", | ||
| &json!({"path": ".env.example", "patch": hunk}), | ||
| ); | ||
| let sensitive = build_approval_grouping_key( | ||
| "apply_patch", | ||
| &json!({"path": ".codewhale/settings.json", "patch": hunk}), | ||
| ); | ||
| assert_ne!( | ||
| benign, sensitive, | ||
| "approving a patch to one file must never cover a patch to another" | ||
| ); | ||
| assert!( | ||
| !format!("{benign:?}").contains("no_files"), | ||
| "a resolvable target must never collapse to the shared constant" | ||
| ); | ||
| } | ||
|
|
||
| /// The executor's `normalize_diff_path` accepts a prefix-less header, so | ||
| /// the fingerprint must too — otherwise a `--no-prefix` diff is a second | ||
| /// route to the shared key. | ||
| #[test] | ||
| fn grouping_key_reads_prefix_less_diff_headers() { | ||
| let prefixed = build_approval_grouping_key( | ||
| "apply_patch", | ||
| &json!({"patch": "--- a/src/auth.rs\n+++ b/src/auth.rs\n@@ -1 +1 @@\n-a\n+b\n"}), | ||
| ); | ||
| let bare = build_approval_grouping_key( | ||
| "apply_patch", | ||
| &json!({"patch": "--- src/auth.rs\n+++ src/auth.rs\n@@ -1 +1 @@\n-a\n+b\n"}), | ||
| ); | ||
| assert_eq!( | ||
| prefixed, bare, | ||
| "the same target written two legal ways is one approval family" | ||
| ); | ||
| let other = build_approval_grouping_key( | ||
| "apply_patch", | ||
| &json!({"patch": "--- src/billing.rs\n+++ src/billing.rs\n@@ -1 +1 @@\n-a\n+b\n"}), | ||
| ); | ||
| assert_ne!(bare, other, "different targets are different families"); | ||
| } | ||
|
|
||
| /// Fail closed: an input the resolver cannot parse is its own family, not | ||
| /// a member of a shared one. Two different unparseable inputs must not | ||
| /// share a grant. | ||
| #[test] | ||
| fn grouping_key_fails_closed_on_an_unresolvable_patch() { | ||
| let a = build_approval_grouping_key("apply_patch", &json!({"patch": "not a diff at all"})); | ||
| let b = build_approval_grouping_key("apply_patch", &json!({"patch": "also not a diff"})); | ||
| assert_ne!(a, b, "unparseable inputs must not share an approval family"); | ||
| for key in [&a, &b] { | ||
| let rendered = format!("{key:?}"); | ||
| assert!( | ||
| !rendered.contains("no_files"), | ||
| "the shared constant must not survive anywhere: {rendered}" | ||
| ); | ||
| } | ||
| } | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [WARNING] Two of the four reported un-scoped shapes have no regression test The PR description names four supported shapes that used to produce the shared There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Add regression tests for the two shapes the PR description names but the new tests do not exercise: a delete-only diff ( |
||
|
|
||
| #[test] | ||
| fn grouping_key_collapses_patch_body_for_same_path() { | ||
| let key_a = build_approval_grouping_key( | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[INFO] Fail-closed guarantee depends on
hash_json_valuecovering the whole inputBoth new arms (
unparsed_<digest>andno_target_<digest>) are only fail-closed ifhash_json_value(input)is a function of the entire JSON value. If it hashes a filtered subset (for example only a specific key, or a canonicalised projection), then two different unparseable patches that agree on that subset would still share one key — i.e. the original escalation would survive in a smaller form.hash_json_valueis defined outside this hunk, so please confirm it digests the full value; a short comment on the helper stating that requirement would keep future edits from weakening it.