1use std::collections::{BTreeMap, BTreeSet, VecDeque};
11use std::fs;
12use std::path::{Component, Path, PathBuf};
13
14use regex::Regex;
15use serde_json::{json, Value};
16
17pub const EXIT_USAGE: i32 = 64;
19pub const EXIT_UNSUPPORTED: i32 = 65;
21pub const EXIT_UNAVAILABLE: i32 = 69;
23pub const EXIT_PROTOCOL: i32 = 70;
25pub const EXIT_REFUSED: i32 = 77;
27pub const EXIT_CONFIG: i32 = 78;
29pub const EXIT_BUDGET: i32 = 124;
31
32pub const EFFECT_MODES: [&str; 2] = ["read-only", "workspace-write"];
33
34pub struct BoundSpec {
36 pub key: &'static str,
37 pub env: &'static str,
38 pub value: u64,
39 pub min: u64,
40 pub max: Option<u64>,
41}
42
43pub const BOUND_DEFAULTS: [BoundSpec; 9] = [
44 BoundSpec {
45 key: "maxSteps",
46 env: "START_FORMAL_AI_MAX_STEPS",
47 value: 8,
48 min: 1,
49 max: Some(64),
50 },
51 BoundSpec {
52 key: "maxRetries",
53 env: "START_FORMAL_AI_MAX_RETRIES",
54 value: 1,
55 min: 0,
56 max: Some(5),
57 },
58 BoundSpec {
59 key: "timeoutMs",
60 env: "START_FORMAL_AI_TIMEOUT_MS",
61 value: 120_000,
62 min: 1,
63 max: None,
64 },
65 BoundSpec {
66 key: "toolTimeoutMs",
67 env: "START_FORMAL_AI_TOOL_TIMEOUT_MS",
68 value: 30_000,
69 min: 1,
70 max: None,
71 },
72 BoundSpec {
73 key: "startupTimeoutMs",
74 env: "START_FORMAL_AI_STARTUP_TIMEOUT_MS",
75 value: 30_000,
76 min: 1,
77 max: None,
78 },
79 BoundSpec {
80 key: "maxOutputBytes",
81 env: "START_FORMAL_AI_MAX_OUTPUT_BYTES",
82 value: 64 * 1024,
83 min: 1,
84 max: Some(16 * 1024 * 1024),
85 },
86 BoundSpec {
87 key: "maxRequestChars",
88 env: "START_FORMAL_AI_MAX_REQUEST_CHARS",
89 value: 4096,
90 min: 1,
91 max: None,
92 },
93 BoundSpec {
94 key: "maxMemoryMb",
95 env: "START_FORMAL_AI_MAX_MEMORY_MB",
96 value: 1024,
97 min: 1,
98 max: None,
99 },
100 BoundSpec {
101 key: "maxFingerprintEntries",
102 env: "START_FORMAL_AI_MAX_FINGERPRINT_ENTRIES",
103 value: 5000,
104 min: 1,
105 max: None,
106 },
107];
108
109#[derive(Debug, Clone, PartialEq)]
111pub struct Bounds {
112 pub max_steps: u64,
113 pub max_retries: u64,
114 pub timeout_ms: u64,
115 pub tool_timeout_ms: u64,
116 pub startup_timeout_ms: u64,
117 pub max_output_bytes: u64,
118 pub max_request_chars: u64,
119 pub max_memory_mb: u64,
120 pub max_fingerprint_entries: u64,
121 pub effects: String,
122}
123
124impl Default for Bounds {
125 fn default() -> Self {
126 resolve_bounds_with(|_| None).expect("defaults are valid")
127 }
128}
129
130impl Bounds {
131 pub fn to_json(&self) -> Value {
133 json!({
134 "maxSteps": self.max_steps,
135 "maxRetries": self.max_retries,
136 "timeoutMs": self.timeout_ms,
137 "toolTimeoutMs": self.tool_timeout_ms,
138 "startupTimeoutMs": self.startup_timeout_ms,
139 "maxOutputBytes": self.max_output_bytes,
140 "maxRequestChars": self.max_request_chars,
141 "maxMemoryMb": self.max_memory_mb,
142 "maxFingerprintEntries": self.max_fingerprint_entries,
143 "effects": self.effects,
144 })
145 }
146}
147
148pub fn resolve_bounds() -> Result<Bounds, String> {
150 resolve_bounds_with(|name| std::env::var(name).ok())
151}
152
153pub fn resolve_bounds_with(get: impl Fn(&str) -> Option<String>) -> Result<Bounds, String> {
156 let mut values = BTreeMap::new();
157 for spec in BOUND_DEFAULTS.iter() {
158 let raw = get(spec.env).unwrap_or_default();
159 if raw.is_empty() {
160 values.insert(spec.key, spec.value);
161 continue;
162 }
163 let trimmed = raw.trim();
164 let parsed = if !trimmed.is_empty() && trimmed.chars().all(|c| c.is_ascii_digit()) {
165 trimmed.parse::<u64>().ok()
166 } else {
167 None
168 };
169 match parsed {
170 Some(value) if value >= spec.min && spec.max.is_none_or(|max| value <= max) => {
171 values.insert(spec.key, value);
172 }
173 _ => {
174 let max = spec
175 .max
176 .map_or_else(|| "any".to_string(), |max| max.to_string());
177 return Err(format!(
178 "{} must be an integer from {} to {}, got \"{}\"",
179 spec.env, spec.min, max, raw
180 ));
181 }
182 }
183 }
184 let effects = get("START_FORMAL_AI_EFFECTS")
185 .filter(|value| !value.is_empty())
186 .unwrap_or_else(|| "read-only".to_string())
187 .trim()
188 .to_string();
189 if !EFFECT_MODES.contains(&effects.as_str()) {
190 return Err(format!(
191 "START_FORMAL_AI_EFFECTS must be one of {}, got \"{}\"",
192 EFFECT_MODES.join(", "),
193 effects
194 ));
195 }
196 Ok(Bounds {
197 max_steps: values["maxSteps"],
198 max_retries: values["maxRetries"],
199 timeout_ms: values["timeoutMs"],
200 tool_timeout_ms: values["toolTimeoutMs"],
201 startup_timeout_ms: values["startupTimeoutMs"],
202 max_output_bytes: values["maxOutputBytes"],
203 max_request_chars: values["maxRequestChars"],
204 max_memory_mb: values["maxMemoryMb"],
205 max_fingerprint_entries: values["maxFingerprintEntries"],
206 effects,
207 })
208}
209
210#[derive(Debug, Clone, PartialEq, Default)]
212pub struct Tokenized {
213 pub tokens: Vec<String>,
214 pub problems: Vec<String>,
215}
216
217pub fn tokenize_command(line: &str) -> Tokenized {
220 let chars: Vec<char> = line.chars().collect();
221 let mut tokens = Vec::new();
222 let mut problems: Vec<String> = Vec::new();
223 let mut add = |problem: String| {
224 if !problems.contains(&problem) {
225 problems.push(problem);
226 }
227 };
228 let mut current: Option<String> = None;
229 let mut quote: Option<char> = None;
230 let mut i = 0;
231 while i < chars.len() {
232 let ch = chars[i];
233 if quote == Some('\'') {
234 if ch == '\'' {
235 quote = None;
236 } else {
237 current.get_or_insert_with(String::new).push(ch);
238 }
239 i += 1;
240 continue;
241 }
242 if quote == Some('"') {
243 if ch == '"' {
244 quote = None;
245 } else if ch == '\\' && i + 1 < chars.len() && "\"\\$`".contains(chars[i + 1]) {
246 i += 1;
247 current.get_or_insert_with(String::new).push(chars[i]);
248 } else {
249 if ch == '$' || ch == '`' {
250 add(format!("expansion \"{}\"", ch));
251 }
252 current.get_or_insert_with(String::new).push(ch);
253 }
254 i += 1;
255 continue;
256 }
257 if ch == ' ' || ch == '\t' {
258 if let Some(token) = current.take() {
259 tokens.push(token);
260 }
261 i += 1;
262 continue;
263 }
264 if ch == '\'' || ch == '"' {
265 quote = Some(ch);
266 current.get_or_insert_with(String::new);
267 i += 1;
268 continue;
269 }
270 if ch == '\\' {
271 if i + 1 < chars.len() && chars[i + 1] != '\n' {
272 i += 1;
273 current.get_or_insert_with(String::new).push(chars[i]);
274 i += 1;
275 continue;
276 }
277 add("line continuation".to_string());
278 i += 1;
279 continue;
280 }
281 if "\n\r;&|<>()$`".contains(ch) {
282 let shown = if ch == '\n' {
283 "\\n".to_string()
284 } else {
285 ch.to_string()
286 };
287 add(format!("shell operator \"{}\"", shown));
288 } else if "*?[]{}".contains(ch) {
289 add(format!("unquoted pattern \"{}\"", ch));
290 } else if ch == '~' && current.is_none() {
291 add("home directory expansion \"~\"".to_string());
292 } else if ch == '#' && current.is_none() {
293 add("comment \"#\"".to_string());
294 }
295 current.get_or_insert_with(String::new).push(ch);
296 i += 1;
297 }
298 if quote.is_some() {
299 add("unterminated quote".to_string());
300 }
301 if let Some(token) = current {
302 tokens.push(token);
303 }
304 Tokenized { tokens, problems }
305}
306
307pub const READ_PROGRAMS: [&str; 24] = [
308 "basename",
309 "cat",
310 "cmp",
311 "diff",
312 "dirname",
313 "du",
314 "file",
315 "find",
316 "git",
317 "grep",
318 "head",
319 "ls",
320 "md5sum",
321 "pwd",
322 "readlink",
323 "realpath",
324 "sha256sum",
325 "sort",
326 "stat",
327 "tail",
328 "test",
329 "tree",
330 "uniq",
331 "wc",
332];
333pub const WRITE_PROGRAMS: [&str; 6] = ["cp", "mkdir", "mv", "rm", "rmdir", "touch"];
334const LISTING_PROGRAMS: [&str; 4] = ["ls", "find", "tree", "du"];
335const GIT_SUBCOMMANDS: [&str; 7] = [
336 "branch",
337 "diff",
338 "log",
339 "ls-files",
340 "rev-parse",
341 "show",
342 "status",
343];
344const GIT_BRANCH_FLAGS: [&str; 6] = ["-a", "-r", "-v", "-vv", "--list", "--show-current"];
345
346fn forbidden_flags(program: &str) -> Option<Regex> {
348 let pattern = match program {
349 "find" => r"^-(delete|exec|execdir|ok|okdir|fprint|fprint0|fprintf|fls)$",
350 "sort" | "tree" => r"^(-o.*|--output.*)$",
351 "file" => r"^(-C|--compile)$",
352 "tail" => r"^(-f|-F|--follow.*|--retry)$",
353 "git" => r"^(--output.*|--ext-diff|-c|--exec-path.*|--git-dir.*|--work-tree.*)$",
354 "rm" => r"^(-[a-zA-Z]*[rR][a-zA-Z]*|--recursive|--no-preserve-root)$",
355 "cp" => r"^(-[a-zA-Z]*[l][a-zA-Z]*|--link|-s|--symbolic-link)$",
356 _ => return None,
357 };
358 Regex::new(pattern).ok()
359}
360
361fn normalize(path: &Path) -> PathBuf {
363 let mut result = PathBuf::new();
364 for component in path.components() {
365 match component {
366 Component::CurDir => {}
367 Component::ParentDir => {
368 result.pop();
369 }
370 other => result.push(other.as_os_str()),
371 }
372 }
373 result
374}
375
376fn real_target(target: &Path) -> PathBuf {
378 let mut existing = target.to_path_buf();
379 let mut rest = Vec::new();
380 while !existing.exists() {
381 match (existing.parent(), existing.file_name()) {
382 (Some(parent), Some(name)) => {
383 rest.push(name.to_os_string());
384 existing = parent.to_path_buf();
385 }
386 _ => return target.to_path_buf(),
387 }
388 }
389 let mut resolved = fs::canonicalize(&existing).unwrap_or(existing);
390 for name in rest.iter().rev() {
391 resolved.push(name);
392 }
393 resolved
394}
395
396pub fn operands(tokens: &[String]) -> Vec<String> {
398 let mut values = Vec::new();
399 for token in tokens.iter().skip(1) {
400 if token.starts_with('-') {
401 if let Some(eq) = token.find('=') {
402 if eq > 0 && token.starts_with("--") {
403 values.push(token[eq + 1..].to_string());
404 }
405 }
406 continue;
407 }
408 values.push(token.clone());
409 }
410 values
411}
412
413pub fn workspace_path(cwd: &Path, operand: &str) -> Option<String> {
415 let root = fs::canonicalize(cwd).unwrap_or_else(|_| cwd.to_path_buf());
416 let resolved = real_target(&normalize(&cwd.join(operand)));
417 let relative = resolved.strip_prefix(&root).ok()?;
418 let text = relative.to_string_lossy().to_string();
419 Some(if text.is_empty() {
420 ".".to_string()
421 } else {
422 text
423 })
424}
425
426fn parent_of(target: &str) -> String {
427 match Path::new(target).parent() {
428 Some(parent) if !parent.as_os_str().is_empty() => parent.to_string_lossy().to_string(),
429 _ => ".".to_string(),
430 }
431}
432
433#[derive(Debug, Clone, Default)]
435pub struct PolicyContext {
436 pub cwd: PathBuf,
437 pub effects: String,
438 pub observed: BTreeSet<String>,
439 pub observed_dirs: BTreeSet<String>,
440}
441
442impl PolicyContext {
443 pub fn new(cwd: &Path, effects: &str) -> Self {
444 Self {
445 cwd: cwd.to_path_buf(),
446 effects: effects.to_string(),
447 ..Self::default()
448 }
449 }
450}
451
452#[derive(Debug, Clone, PartialEq, Default)]
454pub struct Decision {
455 pub allowed: bool,
456 pub reason: Option<String>,
457 pub tokens: Vec<String>,
458 pub kind: Option<&'static str>,
460 pub targets: Vec<String>,
461}
462
463fn refuse(reason: String, tokens: &[String]) -> Decision {
464 Decision {
465 allowed: false,
466 reason: Some(reason),
467 tokens: tokens.to_vec(),
468 ..Decision::default()
469 }
470}
471
472pub fn evaluate_command(line: &str, context: &PolicyContext) -> Decision {
474 let Tokenized { tokens, problems } = tokenize_command(line);
475 if !problems.is_empty() {
476 return refuse(
477 format!(
478 "needs shell syntax start does not run: {}",
479 problems.join(", ")
480 ),
481 &tokens,
482 );
483 }
484 let Some(program) = tokens.first().cloned() else {
485 return refuse("empty command".to_string(), &tokens);
486 };
487 let is_write = WRITE_PROGRAMS.contains(&program.as_str());
488 if !READ_PROGRAMS.contains(&program.as_str()) && !is_write {
489 return refuse(
490 format!("program \"{}\" is not in the supported tool set", program),
491 &tokens,
492 );
493 }
494 if is_write && context.effects != "workspace-write" {
495 return refuse(
496 format!(
497 "\"{}\" changes files; the effect mode is read-only (set START_FORMAL_AI_EFFECTS=workspace-write to allow workspace changes)",
498 program
499 ),
500 &tokens,
501 );
502 }
503 if let Some(forbidden) = forbidden_flags(&program) {
504 if let Some(flag) = tokens.iter().skip(1).find(|t| forbidden.is_match(t)) {
505 return refuse(
506 format!("option \"{}\" of {} is not allowed", flag, program),
507 &tokens,
508 );
509 }
510 }
511 if program == "git" {
512 let sub = tokens.get(1).cloned().unwrap_or_default();
513 if !GIT_SUBCOMMANDS.contains(&sub.as_str()) {
514 return refuse(
515 format!("git subcommand \"{}\" is not read-only", sub),
516 &tokens,
517 );
518 }
519 if sub == "branch"
520 && tokens
521 .iter()
522 .skip(2)
523 .any(|t| !GIT_BRANCH_FLAGS.contains(&t.as_str()))
524 {
525 return refuse("git branch may only list branches".to_string(), &tokens);
526 }
527 }
528 let mut targets = Vec::new();
529 for operand in operands(&tokens) {
530 if operand.starts_with('~') {
531 return refuse(
532 format!("\"{}\" is outside the working directory", operand),
533 &tokens,
534 );
535 }
536 match workspace_path(&context.cwd, &operand) {
539 Some(relative) => targets.push(relative),
540 None => {
541 return refuse(
542 format!("\"{}\" is outside the working directory", operand),
543 &tokens,
544 )
545 }
546 }
547 }
548 if is_write {
549 if targets.is_empty() {
550 return refuse(format!("{} needs an explicit target", program), &tokens);
551 }
552 let unobserved: Vec<String> = targets
553 .iter()
554 .filter(|t| {
555 t.as_str() == "."
556 || !(context.observed.contains(*t)
557 || context.observed_dirs.contains(&parent_of(t)))
558 })
559 .map(|t| format!("\"{}\"", t))
560 .collect();
561 if !unobserved.is_empty() {
562 return refuse(
563 format!(
564 "target {} was not observed before the change",
565 unobserved.join(", ")
566 ),
567 &tokens,
568 );
569 }
570 }
571 Decision {
572 allowed: true,
573 reason: None,
574 tokens,
575 kind: Some(if is_write { "write" } else { "read" }),
576 targets,
577 }
578}
579
580pub fn record_observation(decision: &Decision, context: &mut PolicyContext) {
582 for target in &decision.targets {
583 context.observed.insert(target.clone());
584 }
585 let listing = decision
586 .tokens
587 .first()
588 .is_some_and(|program| LISTING_PROGRAMS.contains(&program.as_str()));
589 if listing {
590 if decision.targets.is_empty() {
591 context.observed_dirs.insert(".".to_string());
592 }
593 for target in &decision.targets {
594 context.observed_dirs.insert(target.clone());
595 }
596 }
597}
598
599#[derive(Debug, Clone, PartialEq, Default)]
601pub struct Fingerprint {
602 pub entries: BTreeMap<String, String>,
603 pub complete: bool,
604}
605
606pub fn fingerprint(cwd: &Path, limit: u64) -> Fingerprint {
609 let mut entries = BTreeMap::new();
610 let mut queue = VecDeque::from([String::new()]);
611 while let Some(relative) = queue.pop_front() {
612 let dir = if relative.is_empty() {
613 cwd.to_path_buf()
614 } else {
615 cwd.join(&relative)
616 };
617 let Ok(read) = fs::read_dir(&dir) else {
618 continue;
619 };
620 let mut names: Vec<String> = read
621 .filter_map(|entry| entry.ok())
622 .map(|entry| entry.file_name().to_string_lossy().to_string())
623 .collect();
624 names.sort();
625 for name in names {
626 if entries.len() as u64 >= limit {
627 return Fingerprint {
628 entries,
629 complete: false,
630 };
631 }
632 let child = if relative.is_empty() {
633 name.clone()
634 } else {
635 format!("{}/{}", relative, name)
636 };
637 let Ok(meta) = fs::symlink_metadata(cwd.join(&child)) else {
638 continue;
639 };
640 let kind = if meta.is_dir() {
641 'd'
642 } else if meta.file_type().is_symlink() {
643 'l'
644 } else {
645 'f'
646 };
647 let mtime = meta
648 .modified()
649 .ok()
650 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
651 .map_or(0, |d| d.as_nanos());
652 entries.insert(child.clone(), format!("{}:{}:{}", kind, meta.len(), mtime));
653 if kind == 'd' && name != ".git" {
654 queue.push_back(child);
655 }
656 }
657 }
658 Fingerprint {
659 entries,
660 complete: true,
661 }
662}
663
664#[derive(Debug, Clone, PartialEq)]
666pub struct EffectDiff {
667 pub added: Vec<String>,
668 pub removed: Vec<String>,
669 pub changed: Vec<String>,
670 pub complete: bool,
671}
672
673impl Default for EffectDiff {
674 fn default() -> Self {
675 Self {
676 added: Vec::new(),
677 removed: Vec::new(),
678 changed: Vec::new(),
679 complete: true,
680 }
681 }
682}
683
684impl EffectDiff {
685 pub fn has_effects(&self) -> bool {
686 !(self.added.is_empty() && self.removed.is_empty() && self.changed.is_empty())
687 }
688
689 pub fn to_json(&self) -> Value {
690 json!({
691 "added": self.added,
692 "removed": self.removed,
693 "changed": self.changed,
694 "complete": self.complete,
695 })
696 }
697}
698
699pub fn diff_fingerprints(before: &Fingerprint, after: &Fingerprint) -> EffectDiff {
700 let mut diff = EffectDiff {
701 complete: before.complete && after.complete,
702 ..EffectDiff::default()
703 };
704 for (name, value) in &after.entries {
705 match before.entries.get(name) {
706 None => diff.added.push(name.clone()),
707 Some(old) if old != value => diff.changed.push(name.clone()),
708 _ => {}
709 }
710 }
711 for name in before.entries.keys() {
712 if !after.entries.contains_key(name) {
713 diff.removed.push(name.clone());
714 }
715 }
716 diff
717}
718
719#[derive(Debug, Clone, PartialEq)]
721pub struct Verification {
722 pub verified: bool,
723 pub detail: String,
724}
725
726impl Verification {
727 pub fn to_json(&self) -> Value {
728 json!({ "verified": self.verified, "detail": self.detail })
729 }
730}
731
732pub fn verify_change(tokens: &[String], cwd: &Path) -> Verification {
734 let program = tokens.first().map(String::as_str).unwrap_or("");
735 let args = operands(tokens);
736 let exists = |p: &str| cwd.join(p).exists();
737 let is_dir = |p: &str| cwd.join(p).is_dir();
738 let mut missing: Vec<String> = Vec::new();
739 match program {
740 "mkdir" => missing = args.iter().filter(|p| !is_dir(p)).cloned().collect(),
741 "touch" => missing = args.iter().filter(|p| !exists(p)).cloned().collect(),
742 "rm" | "rmdir" => {
743 missing = args
744 .iter()
745 .filter(|p| exists(p))
746 .map(|p| format!("{} (still present)", p))
747 .collect()
748 }
749 "cp" | "mv" if !args.is_empty() => {
750 let dest = args[args.len() - 1].clone();
751 let sources = &args[..args.len() - 1];
752 let expected: Vec<String> = if is_dir(&dest) && !sources.is_empty() {
753 sources
754 .iter()
755 .map(|s| {
756 let base = Path::new(s)
757 .file_name()
758 .map(|n| n.to_string_lossy().to_string())
759 .unwrap_or_default();
760 Path::new(&dest).join(base).to_string_lossy().to_string()
761 })
762 .collect()
763 } else {
764 vec![dest]
765 };
766 missing = expected.iter().filter(|p| !exists(p)).cloned().collect();
767 if program == "mv" {
768 missing.extend(
769 sources
770 .iter()
771 .filter(|s| exists(s) && !expected.contains(s))
772 .map(|s| format!("{} (still present)", s)),
773 );
774 }
775 }
776 _ => {}
777 }
778 if missing.is_empty() {
779 Verification {
780 verified: true,
781 detail: format!("readback confirmed {} {}", program, args.join(" ")),
782 }
783 } else {
784 Verification {
785 verified: false,
786 detail: format!("readback failed: {}", missing.join(", ")),
787 }
788 }
789}
790
791#[cfg(test)]
792#[path = "formal_ai_policy_cases.rs"]
793pub(crate) mod formal_ai_policy_cases;