pi-sage 0.2.11 → 0.2.12

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.
@@ -48,7 +48,11 @@ const SAGE_GUIDANCE = [
48
48
  "Sage is advisory-only: treat output as recommendations and implement changes yourself.",
49
49
  "Use one focused Sage question per consultation.",
50
50
  "If user explicitly requests Sage/second opinion, prefer at least one Sage consultation unless blocked by hard safety limits.",
51
- "For `sage_consult` params: `objective` is optional and must be one of debug|design|review|refactor|general (omit if unsure); `urgency` is optional and must be low|medium|high."
51
+ "For `sage_consult` params: `objective` is optional and must be one of debug|design|review|refactor|general (omit if unsure); `urgency` is optional and must be low|medium|high.",
52
+ "In git-review-readonly policy, bash must start with an allowed git read command (status|diff|show|log|blame|rev-parse|branch --show-current).",
53
+ "Read-only pipelines are allowed only with: head, tail, grep, cut, sed, wc, sort, uniq.",
54
+ "Do not use shell chaining or control operators (e.g., ;, &&, ||, >, <, $, backticks).",
55
+ "If a bash command is blocked, fall back to ls/glob/grep/read and continue with a best-effort advisory review instead of stopping."
52
56
  ].join("\n- ");
53
57
 
54
58
  export default function registerSageExtension(pi: ExtensionAPI): void {
@@ -104,6 +108,9 @@ export default function registerSageExtension(pi: ExtensionAPI): void {
104
108
  "Set force=true when the user explicitly asks for Sage to bypass soft limits.",
105
109
  "`objective` is optional: debug|design|review|refactor|general. Omit instead of free text.",
106
110
  "`urgency` is optional: low|medium|high.",
111
+ "For git-review-readonly, prefer plain git read commands and allowed read-only pipelines (head/tail/grep/cut/sed/wc/sort/uniq).",
112
+ "Avoid shell chaining/control operators (;, &&, ||, >, <, $, backticks).",
113
+ "If bash is blocked, continue with ls/glob/grep/read and still deliver best-effort findings.",
107
114
  "After Sage returns, synthesize recommendations and continue execution."
108
115
  ],
109
116
  parameters: Type.Object({
@@ -288,7 +295,7 @@ export default function registerSageExtension(pi: ExtensionAPI): void {
288
295
  model: resolvedModel,
289
296
  reasoningLevel: settings.reasoningLevel,
290
297
  blockCode: error.blockCode,
291
- reason: error.message,
298
+ reason: formatRunnerPolicyReason(error.blockCode, error.message),
292
299
  allowedByContext: true,
293
300
  allowedByBudget: false
294
301
  });
@@ -330,6 +337,22 @@ export default function registerSageExtension(pi: ExtensionAPI): void {
330
337
  });
331
338
  }
332
339
 
