@andares/pi-toolkits 0.7.2 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,35 +5,239 @@
5
5
  * per-segment validation (&& | ; etc.), command-substitution rejection and
6
6
  * deny-by-default for unmodeled operators.
7
7
  *
8
- * On top of that, this wrapper adds write-intent detection for known blind
9
- * spots: allowlisted commands whose arguments can write files (curl -o/-O,
10
- * wget -O, dd of=, tee). Heuristic only — not a security boundary.
8
+ * This wrapper adds four things on top:
9
+ * 1. write-intent detection for known blind spots — allowlisted commands
10
+ * whose arguments can still write files (curl/wget output targets, dd of=,
11
+ * tee, sed -i, sort -o, tree -o, find -delete, the git diff family's
12
+ * --output).
13
+ * 2. exec-intent detection — read-by-name commands whose options run another
14
+ * program (git grep -O, git --ext-diff, find -exec/-ok, fd -x, rg --pre).
15
+ * 3. `extraSafe` patterns for read-only commands the upstream allowlist does
16
+ * not model: git archaeology verbs, package metadata queries,
17
+ * `python -m json.tool`, wget's stdout form.
18
+ * 4. a per-segment read-only bypass for DESTRUCTIVE_PATTERNS that match only
19
+ * a command's *arguments* (`git log --grep=rm`, `git blame src/rm-utils.ts`)
20
+ * — see READ_ONLY_COMMAND_PATTERNS.
21
+ *
22
+ * Heuristic only — not a security boundary.
11
23
  */
12
- import { isSafeCommand } from "@dreki-gg/pi-command-sandbox";
24
+ import {
25
+ hasCommandSubstitution,
26
+ isSafeCommand,
27
+ parseCommandSegments,
28
+ } from "@dreki-gg/pi-command-sandbox";
29
+
30
+ /* -------------------------------------------------------------------------- */
31
+ /* Write intent */
32
+ /* -------------------------------------------------------------------------- */
13
33
 
34
+ /**
35
+ * Commands that are safe by name but whose arguments can write files.
36
+ * Patterns are anchored at the command position and checked per shell segment,
37
+ * so grep/rg *search arguments* mentioning a flag (`grep -rn "curl -so"`) are
38
+ * data, not intent. Stdout forms stay explicitly allowed: `-o -` / `-O -` /
39
+ * `-qO-` / `--output -` / `--output=-` / `--output-document=-`.
40
+ */
14
41
  const WRITE_INTENT_PATTERNS: RegExp[] = [
15
- // curl --output FILE
16
- /\bcurl\b[^|;&]*\s--output\b/i,
17
- // curl -o FILE (curl -o - / -o- = stdout, allowed)
18
- /\bcurl\b[^|;&]*\s-o\s+(?!-)\S+/,
19
- // curl -O (uppercase: save to disk under remote name)
20
- /\bcurl\b[^|;&]*\s-O\b/,
21
- // wget --output-document FILE
22
- /\bwget\b[^|;&]*\s--output-document\b/i,
42
+ // curl --output FILE (curl --output - / --output=- = stdout, allowed)
43
+ /^\s*curl\b[^|;&]*\s--output(?:=|\s+)(?!-)\S+/i,
44
+ // curl -o FILE / -oFILE / -so FILE: short form, attached or clustered
45
+ // (`-o -` / `-o-` / `-so-` are stdout and stay allowed)
46
+ /^\s*curl\b[^|;&]*\s-[a-zA-Z]*o(?:\s+(?!-)\S+|(?!-)\S+)/,
47
+ // curl -O / -sO / --remote-name: always saves to disk
48
+ /^\s*curl\b[^|;&]*\s-[a-zA-Z]*O\b/,
49
+ /^\s*curl\b[^|;&]*\s--remote-name\b/i,
50
+ // wget --output-document FILE (--output-document - = stdout, allowed)
51
+ /^\s*wget\b[^|;&]*\s--output-document(?:=|\s+)(?!-)\S+/i,
23
52
  // wget -O FILE (wget -O - / -qO- = stdout, allowed)
24
- /\bwget\b[^|;&]*\s-O\s+(?!-)\S+/,
53
+ /^\s*wget\b[^|;&]*\s-O\s+(?!-)\S+/,
25
54
  // dd of=FILE
26
- /\bdd\b[^|;&]*\sof=/i,
55
+ /^\s*dd\b[^|;&]*\sof=/i,
27
56
  // tee FILE (writes to stdout AND file)
28
- /\btee\b/i,
57
+ /^\s*tee\b/i,
58
+ // sed -i / -ni: in-place edits; only `sed -n` is otherwise allowlisted
59
+ /^\s*sed\b[^|;&]*\s-[a-zA-Z]*i(?:\s|$)/i,
60
+ /^\s*sed\b[^|;&]*\s--in-place\b/i,
61
+ // sort -o FILE / --output FILE
62
+ /^\s*sort\b[^|;&]*\s(?:-o\s|--output(?:=|\s+))/i,
63
+ // tree -o FILE / --output FILE (short form may be clustered: -ao FILE)
64
+ /^\s*tree\b[^|;&]*\s(?:-[a-zA-Z]*o(?:\s|$)|--output(?:=|\s+))/i,
65
+ // file -C / --compile writes the compiled magic database
66
+ /^\s*file\b[^|;&]*\s(?:-[a-zA-Z]*C(?:\s|$)|--compile\b)/i,
67
+ // find -delete (its -exec/-ok forms are exec intent below)
68
+ /^\s*find\b[^|;&]*\s-delete\b/i,
69
+ // git diff family --output FILE: a real write path on commands that are
70
+ // otherwise read-only (`git diff --output=out.txt` creates the file).
71
+ // `-o` is not a valid short option for these verbs; guarding it is free.
72
+ /^\s*git\s+(?:log|diff|show|diff-tree|whatchanged)\b[^|;&]*(?:\s--output(?:=|\s+)|\s-o\s)\S+/i,
29
73
  ];
