pi-sage 0.2.14 → 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.
- package/.pi/extensions/sage/caller-context.ts +50 -0
- package/.pi/extensions/sage/index.ts +102 -54
- package/.pi/extensions/sage/runner.ts +170 -51
- package/.pi/extensions/sage/settings.ts +2 -1
- package/.pi/extensions/sage/tool-policy.ts +29 -1
- package/.pi/extensions/sage/types.ts +1 -1
- package/README.md +1 -0
- package/docs/SAGE_SPEC.md +17 -15
- package/package.json +1 -1
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { CallerContext } from "./types.js";
|
|
2
|
+
|
|
3
|
+
export type InputSource = "interactive" | "rpc" | "extension";
|
|
4
|
+
export type InteractiveOrRpcSource = "interactive" | "rpc";
|
|
5
|
+
|
|
6
|
+
export function isKnownInputSource(value: string): value is InputSource {
|
|
7
|
+
return value === "interactive" || value === "rpc" || value === "extension";
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function updateLastInteractiveOrRpcSource(
|
|
11
|
+
previous: InteractiveOrRpcSource | undefined,
|
|
12
|
+
source: string
|
|
13
|
+
): InteractiveOrRpcSource | undefined {
|
|
14
|
+
if (source === "interactive" || source === "rpc") {
|
|
15
|
+
return source;
|
|
16
|
+
}
|
|
17
|
+
return previous;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function buildCallerContextFromSignals(input: {
|
|
21
|
+
lastInteractiveOrRpcSource: InteractiveOrRpcSource | undefined;
|
|
22
|
+
unknownSourceSeen: boolean;
|
|
23
|
+
hasUI: boolean;
|
|
24
|
+
roleHint?: string;
|
|
25
|
+
isCI?: boolean;
|
|
26
|
+
isSubagent?: boolean;
|
|
27
|
+
}): CallerContext | null {
|
|
28
|
+
const { lastInteractiveOrRpcSource, unknownSourceSeen, hasUI, roleHint, isCI, isSubagent } = input;
|
|
29
|
+
if (unknownSourceSeen) return null;
|
|
30
|
+
if (!lastInteractiveOrRpcSource) return null;
|
|
31
|
+
|
|
32
|
+
const normalizedRole = roleHint?.trim().toLowerCase();
|
|
33
|
+
const isRpcSource = lastInteractiveOrRpcSource === "rpc";
|
|
34
|
+
const interactive = hasUI && lastInteractiveOrRpcSource === "interactive";
|
|
35
|
+
const isInteractiveSupervisor = interactive && normalizedRole === "supervisor";
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
session: {
|
|
39
|
+
interactive
|
|
40
|
+
},
|
|
41
|
+
agent: {
|
|
42
|
+
role: normalizedRole ?? "primary",
|
|
43
|
+
isSubagent: Boolean(isSubagent),
|
|
44
|
+
isRpcOrchestrated: isRpcSource || Boolean(normalizedRole && normalizedRole !== "primary" && !isInteractiveSupervisor)
|
|
45
|
+
},
|
|
46
|
+
runtime: {
|
|
47
|
+
mode: isCI ? "ci" : hasUI ? (isRpcSource ? "rpc" : "interactive") : "non-interactive"
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
}
|
|
@@ -7,6 +7,12 @@ import {
|
|
|
7
7
|
isHardCostCapExceeded,
|
|
8
8
|
makeBlockedResult
|
|
9
9
|
} from "./policy.js";
|
|
10
|
+
import {
|
|
11
|
+
buildCallerContextFromSignals,
|
|
12
|
+
isKnownInputSource,
|
|
13
|
+
updateLastInteractiveOrRpcSource,
|
|
14
|
+
type InteractiveOrRpcSource
|
|
15
|
+
} from "./caller-context.js";
|
|
10
16
|
import {
|
|
11
17
|
isSageRunnerPolicyError,
|
|
12
18
|
runSageSingleShot,
|
|
@@ -34,8 +40,6 @@ import type {
|
|
|
34
40
|
ToolProfile
|
|
35
41
|
} from "./types.js";
|
|
36
42
|
|
|
37
|
-
type InputSource = "interactive" | "rpc" | "extension";
|
|
38
|
-
|
|
39
43
|
type ModelLike = {
|
|
40
44
|
provider: string;
|
|
41
45
|
id: string;
|
|
@@ -45,7 +49,7 @@ type ModelLike = {
|
|
|
45
49
|
const ROLE_HINT_ENV_KEYS = ["TASKPLANE_ROLE", "TASKPLANE_AGENT_ROLE", "PI_AGENT_ROLE", "ORCH_AGENT_ROLE"];
|
|
46
50
|
|
|
47
51
|
const REASONING_LEVELS: ReasoningLevel[] = ["minimal", "low", "medium", "high", "xhigh"];
|
|
48
|
-
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"];
|
|
49
53
|
const MODEL_INHERIT_VALUE = "inherit";
|
|
50
54
|
|
|
51
55
|
const SAGE_GUIDANCE = [
|
|
@@ -57,7 +61,8 @@ const SAGE_GUIDANCE = [
|
|
|
57
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.",
|
|
58
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).",
|
|
59
63
|
"Read-only pipelines are allowed only with: head, tail, grep, cut, sed, wc, sort, uniq.",
|
|
60
|
-
"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.",
|
|
61
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."
|
|
62
67
|
].join("\n- ");
|
|
63
68
|
|
|
@@ -74,12 +79,22 @@ export default function registerSageExtension(pi: ExtensionAPI): void {
|
|
|
74
79
|
sessionCostTotal: 0
|
|
75
80
|
};
|
|
76
81
|
|
|
77
|
-
let
|
|
82
|
+
let lastInteractiveOrRpcSource: InteractiveOrRpcSource | undefined;
|
|
83
|
+
let unknownInputSourceSeen = false;
|
|
78
84
|
|
|
79
85
|
pi.on("input", (event) => {
|
|
80
|
-
|
|
81
|
-
|
|
86
|
+
const source = String(event.source ?? "");
|
|
87
|
+
|
|
88
|
+
if (!isKnownInputSource(source)) {
|
|
89
|
+
unknownInputSourceSeen = true;
|
|
90
|
+
return { action: "continue" };
|
|
82
91
|
}
|
|
92
|
+
|
|
93
|
+
lastInteractiveOrRpcSource = updateLastInteractiveOrRpcSource(lastInteractiveOrRpcSource, source);
|
|
94
|
+
if (source === "interactive" || source === "rpc") {
|
|
95
|
+
unknownInputSourceSeen = false;
|
|
96
|
+
}
|
|
97
|
+
|
|
83
98
|
return { action: "continue" };
|
|
84
99
|
});
|
|
85
100
|
|
|
@@ -115,7 +130,8 @@ export default function registerSageExtension(pi: ExtensionAPI): void {
|
|
|
115
130
|
"`objective` is optional: debug|design|review|refactor|general. Omit instead of free text.",
|
|
116
131
|
"`urgency` is optional: low|medium|high.",
|
|
117
132
|
"For git-review-readonly, prefer plain git read commands and allowed read-only pipelines (head/tail/grep/cut/sed/wc/sort/uniq).",
|
|
118
|
-
"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.",
|
|
119
135
|
"If bash is blocked, continue with ls/glob/grep/read and still deliver best-effort findings.",
|
|
120
136
|
"After Sage returns, synthesize recommendations and continue execution."
|
|
121
137
|
],
|
|
@@ -166,7 +182,7 @@ export default function registerSageExtension(pi: ExtensionAPI): void {
|
|
|
166
182
|
});
|
|
167
183
|
}
|
|
168
184
|
|
|
169
|
-
const callerContext = buildCallerContext(
|
|
185
|
+
const callerContext = buildCallerContext(lastInteractiveOrRpcSource, unknownInputSourceSeen, ctx.hasUI);
|
|
170
186
|
const eligibility = isEligibleCaller(callerContext);
|
|
171
187
|
if (!eligibility.ok) {
|
|
172
188
|
return makeBlockedResult({
|
|
@@ -225,17 +241,19 @@ export default function registerSageExtension(pi: ExtensionAPI): void {
|
|
|
225
241
|
});
|
|
226
242
|
}
|
|
227
243
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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
|
+
}
|
|
239
257
|
}
|
|
240
258
|
|
|
241
259
|
try {
|
|
@@ -347,6 +365,10 @@ export default function registerSageExtension(pi: ExtensionAPI): void {
|
|
|
347
365
|
});
|
|
348
366
|
}
|
|
349
367
|
|
|
368
|
+
export function shouldValidateCustomAllowedTools(profile: ToolProfile): boolean {
|
|
369
|
+
return profile === "custom-read-only";
|
|
370
|
+
}
|
|
371
|
+
|
|
350
372
|
function formatRunnerPolicyReason(blockCode: BlockCode, reason: string): string {
|
|
351
373
|
if (blockCode === "tool-disallowed") {
|
|
352
374
|
return `${reason}. Use allowed git read commands and read-only pipelines, or continue with ls/glob/grep/read for a best-effort review.`;
|
|
@@ -389,28 +411,19 @@ async function runSageWithFallback(
|
|
|
389
411
|
}
|
|
390
412
|
}
|
|
391
413
|
|
|
392
|
-
function buildCallerContext(
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
agent: {
|
|
406
|
-
role: roleHint ?? "primary",
|
|
407
|
-
isSubagent,
|
|
408
|
-
isRpcOrchestrated: isRpcSource || Boolean(roleHint && roleHint !== "primary" && !isInteractiveSupervisor)
|
|
409
|
-
},
|
|
410
|
-
runtime: {
|
|
411
|
-
mode: process.env.CI ? "ci" : hasUI ? (isRpcSource ? "rpc" : "interactive") : "non-interactive"
|
|
412
|
-
}
|
|
413
|
-
};
|
|
414
|
+
function buildCallerContext(
|
|
415
|
+
lastInteractiveOrRpcSource: InteractiveOrRpcSource | undefined,
|
|
416
|
+
unknownInputSourceSeen: boolean,
|
|
417
|
+
hasUI: boolean
|
|
418
|
+
): CallerContext | null {
|
|
419
|
+
return buildCallerContextFromSignals({
|
|
420
|
+
lastInteractiveOrRpcSource,
|
|
421
|
+
unknownSourceSeen: unknownInputSourceSeen,
|
|
422
|
+
hasUI,
|
|
423
|
+
roleHint: getRoleHint(),
|
|
424
|
+
isCI: Boolean(process.env.CI),
|
|
425
|
+
isSubagent: process.env.PI_SAGE_SUBAGENT === "1"
|
|
426
|
+
});
|
|
414
427
|
}
|
|
415
428
|
|
|
416
429
|
function getRoleHint(): string | undefined {
|
|
@@ -440,6 +453,9 @@ async function runSettingsWizard(ctx: ExtensionCommandContext): Promise<void> {
|
|
|
440
453
|
let rootSelectionIndex = 0;
|
|
441
454
|
|
|
442
455
|
while (true) {
|
|
456
|
+
const yoloActive = draft.toolPolicy.profile === "yolo";
|
|
457
|
+
const ignoredInYoloSuffix = yoloActive ? " (ignored in yolo)" : "";
|
|
458
|
+
|
|
443
459
|
const rootOptions = [
|
|
444
460
|
`Enabled: ${onOff(draft.enabled)}`,
|
|
445
461
|
`Autonomous mode: ${onOff(draft.autonomousEnabled)}`,
|
|
@@ -453,12 +469,12 @@ async function runSettingsWizard(ctx: ExtensionCommandContext): Promise<void> {
|
|
|
453
469
|
`Max question chars: ${draft.maxQuestionChars}`,
|
|
454
470
|
`Max context chars: ${draft.maxContextChars}`,
|
|
455
471
|
`Tool profile: ${draft.toolPolicy.profile}`,
|
|
456
|
-
`Custom tools: ${(draft.toolPolicy.customAllowedTools ?? []).join(",") || "(none)"}`,
|
|
457
|
-
`Max tool calls: ${draft.toolPolicy.maxToolCalls ?? 10}`,
|
|
458
|
-
`Max files read: ${draft.toolPolicy.maxFilesRead ?? 8}`,
|
|
459
|
-
`Max bytes/file: ${draft.toolPolicy.maxBytesPerFile ?? 200 * 1024}`,
|
|
460
|
-
`Max total bytes: ${draft.toolPolicy.maxTotalBytesRead ?? 1024 * 1024}`,
|
|
461
|
-
`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(",")}`,
|
|
462
478
|
`Cost cap/session: ${draft.maxEstimatedCostPerSession ?? "(none)"}`,
|
|
463
479
|
`Save scope: ${scope}`,
|
|
464
480
|
"Test Sage call"
|
|
@@ -561,12 +577,29 @@ async function runSettingsWizard(ctx: ExtensionCommandContext): Promise<void> {
|
|
|
561
577
|
if (action.startsWith("Tool profile:")) {
|
|
562
578
|
const selected = await selectScrollable(ctx, "Tool profile", [...TOOL_PROFILES]);
|
|
563
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
|
+
|
|
564
590
|
draft = { ...draft, toolPolicy: { ...draft.toolPolicy, profile: selected as ToolProfile } };
|
|
565
591
|
persistDraft();
|
|
592
|
+
|
|
593
|
+
if (selected === "yolo") {
|
|
594
|
+
ctx.ui.notify("YOLO mode enabled: tool/path/volume restrictions are bypassed for Sage.", "warning");
|
|
595
|
+
}
|
|
566
596
|
}
|
|
567
597
|
continue;
|
|
568
598
|
}
|
|
569
|
-
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
|
+
}
|
|
570
603
|
const value = await ctx.ui.input("Comma-separated custom tools", "ls,glob,grep,read");
|
|
571
604
|
if (value !== undefined) {
|
|
572
605
|
const tools = value
|
|
@@ -578,7 +611,10 @@ async function runSettingsWizard(ctx: ExtensionCommandContext): Promise<void> {
|
|
|
578
611
|
}
|
|
579
612
|
continue;
|
|
580
613
|
}
|
|
581
|
-
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
|
+
}
|
|
582
618
|
const updated = await setToolPolicyNumberSetting(ctx, draft, "maxToolCalls", "Max tool calls", 1);
|
|
583
619
|
if (updated !== draft) {
|
|
584
620
|
draft = updated;
|
|
@@ -586,7 +622,10 @@ async function runSettingsWizard(ctx: ExtensionCommandContext): Promise<void> {
|
|
|
586
622
|
}
|
|
587
623
|
continue;
|
|
588
624
|
}
|
|
589
|
-
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
|
+
}
|
|
590
629
|
const updated = await setToolPolicyNumberSetting(ctx, draft, "maxFilesRead", "Max files read", 1);
|
|
591
630
|
if (updated !== draft) {
|
|
592
631
|
draft = updated;
|
|
@@ -594,7 +633,10 @@ async function runSettingsWizard(ctx: ExtensionCommandContext): Promise<void> {
|
|
|
594
633
|
}
|
|
595
634
|
continue;
|
|
596
635
|
}
|
|
597
|
-
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
|
+
}
|
|
598
640
|
const updated = await setToolPolicyNumberSetting(ctx, draft, "maxBytesPerFile", "Max bytes per file", 1024);
|
|
599
641
|
if (updated !== draft) {
|
|
600
642
|
draft = updated;
|
|
@@ -602,7 +644,10 @@ async function runSettingsWizard(ctx: ExtensionCommandContext): Promise<void> {
|
|
|
602
644
|
}
|
|
603
645
|
continue;
|
|
604
646
|
}
|
|
605
|
-
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
|
+
}
|
|
606
651
|
const updated = await setToolPolicyNumberSetting(ctx, draft, "maxTotalBytesRead", "Max total bytes", 1024);
|
|
607
652
|
if (updated !== draft) {
|
|
608
653
|
draft = updated;
|
|
@@ -610,7 +655,10 @@ async function runSettingsWizard(ctx: ExtensionCommandContext): Promise<void> {
|
|
|
610
655
|
}
|
|
611
656
|
continue;
|
|
612
657
|
}
|
|
613
|
-
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
|
+
}
|
|
614
662
|
const value = await ctx.ui.input(
|
|
615
663
|
"Comma-separated sensitive path denylist",
|
|
616
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(
|
|
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
|
|
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
|
-
|
|
97
|
-
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
|
207
|
-
|
|
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(
|
|
220
|
+
child.kill();
|
|
213
221
|
} catch {
|
|
214
222
|
// Ignore kill errors
|
|
215
223
|
}
|
|
216
224
|
|
|
217
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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(
|
|
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 === "
|
|
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
|
|
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 {
|
|
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
|
|
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,
|
|
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`,
|
|
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`,
|
|
384
|
-
9. Sage tool
|
|
385
|
-
10. Sage
|
|
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
|
|
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).
|