340
+ function formatRunnerPolicyReason(blockCode: BlockCode, reason: string): string {
341
+ if (blockCode === "tool-disallowed") {
342
+ return `${reason}. Use allowed git read commands and read-only pipelines, or continue with ls/glob/grep/read for a best-effort review.`;
343
+ }
344
+
345
+ if (blockCode === "path-denied") {
346
+ return `${reason}. Restrict file references to workspace paths and avoid sensitive denylist matches.`;
347
+ }
348
+
349
+ if (blockCode === "volume-cap") {
350
+ return `${reason}. Narrow the request scope or increase caps in /sage-settings if appropriate.`;
351
+ }
352
+
353
+ return reason;
354
+ }
355
+
333
356
  function buildCallerContext(lastInputSource: InputSource | undefined, hasUI: boolean): CallerContext | null {
334
357
  if (!lastInputSource) return null;
335
358
 
@@ -11,10 +11,11 @@ const CLI_TOOL_NAME_MAP: Record<string, string> = {
11
11
  const READ_ONLY_CUSTOM_ALLOWLIST = new Set(["ls", "glob", "find", "grep", "read"]);
12
12
  const HARD_DISALLOWED_TOOLS = new Set(["edit", "write", "bash", "sage_consult"]);
13
13
 
14
- const BASH_META_CHARS = /[;&|><`$\n\r]/;
14
+ const BASH_DISALLOWED_CHARS = /[;&><`$\n\r]/;
15
15
  const SAFE_TOKEN = /^[A-Za-z0-9_./:@+=,~%-]+$/;
16
16
 
17
17
  const GIT_SUBCOMMAND_ALLOWLIST = new Set(["status", "diff", "show", "log", "blame", "rev-parse", "branch"]);
18
+ const PIPE_FILTER_ALLOWLIST = new Set(["head", "tail", "grep", "cut", "sed", "wc", "sort", "uniq"]);
18
19
 
19
20
  export interface ResolvedToolPolicy {
20
21
  profile: ToolPolicySettings["profile"];
@@ -148,34 +149,67 @@ export function validateBashCommandForProfile(
148
149
  }
149
150
 
150
151
  const trimmed = command.trim();
152
+ const blocked = (reason: string): { ok: false; blockCode: BlockCode; reason: string } => ({
153
+ ok: false,
154
+ blockCode: "tool-disallowed",
155
+ reason: `${reason} (command: ${truncateForReason(trimmed)})`
156
+ });
157
+
151
158
  if (!trimmed) {
152
- return { ok: false, blockCode: "tool-disallowed", reason: "Empty bash command" };
159
+ return blocked("Empty bash command");
160
+ }
161
+
162
+ if (trimmed.includes("||")) {
163
+ return blocked("Logical OR (||) is not allowed. Use a single git read command or a read-only pipeline");
164
+ }
165
+
166
+ if (BASH_DISALLOWED_CHARS.test(trimmed)) {
167
+ return blocked(
168
+ "Shell control characters are not allowed here. Allowed pattern: git <read-subcommand> [args] optionally piped to head/tail/grep/cut/sed/wc/sort/uniq"
169
+ );
170
+ }
171
+
172
+ const stages = trimmed
173
+ .split("|")
174
+ .map((stage) => stage.trim())
175
+ .filter(Boolean);
176
+
177
+ if (stages.length === 0) {
178
+ return blocked("Invalid command format");
153
179
  }
154
180
 
155
- if (BASH_META_CHARS.test(trimmed)) {
156
- return { ok: false, blockCode: "tool-disallowed", reason: "Shell control characters are not allowed" };
181
+ const firstStageDecision = validateGitReadStage(stages[0]!);
182
+ if (!firstStageDecision.ok) return blocked(firstStageDecision.reason);
183
+
184
+ for (const stage of stages.slice(1)) {
185
+ const filterDecision = validatePipeFilterStage(stage);
186
+ if (!filterDecision.ok) return blocked(filterDecision.reason);
157
187
  }
158
188
 
159
- const tokens = trimmed.split(/\s+/).filter(Boolean);
189
+ return { ok: true, reason: "allowed" };
190
+ }
191
+
192
+ function validateGitReadStage(stage: string): { ok: boolean; reason: string } {
193
+ const tokens = stage.split(/\s+/).filter(Boolean);
160
194
  if (tokens.length < 2 || tokens[0] !== "git") {
161
- return { ok: false, blockCode: "tool-disallowed", reason: "Only git read-only commands are allowed" };
195
+ return { ok: false, reason: "First command in a pipeline must be a git read-only command" };
162
196
  }
163
197
 
164
198
  const subcommand = tokens[1] ?? "";
165
199
  if (!GIT_SUBCOMMAND_ALLOWLIST.has(subcommand)) {
166
- return { ok: false, blockCode: "tool-disallowed", reason: `Git subcommand not allowed: ${subcommand}` };
200
+ return { ok: false, reason: `Git subcommand not allowed: ${subcommand}` };
167
201
  }
168
202
 
169
203
  for (const token of tokens.slice(2)) {
170
204
  if (!SAFE_TOKEN.test(token)) {
171
- return { ok: false, blockCode: "tool-disallowed", reason: `Unsafe token in command: ${token}` };
205
+ return { ok: false, reason: `Unsafe token in git command: ${token}` };
172
206
  }
173
207
  }
174
208
 
175
209
  if (subcommand === "branch") {
176
210
  const args = tokens.slice(2);
177
211
  if (args.length !== 1 || args[0] !== "--show-current") {
178
- return { ok: false, blockCode: "tool-disallowed", reason: "Only 'git branch --show-current' is allowed" };
212
+ return { ok: false, reason: "Only 'git branch --show-current' is allowed" };
179
213
  }
180
214
  }
181
215
 
@@ -183,13 +217,94 @@ export function validateBashCommandForProfile(
183
217
  const args = tokens.slice(2);
184
218
  const allowedArgs = new Set(["--abbrev-ref", "HEAD"]);
185
219
  if (args.some((arg) => !allowedArgs.has(arg))) {
186
- return { ok: false, blockCode: "tool-disallowed", reason: "Unsupported git rev-parse arguments" };
220
+ return { ok: false, reason: "Unsupported git rev-parse arguments" };
221
+ }
222
+ }
223
+
224
+ return { ok: true, reason: "allowed" };
225
+ }
226
+
227
+ function validatePipeFilterStage(stage: string): { ok: boolean; reason: string } {
228
+ const tokens = stage.split(/\s+/).filter(Boolean);
229
+ if (tokens.length === 0) {
230
+ return { ok: false, reason: "Empty pipeline stage" };
231
+ }
232
+
233
+ const command = tokens[0] ?? "";
234
+ if (!PIPE_FILTER_ALLOWLIST.has(command)) {
235
+ return { ok: false, reason: `Pipeline command not allowed: ${command}` };
236
+ }
237
+
238
+ for (const token of tokens.slice(1)) {
239
+ if (!SAFE_TOKEN.test(token)) {
240
+ return { ok: false, reason: `Unsafe token in pipeline command: ${token}` };
187
241
  }
188
242
  }
189
243
 
244
+ const args = tokens.slice(1);
245
+ if (command === "head" || command === "tail") {
246
+ if (args.length === 0) return { ok: true, reason: "allowed" };
247
+ if (args.length === 2 && args[0] === "-n" && /^\d+$/.test(args[1] ?? "")) {
248
+ return { ok: true, reason: "allowed" };
249
+ }
250
+ return { ok: false, reason: `${command} only supports '-n <number>'` };
251
+ }
252
+
253
+ if (command === "grep") {
254
+ const allowedFlags = new Set(["-n", "-i", "-F", "-E", "-v"]);
255
+ for (const arg of args) {
256
+ if (arg.startsWith("-") && !allowedFlags.has(arg)) {
257
+ return { ok: false, reason: `grep flag not allowed: ${arg}` };
258
+ }
259
+ }
260
+ return { ok: true, reason: "allowed" };
261
+ }
262
+
263
+ if (command === "cut") {
264
+ const hasFieldFlag = args.some((arg, index) => arg === "-f" && /^\d+(?:-\d+)?(?:,\d+(?:-\d+)?)*$/.test(args[index + 1] ?? ""));
265
+ if (!hasFieldFlag) {
266
+ return { ok: false, reason: "cut requires '-f <field-spec>'" };
267
+ }
268
+ return { ok: true, reason: "allowed" };
269
+ }
270
+
271
+ if (command === "sed") {
272
+ if (args.length === 2 && args[0] === "-n" && /^\d+(?:,\d+)?p$/.test(args[1] ?? "")) {
273
+ return { ok: true, reason: "allowed" };
274
+ }
275
+ return { ok: false, reason: "sed only supports '-n <start,end>p' or '-n <line>p'" };
276
+ }
277
+
278
+ if (command === "wc") {
279
+ if (args.length === 1 && args[0] === "-l") {
280
+ return { ok: true, reason: "allowed" };
281
+ }
282
+ return { ok: false, reason: "wc only supports '-l'" };
283
+ }
284
+
285
+ if (command === "sort") {
286
+ if (args.length === 0 || (args.length === 1 && args[0] === "-u")) {
287
+ return { ok: true, reason: "allowed" };
288
+ }
289
+ return { ok: false, reason: "sort only supports optional '-u'" };
290
+ }
291
+
292
+ if (command === "uniq") {
293
+ if (args.length === 0 || (args.length === 1 && args[0] === "-c")) {
294
+ return { ok: true, reason: "allowed" };
295
+ }
296
+ return { ok: false, reason: "uniq only supports optional '-c'" };
297
+ }
298
+
190
299
  return { ok: true, reason: "allowed" };
191
300
  }
192
301
 
302
+ function truncateForReason(command: string): string {
303
+ const singleLine = command.replace(/\s+/g, " ").trim();
304
+ if (singleLine.length <= 140) return singleLine;
305
+ return `${singleLine.slice(0, 137)}...`;
306
+ }
307
+
193
308
  function normalizePath(pathValue: string): string {
194
309
  return pathValue.replace(/\\/g, "/").toLowerCase();
195
310
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-sage",
3
- "version": "0.2.11",
3
+ "version": "0.2.12",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Interactive-only advisory Sage extension for Pi",