30
74
 
75
+ /** True when a segment is a write-intent invocation. */
31
76
  function hasWriteIntent(command: string): boolean {
32
- return WRITE_INTENT_PATTERNS.some((pattern) => pattern.test(command));
77
+ return parseCommandSegments(command).some((segment) =>
78
+ WRITE_INTENT_PATTERNS.some((pattern) => pattern.test(segment.command)),
79
+ );
80
+ }
81
+
82
+ /* -------------------------------------------------------------------------- */
83
+ /* Exec intent */
84
+ /* -------------------------------------------------------------------------- */
85
+
86
+ /**
87
+ * Read-by-name commands whose options start another program. Same per-segment,
88
+ * command-position anchoring as the write intent above, so an allowed verb
89
+ * cannot smuggle execution in and search arguments stay data.
90
+ */
91
+ const EXEC_INTENT_PATTERNS: RegExp[] = [
92
+ // git grep -O[pager] / --open-files-in-pager: runs a pager on matched files
93
+ /^\s*git\s+grep\b[^|;&]*\s(?:-O|--open-files-in-pager\b)/i,
94
+ // external diff drivers (from config/gitattributes)
95
+ /^\s*git\s+\S+\b[^|;&]*\s--ext-diff\b/i,
96
+ // env forwards to another command (`env [-i] [-u NAME] [NAME=VALUE] CMD …`);
97
+ // bare env, flag-only and assignment-only forms print the environment and
98
+ // stay allowed (the lookahead keeps NAME=VALUE from posing as CMD)
99
+ /^\s*env\b(?:\s+(?:-\S+|[A-Za-z_][A-Za-z0-9_]*=\S+))*\s+(?![A-Za-z_][A-Za-z0-9_]*=)[^-\s]\S*/,
100
+ // find/fd command runners
101
+ /^\s*find\b[^|;&]*\s-(?:exec|execdir|ok|okdir)\b/i,
102
+ /^\s*fd\b[^|;&]*\s(?:-x|--exec(?:-batch)?)\b/i,
103
+ // ripgrep preprocessor
104
+ /^\s*rg\b[^|;&]*\s--pre(?:=|\s)/i,
105
+ ];
106
+
107
+ /** True when a segment is an exec-intent invocation. */
108
+ function hasExecIntent(command: string): boolean {
109
+ return parseCommandSegments(command).some((segment) =>
110
+ EXEC_INTENT_PATTERNS.some((pattern) => pattern.test(segment.command)),
111
+ );
112
+ }
113
+
114
+ /* -------------------------------------------------------------------------- */
115
+ /* Read-only allowlist extensions */
116
+ /* -------------------------------------------------------------------------- */
117
+
118
+ /**
119
+ * Read-only commands the upstream allowlist lacks, plus the read-only forms of
120
+ * git verbs that also have mutating siblings.
121
+ *
122
+ * Every pattern is anchored and pinned to stdout-only shapes:
123
+ * - no `find -exec`/`-delete`, no `fd -x`, no `sort -o`, no `sed -i`;
124
+ * - `git stash` is pinned to list/show (`drop`/`pop`/`apply` stay blocked);
125
+ * - `git tag` is pinned to -l/--list (create/delete stay blocked);
126
+ * - `git reflog` is pinned to bare/`show` (delete/expire stay blocked);
127
+ * - `git config` is pinned to get/list forms (set/unset stay blocked).
128
+ */
129
+ const READ_ONLY_COMMAND_PATTERNS: RegExp[] = [
130
+ // git: read-only archaeology verbs upstream never modeled
131
+ /^\s*git\s+(?:blame|grep|rev-parse|rev-list|describe|shortlog|cat-file|show-ref|for-each-ref|name-rev|diff-tree|whatchanged|merge-base|cherry|check-ignore|check-attr|count-objects)\b/i,
132
+ // git: upstream read verbs whose arguments may hold destructive words
133
+ /^\s*git\s+(?:log|diff|show|status|ls-files|ls-tree)\b/i,
134
+ // git: read-only forms of verbs that also mutate
135
+ /^\s*git\s+reflog(?:\s+show\b[^|;&]*)?\s*$/i,
136
+ /^\s*git\s+stash\s+(?:list|show)\b/i,
137
+ /^\s*git\s+worktree\s+list\b/i,
138
+ /^\s*git\s+tag\s+(?:-l|--list)\b/i,
139
+ /^\s*git\s+config\s+(?:--get\b|--get-all\b|--get-regexp\b|-l\b|--list\b)/i,
140
+ // harmless navigation/readers commonly chained before a search
141
+ /^\s*cd\b/i,
142
+ /^\s*(?:head|tail|cat|tac|wc|uniq|jq|column)\b/i,
143
+ /^\s*(?:ls|file|stat|tree|du|df|bat|eza)\b/i,
144
+ /^\s*(?:grep|rg)\b/i,
145
+ /^\s*sed\s+-n\b/i,
146
+ // package metadata (network/library reads, never installs)
147
+ /^\s*pnpm\s+(?:view|info|search|licenses)\b/i,
148
+ /^\s*pip3?\s+(?:show|list|freeze)\b/i,
149
+ /^\s*uv\s+(?:pip\s+(?:list|show|freeze)|tree)\b/i,
150
+ /^\s*python3?\s+-m\s+json\.tool\b/i,
151
+ // network reads: wget to stdout only (file targets stay blocked)
152
+ /^\s*wget\b[^|;&]*\s(?:-O\s*-|-O-|-qO-|--output-document(?:=|\s+)-)/i,
153
+ ];
154
+
155
+ /**
156
+ * Gaps in the upstream DESTRUCTIVE_PATTERNS closed here: git commands that can
157
+ * write but are not modeled as destructive — `git branch <name>` creates a ref,
158
+ * `git branch -m/-M/-c/-C` moves/copies, `git remote add/remove/set-url/...`
159
+ * edits .git/config. File-write and command-runner options are handled by the
160
+ * write/exec intent checks above (which run on the raw command, so pipelines
161
+ * are covered too).
162
+ */
163
+ const EXTRA_DESTRUCTIVE_PATTERNS: RegExp[] = [
164
+ // positional argument after `branch` = create branch (flags stay read-only)
165
+ /^\s*git\s+branch\s+(?=[^-\s])/i,
166
+ /^\s*git\s+branch\s+-(?:[mMcC]\b|move\b|copy\b|edit-description\b)/i,
167
+ // remote subcommands other than the read-only ones
168
+ /^\s*git\s+remote\s+(?!-)(?!(?:show|get-url)(?=\s|$))/i,
169
+ ];
170
+
171
+ /**
172
+ * True when every parsed segment is a curated read-only shape: no redirect, no
173
+ * unmodeled operator, each segment matching one of the patterns above.
174
+ *
175
+ * Used as `allowCommand`, which runs *before* the package's destructive check —
176
+ * that is what lets `git log --grep=rm` through while `git log && rm -rf x`
177
+ * still fails (the second segment matches nothing here).
178
+ */
179
+ function allSegmentsReadOnly(command: string): boolean {
180
+ const segments = parseCommandSegments(command);
181
+ if (segments.length === 0) return false;
182
+ return segments.every(
183
+ (segment) =>
184
+ !segment.hasRedirect &&
185
+ segment.unsafeOperator === undefined &&
186
+ READ_ONLY_COMMAND_PATTERNS.some((pattern) =>
187
+ pattern.test(segment.command),
188
+ ),
189
+ );
33
190
  }
