Skip to main content

perspt_sdk/toolset/
base.rs

1//! The base tool catalog (PSP-9 system 5).
2//!
3//! Domain-neutral entries mapping onto the `EffectKind` variants PSP-8
4//! declares. `sed_replace` and `awk_filter` are deliberately absent: their
5//! argument contracts lack the exact-match and state-witness preconditions
6//! governed mutation requires, and `edit_file` / `apply_diff` cover their
7//! uses. They are removed rather than retained through an internal bypass,
8//! because this PSP preserves no unmediated effect path.
9
10use super::entry::{ToolEntry, ToolOrigin};
11use super::footprint::{AccessMode, FootprintSpec, ResourceSelector};
12use crate::capability::{EffectKind, RiskClass};
13
14/// Shorthand: an object schema from `(name, type, description, required)`.
15fn 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
74/// Host-side tool-surface entries (deferred discovery and composition).
75fn 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
113/// Host-side retrieval of evicted context and stored artifacts.
114fn 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
174/// The read-only half of the base catalog.
175fn 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
220/// The pattern-search half of the read catalog.
221fn 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
259/// Language-intelligence read entries.
260fn 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
281/// Repository and escalation entries.
282fn 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
309/// The mutating half of the base catalog.
310fn 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
359/// File relocation entries.
360fn 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
395/// Verifier and command entries; footprints are opaque because their touched
396/// state depends on the workspace, so they serialize rather than race.
397fn 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
461/// The governed dependency-mutation entry (Gate J).
462fn 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
507/// High-risk command and network entries.
508fn 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
543/// Privileged and delegation entries.
544fn 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
578/// Every builtin entry.
579pub 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}