@oisin-ee/oisinpi 0.11.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +20 -0
- package/node_modules/@oisin-ee/pi-guardrails/README.md +5 -35
- package/node_modules/@oisin-ee/pi-guardrails/package.json +1 -1
- package/node_modules/@oisin-ee/pi-guardrails/src/pi/index.ts +10 -53
- package/node_modules/@oisin-ee/pi-prewalk/package.json +1 -1
- package/node_modules/@oisin-ee/pi-tiers/package.json +1 -1
- package/node_modules/@oisin-ee/pi-todo/README.md +3 -13
- package/node_modules/@oisin-ee/pi-todo/package.json +1 -1
- package/node_modules/@oisin-ee/pi-todo/src/index.ts +2 -2
- package/node_modules/@oisin-ee/pi-todo/src/{plannotator.ts → plan-approved.ts} +22 -25
- package/package.json +5 -5
- package/node_modules/@oisin-ee/pi-guardrails/src/pi/planning-policy.ts +0 -763
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
# @oisin-ee/oisinpi
|
|
2
2
|
|
|
3
|
+
## 0.12.0
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Updated dependencies [[`7fcdc45`](https://github.com/oisin-ee/oisinpi/commit/7fcdc45f08c065faf38c3841c0b2d2133ad9322f)]:
|
|
8
|
+
- @oisin-ee/pi-todo@0.12.0
|
|
9
|
+
- @oisin-ee/pi-guardrails@0.12.0
|
|
10
|
+
- @oisin-ee/pi-prewalk@0.12.0
|
|
11
|
+
- @oisin-ee/pi-tiers@0.12.0
|
|
12
|
+
|
|
13
|
+
## 0.11.1
|
|
14
|
+
|
|
15
|
+
### Patch Changes
|
|
16
|
+
|
|
17
|
+
- Updated dependencies [[`f1837f2`](https://github.com/oisin-ee/oisinpi/commit/f1837f28bc25fb00feebf7c47509db9bbd3b897c)]:
|
|
18
|
+
- @oisin-ee/pi-guardrails@0.11.1
|
|
19
|
+
- @oisin-ee/pi-prewalk@0.11.1
|
|
20
|
+
- @oisin-ee/pi-tiers@0.11.1
|
|
21
|
+
- @oisin-ee/pi-todo@0.11.1
|
|
22
|
+
|
|
3
23
|
## 0.11.0
|
|
4
24
|
|
|
5
25
|
### Patch Changes
|
|
@@ -13,43 +13,13 @@ Agent guardrails for [pi](https://pi.dev), Claude Code, and Codex. One set of po
|
|
|
13
13
|
In pi, it also fills a missing shell timeout (120 s) and blocks one above 10 minutes, naming each tool's unit.
|
|
14
14
|
Shell text is parsed with `shfmt`, which must be on `PATH`. A command that cannot be parsed is blocked.
|
|
15
15
|
|
|
16
|
-
##
|
|
16
|
+
## Planning models
|
|
17
17
|
|
|
18
|
-
|
|
19
|
-
|
|
18
|
+
The guardrails do not restrict a model by its identity. The planning selections `tier/plan-claude` and
|
|
19
|
+
`tier/plan-gpt` keep the full policy set: a planning model may run Git commands, write and edit files,
|
|
20
|
+
and use the workflow tools.
|
|
20
21
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
- the read tools: `read`, `ls`, `find`, `grep`, `hypa_read`, `hypa_ls`, `hypa_find`, `hypa_grep`,
|
|
24
|
-
`web_search`, `web_fetch`, `lsp_diagnostics`, `ctx_search`, `ctx_stats`, the `hindsight` read tools, and
|
|
25
|
-
the `codebase_memory` read tools;
|
|
26
|
-
- the workflow controls: `todo`, `ask`, `interview`, `tool_search`, and `codemode`;
|
|
27
|
-
- the Dogsled tools `read`, `status`, `inbox`, `send`, and `done`, and a `spawn` of a bound unit by its id;
|
|
28
|
-
- a shell read: `pwd`, `ls`, `rg`, `grep`, `find` without `-exec`, `-delete`, or `-fprint`, and `git status`,
|
|
29
|
-
`git diff`, `git log`, `git show`, and `git rev-parse` with read-only options.
|
|
30
|
-
|
|
31
|
-
Every trusted name is exact. A tool whose name only resembles a trusted one, such as `dangerous_read` or
|
|
32
|
-
`mcp__untrusted__read`, is blocked. The same holds for a command: only `pwd`, `ls`, `rg`, `grep`, `find`,
|
|
33
|
-
and `git` are reads, so `./ls` and `/repo/tools/git status` are blocked.
|
|
34
|
-
|
|
35
|
-
The shell check uses the `shfmt` syntax tree, so a redirect, an assignment, a substitution, a glob, a
|
|
36
|
-
dollar-quoted word, or a background call is blocked even when the command itself reads. `git diff`,
|
|
37
|
-
`git log -p`, and `git show` must carry `--no-ext-diff --no-textconv`, because a diff driver or a
|
|
38
|
-
textconv filter can run a program. A word after `--` is a path, so it cannot carry those flags.
|
|
39
|
-
|
|
40
|
-
Each command has its own option grammar. An option that needs a value must have one, an option that
|
|
41
|
-
accepts only some values accepts only those, and a `find` option that changes the filesystem is blocked.
|
|
42
|
-
|
|
43
|
-
A `hypa_shell` call with `raw: true` runs without a shell, so the runner splits the command on
|
|
44
|
-
whitespace. The policy classifies those words, so a flag hidden in a quoted argument cannot slip through.
|
|
45
|
-
|
|
46
|
-
Every command of a `ctx_batch_execute` call is checked on its own. A malformed entry is blocked.
|
|
47
|
-
|
|
48
|
-
A nested call that the policy cannot inspect, such as a malformed codemode envelope or a call deeper
|
|
49
|
-
than eight levels, is blocked for a planning model.
|
|
50
|
-
|
|
51
|
-
Every other tool, including `write`, `edit`, and any command that changes the repository or the system,
|
|
52
|
-
is blocked for a planning model. Other models keep the full policy set.
|
|
22
|
+
Planning restrictions belong to Dogsled plan mode, which blocks writes outside markdown files while planning.
|
|
53
23
|
|
|
54
24
|
## pi
|
|
55
25
|
|
|
@@ -9,7 +9,6 @@ import {
|
|
|
9
9
|
commandsFromToolInput,
|
|
10
10
|
} from "./tool-inputs.ts";
|
|
11
11
|
import { codeWaitViolation, enforceTimeout } from "./tool-timeouts.ts";
|
|
12
|
-
import { isPlanningModel, planningToolReason } from "./planning-policy.ts";
|
|
13
12
|
import { classifyEdits, editInputsFromPayload } from "../policies/edit.ts";
|
|
14
13
|
import { SHELL_CLASSIFIERS } from "../policies/shell.ts";
|
|
15
14
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
@@ -24,12 +23,6 @@ interface ToolCall {
|
|
|
24
23
|
readonly input: Record<string, unknown>;
|
|
25
24
|
}
|
|
26
25
|
|
|
27
|
-
/** The model of the session, as the context reports it. */
|
|
28
|
-
interface CallModel {
|
|
29
|
-
readonly provider?: string;
|
|
30
|
-
readonly id?: string;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
26
|
// A codemode script's nested calls nest at most this deep before the gate stops looking.
|
|
34
27
|
const MAX_NESTING = 8,
|
|
35
28
|
/**
|
|
@@ -57,80 +50,44 @@ const MAX_NESTING = 8,
|
|
|
57
50
|
editViolation = (call: ToolCall, cwd: string): string | null =>
|
|
58
51
|
classifyEdits(editInputsFromPayload({ tool_input: call.input, tool_name: call.toolName }), cwd),
|
|
59
52
|
/**
|
|
60
|
-
* The nested calls of a codemode input.
|
|
61
|
-
* for a planning model, so it is reported rather than skipped.
|
|
53
|
+
* The nested calls of a codemode input.
|
|
62
54
|
* @param {Readonly<Record<string, unknown>>} input The tool input.
|
|
63
|
-
* @returns {
|
|
64
|
-
* state of the envelope.
|
|
55
|
+
* @returns {readonly ToolCall[]} The calls of the envelope.
|
|
65
56
|
*/
|
|
66
|
-
nestedCalls = (
|
|
67
|
-
input: Readonly<Record<string, unknown>>,
|
|
68
|
-
): { calls: ToolCall[]; malformed: boolean } => {
|
|
57
|
+
nestedCalls = (input: Readonly<Record<string, unknown>>): readonly ToolCall[] => {
|
|
69
58
|
const uses = input["tool_uses"];
|
|
70
|
-
if (uses === undefined) {
|
|
71
|
-
return { calls: [], malformed: false };
|
|
72
|
-
}
|
|
73
59
|
if (!Array.isArray(uses)) {
|
|
74
|
-
return
|
|
60
|
+
return [];
|
|
75
61
|
}
|
|
76
62
|
const calls: ToolCall[] = [];
|
|
77
|
-
let malformed = false;
|
|
78
63
|
for (const use of uses) {
|
|
79
64
|
if (!isRecord(use)) {
|
|
80
|
-
malformed = true;
|
|
81
65
|
continue;
|
|
82
66
|
}
|
|
83
67
|
const toolName = use["recipient_name"];
|
|
84
68
|
const parameters = use["parameters"];
|
|
85
|
-
if (typeof toolName !== "string") {
|
|
86
|
-
malformed = true;
|
|
87
|
-
}
|
|
88
|
-
if (parameters !== undefined && !isRecord(parameters)) {
|
|
89
|
-
malformed = true;
|
|
90
|
-
}
|
|
91
69
|
calls.push({
|
|
92
70
|
input: isRecord(parameters) ? parameters : {},
|
|
93
71
|
toolName: typeof toolName === "string" ? toolName : "",
|
|
94
72
|
});
|
|
95
73
|
}
|
|
96
|
-
return
|
|
74
|
+
return calls;
|
|
97
75
|
},
|
|
98
|
-
toolViolation = (
|
|
99
|
-
event: ToolCall,
|
|
100
|
-
cwd: string,
|
|
101
|
-
model: CallModel | undefined,
|
|
102
|
-
depth = 0,
|
|
103
|
-
): string | null => {
|
|
104
|
-
const planning = isPlanningModel(model);
|
|
76
|
+
toolViolation = (event: ToolCall, cwd: string, depth = 0): string | null => {
|
|
105
77
|
if (depth > MAX_NESTING) {
|
|
106
|
-
|
|
107
|
-
// cannot allow. Other models keep the old behaviour.
|
|
108
|
-
return planning
|
|
109
|
-
? `planning-readonly: blocked a nested call deeper than ${MAX_NESTING} levels. A planning ` +
|
|
110
|
-
"model may only run calls it can inspect."
|
|
111
|
-
: null;
|
|
78
|
+
return null;
|
|
112
79
|
}
|
|
113
80
|
const call = { input: event.input, toolName: canonicalToolName(event.toolName) },
|
|
114
81
|
own =
|
|
115
82
|
enforceTimeout(call.toolName, call.input) ??
|
|
116
83
|
codeWaitViolation(call.toolName, call.input) ??
|
|
117
|
-
// The planning policy authorizes the raw event name, so a tool that
|
|
118
|
-
// only ends with a trusted name is not that tool.
|
|
119
|
-
(planning ? planningToolReason(event.toolName, event.input) : null) ??
|
|
120
84
|
(SHELL_TOOL_NAMES.has(call.toolName) ? shellViolation(call, cwd) : null) ??
|
|
121
85
|
(EDIT_TOOL_NAMES.has(call.toolName) ? editViolation(call, cwd) : null);
|
|
122
86
|
if (own !== null) {
|
|
123
87
|
return own;
|
|
124
88
|
}
|
|
125
|
-
const
|
|
126
|
-
|
|
127
|
-
return (
|
|
128
|
-
"planning-readonly: blocked a nested call with a malformed envelope. A planning model " +
|
|
129
|
-
"may only run calls it can inspect."
|
|
130
|
-
);
|
|
131
|
-
}
|
|
132
|
-
for (const child of nested.calls) {
|
|
133
|
-
const message = toolViolation(child, cwd, model, depth + 1);
|
|
89
|
+
for (const child of nestedCalls(call.input)) {
|
|
90
|
+
const message = toolViolation(child, cwd, depth + 1);
|
|
134
91
|
if (message !== null) {
|
|
135
92
|
return message;
|
|
136
93
|
}
|
|
@@ -146,7 +103,7 @@ const MAX_NESTING = 8,
|
|
|
146
103
|
export default function piGuardrails(pi: GuardrailsAPI): void {
|
|
147
104
|
pi.on("tool_call", (event, ctx) => {
|
|
148
105
|
try {
|
|
149
|
-
const message = toolViolation(event, ctx.cwd
|
|
106
|
+
const message = toolViolation(event, ctx.cwd);
|
|
150
107
|
return message === null ? undefined : { block: true, reason: message };
|
|
151
108
|
} catch (error) {
|
|
152
109
|
return { block: true, reason: error instanceof Error ? error.message : String(error) };
|
|
@@ -105,24 +105,14 @@ Version 3 adds `display.sections` to the snapshot: four typed status groups (`in
|
|
|
105
105
|
Pi-todo does not answer version 2 requests.
|
|
106
106
|
Incompatible versions leave the standalone widget available. Rendering failures restore it without changing saved tasks.
|
|
107
107
|
|
|
108
|
-
##
|
|
108
|
+
## Dogsled plans
|
|
109
109
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
```json
|
|
114
|
-
{
|
|
115
|
-
"executionMode": "external"
|
|
116
|
-
}
|
|
117
|
-
```
|
|
110
|
+
Dogsled plan mode (`/plan` and `submit_plan`) emits `dogsled:plan-approved` when the user approves a plan.
|
|
111
|
+
Pi-todo continues the approved plan in the same session. No setting is needed.
|
|
118
112
|
|
|
119
113
|
After approval, the agent reads the current todo list and adds the remaining approved work before implementation.
|
|
120
114
|
It preserves unrelated tasks and completed progress. Free-form plans do not need a special checklist format.
|
|
121
115
|
The todo tool owns progress, persistence, reminders, and the shared work display.
|
|
122
|
-
Plannotator returns to idle and does not maintain a second checklist or require `[DONE:n]` markers.
|
|
123
|
-
|
|
124
|
-
This integration uses the supported `plannotator:plan-approved` event, verified with Plannotator `0.28.19`.
|
|
125
|
-
It does not import Plannotator internals or change its code.
|
|
126
116
|
Malformed events, a different working directory, or another owner of the `todo` tool stop the handoff with an error.
|
|
127
117
|
|
|
128
118
|
## Reminder
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { ownsTodo, settle } from "./lifecycle.ts";
|
|
2
2
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
3
|
-
import {
|
|
3
|
+
import { PlanApprovedHandoff } from "./plan-approved.ts";
|
|
4
4
|
import { TaskProvider } from "./provider.ts";
|
|
5
5
|
import { TodoPresentation } from "./presentation.ts";
|
|
6
6
|
import { TodoWork } from "./work.ts";
|
|
@@ -30,7 +30,7 @@ type TodoAPI = WorkHost &
|
|
|
30
30
|
* @param {TodoAPI} pi The extension API methods used here.
|
|
31
31
|
*/
|
|
32
32
|
export default function piTodo(pi: TodoAPI): void {
|
|
33
|
-
const handoff = new
|
|
33
|
+
const handoff = new PlanApprovedHandoff(pi),
|
|
34
34
|
presentation = new TodoPresentation(),
|
|
35
35
|
work = new TodoWork(pi, presentation),
|
|
36
36
|
provider = new TaskProvider(pi),
|
|
@@ -4,7 +4,7 @@ import type { WorkHost } from "./work.ts";
|
|
|
4
4
|
import { ownsTodo } from "./lifecycle.ts";
|
|
5
5
|
import { workUnitGuidance } from "./guidance.ts";
|
|
6
6
|
|
|
7
|
-
/** The supported
|
|
7
|
+
/** The supported Dogsled plan approval event. */
|
|
8
8
|
interface ApprovedPlan {
|
|
9
9
|
readonly cwd: string;
|
|
10
10
|
readonly planFilePath: string;
|
|
@@ -39,8 +39,8 @@ const isApprovedPlan = (value: unknown): value is ApprovedPlan =>
|
|
|
39
39
|
*/
|
|
40
40
|
continuation = (plan: ApprovedPlan): string =>
|
|
41
41
|
[
|
|
42
|
-
"The user approved this plan in
|
|
43
|
-
"Use the todo tool as the only task tracker.
|
|
42
|
+
"The user approved this plan in Dogsled plan mode. Continue execution in this session.",
|
|
43
|
+
"Use the todo tool as the only task tracker.",
|
|
44
44
|
"First call todo with op=view to inspect the current branch list.",
|
|
45
45
|
"Before implementation, represent remaining outcomes with optional bounded subtasks in that list. Use parent plus exact task for child operations.",
|
|
46
46
|
workUnitGuidance,
|
|
@@ -58,7 +58,7 @@ const isApprovedPlan = (value: unknown): value is ApprovedPlan =>
|
|
|
58
58
|
].join("\n");
|
|
59
59
|
|
|
60
60
|
/** Owns the approval subscription, never the todo list or execution progress. */
|
|
61
|
-
class
|
|
61
|
+
class PlanApprovedHandoff {
|
|
62
62
|
readonly #host: PlanHost;
|
|
63
63
|
#unsubscribe: (() => void) | undefined;
|
|
64
64
|
|
|
@@ -76,26 +76,23 @@ class PlannotatorHandoff {
|
|
|
76
76
|
*/
|
|
77
77
|
public start(ctx: PlanContext): void {
|
|
78
78
|
this.stop();
|
|
79
|
-
this.#unsubscribe = this.#host.events.on(
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
}
|
|
97
|
-
},
|
|
98
|
-
);
|
|
79
|
+
this.#unsubscribe = this.#host.events.on("dogsled:plan-approved", (value: unknown): void => {
|
|
80
|
+
if (!isApprovedPlan(value)) {
|
|
81
|
+
ctx.ui.notify("pi-todo: Invalid Dogsled plan approval. No execution started.", "error");
|
|
82
|
+
} else if (value.cwd !== ctx.cwd) {
|
|
83
|
+
ctx.ui.notify(
|
|
84
|
+
"pi-todo: The approved plan belongs to another working directory. No execution started.",
|
|
85
|
+
"error",
|
|
86
|
+
);
|
|
87
|
+
} else if (ownsTodo(this.#host)) {
|
|
88
|
+
this.#host.sendMessage(
|
|
89
|
+
{ content: continuation(value), customType: "todo-plan-approved", display: true },
|
|
90
|
+
{ deliverAs: "followUp", triggerTurn: true },
|
|
91
|
+
);
|
|
92
|
+
} else {
|
|
93
|
+
ctx.ui.notify("pi-todo: Another extension owns todo. No execution started.", "error");
|
|
94
|
+
}
|
|
95
|
+
});
|
|
99
96
|
}
|
|
100
97
|
|
|
101
98
|
/** Removes the previous session's listener before replacement or shutdown. */
|
|
@@ -105,5 +102,5 @@ class PlannotatorHandoff {
|
|
|
105
102
|
}
|
|
106
103
|
}
|
|
107
104
|
|
|
108
|
-
export {
|
|
105
|
+
export { PlanApprovedHandoff };
|
|
109
106
|
export type { PlanContext, PlanHost };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oisin-ee/oisinpi",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
4
|
"description": "Oisin's pi packages in one install: prewalk, todo, tiers, and guardrails.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"guardrails",
|
|
@@ -17,10 +17,10 @@
|
|
|
17
17
|
},
|
|
18
18
|
"type": "module",
|
|
19
19
|
"dependencies": {
|
|
20
|
-
"@oisin-ee/pi-guardrails": "0.
|
|
21
|
-
"@oisin-ee/pi-prewalk": "0.
|
|
22
|
-
"@oisin-ee/pi-tiers": "0.
|
|
23
|
-
"@oisin-ee/pi-todo": "0.
|
|
20
|
+
"@oisin-ee/pi-guardrails": "0.12.0",
|
|
21
|
+
"@oisin-ee/pi-prewalk": "0.12.0",
|
|
22
|
+
"@oisin-ee/pi-tiers": "0.12.0",
|
|
23
|
+
"@oisin-ee/pi-todo": "0.12.0"
|
|
24
24
|
},
|
|
25
25
|
"bundleDependencies": [
|
|
26
26
|
"@oisin-ee/pi-guardrails",
|
|
@@ -1,763 +0,0 @@
|
|
|
1
|
-
/*
|
|
2
|
-
* The read-only policy for planning models. A planning model may read the
|
|
3
|
-
* repository and the tools that report on it; it may not change anything.
|
|
4
|
-
*
|
|
5
|
-
* The model identity comes from the context of the call, read fresh for each
|
|
6
|
-
* tool call: the session can switch models between calls.
|
|
7
|
-
*
|
|
8
|
-
* Every trusted name here is exact. A tool whose name only ends with a trusted
|
|
9
|
-
* name, such as `dangerous_read` or `mcp__untrusted__read`, is a different
|
|
10
|
-
* tool, so the policy blocks it. The same rule holds for the executable of a
|
|
11
|
-
* read command: only `pwd`, `ls`, `rg`, `grep`, `find`, and `git` are reads,
|
|
12
|
-
* not every path whose basename is one of them.
|
|
13
|
-
*/
|
|
14
|
-
import { isRecord } from "../common.ts";
|
|
15
|
-
import type { Command } from "../common.ts";
|
|
16
|
-
import { literalShellCalls } from "../shell-parser.ts";
|
|
17
|
-
|
|
18
|
-
/** The provider of the planning models. */
|
|
19
|
-
const PLANNING_PROVIDER = "tier",
|
|
20
|
-
/** The model ids that plan. */
|
|
21
|
-
PLANNING_MODEL_IDS: ReadonlySet<string> = new Set(["plan-claude", "plan-gpt"]);
|
|
22
|
-
|
|
23
|
-
/** The model of a tool call, as the context reports it. */
|
|
24
|
-
interface ModelIdentity {
|
|
25
|
-
readonly provider?: string;
|
|
26
|
-
readonly id?: string;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
* Whether the model of a tool call is a planning model.
|
|
31
|
-
* @param {ModelIdentity | undefined} model The current model.
|
|
32
|
-
* @returns {boolean} True for a planning model.
|
|
33
|
-
*/
|
|
34
|
-
function isPlanningModel(model: ModelIdentity | undefined): boolean {
|
|
35
|
-
return (
|
|
36
|
-
model?.provider === PLANNING_PROVIDER &&
|
|
37
|
-
model.id !== undefined &&
|
|
38
|
-
PLANNING_MODEL_IDS.has(model.id)
|
|
39
|
-
);
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/** The tools that only report on the repository, by exact name. */
|
|
43
|
-
const READ_TOOLS: ReadonlySet<string> = new Set([
|
|
44
|
-
"read",
|
|
45
|
-
"ls",
|
|
46
|
-
"find",
|
|
47
|
-
"grep",
|
|
48
|
-
"hypa_read",
|
|
49
|
-
"hypa_ls",
|
|
50
|
-
"hypa_find",
|
|
51
|
-
"hypa_grep",
|
|
52
|
-
"web_search",
|
|
53
|
-
"web_fetch",
|
|
54
|
-
"lsp_diagnostics",
|
|
55
|
-
"ctx_search",
|
|
56
|
-
"ctx_stats",
|
|
57
|
-
"mcp__context_mode__ctx_search",
|
|
58
|
-
"mcp__context_mode__ctx_stats",
|
|
59
|
-
"hindsight_search_knowledge_pages",
|
|
60
|
-
"hindsight_list_knowledge_pages",
|
|
61
|
-
"hindsight_read_knowledge_page",
|
|
62
|
-
"hindsight_reflect",
|
|
63
|
-
"hindsight_sync_status",
|
|
64
|
-
"hindsight_diagnose",
|
|
65
|
-
"mcp__codebase_memory__search_graph",
|
|
66
|
-
"mcp__codebase_memory__get_code_snippet",
|
|
67
|
-
"mcp__codebase_memory__trace_path",
|
|
68
|
-
"mcp__codebase_memory__detect_changes",
|
|
69
|
-
]),
|
|
70
|
-
/** The workflow controls a planning model keeps. */
|
|
71
|
-
WORKFLOW_TOOLS: ReadonlySet<string> = new Set([
|
|
72
|
-
"todo",
|
|
73
|
-
"ask",
|
|
74
|
-
"interview",
|
|
75
|
-
"tool_search",
|
|
76
|
-
"codemode",
|
|
77
|
-
]),
|
|
78
|
-
/** The Dogsled tools a planning model may use, by full name. */
|
|
79
|
-
DOGSLED_TOOLS: ReadonlySet<string> = new Set([
|
|
80
|
-
"mcp__dogsled__read",
|
|
81
|
-
"mcp__dogsled__status",
|
|
82
|
-
"mcp__dogsled__inbox",
|
|
83
|
-
"mcp__dogsled__send",
|
|
84
|
-
"mcp__dogsled__done",
|
|
85
|
-
]),
|
|
86
|
-
/** The shell tools a planning model may use, by exact name. */
|
|
87
|
-
SHELL_TOOLS: ReadonlySet<string> = new Set([
|
|
88
|
-
"bash",
|
|
89
|
-
"shell",
|
|
90
|
-
"hypa_shell",
|
|
91
|
-
"ctx_execute",
|
|
92
|
-
"ctx_batch_execute",
|
|
93
|
-
"ctx_execute_file",
|
|
94
|
-
"mcp__context_mode__ctx_execute",
|
|
95
|
-
"mcp__context_mode__ctx_batch_execute",
|
|
96
|
-
"mcp__context_mode__ctx_execute_file",
|
|
97
|
-
"mcp__gateway__keen_bash",
|
|
98
|
-
]),
|
|
99
|
-
/** The shell tools that take a list of commands. */
|
|
100
|
-
BATCH_SHELL_TOOLS: ReadonlySet<string> = new Set([
|
|
101
|
-
"ctx_batch_execute",
|
|
102
|
-
"mcp__context_mode__ctx_batch_execute",
|
|
103
|
-
]),
|
|
104
|
-
/** The shell tools that take a script in a language. */
|
|
105
|
-
CODE_SHELL_TOOLS: ReadonlySet<string> = new Set([
|
|
106
|
-
"ctx_execute",
|
|
107
|
-
"ctx_execute_file",
|
|
108
|
-
"mcp__context_mode__ctx_execute",
|
|
109
|
-
"mcp__context_mode__ctx_execute_file",
|
|
110
|
-
]);
|
|
111
|
-
|
|
112
|
-
const READ_MESSAGE =
|
|
113
|
-
"planning-readonly: a planning model may only read. Allowed tools: read, ls, find, grep, " +
|
|
114
|
-
"hypa_read/hypa_ls/hypa_find/hypa_grep, web_search, web_fetch, lsp_diagnostics, ctx_search, " +
|
|
115
|
-
"ctx_stats, the hindsight and codebase_memory read tools, todo, ask, interview, tool_search, " +
|
|
116
|
-
"codemode, and the Dogsled read/status/inbox/send/done tools. A planning model may not write, " +
|
|
117
|
-
"edit, or run commands that change anything.";
|
|
118
|
-
|
|
119
|
-
/**
|
|
120
|
-
* The message for a tool a planning model may not call.
|
|
121
|
-
* @param {string} toolName The tool name.
|
|
122
|
-
* @returns {string} The block message.
|
|
123
|
-
*/
|
|
124
|
-
function toolMessage(toolName: string): string {
|
|
125
|
-
return `${READ_MESSAGE} Blocked tool: ${toolName}.`;
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
/**
|
|
129
|
-
* Whether a tool call is a bound Dogsled spawn, which a planning model may use
|
|
130
|
-
* to start a bound unit.
|
|
131
|
-
* @param {Readonly<Record<string, unknown>>} input The tool input.
|
|
132
|
-
* @returns {boolean} True for a spawn with a unit id.
|
|
133
|
-
*/
|
|
134
|
-
function isBoundSpawn(input: Readonly<Record<string, unknown>>): boolean {
|
|
135
|
-
const unit = input["unit"];
|
|
136
|
-
if (!isRecord(unit)) {
|
|
137
|
-
return false;
|
|
138
|
-
}
|
|
139
|
-
const id = unit["id"];
|
|
140
|
-
return typeof id === "string" && id.length > 0;
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
/**
|
|
144
|
-
* The value of a text field.
|
|
145
|
-
* @param {unknown} value The field value.
|
|
146
|
-
* @returns {string | undefined} The text, when the value is a string.
|
|
147
|
-
*/
|
|
148
|
-
function textOf(value: unknown): string | undefined {
|
|
149
|
-
return typeof value === "string" ? value : undefined;
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
/**
|
|
153
|
-
* The command text of a shell tool input.
|
|
154
|
-
* @param {Readonly<Record<string, unknown>>} input The tool input.
|
|
155
|
-
* @returns {string | null} The command, or null when the input carries none.
|
|
156
|
-
*/
|
|
157
|
-
function commandTextOf(input: Readonly<Record<string, unknown>>): string | null {
|
|
158
|
-
return textOf(input["command"]) ?? textOf(input["cmd"]) ?? textOf(input["commandText"]) ?? null;
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
/**
|
|
162
|
-
* The argument list of a raw command. The runner splits a raw command on
|
|
163
|
-
* whitespace and does not parse quotes, so the argv holds the words between
|
|
164
|
-
* the spaces.
|
|
165
|
-
* @param {string} text The raw command.
|
|
166
|
-
* @returns {string[]} The words of the argument list.
|
|
167
|
-
*/
|
|
168
|
-
function rawWords(text: string): string[] {
|
|
169
|
-
return text.split(/\s+/u).filter((word) => word.length > 0);
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
/** The options of a command, and the values its options accept. */
|
|
173
|
-
interface OptionTables {
|
|
174
|
-
/** The options that take no value. */
|
|
175
|
-
readonly flags: ReadonlySet<string>;
|
|
176
|
-
/** The options that take a value. */
|
|
177
|
-
readonly valueFlags: ReadonlySet<string>;
|
|
178
|
-
/** The values each option accepts, when it accepts only some. */
|
|
179
|
-
readonly enumValues: Readonly<Record<string, ReadonlySet<string>>>;
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
/** The options of each read command that stay read-only. */
|
|
183
|
-
const READ_COMMAND_FLAGS: Readonly<Record<string, ReadonlySet<string>>> = {
|
|
184
|
-
pwd: new Set(["-L", "-P"]),
|
|
185
|
-
ls: new Set([
|
|
186
|
-
"-1",
|
|
187
|
-
"-a",
|
|
188
|
-
"-A",
|
|
189
|
-
"-d",
|
|
190
|
-
"-F",
|
|
191
|
-
"-h",
|
|
192
|
-
"-l",
|
|
193
|
-
"-r",
|
|
194
|
-
"-R",
|
|
195
|
-
"-S",
|
|
196
|
-
"-t",
|
|
197
|
-
"--all",
|
|
198
|
-
"--almost-all",
|
|
199
|
-
"--human-readable",
|
|
200
|
-
"--long",
|
|
201
|
-
]),
|
|
202
|
-
rg: new Set([
|
|
203
|
-
"-c",
|
|
204
|
-
"-C",
|
|
205
|
-
"-e",
|
|
206
|
-
"-F",
|
|
207
|
-
"-g",
|
|
208
|
-
"-i",
|
|
209
|
-
"-l",
|
|
210
|
-
"-n",
|
|
211
|
-
"-r",
|
|
212
|
-
"-s",
|
|
213
|
-
"-v",
|
|
214
|
-
"-w",
|
|
215
|
-
"--count",
|
|
216
|
-
"--files-with-matches",
|
|
217
|
-
"--fixed-strings",
|
|
218
|
-
"--ignore-case",
|
|
219
|
-
"--line-number",
|
|
220
|
-
"--no-heading",
|
|
221
|
-
"--no-ignore-case",
|
|
222
|
-
"--word-regexp",
|
|
223
|
-
]),
|
|
224
|
-
grep: new Set([
|
|
225
|
-
"-A",
|
|
226
|
-
"-B",
|
|
227
|
-
"-C",
|
|
228
|
-
"-c",
|
|
229
|
-
"-e",
|
|
230
|
-
"-E",
|
|
231
|
-
"-F",
|
|
232
|
-
"-i",
|
|
233
|
-
"-l",
|
|
234
|
-
"-n",
|
|
235
|
-
"-o",
|
|
236
|
-
"-r",
|
|
237
|
-
"-s",
|
|
238
|
-
"-v",
|
|
239
|
-
"-w",
|
|
240
|
-
"--count",
|
|
241
|
-
"--files-with-matches",
|
|
242
|
-
"--ignore-case",
|
|
243
|
-
"--line-number",
|
|
244
|
-
"--recursive",
|
|
245
|
-
"--word-regexp",
|
|
246
|
-
]),
|
|
247
|
-
find: new Set([
|
|
248
|
-
"-E",
|
|
249
|
-
"-H",
|
|
250
|
-
"-L",
|
|
251
|
-
"-name",
|
|
252
|
-
"-iname",
|
|
253
|
-
"-iregex",
|
|
254
|
-
"-maxdepth",
|
|
255
|
-
"-mindepth",
|
|
256
|
-
"-newer",
|
|
257
|
-
"-not",
|
|
258
|
-
"-path",
|
|
259
|
-
"-print",
|
|
260
|
-
"-printf",
|
|
261
|
-
"-regex",
|
|
262
|
-
"-type",
|
|
263
|
-
]),
|
|
264
|
-
},
|
|
265
|
-
/** The options that take a value. */
|
|
266
|
-
READ_COMMAND_VALUE_FLAGS: Readonly<Record<string, ReadonlySet<string>>> = {
|
|
267
|
-
ls: new Set(["--color", "--time-style"]),
|
|
268
|
-
rg: new Set(["-C", "-e", "-g", "--color", "--context", "--glob", "--max-count"]),
|
|
269
|
-
grep: new Set(["-A", "-B", "-C", "-e", "--color", "--context"]),
|
|
270
|
-
find: new Set([
|
|
271
|
-
"-name",
|
|
272
|
-
"-iname",
|
|
273
|
-
"-iregex",
|
|
274
|
-
"-maxdepth",
|
|
275
|
-
"-mindepth",
|
|
276
|
-
"-newer",
|
|
277
|
-
"-path",
|
|
278
|
-
"-regex",
|
|
279
|
-
"-type",
|
|
280
|
-
]),
|
|
281
|
-
},
|
|
282
|
-
/** The values each read option accepts, when it accepts only some. */
|
|
283
|
-
READ_COMMAND_ENUM_VALUES: Readonly<
|
|
284
|
-
Record<string, Readonly<Record<string, ReadonlySet<string>>>>
|
|
285
|
-
> = {
|
|
286
|
-
ls: {
|
|
287
|
-
"--color": new Set(["always", "auto", "never"]),
|
|
288
|
-
"--time-style": new Set(["long-iso"]),
|
|
289
|
-
},
|
|
290
|
-
rg: { "--color": new Set(["never"]) },
|
|
291
|
-
grep: { "--color": new Set(["never"]) },
|
|
292
|
-
},
|
|
293
|
-
/** The find options that change the filesystem. */
|
|
294
|
-
FIND_MUTATIONS: ReadonlySet<string> = new Set([
|
|
295
|
-
"-delete",
|
|
296
|
-
"-exec",
|
|
297
|
-
"-execdir",
|
|
298
|
-
"-fls",
|
|
299
|
-
"-fprint",
|
|
300
|
-
"-fprintf",
|
|
301
|
-
"-ok",
|
|
302
|
-
"-okdir",
|
|
303
|
-
]),
|
|
304
|
-
/** The executables a planning model may run. */
|
|
305
|
-
READ_COMMANDS: ReadonlySet<string> = new Set(["pwd", "ls", "rg", "grep", "find", "git"]),
|
|
306
|
-
/** The git subcommands a planning model may run. */
|
|
307
|
-
GIT_READ_SUBCOMMANDS: ReadonlySet<string> = new Set([
|
|
308
|
-
"diff",
|
|
309
|
-
"log",
|
|
310
|
-
"rev-parse",
|
|
311
|
-
"show",
|
|
312
|
-
"status",
|
|
313
|
-
]),
|
|
314
|
-
/** The git global options a planning model may use. */
|
|
315
|
-
GIT_GLOBAL_FLAGS: ReadonlySet<string> = new Set(["--no-pager", "--literal-pathspecs"]),
|
|
316
|
-
/** The git global options that take a value. */
|
|
317
|
-
GIT_GLOBAL_VALUE_FLAGS: ReadonlySet<string> = new Set(["-C", "--git-dir", "--work-tree"]),
|
|
318
|
-
/** The git options of each read subcommand. */
|
|
319
|
-
GIT_SUBCOMMAND_FLAGS: Readonly<Record<string, ReadonlySet<string>>> = {
|
|
320
|
-
status: new Set([
|
|
321
|
-
"--branch",
|
|
322
|
-
"--ignored",
|
|
323
|
-
"--long",
|
|
324
|
-
"--porcelain",
|
|
325
|
-
"--short",
|
|
326
|
-
"-b",
|
|
327
|
-
"-l",
|
|
328
|
-
"-s",
|
|
329
|
-
"-uno",
|
|
330
|
-
"-unormal",
|
|
331
|
-
"-uall",
|
|
332
|
-
]),
|
|
333
|
-
diff: new Set([
|
|
334
|
-
"--cached",
|
|
335
|
-
"--name-only",
|
|
336
|
-
"--name-status",
|
|
337
|
-
"--no-ext-diff",
|
|
338
|
-
"--no-textconv",
|
|
339
|
-
"--stat",
|
|
340
|
-
"--staged",
|
|
341
|
-
"-M",
|
|
342
|
-
]),
|
|
343
|
-
log: new Set([
|
|
344
|
-
"--abbrev-commit",
|
|
345
|
-
"--decorate",
|
|
346
|
-
"--graph",
|
|
347
|
-
"--no-color",
|
|
348
|
-
"--no-ext-diff",
|
|
349
|
-
"--no-textconv",
|
|
350
|
-
"--oneline",
|
|
351
|
-
"--name-only",
|
|
352
|
-
"--name-status",
|
|
353
|
-
"--patch",
|
|
354
|
-
"--stat",
|
|
355
|
-
"-p",
|
|
356
|
-
]),
|
|
357
|
-
show: new Set([
|
|
358
|
-
"--abbrev-commit",
|
|
359
|
-
"--no-color",
|
|
360
|
-
"--no-ext-diff",
|
|
361
|
-
"--no-textconv",
|
|
362
|
-
"--oneline",
|
|
363
|
-
"--stat",
|
|
364
|
-
]),
|
|
365
|
-
"rev-parse": new Set([
|
|
366
|
-
"--abbrev-ref",
|
|
367
|
-
"--git-dir",
|
|
368
|
-
"--is-inside-work-tree",
|
|
369
|
-
"--quiet",
|
|
370
|
-
"--short",
|
|
371
|
-
"--show-prefix",
|
|
372
|
-
"--show-toplevel",
|
|
373
|
-
"--symbolic-full-name",
|
|
374
|
-
]),
|
|
375
|
-
},
|
|
376
|
-
/** The git options that take a value. */
|
|
377
|
-
GIT_SUBCOMMAND_VALUE_FLAGS: Readonly<Record<string, ReadonlySet<string>>> = {
|
|
378
|
-
status: new Set(["--untracked-files"]),
|
|
379
|
-
diff: new Set(["--color"]),
|
|
380
|
-
log: new Set(["-n", "--max-count", "--color"]),
|
|
381
|
-
show: new Set(["--color"]),
|
|
382
|
-
"rev-parse": new Set(["--verify"]),
|
|
383
|
-
},
|
|
384
|
-
/** The values each git option accepts, when it accepts only some. */
|
|
385
|
-
GIT_SUBCOMMAND_ENUM_VALUES: Readonly<
|
|
386
|
-
Record<string, Readonly<Record<string, ReadonlySet<string>>>>
|
|
387
|
-
> = {
|
|
388
|
-
status: { "--untracked-files": new Set(["all", "normal", "no"]) },
|
|
389
|
-
diff: { "--color": new Set(["never"]) },
|
|
390
|
-
log: { "--color": new Set(["never"]) },
|
|
391
|
-
show: { "--color": new Set(["never"]) },
|
|
392
|
-
};
|
|
393
|
-
|
|
394
|
-
const EXT_DIFF_MESSAGE =
|
|
395
|
-
"planning-readonly: blocked a git command that can run an external diff driver or a textconv " +
|
|
396
|
-
"filter. Add --no-ext-diff --no-textconv and run it again.";
|
|
397
|
-
|
|
398
|
-
/**
|
|
399
|
-
* Whether a word is an option of a command, so it cannot be the value of the
|
|
400
|
-
* option before it.
|
|
401
|
-
* @param {string} word The word.
|
|
402
|
-
* @param {OptionTables} tables The options of the command.
|
|
403
|
-
* @returns {boolean} True for an option word.
|
|
404
|
-
*/
|
|
405
|
-
function isOptionWord(word: string, tables: OptionTables): boolean {
|
|
406
|
-
if (!word.startsWith("-") || word === "-") {
|
|
407
|
-
return false;
|
|
408
|
-
}
|
|
409
|
-
if (tables.flags.has(word) || tables.valueFlags.has(word)) {
|
|
410
|
-
return true;
|
|
411
|
-
}
|
|
412
|
-
if (word.startsWith("--") && word.includes("=")) {
|
|
413
|
-
const name = word.slice(0, word.indexOf("="));
|
|
414
|
-
return tables.flags.has(name) || tables.valueFlags.has(name);
|
|
415
|
-
}
|
|
416
|
-
if (word.startsWith("--")) {
|
|
417
|
-
return false;
|
|
418
|
-
}
|
|
419
|
-
for (const letter of word.slice(1)) {
|
|
420
|
-
if (!tables.flags.has(`-${letter}`) && !tables.valueFlags.has(`-${letter}`)) {
|
|
421
|
-
return false;
|
|
422
|
-
}
|
|
423
|
-
}
|
|
424
|
-
return true;
|
|
425
|
-
}
|
|
426
|
-
|
|
427
|
-
/**
|
|
428
|
-
* The block message for an option a planning model may not use.
|
|
429
|
-
* @param {string} detail The option and its value, for the message.
|
|
430
|
-
* @returns {string} The block message.
|
|
431
|
-
*/
|
|
432
|
-
function blockedOption(detail: string): string {
|
|
433
|
-
return `planning-readonly: blocked ${detail}. A planning model may use only read-only options.`;
|
|
434
|
-
}
|
|
435
|
-
|
|
436
|
-
/**
|
|
437
|
-
* The reason an option word is not allowed, or null when it is.
|
|
438
|
-
* @param {readonly string[]} words The words of the call.
|
|
439
|
-
* @param {number} index The index of the option.
|
|
440
|
-
* @param {OptionTables} tables The options of the command.
|
|
441
|
-
* @param {string} label The name of the command, for the message.
|
|
442
|
-
* @returns {string | null} The block message.
|
|
443
|
-
*/
|
|
444
|
-
function optionReason(
|
|
445
|
-
words: readonly string[],
|
|
446
|
-
index: number,
|
|
447
|
-
tables: OptionTables,
|
|
448
|
-
label: string,
|
|
449
|
-
): string | null {
|
|
450
|
-
const word = words[index] ?? "",
|
|
451
|
-
{ flags, valueFlags, enumValues } = tables,
|
|
452
|
-
blocked = blockedOption,
|
|
453
|
-
needsValue = (): string =>
|
|
454
|
-
`planning-readonly: blocked the option ${word} of ${label}. The option needs a value.`;
|
|
455
|
-
if (word === "--" || !word.startsWith("-") || word === "-") {
|
|
456
|
-
return null;
|
|
457
|
-
}
|
|
458
|
-
const equals = word.indexOf("="),
|
|
459
|
-
name = word.startsWith("--") && equals !== -1 ? word.slice(0, equals) : word,
|
|
460
|
-
inline = word.startsWith("--") && equals !== -1 ? word.slice(equals + 1) : undefined;
|
|
461
|
-
if (valueFlags.has(name)) {
|
|
462
|
-
const allowed = enumValues[name];
|
|
463
|
-
if (inline !== undefined) {
|
|
464
|
-
return allowed !== undefined && !allowed.has(inline)
|
|
465
|
-
? blocked(`the option ${word} of ${label}`)
|
|
466
|
-
: null;
|
|
467
|
-
}
|
|
468
|
-
const value = words[index + 1];
|
|
469
|
-
if (value === undefined || value === "--" || isOptionWord(value, tables)) {
|
|
470
|
-
return needsValue();
|
|
471
|
-
}
|
|
472
|
-
return allowed !== undefined && !allowed.has(value)
|
|
473
|
-
? blocked(`the option ${word} of ${label} with the value ${value}`)
|
|
474
|
-
: null;
|
|
475
|
-
}
|
|
476
|
-
if (flags.has(name)) {
|
|
477
|
-
return null;
|
|
478
|
-
}
|
|
479
|
-
if (word.startsWith("--")) {
|
|
480
|
-
return blocked(`the option ${word} of ${label}`);
|
|
481
|
-
}
|
|
482
|
-
// A short cluster such as `-la` matches when every letter is a known option.
|
|
483
|
-
const letters: string[] = [];
|
|
484
|
-
for (const letter of word.slice(1)) {
|
|
485
|
-
letters.push(`-${letter}`);
|
|
486
|
-
}
|
|
487
|
-
if (letters.length < 2) {
|
|
488
|
-
return blocked(`the option ${word} of ${label}`);
|
|
489
|
-
}
|
|
490
|
-
for (const [position, letter] of letters.entries()) {
|
|
491
|
-
if (flags.has(letter)) {
|
|
492
|
-
continue;
|
|
493
|
-
}
|
|
494
|
-
// A letter that takes a value must be the last of the cluster, because it
|
|
495
|
-
// takes the word that follows the cluster.
|
|
496
|
-
if (valueFlags.has(letter) && position === letters.length - 1) {
|
|
497
|
-
const value = words[index + 1];
|
|
498
|
-
if (value === undefined || value === "--" || isOptionWord(value, tables)) {
|
|
499
|
-
return needsValue();
|
|
500
|
-
}
|
|
501
|
-
continue;
|
|
502
|
-
}
|
|
503
|
-
return blocked(`the option ${word} of ${label}`);
|
|
504
|
-
}
|
|
505
|
-
return null;
|
|
506
|
-
}
|
|
507
|
-
|
|
508
|
-
/**
|
|
509
|
-
* The number of words an option occupies, so the value of an option is not
|
|
510
|
-
* read as an option itself.
|
|
511
|
-
* @param {readonly string[]} words The words of the call.
|
|
512
|
-
* @param {number} index The index of the option.
|
|
513
|
-
* @param {OptionTables} tables The options of the command.
|
|
514
|
-
* @returns {number} The number of words.
|
|
515
|
-
*/
|
|
516
|
-
function optionWidth(words: readonly string[], index: number, tables: OptionTables): number {
|
|
517
|
-
const word = words[index] ?? "",
|
|
518
|
-
name = word.startsWith("--") && word.includes("=") ? word.slice(0, word.indexOf("=")) : word;
|
|
519
|
-
if (word.includes("=")) {
|
|
520
|
-
return 1;
|
|
521
|
-
}
|
|
522
|
-
if (tables.valueFlags.has(name)) {
|
|
523
|
-
const value = words[index + 1];
|
|
524
|
-
return value === undefined || value === "--" || isOptionWord(value, tables) ? 1 : 2;
|
|
525
|
-
}
|
|
526
|
-
// A short cluster that ends in a letter that takes a value takes the word
|
|
527
|
-
// that follows the cluster.
|
|
528
|
-
if (word.startsWith("-") && !word.startsWith("--") && word.length > 1) {
|
|
529
|
-
const last = `-${word.slice(-1)}`;
|
|
530
|
-
if (tables.valueFlags.has(last)) {
|
|
531
|
-
const value = words[index + 1];
|
|
532
|
-
return value === undefined || value === "--" || isOptionWord(value, tables) ? 1 : 2;
|
|
533
|
-
}
|
|
534
|
-
}
|
|
535
|
-
return 1;
|
|
536
|
-
}
|
|
537
|
-
|
|
538
|
-
/**
|
|
539
|
-
* The reason a git command is not a read, or null when it is one.
|
|
540
|
-
* @param {readonly string[]} words The words of the call, without `git`.
|
|
541
|
-
* @returns {string | null} The block message.
|
|
542
|
-
*/
|
|
543
|
-
function gitReason(words: readonly string[]): string | null {
|
|
544
|
-
const rest = [...words];
|
|
545
|
-
while (rest.length > 0) {
|
|
546
|
-
const word = rest[0] ?? "";
|
|
547
|
-
if (GIT_GLOBAL_VALUE_FLAGS.has(word)) {
|
|
548
|
-
const value = rest[1];
|
|
549
|
-
if (value === undefined) {
|
|
550
|
-
return `planning-readonly: blocked the git option ${word}. The option needs a value.`;
|
|
551
|
-
}
|
|
552
|
-
rest.splice(0, 2);
|
|
553
|
-
continue;
|
|
554
|
-
}
|
|
555
|
-
if (GIT_GLOBAL_FLAGS.has(word)) {
|
|
556
|
-
rest.shift();
|
|
557
|
-
continue;
|
|
558
|
-
}
|
|
559
|
-
break;
|
|
560
|
-
}
|
|
561
|
-
const subcommand = rest.shift();
|
|
562
|
-
if (subcommand === undefined || !GIT_READ_SUBCOMMANDS.has(subcommand)) {
|
|
563
|
-
return (
|
|
564
|
-
"planning-readonly: a planning model may only run git status, git diff, git log, git show, " +
|
|
565
|
-
"and git rev-parse."
|
|
566
|
-
);
|
|
567
|
-
}
|
|
568
|
-
const tables: OptionTables = {
|
|
569
|
-
flags: GIT_SUBCOMMAND_FLAGS[subcommand] ?? new Set<string>(),
|
|
570
|
-
valueFlags: GIT_SUBCOMMAND_VALUE_FLAGS[subcommand] ?? new Set<string>(),
|
|
571
|
-
enumValues: GIT_SUBCOMMAND_ENUM_VALUES[subcommand] ?? {},
|
|
572
|
-
};
|
|
573
|
-
let noExtDiff = false,
|
|
574
|
-
noTextconv = false;
|
|
575
|
-
for (let index = 0; index < rest.length; index += 1) {
|
|
576
|
-
const word = rest[index] ?? "";
|
|
577
|
-
// After `--` every word is a path, not an option, so it cannot ask for a
|
|
578
|
-
// safe diff driver.
|
|
579
|
-
if (word === "--") {
|
|
580
|
-
break;
|
|
581
|
-
}
|
|
582
|
-
if (subcommand === "log" && /^-\d+$/u.test(word)) {
|
|
583
|
-
continue;
|
|
584
|
-
}
|
|
585
|
-
const reason = optionReason(rest, index, tables, `git ${subcommand}`);
|
|
586
|
-
if (reason !== null) {
|
|
587
|
-
return reason;
|
|
588
|
-
}
|
|
589
|
-
if (word === "--no-ext-diff") {
|
|
590
|
-
noExtDiff = true;
|
|
591
|
-
}
|
|
592
|
-
if (word === "--no-textconv") {
|
|
593
|
-
noTextconv = true;
|
|
594
|
-
}
|
|
595
|
-
index += optionWidth(rest, index, tables) - 1;
|
|
596
|
-
}
|
|
597
|
-
const patches = subcommand === "diff" || subcommand === "show",
|
|
598
|
-
logsPatch = subcommand === "log" && rest.some((word) => word === "-p" || word === "--patch");
|
|
599
|
-
if ((patches || logsPatch) && !(noExtDiff && noTextconv)) {
|
|
600
|
-
return EXT_DIFF_MESSAGE;
|
|
601
|
-
}
|
|
602
|
-
return null;
|
|
603
|
-
}
|
|
604
|
-
|
|
605
|
-
/**
|
|
606
|
-
* The reason a read command is not read-only, or null when it is one.
|
|
607
|
-
* @param {readonly string[]} words The words of the call.
|
|
608
|
-
* @returns {string | null} The block message.
|
|
609
|
-
*/
|
|
610
|
-
function readCommandReason(words: readonly string[]): string | null {
|
|
611
|
-
const base = words[0] ?? "";
|
|
612
|
-
if (!READ_COMMANDS.has(base)) {
|
|
613
|
-
return (
|
|
614
|
-
`planning-readonly: blocked the command ${base}. A planning model may only run pwd, ls, ` +
|
|
615
|
-
"rg, grep, find, and read-only git commands."
|
|
616
|
-
);
|
|
617
|
-
}
|
|
618
|
-
if (base === "git") {
|
|
619
|
-
return gitReason(words.slice(1));
|
|
620
|
-
}
|
|
621
|
-
const tables: OptionTables = {
|
|
622
|
-
flags: READ_COMMAND_FLAGS[base] ?? new Set<string>(),
|
|
623
|
-
valueFlags: READ_COMMAND_VALUE_FLAGS[base] ?? new Set<string>(),
|
|
624
|
-
enumValues: READ_COMMAND_ENUM_VALUES[base] ?? {},
|
|
625
|
-
};
|
|
626
|
-
for (let index = 1; index < words.length; index += 1) {
|
|
627
|
-
const word = words[index] ?? "";
|
|
628
|
-
if (base === "find" && FIND_MUTATIONS.has(word)) {
|
|
629
|
-
return (
|
|
630
|
-
`planning-readonly: blocked the find option ${word}. A planning model may only list ` +
|
|
631
|
-
"files."
|
|
632
|
-
);
|
|
633
|
-
}
|
|
634
|
-
const reason = optionReason(words, index, tables, base);
|
|
635
|
-
if (reason !== null) {
|
|
636
|
-
return reason;
|
|
637
|
-
}
|
|
638
|
-
index += optionWidth(words, index, tables) - 1;
|
|
639
|
-
}
|
|
640
|
-
return null;
|
|
641
|
-
}
|
|
642
|
-
|
|
643
|
-
/**
|
|
644
|
-
* The reason a shell command is not read-only, or null when it is one.
|
|
645
|
-
* @param {Command} command The shell text or an argument list.
|
|
646
|
-
* @returns {string | null} The block message.
|
|
647
|
-
*/
|
|
648
|
-
function planningShellReason(command: Command): string | null {
|
|
649
|
-
if (typeof command !== "string") {
|
|
650
|
-
if (command.length === 0) {
|
|
651
|
-
return "planning-readonly: blocked an empty shell command.";
|
|
652
|
-
}
|
|
653
|
-
return readCommandReason([...command]);
|
|
654
|
-
}
|
|
655
|
-
const calls = literalShellCalls(command);
|
|
656
|
-
if (calls === null) {
|
|
657
|
-
return (
|
|
658
|
-
"planning-readonly: blocked a shell command that uses a redirect, an assignment, a " +
|
|
659
|
-
"substitution, a glob, or other dynamic syntax. A planning model may only run plain " +
|
|
660
|
-
"read-only commands."
|
|
661
|
-
);
|
|
662
|
-
}
|
|
663
|
-
if (calls.length === 0) {
|
|
664
|
-
return "planning-readonly: blocked an empty shell command.";
|
|
665
|
-
}
|
|
666
|
-
for (const words of calls) {
|
|
667
|
-
if (words.length === 0) {
|
|
668
|
-
return "planning-readonly: blocked an empty shell command.";
|
|
669
|
-
}
|
|
670
|
-
const reason = readCommandReason(words);
|
|
671
|
-
if (reason !== null) {
|
|
672
|
-
return reason;
|
|
673
|
-
}
|
|
674
|
-
}
|
|
675
|
-
return null;
|
|
676
|
-
}
|
|
677
|
-
|
|
678
|
-
/**
|
|
679
|
-
* The reason a shell tool call is not read-only, or null when it is one.
|
|
680
|
-
* @param {string} toolName The tool name.
|
|
681
|
-
* @param {Readonly<Record<string, unknown>>} input The tool input.
|
|
682
|
-
* @returns {string | null} The block message.
|
|
683
|
-
*/
|
|
684
|
-
function planningShellToolReason(
|
|
685
|
-
toolName: string,
|
|
686
|
-
input: Readonly<Record<string, unknown>>,
|
|
687
|
-
): string | null {
|
|
688
|
-
if (BATCH_SHELL_TOOLS.has(toolName)) {
|
|
689
|
-
const commands = input["commands"];
|
|
690
|
-
if (!Array.isArray(commands)) {
|
|
691
|
-
return `planning-readonly: blocked ${toolName}. The commands must be a list.`;
|
|
692
|
-
}
|
|
693
|
-
for (const entry of commands) {
|
|
694
|
-
if (!isRecord(entry)) {
|
|
695
|
-
return `planning-readonly: blocked ${toolName}. A command of the batch is malformed.`;
|
|
696
|
-
}
|
|
697
|
-
const text = textOf(entry["command"]);
|
|
698
|
-
if (text === undefined) {
|
|
699
|
-
return `planning-readonly: blocked ${toolName}. A command of the batch has no command.`;
|
|
700
|
-
}
|
|
701
|
-
const reason = planningShellReason(text);
|
|
702
|
-
if (reason !== null) {
|
|
703
|
-
return reason;
|
|
704
|
-
}
|
|
705
|
-
}
|
|
706
|
-
return null;
|
|
707
|
-
}
|
|
708
|
-
if (CODE_SHELL_TOOLS.has(toolName)) {
|
|
709
|
-
if (input["language"] !== "shell") {
|
|
710
|
-
return `planning-readonly: blocked ${toolName}. A planning model may only run shell code.`;
|
|
711
|
-
}
|
|
712
|
-
const code = textOf(input["code"]);
|
|
713
|
-
if (code === undefined) {
|
|
714
|
-
return `planning-readonly: blocked ${toolName}. The code is missing.`;
|
|
715
|
-
}
|
|
716
|
-
return planningShellReason(code);
|
|
717
|
-
}
|
|
718
|
-
const text = commandTextOf(input);
|
|
719
|
-
if (text === null) {
|
|
720
|
-
return `planning-readonly: blocked ${toolName}. The command is missing.`;
|
|
721
|
-
}
|
|
722
|
-
// A raw command runs without a shell, so the runner splits it on whitespace
|
|
723
|
-
// and the words are the argument list.
|
|
724
|
-
if (toolName === "hypa_shell" && input["raw"] === true) {
|
|
725
|
-
return planningShellReason(rawWords(text));
|
|
726
|
-
}
|
|
727
|
-
return planningShellReason(text);
|
|
728
|
-
}
|
|
729
|
-
|
|
730
|
-
/**
|
|
731
|
-
* The block message for a tool call a planning model may not make, or null
|
|
732
|
-
* when the call is a read the policy allows.
|
|
733
|
-
* @param {string} toolName The tool name.
|
|
734
|
-
* @param {Readonly<Record<string, unknown>>} input The tool input.
|
|
735
|
-
* @returns {string | null} The block message.
|
|
736
|
-
*/
|
|
737
|
-
function planningToolReason(
|
|
738
|
-
toolName: string,
|
|
739
|
-
input: Readonly<Record<string, unknown>>,
|
|
740
|
-
): string | null {
|
|
741
|
-
if (WORKFLOW_TOOLS.has(toolName)) {
|
|
742
|
-
return null;
|
|
743
|
-
}
|
|
744
|
-
if (DOGSLED_TOOLS.has(toolName)) {
|
|
745
|
-
return null;
|
|
746
|
-
}
|
|
747
|
-
if (toolName === "mcp__dogsled__spawn") {
|
|
748
|
-
return isBoundSpawn(input)
|
|
749
|
-
? null
|
|
750
|
-
: "planning-readonly: blocked a Dogsled spawn without a bound unit. A planning model may " +
|
|
751
|
-
"only spawn a bound unit by its id.";
|
|
752
|
-
}
|
|
753
|
-
if (READ_TOOLS.has(toolName)) {
|
|
754
|
-
return null;
|
|
755
|
-
}
|
|
756
|
-
if (SHELL_TOOLS.has(toolName)) {
|
|
757
|
-
return planningShellToolReason(toolName, input);
|
|
758
|
-
}
|
|
759
|
-
return toolMessage(toolName);
|
|
760
|
-
}
|
|
761
|
-
|
|
762
|
-
export { isPlanningModel, planningShellReason, planningToolReason, readCommandReason };
|
|
763
|
-
export type { ModelIdentity };
|