1use super::entry::{ToolEntry, ToolOrigin};
11use super::footprint::{AccessMode, FootprintSpec, ResourceSelector};
12use crate::capability::{EffectKind, RiskClass};
13
14fn schema(fields: &[(&str, &str, &str, bool)]) -> serde_json::Value {
16 let mut properties = serde_json::Map::new();
17 let mut required = Vec::new();
18 for (name, kind, description, is_required) in fields {
19 properties.insert(
20 (*name).to_string(),
21 serde_json::json!({"type": kind, "description": description}),
22 );
23 if *is_required {
24 required.push(serde_json::Value::String((*name).to_string()));
25 }
26 }
27 serde_json::json!({
28 "type": "object",
29 "properties": properties,
30 "required": required,
31 "additionalProperties": false,
32 })
33}
34
35fn path_read() -> FootprintSpec {
36 FootprintSpec::new(vec![ResourceSelector::PathArgument {
37 field: "path".into(),
38 access: AccessMode::Read,
39 }])
40}
41
42fn path_write() -> FootprintSpec {
43 FootprintSpec::new(vec![ResourceSelector::PathArgument {
44 field: "path".into(),
45 access: AccessMode::Write,
46 }])
47}
48
49fn entry(
50 name: &str,
51 description: &str,
52 effect: EffectKind,
53 risk: RiskClass,
54 arg_schema: serde_json::Value,
55 footprint: FootprintSpec,
56 durable: bool,
57) -> ToolEntry {
58 ToolEntry {
59 name: name.into(),
60 description: description.into(),
61 discovery_summary: String::new(),
62 description_templates: None,
63 effect,
64 risk,
65 schema: arg_schema,
66 footprint,
67 proposal_bindings: Vec::new(),
68 durable,
69 origin: ToolOrigin::Builtin,
70 hot: false,
71 }
72}
73
74fn surface_entries() -> Vec<ToolEntry> {
76 vec![
77 entry(
78 "tool_search",
79 "Find governed tools by capability, name, and description; matching \
80 schemas become available on the next turn",
81 EffectKind::ToolSearch,
82 RiskClass::Low,
83 schema(&[
84 ("query", "string", "Capability or operation to find", true),
85 ("limit", "integer", "Maximum matches, from 1 to 12", false),
86 ]),
87 FootprintSpec::default(),
88 false,
89 )
90 .hot(),
91 entry(
92 "tool_program",
93 "Run a bounded pure Starlark program that returns nested tool \
94 proposals; each nested call is mediated separately",
95 EffectKind::ToolProgram,
96 RiskClass::Low,
97 schema(&[(
98 "source",
99 "string",
100 "Starlark whose final expression is main and whose main() returns call JSON",
101 true,
102 )]),
103 FootprintSpec::default(),
104 false,
105 )
106 .hot(),
107 ]
108 .into_iter()
109 .chain(recall_entries())
110 .collect()
111}
112
113fn recall_entries() -> Vec<ToolEntry> {
115 vec![
116 entry(
117 "context_recall",
118 "Restore evicted context pages by page id (from an eviction note \
119 or the page index), by path, or by typed diagnostic/test key; \
120 returns the pages' original content",
121 EffectKind::DataRead,
122 RiskClass::Low,
123 schema(&[
124 (
125 "page_id",
126 "string",
127 "Content-addressed page id or its 16-hex prefix",
128 false,
129 ),
130 ("path", "string", "A path a tool call named", false),
131 ("diagnostic_id", "string", "A typed diagnostic id", false),
132 ("test_id", "string", "A typed test id", false),
133 ("symbol", "string", "A typed symbol name", false),
134 (
135 "provenance_key",
136 "string",
137 "A provenance key (the page's content address)",
138 false,
139 ),
140 ]),
141 FootprintSpec::opaque(),
142 false,
143 )
144 .hot(),
145 entry(
146 "read_artifact",
147 "Read a window of a stored artifact by the artifact:<handle> shown in a \
148 truncated-output note; returns bytes offset..offset+limit with a \
149 continuation hint",
150 EffectKind::DataRead,
151 RiskClass::Low,
152 schema(&[
153 (
154 "handle",
155 "string",
156 "The content handle from a [full output: artifact:…] note",
157 true,
158 ),
159 (
160 "offset",
161 "integer",
162 "0-based byte offset (default 0)",
163 false,
164 ),
165 ("limit", "integer", "Bytes to return, capped at 7168", false),
166 ]),
167 FootprintSpec::opaque(),
168 false,
169 )
170 .hot(),
171 ]
172}
173
174fn read_entries() -> Vec<ToolEntry> {
176 let mut entries = surface_entries();
177 entries.extend([
178 entry(
179 "read_file",
180 "Read a file's contents with optional offset/limit windows; returns line-numbered text",
181 EffectKind::ReadFile,
182 RiskClass::Low,
183 schema(&[
184 ("path", "string", "Workspace-relative file path", true),
185 ("offset", "integer", "1-based first line to read", false),
186 (
187 "limit",
188 "integer",
189 "Maximum number of lines (capped at 2000)",
190 false,
191 ),
192 ]),
193 path_read(),
194 false,
195 )
196 .hot(),
197 entry(
198 "list_files",
199 "List files in a directory, respecting ignore files",
200 EffectKind::List,
201 RiskClass::Low,
202 schema(&[
203 (
204 "path",
205 "string",
206 "Directory to list (default workspace root)",
207 false,
208 ),
209 ("offset", "integer", "0-based entry to continue from", false),
210 ("limit", "integer", "Maximum entries to return", false),
211 ]),
212 path_read(),
213 false,
214 ),
215 ]);
216 entries.extend(search_read_entries());
217 entries
218}
219
220fn search_read_entries() -> Vec<ToolEntry> {
222 vec![
223 entry(
224 "glob",
225 "Match files by glob pattern, sorted by modification time",
226 EffectKind::Search,
227 RiskClass::Low,
228 schema(&[
229 ("pattern", "string", "Glob pattern, e.g. src/**/*.rs", true),
230 ("offset", "integer", "0-based match to continue from", false),
231 ("limit", "integer", "Maximum matches to return", false),
232 ]),
233 FootprintSpec::default(),
234 false,
235 )
236 .hot(),
237 entry(
238 "grep",
239 "Search file contents by regex with optional path filter and context lines",
240 EffectKind::Search,
241 RiskClass::Low,
242 schema(&[
243 ("query", "string", "Regex to search for", true),
244 ("path", "string", "Directory or file to search in", false),
245 (
246 "context",
247 "integer",
248 "Context lines around each match",
249 false,
250 ),
251 ]),
252 path_read(),
253 false,
254 )
255 .hot(),
256 ]
257}
258
259fn intel_entries() -> Vec<ToolEntry> {
261 vec![entry(
262 "lsp_query",
263 "Query language intelligence: definitions, references, hover, diagnostics",
264 EffectKind::LspQuery,
265 RiskClass::Low,
266 schema(&[
267 (
268 "kind",
269 "string",
270 "definition | references | hover | diagnostics",
271 true,
272 ),
273 ("path", "string", "File the query targets", true),
274 ("symbol", "string", "Symbol name, when applicable", false),
275 ]),
276 path_read(),
277 false,
278 )]
279}
280
281fn context_entries() -> Vec<ToolEntry> {
283 vec![
284 entry(
285 "git_read",
286 "Read repository state: status, diff, log, show",
287 EffectKind::GitRead,
288 RiskClass::Low,
289 schema(&[
290 ("subcommand", "string", "status | diff | log | show", true),
291 ("args", "string", "Additional read-only arguments", false),
292 ]),
293 FootprintSpec::default(),
294 false,
295 ),
296 entry(
297 "ask_user",
298 "Escalate to the user for approval, a capability grant, or a decision",
299 EffectKind::AskUser,
300 RiskClass::Low,
301 schema(&[("question", "string", "The question or request", true)]),
302 FootprintSpec::default(),
303 false,
304 )
305 .hot(),
306 ]
307}
308
309fn write_entries() -> Vec<ToolEntry> {
311 vec![
312 entry(
313 "write_file",
314 "Create or replace a whole file",
315 EffectKind::WriteArtifact,
316 RiskClass::Medium,
317 schema(&[
318 ("path", "string", "Workspace-relative file path", true),
319 ("content", "string", "Complete new file contents", true),
320 ]),
321 path_write(),
322 false,
323 ),
324 entry(
325 "edit_file",
326 "Exact-string replace with a uniqueness check; fails closed on ambiguity",
327 EffectKind::ApplyPatch,
328 RiskClass::Medium,
329 schema(&[
330 ("path", "string", "File to edit", true),
331 (
332 "old_string",
333 "string",
334 "Exact text to replace (must be unique)",
335 true,
336 ),
337 ("new_string", "string", "Replacement text", true),
338 ]),
339 path_write(),
340 false,
341 )
342 .hot(),
343 entry(
344 "apply_diff",
345 "Apply a unified diff to a file",
346 EffectKind::ApplyPatch,
347 RiskClass::Medium,
348 schema(&[
349 ("path", "string", "File to patch", true),
350 ("diff", "string", "Unified diff content", true),
351 ]),
352 path_write(),
353 false,
354 )
355 .hot(),
356 ]
357}
358
359fn relocation_entries() -> Vec<ToolEntry> {
361 vec![
362 entry(
363 "move_file",
364 "Move or rename a file",
365 EffectKind::MoveFile,
366 RiskClass::Medium,
367 schema(&[
368 ("path", "string", "Source path", true),
369 ("to", "string", "Destination path", true),
370 ]),
371 FootprintSpec::new(vec![
372 ResourceSelector::PathArgument {
373 field: "path".into(),
374 access: AccessMode::Write,
375 },
376 ResourceSelector::PathArgument {
377 field: "to".into(),
378 access: AccessMode::Write,
379 },
380 ]),
381 false,
382 ),
383 entry(
384 "delete_file",
385 "Delete a file",
386 EffectKind::DeleteFile,
387 RiskClass::Medium,
388 schema(&[("path", "string", "File to delete", true)]),
389 path_write(),
390 false,
391 ),
392 ]
393}
394
395fn command_entries() -> Vec<ToolEntry> {
398 vec![
399 entry(
400 "exec",
401 "Run one shell-free, read-only OS program in the workspace sandbox; \
402 use rg, git diff/status/log/show, awk, sed -n, or coreutils. For \
403 tests use run_test, for builds run_build, for formatting \
404 run_formatter — exec denies them",
405 EffectKind::Search,
406 RiskClass::Low,
407 schema(&[(
408 "command",
409 "string",
410 "One direct program invocation; pipes, redirects, expansion, and \
411 mutation commands are denied",
412 true,
413 )]),
414 FootprintSpec::opaque(),
415 false,
416 )
417 .hot(),
418 entry(
419 "run_test",
420 "Run the domain's declared test command; output is parsed into residuals",
421 EffectKind::RunTest,
422 RiskClass::Medium,
423 schema(&[("filter", "string", "Optional test name filter", false)]),
424 FootprintSpec::opaque(),
425 false,
426 )
427 .hot(),
428 entry(
429 "run_build",
430 "Run the domain's declared build command",
431 EffectKind::RunBuild,
432 RiskClass::Medium,
433 schema(&[]),
434 FootprintSpec::opaque(),
435 false,
436 )
437 .hot(),
438 entry(
439 "run_formatter",
440 "Run the domain's declared formatter",
441 EffectKind::RunFormatter,
442 RiskClass::Medium,
443 schema(&[]),
444 FootprintSpec::opaque(),
445 false,
446 )
447 .hot(),
448 entry(
449 "run_repo_script",
450 "Run a script declared in the project profile",
451 EffectKind::RunRepoScript,
452 RiskClass::Medium,
453 schema(&[("name", "string", "Declared script name", true)]),
454 FootprintSpec::opaque(),
455 false,
456 ),
457 dependency_entry(),
458 ]
459}
460
461fn dependency_entry() -> ToolEntry {
463 entry(
464 "mutate_dependencies",
465 "Add, remove, or update dependencies via the domain package manager",
466 EffectKind::MutateDependencies,
467 RiskClass::High,
468 serde_json::json!({
469 "type": "object",
470 "properties": {
471 "action": {
472 "type": "string",
473 "description": "add | remove | update"
474 },
475 "packages": {
476 "type": "array",
477 "description": "Package names (optionally versioned)",
478 "items": {"type": "string"},
479 "maxItems": 16
480 },
481 "dev": {
482 "type": "boolean",
483 "description": "Add as a development dependency"
484 }
485 },
486 "required": ["action", "packages"],
487 "additionalProperties": false
488 }),
489 FootprintSpec::new(vec![
490 ResourceSelector::Literal {
491 resource: crate::scheduler::Resource::Manifest("workspace".into()),
492 access: AccessMode::Write,
493 },
494 ResourceSelector::Literal {
495 resource: crate::scheduler::Resource::Lockfile("workspace".into()),
496 access: AccessMode::Write,
497 },
498 ResourceSelector::Literal {
499 resource: crate::scheduler::Resource::Toolchain("workspace".into()),
500 access: AccessMode::Read,
501 },
502 ]),
503 true,
504 )
505}
506
507fn high_risk_entries() -> Vec<ToolEntry> {
509 vec![
510 entry(
511 "run_shell",
512 "Run a shell command; requires an explicit RunShell capability",
513 EffectKind::RunShell,
514 RiskClass::High,
515 schema(&[("command", "string", "The command line", true)]),
516 FootprintSpec::opaque(),
517 true,
518 ),
519 entry(
520 "git_write",
521 "Stage and commit; never push or hard-reset by default",
522 EffectKind::GitWrite,
523 RiskClass::High,
524 schema(&[
525 ("subcommand", "string", "add | commit", true),
526 ("args", "string", "Arguments", false),
527 ]),
528 FootprintSpec::opaque(),
529 true,
530 ),
531 entry(
532 "fetch_url",
533 "Fetch a URL; defaults to ask, domain allow-lists permitted",
534 EffectKind::NetworkFetch,
535 RiskClass::High,
536 schema(&[("url", "string", "The URL to fetch", true)]),
537 FootprintSpec::opaque(),
538 true,
539 ),
540 ]
541}
542
543fn privileged_entries() -> Vec<ToolEntry> {
545 vec![
546 entry(
547 "update_graph",
548 "Split, add, or retire work-graph nodes under PSP-8 revision rules",
549 EffectKind::UpdateGraph,
550 RiskClass::Critical,
551 schema(&[(
552 "revision",
553 "string",
554 "Serialized graph revision request",
555 true,
556 )]),
557 FootprintSpec::new(vec![ResourceSelector::Literal {
558 resource: crate::scheduler::Resource::WorkGraph,
559 access: AccessMode::Write,
560 }]),
561 true,
562 ),
563 entry(
564 "spawn_agent",
565 "Delegate to a child agent; the child capability MUST attenuate (Theorem 1)",
566 EffectKind::SpawnAgent,
567 RiskClass::Critical,
568 schema(&[
569 ("role", "string", "explorer | worker | reviewer", true),
570 ("goal", "string", "The child's goal", true),
571 ]),
572 FootprintSpec::opaque(),
573 true,
574 ),
575 ]
576}
577
578pub fn base_entries() -> Vec<ToolEntry> {
580 let mut entries = read_entries();
581 entries.extend(intel_entries());
582 entries.extend(context_entries());
583 entries.extend(write_entries());
584 entries.extend(relocation_entries());
585 entries.extend(command_entries());
586 entries.extend(high_risk_entries());
587 entries.extend(privileged_entries());
588 entries
589}
590
591#[cfg(test)]
592mod tests {
593 use super::*;
594
595 #[test]
596 fn every_base_entry_validates_at_assembly() {
597 for entry in base_entries() {
598 entry
599 .validate()
600 .unwrap_or_else(|e| panic!("{}: {e}", entry.name));
601 }
602 }
603
604 #[test]
605 fn the_governed_catalog_omits_sed_and_awk() {
606 let names: Vec<String> = base_entries().into_iter().map(|e| e.name).collect();
607 assert!(!names.contains(&"sed_replace".to_string()));
608 assert!(!names.contains(&"awk_filter".to_string()));
609 assert!(names.contains(&"edit_file".to_string()));
610 }
611
612 #[test]
613 fn privileged_effects_are_critical_risk() {
614 for entry in base_entries() {
615 if entry.effect.is_privileged() {
616 assert_eq!(entry.risk, RiskClass::Critical, "{}", entry.name);
617 }
618 }
619 }
620
621 #[test]
622 fn read_only_entries_never_mark_durable() {
623 for entry in base_entries() {
624 if entry.effect.is_read_only() {
625 assert!(!entry.durable, "{}", entry.name);
626 }
627 }
628 }
629}