34
191
 
35
192
  /** True when `command` is considered read-only by the sandbox. */
36
193
  export function isReadOnlyCommand(command: string): boolean {
37
194
  if (hasWriteIntent(command)) return false;
38
- return isSafeCommand(command);
195
+ if (hasExecIntent(command)) return false;
196
+ // The read-only-segment bypass below runs before the package's own
197
+ // substitution check, so reject command substitution here first.
198
+ if (hasCommandSubstitution(command)) return false;
199
+ return isSafeCommand(command, {
200
+ extraSafe: READ_ONLY_COMMAND_PATTERNS,
201
+ extraDestructive: EXTRA_DESTRUCTIVE_PATTERNS,
202
+ allowCommand: allSegmentsReadOnly,
203
+ });
204
+ }
205
+
206
+ /* -------------------------------------------------------------------------- */
207
+ /* Block classification (for actionable rejection messages) */
208
+ /* -------------------------------------------------------------------------- */
209
+
210
+ /** Why a bash command was rejected; drives the message shown to the model. */
211
+ export type BashBlockKind =
212
+ | "substitution"
213
+ | "redirect"
214
+ | "exec"
215
+ | "write"
216
+ | "download"
217
+ | "git"
218
+ | "install"
219
+ | "unlisted";
220
+
221
+ const INSTALL_INTENT: RegExp[] = [
222
+ /\b(?:npm|pnpm|yarn|bun)\s+(?:i|install|add|remove|rm|update|up|ci|link|publish|dlx|exec|create)\b/i,
223
+ /\bpip3?\s+(?:install|uninstall)\b/i,
224
+ /\b(?:apt|apt-get|brew|cargo|go)\s+(?:install|add|get)\b/i,
225
+ ];
226
+
227
+ /** Classic file writers, for classifying a rejection as a write attempt. */
228
+ const WRITER_INTENT =
229
+ /\b(?:rm|rmdir|mv|cp|mkdir|touch|chmod|chown|chgrp|ln|truncate|shred|dd|sed\s+-i|sort\s+-o|yq\s+-i)\b/i;
230
+
231
+ /** Classify a rejected command so the block reason can point at alternatives. */
232
+ export function classifyBlockedCommand(command: string): BashBlockKind {
233
+ if (hasCommandSubstitution(command)) return "substitution";
234
+ if (parseCommandSegments(command).some((segment) => segment.hasRedirect)) {
235
+ return "redirect";
236
+ }
237
+ if (hasExecIntent(command)) return "exec";
238
+ if (hasWriteIntent(command) || WRITER_INTENT.test(command)) return "write";
239
+ if (/\b(?:wget|curl)\b/i.test(command)) return "download";
240
+ if (/^\s*git\b/i.test(command)) return "git";
241
+ if (INSTALL_INTENT.some((pattern) => pattern.test(command))) return "install";
242
+ return "unlisted";
39
243
  }
