返回 CodeWhale
repo_law.rs
根目录 / crates / tui / src / repo_law.rs
1 //! Mechanical enforcement of repo-law protected invariants.
2 //!
3 //! `.codewhale/constitution.json` invariants were previously advisory prose
4 //! rendered into the prompt. Entries that carry `paths` globs now also
5 //! compile into write holds evaluated in the engine's tool gate — the law
6 //! becomes mechanism, with a receipt naming the invariant.
7 //!
8 //! The contract mirrors the project-overlay rule ("overrides may only
9 //! tighten"):
10 //!
11 //! - Law can only ADD holds. There is no allow/widen shape in the schema, so
12 //! a crafted constitution cannot grant authority.
13 //! - `ask` force-prompts only in Ask posture. Auto-Review, Full Access, and
14 //! Never never open tool-approval prompts, so the same law fails closed
15 //! there. `block` denies outright in every posture.
16 //! - No constitution (or an empty one) means no holds. A constitution that
17 //! exists but cannot be read or parsed, or an enforced invariant whose glob
18 //! does not compile, holds every write (`ask`) naming the problem: the law
19 //! the file meant to state is unknown, and enforcing less than it says
20 //! would fail open. Fixing the file releases the hold.
21 //! - Only the repo-local constitution participates. The user-global
22 //! constitution stays advisory prose and never reaches this module.
23
24 use std::path::Path;
25
26 use serde_json::Value;
27
28 use crate::project_context::{RepoLawAction, RepoLawRule, load_repo_law_rules};
29 use crate::tools::apply_patch::{NormalizedApplyPatchInput, normalize_apply_patch_input};
30
31 /// Semantic write actions whose inputs name filesystem targets we can hold.
32 /// Canonical action families are resolved to this policy vocabulary before the
33 /// check, so removing callable compatibility aliases cannot open a law bypass.
34 const WRITE_POLICY_ACTIONS: &[&str] = &["write_file", "edit_file", "apply_patch", "fim_edit"];
35
36 #[derive(Debug, Clone, PartialEq, Eq)]
37 pub(crate) enum RepoLawPlanDecision {
38 /// Request a policy-forced approval naming the law. The engine converts
39 /// this to a hard block in non-interactive Full Access.
40 ForcePrompt(String),
41 /// Deny the call outright, naming the law.
42 Block(String),
43 }
44
45 /// Evaluate the workspace's repo law against a proposed tool call. Returns
46 /// `None` for tools without write targets, workspaces without enforceable
47 /// law, and writes outside every protected glob.
48 pub(crate) fn repo_law_plan_decision(
49 workspace: &Path,
50 tool_name: &str,
51 tool_input: &Value,
52 ) -> Option<RepoLawPlanDecision> {
53 let policy_action =
54 crate::tools::canonical_action::canonical_action_alias(tool_name, tool_input);
55 if !WRITE_POLICY_ACTIONS.contains(&policy_action) {
56 return None;
57 }
58 let targets = write_target_paths(workspace, tool_input);
59 if targets.is_empty() {
60 return None;
61 }
62 let rules = match load_repo_law_rules(workspace) {
63 Ok(rules) => rules,
64 Err(problem) => {
65 return Some(RepoLawPlanDecision::ForcePrompt(format!(
66 "Repo law holds every write until .codewhale/constitution.json is fixed: {problem}"
67 )));
68 }
69 };
70 if rules.is_empty() {
71 return None;
72 }
73
74 // Strongest action wins across all (rule, target) matches.
75 let mut hold: Option<(&RepoLawRule, &str)> = None;
76 for rule in &rules {
77 for target in &targets {
78 if rule.globs.is_match(target) {
79 let stronger = matches!(rule.action, RepoLawAction::Block) || hold.is_none();
80 let already_blocking = hold
81 .as_ref()
82 .is_some_and(|(held, _)| matches!(held.action, RepoLawAction::Block));
83 if stronger && !already_blocking {
84 hold = Some((rule, target.as_str()));
85 }
86 }
87 }
88 }
89 let (rule, target) = hold?;
90 let protects = rule.patterns.join(", ");
91 let reason = format!(
92 "Repo law holds this write: \"{}\" protects {protects} (matched {target}, .codewhale/constitution.json)",
93 rule.text
94 );
95 Some(match rule.action {
96 RepoLawAction::Ask => RepoLawPlanDecision::ForcePrompt(reason),
97 RepoLawAction::Block => RepoLawPlanDecision::Block(reason),
98 })
99 }
100
101 /// Extract workspace-relative write targets from a tool input. Covers the
102 /// `path`/`target`/`destination` params and every path alias the file tools
103 /// fold onto `path` (`file_path`, `filePath`), canonical
104 /// `replace[].path`, legacy `changes[].path`, and
105 /// every unified-diff / codex-envelope header shape the patch tools accept —
106 /// old (`--- `) and new (`+++ `) paths, with or without an `a/`/`b/` prefix,
107 /// tab-timestamp suffixes stripped, and `/dev/null` (deletion) falling back
108 /// to the counterpart path. Missing any shape the tool honors is a hold
109 /// bypass, so this deliberately over-collects candidate paths.
110 fn write_target_paths(workspace: &Path, input: &Value) -> Vec<String> {
111 let mut targets = Vec::new();
112 for key in crate::tools::file::path_argument_keys().chain(["target", "destination"]) {
113 if let Some(path) = input.get(key).and_then(Value::as_str) {
114 push_normalized(&mut targets, workspace, path);
115 }
116 }
117 match normalize_apply_patch_input(input) {
118 Ok(NormalizedApplyPatchInput::Replacement { entries, .. }) => {
119 for change in entries {
120 if let Some(path) = change.get("path").and_then(Value::as_str) {
121 push_normalized(&mut targets, workspace, path);
122 }
123 }
124 }
125 Ok(NormalizedApplyPatchInput::Patch(patch)) => {
126 let mut pending_old: Option<String> = None;
127 for line in patch.lines() {
128 if let Some(rest) = line.strip_prefix("*** Update File: ") {
129 push_normalized(&mut targets, workspace, rest.trim());
130 } else if let Some(rest) = line.strip_prefix("*** Add File: ") {
131 push_normalized(&mut targets, workspace, rest.trim());
132 } else if let Some(rest) = line.strip_prefix("*** Delete File: ") {
133 push_normalized(&mut targets, workspace, rest.trim());
134 } else if let Some(rest) = line.strip_prefix("--- ") {
135 // Old path: remember it so a `+++ /dev/null` deletion still
136 // holds the file being removed.
137 pending_old = diff_header_path(rest);
138 if let Some(ref p) = pending_old {
139 push_normalized(&mut targets, workspace, p);
140 }
141 } else if let Some(rest) = line.strip_prefix("+++ ") {
142 match diff_header_path(rest) {
143 Some(new_path) => push_normalized(&mut targets, workspace, &new_path),
144 // `+++ /dev/null` → deletion; the target is the old path.
145 None => {
146 if let Some(old) = pending_old.take() {
147 push_normalized(&mut targets, workspace, &old);
148 }
149 }
150 }
151 }
152 }
153 }
154 Err(_) => {}
155 }
156 targets.sort();
157 targets.dedup();
158 targets
159 }
160
161 /// Parse a unified-diff header path: strip an optional `a/`/`b/` prefix and a
162 /// tab-delimited timestamp suffix. Returns `None` for `/dev/null` (absence).
163 fn diff_header_path(rest: &str) -> Option<String> {
164 // Headers may carry a "\t<timestamp>" suffix; the path is the first field.
165 let path = rest.split('\t').next().unwrap_or(rest).trim();
166 if path.is_empty() || path == "/dev/null" {
167 return None;
168 }
169 let stripped = path
170 .strip_prefix("a/")
171 .or_else(|| path.strip_prefix("b/"))
172 .unwrap_or(path);
173 Some(stripped.to_string())
174 }
175
176 /// Normalize to a forward-slash, workspace-relative string so globs written
177 /// as `crates/x/**` match regardless of how the tool spelled the path. Crucially
178 /// this collapses `.`/`..` path components the same way the write tools'
179 /// `resolve_path` does, so an interior `crates/./protocol/x` or
180 /// `x/../crates/protocol/x` cannot spell its way past a glob (a confirmed
181 /// bypass before this).
182 fn push_normalized(targets: &mut Vec<String>, workspace: &Path, raw: &str) {
183 let trimmed = raw.trim().replace('\\', "/");
184 if trimmed.is_empty() {
185 return;
186 }
187 // Make workspace-relative when the tool gave an absolute path inside it.
188 let path = Path::new(&trimmed);
189 let relative = path.strip_prefix(workspace).unwrap_or(path);
190
191 // Lexically collapse CurDir (`.`) and ParentDir (`..`) components, and
192 // drop any leading root/empty component. An absolute path outside the
193 // workspace keeps its tail (e.g. `/etc/passwd` -> `etc/passwd`) so a
194 // `**/passwd` glob still matches while a workspace-anchored glob does not.
195 let mut parts: Vec<String> = Vec::new();
196 for component in relative.to_string_lossy().split('/') {
197 match component {
198 "" | "." => {}
199 ".." => {
200 // A `..` that pops above the root escapes the workspace; keep
201 // an explicit marker so it can never match a workspace-relative
202 // glob, and the ordinary approval/sandbox gates still govern it.
203 if parts.pop().is_none() {
204 parts.push("..".to_string());
205 }
206 }
207 other => parts.push(other.to_string()),
208 }
209 }
210 let normalized = parts.join("/");
211 if !normalized.is_empty() {
212 targets.push(normalized);
213 }
214 }
215
216 #[cfg(test)]
217 mod tests {
218 use super::*;
219 use serde_json::json;
220 use tempfile::TempDir;
221
222 fn write_law(workspace: &Path, body: &str) {
223 let dir = workspace.join(".codewhale");
224 std::fs::create_dir_all(&dir).unwrap();
225 std::fs::write(dir.join("constitution.json"), body).unwrap();
226 }
227
228 const LAW: &str = r#"{
229 "authority": ["AGENTS.md"],
230 "protected_invariants": [
231 "Keep DeepSeek support first-class.",
232 { "text": "The wire format is frozen", "paths": ["crates/protocol/**"], "action": "block" },
233 { "text": "Release notes need human review", "paths": ["CHANGELOG.md"] }
234 ]
235 }"#;
236
237 #[test]
238 fn advisory_only_law_never_holds() {
239 let tmp = TempDir::new().unwrap();
240 write_law(
241 tmp.path(),
242 r#"{"protected_invariants": ["Prose only, no paths."]}"#,
243 );
244 assert_eq!(
245 repo_law_plan_decision(
246 tmp.path(),
247 "write_file",
248 &json!({"path": "src/main.rs", "content": "x"}),
249 ),
250 None
251 );
252 }
253
254 #[test]
255 fn block_action_denies_protected_write() {
256 let tmp = TempDir::new().unwrap();
257 write_law(tmp.path(), LAW);
258 let decision = repo_law_plan_decision(
259 tmp.path(),
260 "write_file",
261 &json!({"path": "crates/protocol/wire.rs", "content": "x"}),
262 );
263 let Some(RepoLawPlanDecision::Block(reason)) = decision else {
264 panic!("expected block, got {decision:?}");
265 };
266 assert!(reason.contains("The wire format is frozen"), "{reason}");
267 assert!(reason.contains("crates/protocol/wire.rs"), "{reason}");
268 assert!(reason.contains(".codewhale/constitution.json"), "{reason}");
269 }
270
271 #[test]
272 fn ask_action_force_prompts_and_names_the_law() {
273 let tmp = TempDir::new().unwrap();
274 write_law(tmp.path(), LAW);
275 let decision = repo_law_plan_decision(
276 tmp.path(),
277 "edit_file",
278 &json!({"path": "CHANGELOG.md", "old": "a", "new": "b"}),
279 );
280 let Some(RepoLawPlanDecision::ForcePrompt(reason)) = decision else {
281 panic!("expected force prompt, got {decision:?}");
282 };
283 assert!(
284 reason.contains("Release notes need human review"),
285 "{reason}"
286 );
287 }
288
289 #[test]
290 fn canonical_file_write_and_edit_actions_receive_the_same_holds() {
291 let tmp = TempDir::new().unwrap();
292 write_law(tmp.path(), LAW);
293
294 let blocked = repo_law_plan_decision(
295 tmp.path(),
296 "File",
297 &json!({
298 "action": "write",
299 "path": "crates/protocol/wire.rs",
300 "content": "x"
301 }),
302 );
303 assert!(matches!(blocked, Some(RepoLawPlanDecision::Block(_))));
304
305 let held = repo_law_plan_decision(
306 tmp.path(),
307 "File",
308 &json!({
309 "action": "edit",
310 "path": "CHANGELOG.md",
311 "search": "before",
312 "replace": "after"
313 }),
314 );
315 assert!(matches!(held, Some(RepoLawPlanDecision::ForcePrompt(_))));
316 }
317
318 #[test]
319 fn path_alias_spellings_receive_the_same_holds() {
320 let tmp = TempDir::new().unwrap();
321 write_law(tmp.path(), LAW);
322 for (tool, input) in [
323 (
324 "write_file",
325 json!({"filePath": "crates/protocol/wire.rs", "content": "x"}),
326 ),
327 (
328 "write_file",
329 json!({"file_path": "crates/protocol/wire.rs", "content": "x"}),
330 ),
331 (
332 "File",
333 json!({"action": "edit", "filePath": "crates/protocol/wire.rs", "search": "a", "replace": "b"}),
334 ),
335 ] {
336 let decision = repo_law_plan_decision(tmp.path(), tool, &input);
337 assert!(
338 matches!(decision, Some(RepoLawPlanDecision::Block(_))),
339 "{tool} {input}: {decision:?}"
340 );
341 }
342 }
343
344 #[test]
345 fn unprotected_writes_and_non_write_tools_pass() {
346 let tmp = TempDir::new().unwrap();
347 write_law(tmp.path(), LAW);
348 assert_eq!(
349 repo_law_plan_decision(
350 tmp.path(),
351 "write_file",
352 &json!({"path": "src/main.rs", "content": "x"}),
353 ),
354 None
355 );
356 assert_eq!(
357 repo_law_plan_decision(
358 tmp.path(),
359 "read_file",
360 &json!({"path": "crates/protocol/wire.rs"}),
361 ),
362 None
363 );
364 }
365
366 #[test]
367 fn apply_patch_targets_are_extracted_from_all_shapes() {
368 let tmp = TempDir::new().unwrap();
369 write_law(tmp.path(), LAW);
370 // Canonical replace[].path shape.
371 let decision = repo_law_plan_decision(
372 tmp.path(),
373 "apply_patch",
374 &json!({"replace": [{"path": "crates/protocol/msg.rs"}]}),
375 );
376 assert!(matches!(decision, Some(RepoLawPlanDecision::Block(_))));
377 // Legacy changes[].path shape must receive the same hold.
378 let decision = repo_law_plan_decision(
379 tmp.path(),
380 "apply_patch",
381 &json!({"changes": [{"path": "crates/protocol/msg.rs"}]}),
382 );
383 assert!(matches!(decision, Some(RepoLawPlanDecision::Block(_))));
384 // unified diff shape
385 let decision = repo_law_plan_decision(
386 tmp.path(),
387 "apply_patch",
388 &json!({"patch": "--- a/crates/protocol/msg.rs\n+++ b/crates/protocol/msg.rs\n@@\n"}),
389 );
390 assert!(matches!(decision, Some(RepoLawPlanDecision::Block(_))));
391 // codex envelope shape
392 let decision = repo_law_plan_decision(
393 tmp.path(),
394 "apply_patch",
395 &json!({"patch": "*** Begin Patch\n*** Update File: crates/protocol/msg.rs\n*** End Patch\n"}),
396 );
397 assert!(matches!(decision, Some(RepoLawPlanDecision::Block(_))));
398 }
399
400 #[test]
401 fn block_outranks_ask_when_both_match() {
402 let tmp = TempDir::new().unwrap();
403 write_law(
404 tmp.path(),
405 r#"{"protected_invariants": [
406 { "text": "ask first", "paths": ["docs/**"] },
407 { "text": "never", "paths": ["docs/frozen/**"], "action": "block" }
408 ]}"#,
409 );
410 let decision = repo_law_plan_decision(
411 tmp.path(),
412 "write_file",
413 &json!({"path": "docs/frozen/spec.md", "content": "x"}),
414 );
415 assert!(matches!(decision, Some(RepoLawPlanDecision::Block(_))));
416 }
417
418 #[test]
419 fn absolute_and_dot_prefixed_paths_normalize_to_workspace_relative() {
420 let tmp = TempDir::new().unwrap();
421 write_law(tmp.path(), LAW);
422 let absolute = tmp.path().join("crates/protocol/wire.rs");
423 let decision = repo_law_plan_decision(
424 tmp.path(),
425 "write_file",
426 &json!({"path": absolute.to_string_lossy(), "content": "x"}),
427 );
428 assert!(matches!(decision, Some(RepoLawPlanDecision::Block(_))));
429 let decision = repo_law_plan_decision(
430 tmp.path(),
431 "write_file",
432 &json!({"path": "./CHANGELOG.md", "content": "x"}),
433 );
434 assert!(matches!(
435 decision,
436 Some(RepoLawPlanDecision::ForcePrompt(_))
437 ));
438 }
439
440 #[test]
441 fn malformed_law_and_bad_globs_hold_every_write() {
442 let tmp = TempDir::new().unwrap();
443 let write = json!({"path": "src/unrelated.rs", "content": "x"});
444 write_law(tmp.path(), "{ not json");
445 let Some(RepoLawPlanDecision::ForcePrompt(reason)) =
446 repo_law_plan_decision(tmp.path(), "write_file", &write)
447 else {
448 panic!("an unparseable constitution must hold writes");
449 };
450 assert!(reason.contains("constitution.json"), "{reason}");
451 write_law(
452 tmp.path(),
453 r#"{"protected_invariants": [
454 { "text": "broken glob", "paths": ["crates/[invalid"] }
455 ]}"#,
456 );
457 let Some(RepoLawPlanDecision::ForcePrompt(reason)) =
458 repo_law_plan_decision(tmp.path(), "write_file", &write)
459 else {
460 panic!("an invariant whose glob does not compile must hold writes");
461 };
462 assert!(reason.contains("crates/[invalid"), "{reason}");
463 // Non-write tools stay unaffected, and an empty file is no law.
464 assert_eq!(
465 repo_law_plan_decision(tmp.path(), "read_file", &json!({"path": "a.rs"})),
466 None
467 );
468 write_law(tmp.path(), " \n");
469 assert_eq!(
470 repo_law_plan_decision(tmp.path(), "write_file", &write),
471 None
472 );
473 }
474
475 #[test]
476 fn interior_dot_and_parent_segments_cannot_evade_a_block() {
477 let tmp = TempDir::new().unwrap();
478 write_law(tmp.path(), LAW);
479 for path in [
480 "crates/./protocol/wire.rs",
481 "crates/../crates/protocol/wire.rs",
482 "x/../crates/protocol/wire.rs",
483 "./crates/protocol/wire.rs",
484 ] {
485 let decision = repo_law_plan_decision(
486 tmp.path(),
487 "write_file",
488 &json!({ "path": path, "content": "x" }),
489 );
490 assert!(
491 matches!(decision, Some(RepoLawPlanDecision::Block(_))),
492 "{path} must be held, got {decision:?}"
493 );
494 }
495 }
496
497 #[test]
498 fn fim_edit_is_gated_like_other_write_tools() {
499 let tmp = TempDir::new().unwrap();
500 write_law(tmp.path(), LAW);
501 let decision = repo_law_plan_decision(
502 tmp.path(),
503 "fim_edit",
504 &json!({ "path": "crates/protocol/wire.rs", "prefix": "a", "suffix": "b" }),
505 );
506 assert!(
507 matches!(decision, Some(RepoLawPlanDecision::Block(_))),
508 "{decision:?}"
509 );
510 }
511
512 #[test]
513 fn apply_patch_header_variants_are_all_extracted() {
514 let tmp = TempDir::new().unwrap();
515 write_law(tmp.path(), LAW);
516 // no a/ or b/ prefix
517 let d = repo_law_plan_decision(
518 tmp.path(),
519 "apply_patch",
520 &json!({ "patch": "--- crates/protocol/wire.rs\n+++ crates/protocol/wire.rs\n@@\n" }),
521 );
522 assert!(
523 matches!(d, Some(RepoLawPlanDecision::Block(_))),
524 "no-prefix: {d:?}"
525 );
526 // deletion: +++ /dev/null, target is the old path
527 let d = repo_law_plan_decision(
528 tmp.path(),
529 "apply_patch",
530 &json!({ "patch": "--- a/crates/protocol/wire.rs\n+++ /dev/null\n@@ -1 +0,0 @@\n-x\n" }),
531 );
532 assert!(
533 matches!(d, Some(RepoLawPlanDecision::Block(_))),
534 "deletion: {d:?}"
535 );
536 // tab-timestamp suffix on the header
537 let d = repo_law_plan_decision(
538 tmp.path(),
539 "apply_patch",
540 &json!({ "patch": "--- a/x\t2026-01-01\n+++ b/crates/protocol/wire.rs\t2026-01-01 10:00:00\n@@\n" }),
541 );
542 assert!(
543 matches!(d, Some(RepoLawPlanDecision::Block(_))),
544 "tab-timestamp: {d:?}"
545 );
546 }
547
548 #[test]
549 fn no_law_file_means_no_holds() {
550 let tmp = TempDir::new().unwrap();
551 assert_eq!(
552 repo_law_plan_decision(
553 tmp.path(),
554 "write_file",
555 &json!({"path": "anything.rs", "content": "x"}),
556 ),
557 None
558 );
559 }
560 }
561
561 lines RUST