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