@@ -2,12 +2,27 @@
2
2
  * ask feature — constants (tool names, UI keys, labels).
3
3
  */
4
4
 
5
- /** Tools hard-removed from the active set while ask mode is on. */
5
+ /**
6
+ * Tools hard-removed from the active set while ask mode is on.
7
+ *
8
+ * `write`/`edit` are the obvious ones. `powershell` is a shell too: pi
9
+ * registers it on every platform (inactive by default, enabled by settings),
10
+ * and it would otherwise hand ask mode a full write channel.
11
+ */
6
12
  export const ASK_BLOCKED_TOOLS: ReadonlySet<string> = new Set([
7
13
  "write",
8
14
  "edit",
15
+ "powershell",
9
16
  ]);
10
17
 
18
+ /**
19
+ * Read-only helper tools activated for the duration of ask mode (when the host
20
+ * has them registered). pi registers `grep`/`find`/`ls` but leaves them
21
+ * inactive by default; ask mode is a reading mode, so the model gets proper
22
+ * search tools instead of bash-only search. Restored exactly on exit.
23
+ */
24
+ export const ASK_READ_ONLY_HELPERS: readonly string[] = ["grep", "find", "ls"];
25
+
11
26
  /** Footer status key (extension-scoped) and label. */
12
27
  export const ASK_STATUS_KEY = "ask-mode";
13
28
  export const ASK_STATUS_LABEL = "ask";
