pi-sage 0.2.15 → 0.2.16

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.
@@ -49,7 +49,7 @@ type ModelLike = {
49
49
  const ROLE_HINT_ENV_KEYS = ["TASKPLANE_ROLE", "TASKPLANE_AGENT_ROLE", "PI_AGENT_ROLE", "ORCH_AGENT_ROLE"];
50
50
 
51
51
  const REASONING_LEVELS: ReasoningLevel[] = ["minimal", "low", "medium", "high", "xhigh"];
52
- const TOOL_PROFILES: ToolProfile[] = ["none", "read-only-lite", "git-review-readonly", "custom-read-only"];
52
+ const TOOL_PROFILES: ToolProfile[] = ["none", "read-only-lite", "git-review-readonly", "custom-read-only", "yolo"];
53
53
  const MODEL_INHERIT_VALUE = "inherit";
54
54
 
55
55
  const SAGE_GUIDANCE = [
@@ -61,7 +61,8 @@ const SAGE_GUIDANCE = [
61
61
  "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.",
62
62
  "In git-review-readonly policy, bash must start with an allowed git read command (status|diff|show|log|blame|rev-parse|branch --show-current).",
63
63
  "Read-only pipelines are allowed only with: head, tail, grep, cut, sed, wc, sort, uniq.",
64
- "Do not use shell chaining or control operators (e.g., ;, &&, ||, >, <, $, backticks).",
64
+ "Do not use shell chaining or control operators (e.g., ;, &&, ||, >, <, $, backticks) unless tool profile is explicitly set to yolo.",
65
+ "If tool profile is yolo, Sage may use unrestricted available tools/commands (including node/npm/cd/tests); use only when operator intentionally accepts elevated risk.",
65
66
  "If a bash command is blocked, fall back to ls/glob/grep/read and continue with a best-effort advisory review instead of stopping."
66
67
  ].join("\n- ");
67
68
 
@@ -129,7 +130,8 @@ export default function registerSageExtension(pi: ExtensionAPI): void {
129
130
  "`objective` is optional: debug|design|review|refactor|general. Omit instead of free text.",
130
131
  "`urgency` is optional: low|medium|high.",
131
132
  "For git-review-readonly, prefer plain git read commands and allowed read-only pipelines (head/tail/grep/cut/sed/wc/sort/uniq).",
132
- "Avoid shell chaining/control operators (;, &&, ||, >, <, $, backticks).",
133
+ "Avoid shell chaining/control operators (;, &&, ||, >, <, $, backticks) unless tool profile is explicitly set to yolo.",
134
+ "If tool profile is yolo, unrestricted tools/commands (including node/npm/cd/tests) are permitted by policy and should be used carefully.",
133
135
  "If bash is blocked, continue with ls/glob/grep/read and still deliver best-effort findings.",
134
136
  "After Sage returns, synthesize recommendations and continue execution."
135
137
  ],
@@ -239,17 +241,19 @@ export default function registerSageExtension(pi: ExtensionAPI): void {
239
241
  });
240
242
  }
241
243
 
242
- const disallowedCustomTools = getDisallowedCustomTools(settings.toolPolicy.customAllowedTools);
243
- if (disallowedCustomTools.length > 0) {
244
- return makeBlockedResult({
245
- mode,
246
- model: resolvedModel,
247
- reasoningLevel: settings.reasoningLevel,
248
- blockCode: "tool-disallowed",
249
- reason: `Custom tool list contains disallowed tools: ${disallowedCustomTools.join(", ")}`,
250
- allowedByContext: true,
251
- allowedByBudget: false
252
- });
244
+ if (shouldValidateCustomAllowedTools(settings.toolPolicy.profile)) {
245
+ const disallowedCustomTools = getDisallowedCustomTools(settings.toolPolicy.customAllowedTools);
246
+ if (disallowedCustomTools.length > 0) {
247
+ return makeBlockedResult({
248
+ mode,
249
+ model: resolvedModel,
250
+ reasoningLevel: settings.reasoningLevel,
251
+ blockCode: "tool-disallowed",
252
+ reason: `Custom tool list contains disallowed tools: ${disallowedCustomTools.join(", ")}`,
253
+ allowedByContext: true,
254
+ allowedByBudget: false
255
+ });
256
+ }
253
257
  }
254
258
 
255
259
  try {
@@ -361,6 +365,10 @@ export default function registerSageExtension(pi: ExtensionAPI): void {
361
365
  });
362
366
  }
363
367
 
