@oisin-ee/oisinpi 0.10.1 → 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 CHANGED
@@ -1,5 +1,26 @@
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
+
13
+ ## 0.10.2
14
+
15
+ ### Patch Changes
16
+
17
+ - [#35](https://github.com/oisin-ee/oisinpi/pull/35) [`a4554dc`](https://github.com/oisin-ee/oisinpi/commit/a4554dc2e64ba6e82fe5731992ba26c0c31c59b2) Thanks [@momo-momokaya](https://github.com/apps/momo-momokaya)! - A worker can call `dogsled done` after it closes its seeded todo leaf. Before, the todo gate blocked `mcp__dogsled__done`, so a finished worker could not report done.
18
+ - Updated dependencies [[`a4554dc`](https://github.com/oisin-ee/oisinpi/commit/a4554dc2e64ba6e82fe5731992ba26c0c31c59b2)]:
19
+ - @oisin-ee/pi-todo@0.10.2
20
+ - @oisin-ee/pi-guardrails@0.10.2
21
+ - @oisin-ee/pi-prewalk@0.10.2
22
+ - @oisin-ee/pi-tiers@0.10.2
23
+
3
24
  ## 0.10.1
4
25
 
5
26
  ### 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
- export { shellCallGuards, shellCalls };
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 };
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oisin-ee/pi-guardrails",
3
- "version": "0.10.1",
3
+ "version": "0.11.0",
4
4
  "description": "Agent guardrails for pi, Claude Code, and Codex: Git safety, no waiting, no typing into other agents, GitOps-only clusters.",
5
5
  "keywords": [
6
6
  "claude-code",
@@ -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
- nestedCalls = (input: Readonly<Record<string, unknown>>): ToolCall[] => {
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
- return uses.filter(isRecord).map((use) => ({
58
- input: isRecord(use["parameters"]) ? use["parameters"] : {},
59
- toolName: typeof use["recipient_name"] === "string" ? use["recipient_name"] : "",
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 = (event: ToolCall, cwd: string, depth = 0): string | null => {
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
- return null;
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
- for (const nested of nestedCalls(call.input)) {
76
- const message = toolViolation(nested, cwd, depth + 1);
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) };