@@ -2,13 +2,15 @@
2
2
  * ask feature — read-only Q&A mode.
3
3
  *
4
4
  * Design notes (see PLAN.md):
5
- * - Hard guarantee: write/edit are removed from the active tool set via
6
- * pi.setActiveTools(); pi's runtime rejects calls to non-active tools
5
+ * - Hard guarantee: write/edit/powershell are removed from the active tool set
6
+ * via pi.setActiveTools(); pi's runtime rejects calls to non-active tools
7
7
  * ("Tool not found") so nothing executes.
8
+ * - Read-only helper tools (grep/find/ls) are activated for the duration so
9
+ * the model gets proper search tools; the entry snapshot restores exactly.
8
10
  * - bash stays available but is gated by a read-only sandbox
9
11
  * (see bash-sandbox.ts) via the tool_call hook.
10
12
  * - Mode banner appended to the system prompt via before_agent_start
11
- * chained systemPrompt (see prompt.ts).
13
+ * chained systemPrompt (see prompt.ts), listing blocked -> alternative.
12
14
  */
13
15
  import type {
14
16
  ExtensionAPI,
@@ -21,11 +23,13 @@ import {
21
23
  } from "../../lib/tools.js";
22
24
  import {
23
25
  ASK_BLOCKED_TOOLS,
26
+ ASK_READ_ONLY_HELPERS,
24
27
  ASK_STATUS_KEY,
25
28
  ASK_STATUS_LABEL,
26
29
  } from "./constants.js";
27
30
  import { isReadOnlyCommand } from "./bash-sandbox.js";
28
31
  import { appendAskBanner } from "./prompt.js";
32
+ import { blockedBashReason, blockedToolReason } from "./reasons.js";
29
33
 
30
34
  export function registerAsk(pi: ExtensionAPI): void {
31
35
  let enabled = false;
@@ -40,14 +44,30 @@ export function registerAsk(pi: ExtensionAPI): void {
40
44
  );
41
45
  }
42
46
 
47
+ /** Read-only helpers currently active (drives the banner's search hint). */
48
+ function activeHelperTools(): string[] {
49
+ const active = new Set(pi.getActiveTools());
50
+ return ASK_READ_ONLY_HELPERS.filter((name) => active.has(name));
51
+ }
52
+
43
53
  function enter(ctx: ExtensionContext): void {
44
54
  if (enabled) return;
45
55
  enabled = true;
46
56
  snapshot = snapshotActiveTools(pi);
47
- pi.setActiveTools(withoutTools(snapshot, ASK_BLOCKED_TOOLS));
57
+ // Activate grep/find/ls when the host has them registered and they were
58
+ // not already active. Names must be registered; unknown names are ignored
59
+ // by setActiveTools, but filtering here keeps the intent explicit.
60
+ const registered = new Set(pi.getAllTools().map((tool) => tool.name));
61
+ const helpers = ASK_READ_ONLY_HELPERS.filter(
62
+ (name) => registered.has(name) && !snapshot.includes(name),
63
+ );
64
+ pi.setActiveTools([
65
+ ...withoutTools(snapshot, ASK_BLOCKED_TOOLS),
66
+ ...helpers,
67
+ ]);
48
68
  updateStatus(ctx);
49
69
  ctx.ui.notify(
50
- "Ask mode ON — read-only (write/edit disabled). /ask to exit.",
70
+ "Ask mode ON — read-only (write/edit/powershell disabled, bash sandboxed). /ask to exit.",
51
71
  "info",
52
72
  );
53
73
  }
@@ -77,27 +97,23 @@ export function registerAsk(pi: ExtensionAPI): void {
77
97
  });
78
98
 
79
99
  // Defense-in-depth gate while ask mode is active:
80
- // - write/edit are already removed from the active set (runtime rejects
81
- // them), this blocks them again in case anything re-enables them.
100
+ // - write/edit/powershell are already removed from the active set (runtime
101
+ // rejects them); this blocks them again in case anything re-enables them.
82
102
  // - bash is kept available but only read-only commands pass the sandbox.