368
+ export function shouldValidateCustomAllowedTools(profile: ToolProfile): boolean {
369
+ return profile === "custom-read-only";
370
+ }
371
+
364
372
  function formatRunnerPolicyReason(blockCode: BlockCode, reason: string): string {
365
373
  if (blockCode === "tool-disallowed") {
366
374
  return `${reason}. Use allowed git read commands and read-only pipelines, or continue with ls/glob/grep/read for a best-effort review.`;
@@ -445,6 +453,9 @@ async function runSettingsWizard(ctx: ExtensionCommandContext): Promise<void> {
445
453
  let rootSelectionIndex = 0;
446
454
 
447
455
  while (true) {
456
+ const yoloActive = draft.toolPolicy.profile === "yolo";
457
+ const ignoredInYoloSuffix = yoloActive ? " (ignored in yolo)" : "";
458
+
448
459
  const rootOptions = [
449
460
  `Enabled: ${onOff(draft.enabled)}`,
450
461
  `Autonomous mode: ${onOff(draft.autonomousEnabled)}`,
@@ -458,12 +469,12 @@ async function runSettingsWizard(ctx: ExtensionCommandContext): Promise<void> {
458
469
  `Max question chars: ${draft.maxQuestionChars}`,
459
470
  `Max context chars: ${draft.maxContextChars}`,
460
471
  `Tool profile: ${draft.toolPolicy.profile}`,
461
- `Custom tools: ${(draft.toolPolicy.customAllowedTools ?? []).join(",") || "(none)"}`,
462
- `Max tool calls: ${draft.toolPolicy.maxToolCalls ?? 10}`,
463
- `Max files read: ${draft.toolPolicy.maxFilesRead ?? 8}`,
464
- `Max bytes/file: ${draft.toolPolicy.maxBytesPerFile ?? 200 * 1024}`,
465
- `Max total bytes: ${draft.toolPolicy.maxTotalBytesRead ?? 1024 * 1024}`,
466
- `Sensitive denylist: ${(draft.toolPolicy.sensitivePathDenylist ?? []).join(",")}`,
472
+ `Custom tools${ignoredInYoloSuffix}: ${(draft.toolPolicy.customAllowedTools ?? []).join(",") || "(none)"}`,
473
+ `Max tool calls${ignoredInYoloSuffix}: ${draft.toolPolicy.maxToolCalls ?? 10}`,
474
+ `Max files read${ignoredInYoloSuffix}: ${draft.toolPolicy.maxFilesRead ?? 8}`,
475
+ `Max bytes/file${ignoredInYoloSuffix}: ${draft.toolPolicy.maxBytesPerFile ?? 200 * 1024}`,
476
+ `Max total bytes${ignoredInYoloSuffix}: ${draft.toolPolicy.maxTotalBytesRead ?? 1024 * 1024}`,
477
+ `Sensitive denylist${ignoredInYoloSuffix}: ${(draft.toolPolicy.sensitivePathDenylist ?? []).join(",")}`,
467
478
  `Cost cap/session: ${draft.maxEstimatedCostPerSession ?? "(none)"}`,
468
479
  `Save scope: ${scope}`,
469
480
  "Test Sage call"
@@ -566,12 +577,29 @@ async function runSettingsWizard(ctx: ExtensionCommandContext): Promise<void> {
566
577
  if (action.startsWith("Tool profile:")) {
567
578
  const selected = await selectScrollable(ctx, "Tool profile", [...TOOL_PROFILES]);
568
579
  if (selected && TOOL_PROFILES.includes(selected as ToolProfile)) {
580
+ if (selected === "yolo" && draft.toolPolicy.profile !== "yolo") {
581
+ const confirm = await selectScrollable(ctx, "Enable YOLO mode?", [
582
+ "No, keep current profile",
583
+ "Yes, enable yolo (unrestricted)"
584
+ ]);
585
+ if (confirm !== "Yes, enable yolo (unrestricted)") {
586
+ continue;
587
+ }
588
+ }
589
+
569
590
  draft = { ...draft, toolPolicy: { ...draft.toolPolicy, profile: selected as ToolProfile } };
570
591
  persistDraft();
592
+
593
+ if (selected === "yolo") {
594
+ ctx.ui.notify("YOLO mode enabled: tool/path/volume restrictions are bypassed for Sage.", "warning");
595
+ }
571
596
  }
572
597
  continue;
573
598
  }
574
- if (action.startsWith("Custom tools:")) {
599
+ if (action.startsWith("Custom tools")) {
600
+ if (draft.toolPolicy.profile === "yolo") {
601
+ ctx.ui.notify("Custom tools are ignored while tool profile is yolo", "warning");
602
+ }
575
603
  const value = await ctx.ui.input("Comma-separated custom tools", "ls,glob,grep,read");
576
604
  if (value !== undefined) {
577
605
  const tools = value
@@ -583,7 +611,10 @@ async function runSettingsWizard(ctx: ExtensionCommandContext): Promise<void> {
583
611
  }
584
612
  continue;
585
613
  }
586
- if (action.startsWith("Max tool calls:")) {
614
+ if (action.startsWith("Max tool calls")) {
615
+ if (draft.toolPolicy.profile === "yolo") {
616
+ ctx.ui.notify("Max tool calls is ignored while tool profile is yolo", "warning");
617
+ }
587
618
  const updated = await setToolPolicyNumberSetting(ctx, draft, "maxToolCalls", "Max tool calls", 1);
588
619
  if (updated !== draft) {
589
620
  draft = updated;
@@ -591,7 +622,10 @@ async function runSettingsWizard(ctx: ExtensionCommandContext): Promise<void> {
591
622
  }
592
623
  continue;
593
624
  }
594
- if (action.startsWith("Max files read:")) {
625
+ if (action.startsWith("Max files read")) {
626
+ if (draft.toolPolicy.profile === "yolo") {
627
+ ctx.ui.notify("Max files read is ignored while tool profile is yolo", "warning");
628
+ }
595
629
  const updated = await setToolPolicyNumberSetting(ctx, draft, "maxFilesRead", "Max files read", 1);
596
630
  if (updated !== draft) {
597
631
  draft = updated;
@@ -599,7 +633,10 @@ async function runSettingsWizard(ctx: ExtensionCommandContext): Promise<void> {
599
633
  }
600
634
  continue;
601
635
  }
602
- if (action.startsWith("Max bytes/file:")) {
636
+ if (action.startsWith("Max bytes/file")) {
637
+ if (draft.toolPolicy.profile === "yolo") {
638
+ ctx.ui.notify("Max bytes/file is ignored while tool profile is yolo", "warning");
639
+ }
603
640
  const updated = await setToolPolicyNumberSetting(ctx, draft, "maxBytesPerFile", "Max bytes per file", 1024);
604
641
  if (updated !== draft) {
605
642
  draft = updated;
@@ -607,7 +644,10 @@ async function runSettingsWizard(ctx: ExtensionCommandContext): Promise<void> {
607
644
  }
608
645
  continue;
609
646
  }
610
- if (action.startsWith("Max total bytes:")) {
647
+ if (action.startsWith("Max total bytes")) {
648
+ if (draft.toolPolicy.profile === "yolo") {
649
+ ctx.ui.notify("Max total bytes is ignored while tool profile is yolo", "warning");
650
+ }
611
651
  const updated = await setToolPolicyNumberSetting(ctx, draft, "maxTotalBytesRead", "Max total bytes", 1024);
612
652
  if (updated !== draft) {
613
653
  draft = updated;
@@ -615,7 +655,10 @@ async function runSettingsWizard(ctx: ExtensionCommandContext): Promise<void> {
615
655
  }
616
656
  continue;
617
657
  }
618
- if (action.startsWith("Sensitive denylist:")) {
658
+ if (action.startsWith("Sensitive denylist")) {
659
+ if (draft.toolPolicy.profile === "yolo") {
660
+ ctx.ui.notify("Sensitive denylist is ignored while tool profile is yolo", "warning");
661
+ }
619
662
  const value = await ctx.ui.input(
620
663
  "Comma-separated sensitive path denylist",
621
664
  (draft.toolPolicy.sensitivePathDenylist ?? []).join(",")
@@ -78,10 +78,15 @@ export async function runSageSingleShot(input: SageRunnerInput): Promise<SageRun
78
78
 
79
79
  const invocation = resolvePiInvocation();
80
80
  const prompt = buildSagePrompt(input);
81
- const promptDir = await mkdtemp(join(tmpdir(), "pi-sage-"));
81
+ const promptDir = await mkdtemp(join(resolveSageTmpBase(), "pi-sage-"));
82
82
  const promptPath = join(promptDir, "prompt.txt");
83
83
  await writeFile(promptPath, prompt, "utf8");
84
- const args = [...invocation.prefixArgs, ...buildPiArgs(input.model, input.reasoningLevel, policy.cliTools), `@${promptPath}`];
84
+ const isYoloProfile = policy.profile === "yolo";
85
+ const args = [
86
+ ...invocation.prefixArgs,
87
+ ...buildPiArgs(input.model, input.reasoningLevel, policy.cliTools, policy.allowAllCliTools),
88
+ `@${promptPath}`
89
+ ];
85
90
 
86
91
  const child = spawn(invocation.command, args, {
87
92
  cwd: input.cwd,
@@ -93,14 +98,8 @@ export async function runSageSingleShot(input: SageRunnerInput): Promise<SageRun
93
98
  stdio: ["ignore", "pipe", "pipe"]
94
99
  });
95
100
 
96
- const cleanupPromptFile = (): void => {
97
- void rm(promptDir, { recursive: true, force: true });
98
- };
99
-
100
- child.once("close", cleanupPromptFile);
101
- child.once("error", cleanupPromptFile);
102
-
103
- let stdoutBuffer = "";
101
+ try {
102
+ let stdoutBuffer = "";
104
103
  let stderrBuffer = "";
105
104
  let assistantText = "";
106
105
  let stopReason = "unknown";
@@ -135,6 +134,11 @@ export async function runSageSingleShot(input: SageRunnerInput): Promise<SageRun
135
134
  if (parsed.type === "tool_execution_start") {
136
135
  const toolName = parsed.toolName ?? "unknown";
137
136
 
137
+ if (toolName === "sage_consult") {
138
+ failPolicy("tool-disallowed", "Sage recursion is not allowed");
139
+ continue;
140
+ }
141
+
138
142
  if (!isToolAllowed(toolName, policy.allowedTools)) {
139
143
  failPolicy("tool-disallowed", `Tool not allowed by Sage policy: ${toolName}`);
140
144
  continue;
@@ -150,18 +154,20 @@ export async function runSageSingleShot(input: SageRunnerInput): Promise<SageRun
150
154
  }
151
155
 
152
156
  toolUsage.callsUsed += 1;
153
- if (toolUsage.callsUsed > policy.maxToolCalls) {
157
+ if (!isYoloProfile && toolUsage.callsUsed > policy.maxToolCalls) {
154
158
  failPolicy("volume-cap", "Exceeded max tool calls");
155
159
  continue;
156
160
  }
157
161
 
158
- const candidatePaths = extractCandidatePaths(parsed.args);
159
- for (const candidatePath of candidatePaths) {
160
- const normalizedPath = isAbsolute(candidatePath) ? candidatePath : resolve(input.cwd, candidatePath);
161
- const pathDecision = isPathAllowed(normalizedPath, [input.cwd], policy.sensitivePathDenylist);
162
- if (!pathDecision.ok) {
163
- failPolicy(pathDecision.blockCode ?? "path-denied", pathDecision.reason);
164
- break;
162
+ if (!isYoloProfile) {
163
+ const candidatePaths = extractCandidatePaths(parsed.args);
164
+ for (const candidatePath of candidatePaths) {
165
+ const normalizedPath = isAbsolute(candidatePath) ? candidatePath : resolve(input.cwd, candidatePath);
166
+ const pathDecision = isPathAllowed(normalizedPath, [input.cwd], policy.sensitivePathDenylist);
167
+ if (!pathDecision.ok) {
168
+ failPolicy(pathDecision.blockCode ?? "path-denied", pathDecision.reason);
169
+ break;
170
+ }
165
171
  }
166
172
  }
167
173
  }
@@ -170,19 +176,19 @@ export async function runSageSingleShot(input: SageRunnerInput): Promise<SageRun
170
176
  const chunkBytes = estimateContentBytes(parsed.result?.content);
171
177
  toolUsage.bytesRead += chunkBytes;
172
178
 
173
- if (chunkBytes > policy.maxBytesPerFile) {
179
+ if (!isYoloProfile && chunkBytes > policy.maxBytesPerFile) {
174
180
  failPolicy("volume-cap", "Exceeded max bytes per file");
175
181
  continue;
176
182
  }
177
183
 
178
- if (toolUsage.bytesRead > policy.maxTotalBytesRead) {
184
+ if (!isYoloProfile && toolUsage.bytesRead > policy.maxTotalBytesRead) {
179
185
  failPolicy("volume-cap", "Exceeded max total bytes read");
180
186
  continue;
181
187
  }
182
188
 
183
189
  if (parsed.toolName === "read") {
184
190
  toolUsage.filesRead += 1;
185
- if (toolUsage.filesRead > policy.maxFilesRead) {
191
+ if (!isYoloProfile && toolUsage.filesRead > policy.maxFilesRead) {
186
192
  failPolicy("volume-cap", "Exceeded max files read");
187
193
  continue;
188
194
  }
@@ -203,39 +209,74 @@ export async function runSageSingleShot(input: SageRunnerInput): Promise<SageRun
203
209
  stderrBuffer += chunk;
204
210
  });
205
211
 
206
- let timedOut = false;
207
- let escalationTimer: NodeJS.Timeout | undefined;
212
+ let timeoutSignal: (() => void) | undefined;
213
+
214
+ const timeoutTriggered = new Promise<{ kind: "timed-out" }>((resolve) => {
215
+ timeoutSignal = () => resolve({ kind: "timed-out" });
216
+ });
208
217
 
209
218
  const timeout = setTimeout(() => {
210
- timedOut = true;
211
219
  try {
212
- child.kill("SIGTERM");
220
+ child.kill();
213
221
  } catch {
214
222
  // Ignore kill errors
215
223
  }
216
224
 
217
- escalationTimer = setTimeout(() => {
218
- try {
219
- child.kill("SIGKILL");
220
- } catch {
221
- // Ignore kill errors
222
- }
223
- }, 2000);
225
+ timeoutSignal?.();
224
226
  }, input.timeoutMs);
225
227
 
226
228
  if (input.signal) {
227
- const onAbort = () => child.kill("SIGTERM");
229
+ const onAbort = () => child.kill();
228
230
  input.signal.addEventListener("abort", onAbort, { once: true });
229
231
  child.once("close", () => input.signal?.removeEventListener("abort", onAbort));
230
232
  }
231
233
 
232
- const exit = await new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve, reject) => {
234
+ const closePromise = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve, reject) => {
233
235
  child.once("error", (error) => reject(error));
234
236
  child.once("close", (code, signal) => resolve({ code, signal }));
235
237
  });
236
238
 
239
+ const closeOutcome = closePromise
240
+ .then((exit) => ({ kind: "exit" as const, exit }))
241
+ .catch((error) => ({ kind: "error" as const, error }));
242
+
243
+ const raced = await Promise.race([closeOutcome, timeoutTriggered]);
244
+
237
245
  clearTimeout(timeout);
238
- if (escalationTimer) clearTimeout(escalationTimer);
246
+
247
+ if (raced.kind === "timed-out") {
248
+ const softClosed = await waitForCloseOutcome(closeOutcome, 400);
249
+ if (!softClosed) {
250
+ await forceKillProcessTree(child.pid);
251
+ }
252
+
253
+ const hardClosed = await waitForCloseOutcome(closeOutcome, 1500);
254
+ if (!hardClosed) {
255
+ try {
256
+ child.stdout.destroy();
257
+ } catch {
258
+ // Ignore stream destroy errors
259
+ }
260
+ try {
261
+ child.stderr.destroy();
262
+ } catch {
263
+ // Ignore stream destroy errors
264
+ }
265
+ try {
266
+ child.unref();
267
+ } catch {
268
+ // Ignore unref errors
269
+ }
270
+ }
271
+
272
+ throw new Error("Sage subprocess timed out");
273
+ }
274
+
275
+ if (raced.kind === "error") {
276
+ throw raced.error;
277
+ }
278
+
279
+ const exit = raced.exit;
239
280
 
240
281
  if (policyViolation) {
241
282
  throw policyViolation;
@@ -254,7 +295,7 @@ export async function runSageSingleShot(input: SageRunnerInput): Promise<SageRun
254
295
 
255
296
  const latencyMs = Date.now() - startedAt;
256
297
 
257
- if (timedOut || (exit.signal === "SIGTERM" && latencyMs >= input.timeoutMs)) {
298
+ if (exit.signal === "SIGTERM" && latencyMs >= input.timeoutMs) {
258
299
  throw new Error("Sage subprocess timed out");
259
300
  }
260
301
 
@@ -266,13 +307,72 @@ export async function runSageSingleShot(input: SageRunnerInput): Promise<SageRun
266
307
  throw new Error(`Sage subprocess returned no assistant text (code ${String(exit.code)}): ${stderrBuffer.trim() || "no stderr"}`);
267
308
  }
268
309
 
269
- return {
270
- text: assistantText,
271
- latencyMs,
272
- stopReason,
273
- usage,
274
- toolUsage
275
- };
310
+ return {
311
+ text: assistantText,
312
+ latencyMs,
313
+ stopReason,
314
+ usage,
315
+ toolUsage
316
+ };
317
+ } finally {
318
+ await rm(promptDir, { recursive: true, force: true });
319
+ }
320
+ }
321
+
322
+ async function forceKillProcessTree(pid: number | undefined): Promise<void> {
323
+ if (typeof pid !== "number" || Number.isFinite(pid) === false) return;
324
+
325
+ if (process.platform === "win32") {
326
+ await new Promise<void>((resolve) => {
327
+ let settled = false;
328
+ let timer: NodeJS.Timeout | undefined;
329
+
330
+ const done = () => {
331
+ if (settled) return;
332
+ settled = true;
333
+ if (timer) clearTimeout(timer);
334
+ resolve();
335
+ };
336
+
337
+ try {
338
+ const killer = spawn("taskkill", ["/pid", String(pid), "/T", "/F"], {
339
+ stdio: "ignore",
340
+ windowsHide: true
341
+ });
342
+
343
+ killer.once("close", done);
344
+ killer.once("error", done);
345
+ timer = setTimeout(done, 1000);
346
+ } catch {
347
+ done();
348
+ }
349
+ });
350
+ return;
351
+ }
352
+
353
+ try {
354
+ process.kill(pid, "SIGKILL");
355
+ } catch {
356
+ // Ignore process kill errors
357
+ }
358
+ }
359
+
360
+ async function waitForCloseOutcome(
361
+ closeOutcome: Promise<{ kind: "exit"; exit: { code: number | null; signal: NodeJS.Signals | null } } | { kind: "error"; error: unknown }>,
362
+ timeoutMs: number
363
+ ): Promise<boolean> {
364
+ const result = await Promise.race<boolean>([
365
+ closeOutcome.then(() => true),
366
+ new Promise<boolean>((resolve) => setTimeout(() => resolve(false), timeoutMs))
367
+ ]);
368
+
369
+ return result;
370
+ }
371
+
372
+ function resolveSageTmpBase(): string {
373
+ const envBase = process.env.PI_SAGE_TMP_DIR?.trim();
374
+ if (envBase) return envBase;
375
+ return tmpdir();
276
376
  }
277
377
 
278
378
  function resolvePiInvocation(): { command: string; prefixArgs: string[]; shell: boolean } {
@@ -313,7 +413,12 @@ function tokenizeCommand(command: string): string[] {
313
413
  return tokens;
314
414
  }
315
415
 
316
- function buildPiArgs(model: string, reasoningLevel: ReasoningLevel, cliTools: string[]): string[] {
416
+ function buildPiArgs(
417
+ model: string,
418
+ reasoningLevel: ReasoningLevel,
419
+ cliTools: string[],
420
+ allowAllCliTools = false
421
+ ): string[] {
317
422
  const args = [
318
423
  "--mode",
319
424
  "json",
@@ -329,6 +434,10 @@ function buildPiArgs(model: string, reasoningLevel: ReasoningLevel, cliTools: st
329
434
  reasoningLevel
330
435
  ];
331
436
 
437
+ if (allowAllCliTools) {
438
+ return args;
439
+ }
440
+
332
441
  if (cliTools.length === 0) {
333
442
  args.push("--no-tools");
334
443
  } else {
@@ -346,18 +455,27 @@ function buildSagePrompt(input: SageRunnerInput): string {
346
455
 
347
456
  lines.push("");
348
457
  lines.push("Operational constraints:");
349
- lines.push("- Do not run node/npm/cd/tests.");
350
458
 
351
- if (input.toolPolicy.profile === "git-review-readonly") {
459
+ if (input.toolPolicy.profile === "yolo") {
460
+ lines.push("- Tool profile is yolo: unrestricted tools/commands are available for this consultation.");
461
+ lines.push("- Node/npm/cd/test commands are permitted in yolo when needed.");
462
+ lines.push("- Prefer non-destructive actions and prioritize completing a useful review.");
463
+ } else {
464
+ lines.push("- Do not run node/npm/cd/tests.");
465
+
466
+ if (input.toolPolicy.profile === "git-review-readonly") {
467
+ lines.push(
468
+ "- If using bash, only use allowed git read commands (status|diff|show|log|blame|rev-parse|branch --show-current) and allowed read-only pipes (head/tail/grep/cut/sed/wc/sort/uniq)."
469
+ );
470
+ } else if (input.toolPolicy.profile === "read-only-lite") {
471
+ lines.push("- Bash is unavailable in this profile. Use ls/glob/grep/read only.");
472
+ }
473
+
352
474
  lines.push(
353
- "- If using bash, only use allowed git read commands (status|diff|show|log|blame|rev-parse|branch --show-current) and allowed read-only pipes (head/tail/grep/cut/sed/wc/sort/uniq)."
475
+ "- If a command is blocked or unavailable, continue with ls/glob/grep/read and still deliver best-effort findings."
354
476
  );
355
- } else if (input.toolPolicy.profile === "read-only-lite") {
356
- lines.push("- Bash is unavailable in this profile. Use ls/glob/grep/read only.");
357
477
  }
358
478
 
359
- lines.push("- If a command is blocked or unavailable, continue with ls/glob/grep/read and still deliver best-effort findings.");
360
-
361
479
  lines.push("");
362
480
  lines.push(`Question: ${input.question}`);
363
481
 
@@ -478,6 +596,7 @@ function extractBashCommand(args: unknown): string | undefined {
478
596
 
479
597
  function isToolAllowed(toolName: string, allowedTools: string[]): boolean {
480
598
  const allowed = new Set(allowedTools);
599
+ if (allowed.has("*")) return true;
481
600
  if (allowed.has(toolName)) return true;
482
601
 
483
602
  if (toolName === "find" && allowed.has("glob")) {
@@ -132,7 +132,8 @@ function parseSettingsRaw(content: string): Partial<SageSettings> | undefined {
132
132
  toolPolicyRaw.profile === "none" ||
133
133
  toolPolicyRaw.profile === "read-only-lite" ||
134
134
  toolPolicyRaw.profile === "custom-read-only" ||
135
- toolPolicyRaw.profile === "git-review-readonly"
135
+ toolPolicyRaw.profile === "git-review-readonly" ||
136
+ toolPolicyRaw.profile === "yolo"
136
137
  ? toolPolicyRaw.profile
137
138
  : undefined;
138
139
 
@@ -3,6 +3,7 @@ import type { ToolPolicySettings } from "./settings.js";
3
3
 
4
4
  export const READ_ONLY_LITE_TOOLS = ["ls", "glob", "grep", "read"] as const;
5
5
  export const GIT_REVIEW_READONLY_TOOLS = ["ls", "glob", "grep", "read", "bash"] as const;
6
+ export const YOLO_TOOLS = ["*"] as const;
6
7
 
7
8
  const CLI_TOOL_NAME_MAP: Record<string, string> = {
8
9
  glob: "find"
@@ -21,6 +22,7 @@ export interface ResolvedToolPolicy {
21
22
  profile: ToolPolicySettings["profile"];
22
23
  allowedTools: string[];
23
24
  cliTools: string[];
25
+ allowAllCliTools: boolean;
24
26
  maxToolCalls: number;
25
27
  maxFilesRead: number;
26
28
  maxBytesPerFile: number;
@@ -41,6 +43,7 @@ export function resolveToolPolicy(settings: ToolPolicySettings): ResolvedToolPol
41
43
  profile: "none",
42
44
  allowedTools: [],
43
45
  cliTools: [],
46
+ allowAllCliTools: false,
44
47
  maxToolCalls: settings.maxToolCalls ?? 10,
45
48
  maxFilesRead: settings.maxFilesRead ?? 8,
46
49
  maxBytesPerFile: settings.maxBytesPerFile ?? 200 * 1024,
@@ -55,6 +58,7 @@ export function resolveToolPolicy(settings: ToolPolicySettings): ResolvedToolPol
55
58
  profile: "read-only-lite",
56
59
  allowedTools,
57
60
  cliTools: toCliTools(allowedTools),
61
+ allowAllCliTools: false,
58
62
  maxToolCalls: settings.maxToolCalls ?? 10,
59
63
  maxFilesRead: settings.maxFilesRead ?? 8,
60
64
  maxBytesPerFile: settings.maxBytesPerFile ?? 200 * 1024,
@@ -69,6 +73,7 @@ export function resolveToolPolicy(settings: ToolPolicySettings): ResolvedToolPol
69
73
  profile: "git-review-readonly",
70
74
  allowedTools,
71
75
  cliTools: toCliTools(allowedTools),
76
+ allowAllCliTools: false,
72
77
  maxToolCalls: settings.maxToolCalls ?? 20,
73
78
  maxFilesRead: settings.maxFilesRead ?? 20,
74
79
  maxBytesPerFile: settings.maxBytesPerFile ?? 300 * 1024,
@@ -77,6 +82,20 @@ export function resolveToolPolicy(settings: ToolPolicySettings): ResolvedToolPol
77
82
  };
78
83
  }
79
84
 
85
+ if (requestedProfile === "yolo") {
86
+ return {
87
+ profile: "yolo",
88
+ allowedTools: [...YOLO_TOOLS],
89
+ cliTools: [],
90
+ allowAllCliTools: true,
91
+ maxToolCalls: Number.MAX_SAFE_INTEGER,
92
+ maxFilesRead: Number.MAX_SAFE_INTEGER,
93
+ maxBytesPerFile: Number.MAX_SAFE_INTEGER,
94
+ maxTotalBytesRead: Number.MAX_SAFE_INTEGER,
95
+ sensitivePathDenylist: []
96
+ };
97
+ }
98
+
80
99
  const requested = settings.customAllowedTools ?? [];
81
100
  const filtered = requested
82
101
  .map((tool) => tool.trim())
@@ -90,6 +109,7 @@ export function resolveToolPolicy(settings: ToolPolicySettings): ResolvedToolPol
90
109
  profile: "custom-read-only",
91
110
  allowedTools: deduped,
92
111
  cliTools: toCliTools(deduped),
112
+ allowAllCliTools: false,
93
113
  maxToolCalls: settings.maxToolCalls ?? 10,
94
114
  maxFilesRead: settings.maxFilesRead ?? 8,
95
115
  maxBytesPerFile: settings.maxBytesPerFile ?? 200 * 1024,
@@ -144,8 +164,16 @@ export function validateBashCommandForProfile(
144
164
  profile: ToolProfile,
145
165
  command: string
146
166
  ): { ok: boolean; blockCode?: BlockCode; reason: string } {
167
+ if (profile === "yolo") {
168
+ return { ok: true, reason: "allowed (yolo)" };
169
+ }
170
+
147
171
  if (profile !== "git-review-readonly") {
148
- return { ok: false, blockCode: "tool-disallowed", reason: "bash is only allowed in git-review-readonly profile" };
172
+ return {
173
+ ok: false,
174
+ blockCode: "tool-disallowed",
175
+ reason: "bash is only allowed in git-review-readonly or yolo profiles"
176
+ };
149
177
  }
150
178
 
151
179
  const trimmed = command.trim();
@@ -39,7 +39,7 @@ export interface CallerDecision {
39
39
  blockCode?: BlockCode;
40
40
  }
41
41
 
42
- export type ToolProfile = "none" | "read-only-lite" | "custom-read-only" | "git-review-readonly";
42
+ export type ToolProfile = "none" | "read-only-lite" | "custom-read-only" | "git-review-readonly" | "yolo";
43
43
 
44
44
  export interface ToolPolicyCaps {
45
45
  maxToolCalls: number;
package/README.md CHANGED
@@ -48,6 +48,7 @@ Then in Pi run:
48
48
  - `git-review-readonly` (default): adds restricted `bash` for allowlisted **read-only git** commands
49
49
  - `none`
50
50
  - `custom-read-only`
51
+ - `yolo`: unrestricted available tools/commands (including node/npm/cd/tests). High risk, explicit opt-in only.
51
52
 
52
53
  ## Settings files (global + project override)
53
54
 
package/docs/SAGE_SPEC.md CHANGED
@@ -28,7 +28,7 @@ This spec targets a **final architecture** (not MVP): Sage is implemented as a *
28
28
  1. Multi-Sage swarms or planner/reviewer pipelines.
29
29
  2. Full UI dashboard beyond command-based configuration and standard tool rendering.
30
30
  3. Automatic persistent learning from previous Sage calls.
31
- 4. Having Sage directly modify files, run shell commands, or perform orchestration actions.
31
+ 4. Having Sage directly modify files or perform orchestration actions. Shell/tool usage is profile-controlled (default restricted; optional `yolo` can relax constraints).
32
32
 
33
33
  ---
34
34
 
@@ -73,7 +73,7 @@ This spec targets a **final architecture** (not MVP): Sage is implemented as a *
73
73
  - Provides dedicated Sage system prompt.
74
74
  - Enforces **single-shot execution** per call (one request → one response).
75
75
  - Enforces advisory tool policy (default `git-review-readonly`: `ls,glob,grep,read,bash` with strict allowlisted read-only git commands).
76
- - Supports stricter `none` mode, optional `git-review-readonly` mode, and constrained custom read-only lists.
76
+ - Supports stricter `none` mode, optional `git-review-readonly` mode, constrained custom read-only lists, and explicit opt-in `yolo` mode.
77
77
  - Explicitly disables `sage_consult` inside Sage subprocess context to prevent subagent recursion.
78
78
 
79
79
  3. **Sage Settings Store**
@@ -133,7 +133,7 @@ Tool `details` returns structured metadata:
133
133
  costTotal?: number;
134
134
  };
135
135
  toolUsage?: {
136
- profile: "none"|"read-only-lite"|"git-review-readonly"|"custom-read-only";
136
+ profile: "none"|"read-only-lite"|"git-review-readonly"|"custom-read-only"|"yolo";
137
137
  callsUsed: number;
138
138
  filesRead: number;
139
139
  bytesRead: number;
@@ -297,6 +297,7 @@ Interactive command to configure Sage runtime behavior.
297
297
  - read-only-lite (`ls,glob,grep,read`)
298
298
  - git-review-readonly (`ls,glob,grep,read,bash`) with strict allowlisted git read commands only **default**
299
299
  - custom read-only list (must exclude mutating/execution tools)
300
+ - yolo (unrestricted available tools/commands; explicit opt-in, high risk)
300
301
  10. **Per-call max tool calls** (default: 250)
301
302
  11. **Per-call max files read** (default: 100)
302
303
  12. **Per-file max bytes** (default: 200KB)
@@ -323,13 +324,14 @@ Interactive command to configure Sage runtime behavior.
323
324
  1. Default Sage tool profile is `git-review-readonly`: `ls,glob,grep,read,bash` with strict read-only git command allowlist.
324
325
  2. Allow `none` profile for stricter environments.
325
326
  3. Custom profile must be read-only; mutating/execution tools are disallowed.
326
- 4. Explicitly disallow: `edit`, `write`, git-mutating tools, orchestration-control tools, and network/http tools.
327
+ 4. Explicitly disallow: `edit`, `write`, git-mutating tools, orchestration-control tools, and network/http tools in non-`yolo` profiles.
327
328
  5. Exception: `git-review-readonly` profile may allow `bash` only for allowlisted read-only git commands (e.g., status/diff/show/log/blame/rev-parse/branch --show-current).
329
+ 6. Optional `yolo` profile is explicit opt-in and lifts tool/bash/path/volume guardrails for maximum flexibility (including node/npm/cd/test command usage).
328
330
 
329
331
  ### 11.2 Data-access and volume guardrails
330
- 1. Restrict reads to workspace/project roots.
331
- 2. Denylist sensitive paths by default (e.g., `.env*`, `*.pem`, `*.key`, `id_rsa*`, credential stores).
332
- 3. Enforce caps per Sage call:
332
+ 1. Restrict reads to workspace/project roots (except `yolo`).
333
+ 2. Denylist sensitive paths by default (e.g., `.env*`, `*.pem`, `*.key`, `id_rsa*`, credential stores) except `yolo`.
334
+ 3. Enforce caps per Sage call (except `yolo`):
333
335
  - max tool calls: `250`
334
336
  - max files read: `100`
335
337
  - max bytes per file: `200KB`
@@ -343,7 +345,7 @@ Interactive command to configure Sage runtime behavior.
343
345
  Each Sage call should expose:
344
346
  - mode (autonomous vs user-requested),
345
347
  - model + reasoning level,
346
- - tool profile used (`none`, `read-only-lite`, `git-review-readonly`, or `custom-read-only`),
348
+ - tool profile used (`none`, `read-only-lite`, `git-review-readonly`, `custom-read-only`, or `yolo`),
347
349
  - elapsed time,
348
350
  - token usage + cost (if available),
349
351
  - refusal/error reason when blocked.
@@ -380,9 +382,9 @@ On tool-policy/data-policy block:
380
382
  5. Explicit user-requested Sage consultation can bypass soft limits, but still respects hard safety limits and caller-scope restrictions.
381
383
  6. No `/sage` command exists.
382
384
  7. `/sage-settings` supports model configuration interactively (including `inherit`) and persists selected value.
383
- 8. Default Sage tool profile is `git-review-readonly` (`ls,glob,grep,read,bash` with strict allowlisted read-only git commands), with `none`, `read-only-lite`, and constrained custom read-only options available.
384
- 9. Sage tool profile blocks mutating/execution/network/orchestration tools (including `edit`, `write`, and `sage_consult`), except allowlisted git-read bash commands in `git-review-readonly`.
385
- 10. Sage enforces per-call guardrails (max tool calls/files/bytes and capped `grep`/`glob` output).
385
+ 8. Default Sage tool profile is `git-review-readonly` (`ls,glob,grep,read,bash` with strict allowlisted read-only git commands), with `none`, `read-only-lite`, constrained custom read-only, and opt-in `yolo` options available.
386
+ 9. Non-`yolo` Sage tool profiles block mutating/execution/network/orchestration tools (including `edit`, `write`, and `sage_consult`), except allowlisted git-read bash commands in `git-review-readonly`.
387
+ 10. Non-`yolo` Sage calls enforce per-call guardrails (max tool calls/files/bytes and capped `grep`/`glob` output).
386
388
  11. Sage calls obey hard safety limits (caller context, model/auth availability, timeout, optional absolute cost cap).
387
389
  12. Sage subprocess cannot invoke `sage_consult` (no recursion).
388
390
  13. Sage consultations are single-shot per call.
@@ -399,7 +401,7 @@ On tool-policy/data-policy block:
399
401
  5. Start with model interpretation for explicit-request detection; add phrase-detection hook only if testing reveals reliability issues.
400
402
  6. Sage subagents cannot invoke other Sage subagents (no recursion).
401
403
  7. Sage is advisory-only and should not perform implementation actions.
402
- 8. Default Sage tool profile is `git-review-readonly` (`ls,glob,grep,read,bash`) with strict guardrails; `bash` is restricted to allowlisted read-only git commands.
404
+ 8. Default Sage tool profile is `git-review-readonly` (`ls,glob,grep,read,bash`) with strict guardrails; `bash` is restricted to allowlisted read-only git commands. `yolo` is explicit opt-in and intentionally relaxes these safeguards.
403
405
 
404
406
  ---
405
407
 
@@ -408,7 +410,7 @@ On tool-policy/data-policy block:
408
410
  1. Build settings store + `/sage-settings` command.
409
411
  2. Implement `sage_consult` subprocess runner + structured result metadata.
410
412
  3. Add hard caller-context gate (interactive top-level primary only; block CI/RPC-orchestrated roles).
411
- 4. Implement advisory tool policy (`read-only-lite`, `none`, `git-review-readonly`, constrained custom) and hard denylist for mutating/execution tools.
413
+ 4. Implement advisory tool policy (`read-only-lite`, `none`, `git-review-readonly`, constrained custom) and optional opt-in `yolo` mode for unrestricted tooling.
412
414
  5. Add data-volume guardrails (tool-call/file/byte caps + `grep`/`glob` result caps + sensitive-path denylist).
413
415
  6. Add system-prompt guidance injection for autonomous invocation.
414
416
  7. Add budget gates and policy telemetry.
@@ -420,7 +422,7 @@ On tool-policy/data-policy block:
420
422
  ## 17) Concrete Implementation Checklist (`.pi/extensions/sage/index.ts`)
421
423
 
422
424
  ### 17.1 Types and constants
423
- - [ ] Add `ToolProfile = "none" | "read-only-lite" | "git-review-readonly" | "custom-read-only"`.
425
+ - [ ] Add `ToolProfile = "none" | "read-only-lite" | "git-review-readonly" | "custom-read-only" | "yolo"`.
424
426
  - [ ] Add `BlockCode = "ineligible-caller" | "non-interactive" | "ci-mode" | "rpc-role" | "subagent" | "unknown-context" | "tool-disallowed" | "path-denied" | "volume-cap"`.
425
427
  - [ ] Add `CallerContext` type:
426
428
  - `session.interactive: boolean`
@@ -466,7 +468,7 @@ On tool-policy/data-policy block:
466
468
  - [ ] Ensure custom profile cannot include tools outside read-only allowlist.
467
469
 
468
470
  ### 17.5 `/sage-settings` command wiring
469
- - [ ] Add profile picker: `none`, `read-only-lite`, `git-review-readonly`, `custom-read-only`.
471
+ - [ ] Add profile picker: `none`, `read-only-lite`, `git-review-readonly`, `custom-read-only`, `yolo`.
470
472
  - [ ] For `custom-read-only`, show multi-select constrained to read-only tools.
471
473
  - [ ] Add numeric inputs for guardrails (tool calls/files/bytes).
472
474
  - [ ] Add editable sensitive-path denylist (with safe defaults preloaded).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-sage",
3
- "version": "0.2.15",
3
+ "version": "0.2.16",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Interactive-only advisory Sage extension for Pi",