@oisin-ee/oisinpi 0.10.2 → 0.11.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 +10 -0
- package/node_modules/@oisin-ee/pi-guardrails/README.md +38 -0
- package/node_modules/@oisin-ee/pi-guardrails/dist/shell-parser.js +65 -2
- package/node_modules/@oisin-ee/pi-guardrails/package.json +1 -1
- package/node_modules/@oisin-ee/pi-guardrails/src/pi/index.ts +68 -11
- package/node_modules/@oisin-ee/pi-guardrails/src/pi/planning-policy.ts +763 -0
- package/node_modules/@oisin-ee/pi-guardrails/src/shell-parser.ts +74 -2
- 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 +8 -0
- package/node_modules/@oisin-ee/pi-todo/package.json +1 -1
- package/node_modules/@oisin-ee/pi-todo/src/gate.ts +12 -3
- package/node_modules/@oisin-ee/pi-todo/src/guidance.ts +3 -1
- package/node_modules/@oisin-ee/pi-todo/src/runtime-fixture.ts +93 -42
- package/package.json +5 -5
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# @oisin-ee/oisinpi
|
|
2
2
|
|
|
3
|
+
## 0.11.0
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Updated dependencies [[`49b00f1`](https://github.com/oisin-ee/oisinpi/commit/49b00f117d4d64a51cdb2b502ac505a99ffe83dc), [`0e37d82`](https://github.com/oisin-ee/oisinpi/commit/0e37d8259d4a7cb4cffbeccfce2a36caa0274b6e)]:
|
|
8
|
+
- @oisin-ee/pi-guardrails@0.11.0
|
|
9
|
+
- @oisin-ee/pi-todo@0.11.0
|
|
10
|
+
- @oisin-ee/pi-prewalk@0.11.0
|
|
11
|
+
- @oisin-ee/pi-tiers@0.11.0
|
|
12
|
+
|
|
3
13
|
## 0.10.2
|
|
4
14
|
|
|
5
15
|
### Patch Changes
|
|
@@ -13,6 +13,44 @@ 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
|
+
## Read-only planning models
|
|
17
|
+
|
|
18
|
+
A planning model (`tier/plan-claude` or `tier/plan-gpt`) may only read. The guardrails read the model of
|
|
19
|
+
each tool call from the session context, so the rule follows a model switch.
|
|
20
|
+
|
|
21
|
+
A planning model may call:
|
|
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.
|
|
53
|
+
|
|
16
54
|
## pi
|
|
17
55
|
|
|
18
56
|
```bash
|
|
@@ -95,7 +95,7 @@ pipelineStages = (stmt, source) => {
|
|
|
95
95
|
}
|
|
96
96
|
};
|
|
97
97
|
visit(tree);
|
|
98
|
-
return { calls, guards };
|
|
98
|
+
return { tree, calls, guards };
|
|
99
99
|
}, parsedOf = (command) => {
|
|
100
100
|
if (cache.command !== command || cache.parsed === undefined) {
|
|
101
101
|
cache.parsed = parse(command);
|
|
@@ -117,4 +117,67 @@ shellCalls = (command) => parsedOf(command).calls.map((words) => [...words]),
|
|
|
117
117
|
* @returns {(string[][] | null)[]} One guard per call.
|
|
118
118
|
*/
|
|
119
119
|
shellCallGuards = (command) => parsedOf(command).guards.map((stages) => stages?.map((words) => [...words]) ?? null);
|
|
120
|
-
|
|
120
|
+
/**
|
|
121
|
+
* Whether a literal contains a glob character that the shell would expand. An
|
|
122
|
+
* escaped character is literal, so it does not expand.
|
|
123
|
+
* @param {string} value The raw literal text.
|
|
124
|
+
* @returns {boolean} True for an active glob character.
|
|
125
|
+
*/
|
|
126
|
+
function hasActiveGlob(value) {
|
|
127
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
128
|
+
const character = value[index] ?? "";
|
|
129
|
+
if (!"*?[]{}".includes(character)) {
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
let backslashes = 0;
|
|
133
|
+
for (let before = index - 1; before >= 0 && value[before] === "\\"; before -= 1) {
|
|
134
|
+
backslashes += 1;
|
|
135
|
+
}
|
|
136
|
+
if (backslashes % 2 === 0) {
|
|
137
|
+
return true;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Returns literal calls only when the complete AST has read-only shell syntax.
|
|
144
|
+
* @param {string} command Shell text.
|
|
145
|
+
* @returns {string[][] | null} Literal argv, or null for unsupported syntax.
|
|
146
|
+
*/
|
|
147
|
+
function literalShellCalls(command) {
|
|
148
|
+
const parsed = parsedOf(command), allowed = new Set([
|
|
149
|
+
"File",
|
|
150
|
+
"Stmt",
|
|
151
|
+
"CallExpr",
|
|
152
|
+
"Word",
|
|
153
|
+
"Lit",
|
|
154
|
+
"SglQuoted",
|
|
155
|
+
"DblQuoted",
|
|
156
|
+
"BinaryCmd",
|
|
157
|
+
]), safe = (node, quoted = false) => {
|
|
158
|
+
if (Array.isArray(node)) {
|
|
159
|
+
return node.every((item) => safe(item, quoted));
|
|
160
|
+
}
|
|
161
|
+
if (!isNode(node)) {
|
|
162
|
+
return true;
|
|
163
|
+
}
|
|
164
|
+
if (node.Type !== undefined && !allowed.has(node.Type)) {
|
|
165
|
+
return false;
|
|
166
|
+
}
|
|
167
|
+
if (node.Dollar === true ||
|
|
168
|
+
node.Background === true ||
|
|
169
|
+
(node.Redirs ?? []).length > 0 ||
|
|
170
|
+
(node.Assigns ?? []).length > 0 ||
|
|
171
|
+
(node.Type === "CallExpr" && (node.Args ?? []).length === 0) ||
|
|
172
|
+
(node.Type === "BinaryCmd" && !["&&", "||", "|"].includes(node.Op ?? "")) ||
|
|
173
|
+
(node.Type === "Lit" && !quoted && hasActiveGlob(node.Value ?? "")) ||
|
|
174
|
+
(node.Type === "Lit" && !quoted && (node.Value ?? "").startsWith("~"))) {
|
|
175
|
+
return false;
|
|
176
|
+
}
|
|
177
|
+
return Object.values(node).every((value) => safe(value, quoted || node.Type === "DblQuoted"));
|
|
178
|
+
};
|
|
179
|
+
return safe(parsed.tree) && parsed.calls.length > 0
|
|
180
|
+
? parsed.calls.map((words) => [...words])
|
|
181
|
+
: null;
|
|
182
|
+
}
|
|
183
|
+
export { literalShellCalls, shellCallGuards, shellCalls };
|
|
@@ -9,6 +9,7 @@ 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";
|
|
12
13
|
import { classifyEdits, editInputsFromPayload } from "../policies/edit.ts";
|
|
13
14
|
import { SHELL_CLASSIFIERS } from "../policies/shell.ts";
|
|
14
15
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
@@ -23,6 +24,12 @@ interface ToolCall {
|
|
|
23
24
|
readonly input: Record<string, unknown>;
|
|
24
25
|
}
|
|
25
26
|
|
|
27
|
+
/** The model of the session, as the context reports it. */
|
|
28
|
+
interface CallModel {
|
|
29
|
+
readonly provider?: string;
|
|
30
|
+
readonly id?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
26
33
|
// A codemode script's nested calls nest at most this deep before the gate stops looking.
|
|
27
34
|
const MAX_NESTING = 8,
|
|
28
35
|
/**
|
|
@@ -49,31 +56,81 @@ const MAX_NESTING = 8,
|
|
|
49
56
|
),
|
|
50
57
|
editViolation = (call: ToolCall, cwd: string): string | null =>
|
|
51
58
|
classifyEdits(editInputsFromPayload({ tool_input: call.input, tool_name: call.toolName }), cwd),
|
|
52
|
-
|
|
59
|
+
/**
|
|
60
|
+
* The nested calls of a codemode input. A malformed envelope is a failure
|
|
61
|
+
* for a planning model, so it is reported rather than skipped.
|
|
62
|
+
* @param {Readonly<Record<string, unknown>>} input The tool input.
|
|
63
|
+
* @returns {{ calls: ToolCall[]; malformed: boolean }} The calls and the
|
|
64
|
+
* state of the envelope.
|
|
65
|
+
*/
|
|
66
|
+
nestedCalls = (
|
|
67
|
+
input: Readonly<Record<string, unknown>>,
|
|
68
|
+
): { calls: ToolCall[]; malformed: boolean } => {
|
|
53
69
|
const uses = input["tool_uses"];
|
|
70
|
+
if (uses === undefined) {
|
|
71
|
+
return { calls: [], malformed: false };
|
|
72
|
+
}
|
|
54
73
|
if (!Array.isArray(uses)) {
|
|
55
|
-
return [];
|
|
74
|
+
return { calls: [], malformed: true };
|
|
56
75
|
}
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
76
|
+
const calls: ToolCall[] = [];
|
|
77
|
+
let malformed = false;
|
|
78
|
+
for (const use of uses) {
|
|
79
|
+
if (!isRecord(use)) {
|
|
80
|
+
malformed = true;
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
const toolName = use["recipient_name"];
|
|
84
|
+
const parameters = use["parameters"];
|
|
85
|
+
if (typeof toolName !== "string") {
|
|
86
|
+
malformed = true;
|
|
87
|
+
}
|
|
88
|
+
if (parameters !== undefined && !isRecord(parameters)) {
|
|
89
|
+
malformed = true;
|
|
90
|
+
}
|
|
91
|
+
calls.push({
|
|
92
|
+
input: isRecord(parameters) ? parameters : {},
|
|
93
|
+
toolName: typeof toolName === "string" ? toolName : "",
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
return { calls, malformed };
|
|
61
97
|
},
|
|
62
|
-
toolViolation = (
|
|
98
|
+
toolViolation = (
|
|
99
|
+
event: ToolCall,
|
|
100
|
+
cwd: string,
|
|
101
|
+
model: CallModel | undefined,
|
|
102
|
+
depth = 0,
|
|
103
|
+
): string | null => {
|
|
104
|
+
const planning = isPlanningModel(model);
|
|
63
105
|
if (depth > MAX_NESTING) {
|
|
64
|
-
|
|
106
|
+
// A planning model fails closed: a call it cannot inspect is a call it
|
|
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;
|
|
65
112
|
}
|
|
66
113
|
const call = { input: event.input, toolName: canonicalToolName(event.toolName) },
|
|
67
114
|
own =
|
|
68
115
|
enforceTimeout(call.toolName, call.input) ??
|
|
69
116
|
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) ??
|
|
70
120
|
(SHELL_TOOL_NAMES.has(call.toolName) ? shellViolation(call, cwd) : null) ??
|
|
71
121
|
(EDIT_TOOL_NAMES.has(call.toolName) ? editViolation(call, cwd) : null);
|
|
72
122
|
if (own !== null) {
|
|
73
123
|
return own;
|
|
74
124
|
}
|
|
75
|
-
|
|
76
|
-
|
|
125
|
+
const nested = nestedCalls(call.input);
|
|
126
|
+
if (nested.malformed && planning) {
|
|
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);
|
|
77
134
|
if (message !== null) {
|
|
78
135
|
return message;
|
|
79
136
|
}
|
|
@@ -89,7 +146,7 @@ const MAX_NESTING = 8,
|
|
|
89
146
|
export default function piGuardrails(pi: GuardrailsAPI): void {
|
|
90
147
|
pi.on("tool_call", (event, ctx) => {
|
|
91
148
|
try {
|
|
92
|
-
const message = toolViolation(event, ctx.cwd);
|
|
149
|
+
const message = toolViolation(event, ctx.cwd, ctx.model);
|
|
93
150
|
return message === null ? undefined : { block: true, reason: message };
|
|
94
151
|
} catch (error) {
|
|
95
152
|
return { block: true, reason: error instanceof Error ? error.message : String(error) };
|