103
+ // The rejection `reason` reaches the model as the tool result, so it names
104
+ // the nearest allowed alternative (see reasons.ts).
83
105
  pi.on("tool_call", (event) => {
84
106
  if (!enabled) return;
85
107
 
86
- if (event.toolName === "write" || event.toolName === "edit") {
87
- return {
88
- block: true,
89
- reason:
90
- "Ask mode: write/edit are disabled. Use /ask to exit ask mode first.",
91
- };
108
+ if (ASK_BLOCKED_TOOLS.has(event.toolName)) {
109
+ return { block: true, reason: blockedToolReason(event.toolName) };
92
110
  }
93
111
 
94
112
  if (event.toolName === "bash") {
95
- const command = event.input.command as string;
113
+ const command =
114
+ typeof event.input.command === "string" ? event.input.command : "";
96
115
  if (!isReadOnlyCommand(command)) {
97
- return {
98
- block: true,
99
- reason: `Ask mode: only read-only bash commands are allowed.\nCommand: ${command}\nUse /ask to exit ask mode first.`,
100
- };
116
+ return { block: true, reason: blockedBashReason(command) };
101
117
  }
102
118
  }
103
119
  });
@@ -107,6 +123,10 @@ export function registerAsk(pi: ExtensionAPI): void {
107
123
  if (!enabled) return;
108
124
  // event.systemPrompt is the current (possibly already modified by other
109
125
  // extensions) system prompt — append, don't replace.
110
- return { systemPrompt: appendAskBanner(event.systemPrompt) };
126
+ return {
127
+ systemPrompt: appendAskBanner(event.systemPrompt, {
128
+ helperTools: activeHelperTools(),
129
+ }),
130
+ };
111
131
  });
112
132
  }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Tests for the ask-mode banner: current state + blocked -> alternative hints.
3
+ */
4
+ import { describe, expect, it } from "vitest";
5
+ import { appendAskBanner, buildAskBanner } from "./prompt.js";
6
+
7
+ describe("buildAskBanner", () => {
8
+ it("states the hard-blocked tools and the exit path", () => {
9
+ const banner = buildAskBanner();
10
+ expect(banner).toContain("[ASK MODE — read-only]");
11
+ expect(banner).toContain("write, edit, powershell");
12
+ expect(banner).toContain("/ask");
13
+ });
14
+
15
+ it("names the alternatives for blocked commands", () => {
16
+ const banner = buildAskBanner();
17
+ expect(banner).toContain("`read` tool");
18
+ expect(banner).toContain("`curl -s <url>`");
19
+ expect(banner).toContain("cat/sed tricks");
20
+ });
21
+
22
+ it("lists the read-only git families", () => {
23
+ const banner = buildAskBanner();
24
+ expect(banner).toContain("blame");
25
+ expect(banner).toContain("reflog show");
26
+ expect(banner).toContain("stash list");
27
+ });
28
+
29
+ it("adds the helper-tool hint only when helpers are active", () => {
30
+ const withHelpers = buildAskBanner({ helperTools: ["grep", "find", "ls"] });
31
+ expect(withHelpers).toContain("`grep`/`find`/`ls` tools");
32
+
33
+ const withoutHelpers = buildAskBanner();
34
+ expect(withoutHelpers).not.toContain("active during ask mode");
35
+ });
36
+ });
37
+
38
+ describe("appendAskBanner", () => {
39
+ it("appends instead of replacing the existing system prompt", () => {
40
+ const original = "You are a helpful coding agent.";
41
+ const combined = appendAskBanner(original, { helperTools: ["grep"] });
42
+ expect(combined.startsWith(original)).toBe(true);
43
+ expect(combined).toContain("[ASK MODE — read-only]");
44
+ });
45
+ });
@@ -1,22 +1,61 @@
1
1
  /**
2
2
  * ask feature — system prompt banner.
3
3
  *
4
- * Appended to the system prompt (chained append, not replacement) while
5
- * ask mode is active so the model knows the current mode and its
6
- * restrictions. Do NOT use the removed `systemPromptAppend` field or
7
- * wholesale systemPrompt replacement (see PLAN.md).
4
+ * Appended to the system prompt (chained append, not replacement) while ask
5
+ * mode is active so the model knows the current mode, what it may still use,
6
+ * and — for everything blocked — what to use instead. Do NOT use the removed
7
+ * `systemPromptAppend` field or wholesale systemPrompt replacement (see
8
+ * PLAN.md).
8
9
  */
9
10
 
