pi-sage 0.2.11 → 0.2.13
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
|
|
|
@@ -343,6 +343,21 @@ function buildSagePrompt(input: SageRunnerInput): string {
|
|
|
343
343
|
lines.push("You are Sage, an advisory reasoning subagent.");
|
|
344
344
|
lines.push("Provide analysis and recommendations only. Do not claim to have applied changes.");
|
|
345
345
|
lines.push("Keep your response concise and actionable.");
|
|
346
|
+
|
|
347
|
+
lines.push("");
|
|
348
|
+
lines.push("Operational constraints:");
|
|
349
|
+
lines.push("- Do not run node/npm/cd/tests.");
|
|
350
|
+
|
|
351
|
+
if (input.toolPolicy.profile === "git-review-readonly") {
|
|
352
|
+
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)."
|
|
354
|
+
);
|
|
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
|
+
}
|
|
358
|
+
|
|
359
|
+
lines.push("- If a command is blocked or unavailable, continue with ls/glob/grep/read and still deliver best-effort findings.");
|
|
360
|
+
|
|
346
361
|
lines.push("");
|
|
347
362
|
lines.push(`Question: ${input.question}`);
|
|
348
363
|
|
|
@@ -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
|
|
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
|
|
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
|
-
|
|
156
|
-
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
}
|