10
- export const ASK_BANNER = `[ASK MODE]
11
- You are in ask mode — a read-only Q&A mode. You may read, search, and analyze the codebase, but you MUST NOT modify anything.
11
+ export interface AskBannerOptions {
12
+ /** Read-only helper tools active in this session (subset of grep/find/ls). */
13
+ readonly helperTools?: readonly string[];
14
+ }
15
+
16
+ const BANNER_HEADER = `[ASK MODE — read-only]
17
+ You are in ask mode: answer questions and read code; nothing may be modified.
18
+
19
+ Hard-blocked (calls fail — no workaround): write, edit, powershell.
20
+ If fulfilling a request needs a change, say exactly what to change and tell the
21
+ user to exit ask mode with /ask first.
22
+
23
+ bash runs under a read-only allowlist. Working examples:
24
+ - files: ls, cat, head, tail, sed -n, wc, file, stat, diff, tree
25
+ - search: grep, rg, fd, find (without -exec), jq
26
+ - git: status, log, diff, show, branch, remote, blame, grep, rev-parse,
27
+ describe, shortlog, reflog show, stash list, tag -l, cat-file
28
+ - network: curl -s <url>, wget -O - <url>
29
+ - packages: npm view, pnpm view, pip show/list, python3 -m json.tool
12
30
 
13
- Restrictions:
14
- - write and edit are disabled (hard-blocked; calls will fail)
15
- - bash is restricted to read-only commands (ls, cat, grep, git status/log/diff, etc.)
31
+ Blocked without exception: redirection (> >>), tee, dd, rm/mv/cp/mkdir, sed -i,
32
+ installs/builds, git mutations (add/commit/checkout/reset/stash drop/...), and
33
+ command substitution $(...) / backticks.`;
16
34
 
17
- Answer the user's question directly. If a question requires changing files, say so and suggest exiting ask mode with /ask.`;
35
+ const BANNER_FOOTER = `Never write a file, not even a temporary one.`;
36
+
37
+ /** Build the banner, adapting the search hint to the active helper tools. */
38
+ export function buildAskBanner(options: AskBannerOptions = {}): string {
39
+ const helpers = options.helperTools ?? [];
40
+ const helperLabel = helpers.map((name) => `\`${name}\``).join("/");
41
+ const helperLine =
42
+ helpers.length > 0
43
+ ? `\n- search with the ${helperLabel} tools (active during ask mode)`
44
+ : "";
45
+ return `${BANNER_HEADER}
46
+
47
+ If a bash command is rejected, do not retry variants or invent workarounds:
48
+ - read file contents with the \`read\` tool instead of cat/sed tricks${helperLine}
49
+ - fetch a URL with \`curl -s <url>\` instead of wget
50
+ - otherwise tell the user which command is blocked and why
51
+
52
+ ${BANNER_FOOTER}`;
53
+ }
18
54
 
19
55
  /** Append the ask-mode banner to an existing system prompt. */
20
- export function appendAskBanner(systemPrompt: string): string {
21
- return `${systemPrompt}\n\n${ASK_BANNER}`;
56
+ export function appendAskBanner(
57
+ systemPrompt: string,
58
+ options: AskBannerOptions = {},
59
+ ): string {
60
+ return `${systemPrompt}\n\n${buildAskBanner(options)}`;
22
61
  }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Tests for ask-mode rejection messages: each one must point at a next step.
3
+ */
4
+ import { describe, expect, it } from "vitest";
5
+ import { blockedBashReason, blockedToolReason } from "./reasons.js";
6
+
7
+ describe("blockedToolReason", () => {
8
+ it.each(["write", "edit", "powershell"])(
9
+ "names the exit path for %s",
10
+ (tool) => {
11
+ const reason = blockedToolReason(tool);
12
+ expect(reason).toContain(tool);
13
+ expect(reason).toContain("/ask");
14
+ },
15
+ );
16
+ });
17
+
18
+ describe("blockedBashReason", () => {
19
+ it.each([
20
+ ["rm -rf /", "writes to disk"],
21
+ ["cat a > b", "redirection"],
22
+ ["echo $(id)", "command substitution"],
23
+ ["wget https://example.com", "curl -s"],
24
+ ["git commit -m x", "read-only git"],
25
+ ["git grep -O x", "run another program"],
26
+ ["pnpm install", "install/build"],
27
+ ["vim README.md", "allowlist"],
28
+ ] as const)("for %s mentions: %s", (command, needle) => {
29
+ expect(blockedBashReason(command)).toContain(needle);
30
+ });
31
+
32
+ it("always echoes the blocked command", () => {
33
+ expect(blockedBashReason("git push")).toContain("Command: git push");
34
+ });
35
+ });
@@ -0,0 +1,68 @@
1
+ /**
2
+ * ask feature — actionable rejection messages.
3
+ *
4
+ * The `tool_call` hook's `reason` is delivered to the model as the tool result
5
+ * (verified against pi 1.3.0: `extensions/runner.js` returns the `{block,
6
+ * reason}` result unchanged and pi-agent-core's `agent-loop.js` turns it into
7
+ * `createErrorToolResult(reason)`; both paths are byte-identical to 1.2.0), so it
8
+ * is the best place to name the nearest allowed alternative and stop the model
9
+ * from retrying variants of a blocked command.
10
+ */
11
+ import { classifyBlockedCommand } from "./bash-sandbox.js";
12
+
13
+ /** Reason for a hard-blocked tool call (write / edit / powershell). */
14
+ export function blockedToolReason(toolName: string): string {
15
+ if (toolName === "powershell") {
16
+ return "Ask mode: powershell is disabled — ask mode is read-only. " +
17
+ "Inspect files with the read/grep/find/ls tools or the read-only bash " +
18
+ "allowlist; if a command must change something, tell the user and ask " +
19
+ "them to exit ask mode with /ask.";
20
+ }
21
+ return `Ask mode: ${toolName} is disabled — ask mode is read-only. ` +
22
+ "If a change is needed, explain what to change and ask the user to exit " +
23
+ "ask mode with /ask.";
24
+ }
25
+
26
+ /** Reason for a rejected bash command, classified by intent. */
27
+ export function blockedBashReason(command: string): string {
28
+ const suffix = `\nCommand: ${command}`;
29
+ switch (classifyBlockedCommand(command)) {
30
+ case "substitution":
31
+ return "Ask mode: command substitution — $(...) and backticks — is " +
32
+ "blocked because it can run arbitrary commands. Run the inner " +
33
+ "command directly instead." + suffix;
34
+ case "redirect":
35
+ return "Ask mode: output redirection is blocked — ask mode cannot " +
36
+ "write files, not even temporary ones. Use the read/grep/find/ls " +
37
+ "tools to inspect files instead." + suffix;
38
+ case "exec":
39
+ return "Ask mode: this command can run another program (git grep -O, " +
40
+ "git --ext-diff, find -exec/-ok, fd -x, rg --pre, env CMD). Only " +
41
+ "read-only operations are allowed; run the underlying read-only " +
42
+ "command directly instead." + suffix;
43
+ case "write":
44
+ return "Ask mode: this command writes to disk (curl -o/-O, wget -O, " +
45
+ "dd of=, tee, sed -i, git --output, ...). Nothing can be written in " +
46
+ "ask mode. To read a URL without saving it use `curl -s <url>`; to " +
47
+ "read files use the `read` tool." + suffix;
48
+ case "download":
49
+ return "Ask mode: downloads to disk are blocked. Use `curl -s <url>` " +
50
+ "or `wget -O - <url>` to read a URL to stdout instead." + suffix;
51
+ case "git":
52
+ return "Ask mode: only read-only git commands are allowed (status, " +
53
+ "log, diff, show, blame, grep, rev-parse, describe, shortlog, " +
54
+ "reflog show, stash list, tag -l, cat-file). To change the " +
55
+ "repository, ask the user to exit ask mode with /ask." + suffix;
56
+ case "install":
57
+ return "Ask mode: install/build commands are blocked. Read-only " +
58
+ "package queries (npm view, pnpm view, pip show/list) are allowed; " +
59
+ "to actually install or build, ask the user to exit ask mode with " +
60
+ "/ask." + suffix;
61
+ default:
62
+ return "Ask mode: this command is not on the read-only allowlist " +
63
+ "(or is classified as destructive), so it was not executed — it may " +
64
+ "itself be read-only. Do not retry variants: use the " +
65
+ "read/grep/find/ls tools, a recognised read-only bash command, or " +
66
+ "tell the user the command is blocked." + suffix;
67
+ }
68
+ }
@@ -16,7 +16,8 @@
16
16
  * Dispatch uses `sendUserMessage(cmd, { expandPromptTemplates: true })` —
17
17
  * that flag is what routes "/cmd args" through _tryExecuteExtensionCommand
18
18
  * instead of the model prompt path. The wrapper defaults it to false
19
- * (verified against pi 1.1.0 agent-session.js; see AGENTS.md host-compat
19
+ * (each byte-identical across every published version of the supported
20
+ * window, 1.0.0 → 1.3.0; see AGENTS.md host-compat
20
21
  * discipline for the version matrix).
21
22
  *
22
23
  * Only extension commands can run this way. Built-in commands (/compact,