@mgiles/perk 1.0.1
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/README.md +105 -0
- package/extension/adapters/planAdapterPlannotator.ts +269 -0
- package/extension/adapters/planAdapterTombell.ts +147 -0
- package/extension/adapters/todoAdapterJuicesharp.ts +105 -0
- package/extension/checkpoints/checkpoints.ts +542 -0
- package/extension/checkpoints/planSteps.ts +108 -0
- package/extension/doors/address.ts +360 -0
- package/extension/doors/askUser.ts +194 -0
- package/extension/doors/ciExecutor.ts +583 -0
- package/extension/doors/land.ts +222 -0
- package/extension/doors/learn.ts +235 -0
- package/extension/doors/learnDocs.ts +99 -0
- package/extension/doors/lifecycleGates.ts +171 -0
- package/extension/doors/prReview.ts +339 -0
- package/extension/doors/ready.ts +86 -0
- package/extension/doors/selfcheck.ts +155 -0
- package/extension/doors/submit.ts +253 -0
- package/extension/factories/objective.ts +240 -0
- package/extension/factories/objectiveAuthor.ts +114 -0
- package/extension/factories/objectiveDraft.ts +343 -0
- package/extension/factories/objectivePlan.ts +838 -0
- package/extension/factories/objectiveSave.ts +285 -0
- package/extension/factories/planDraft.ts +140 -0
- package/extension/factories/planMode.ts +214 -0
- package/extension/factories/planReview.ts +644 -0
- package/extension/factories/planSave.ts +589 -0
- package/extension/factories/planTitle.ts +123 -0
- package/extension/index.ts +459 -0
- package/extension/substrate/bindingDelivery.ts +199 -0
- package/extension/substrate/bindings.ts +180 -0
- package/extension/substrate/cache.ts +163 -0
- package/extension/substrate/coldDoor.ts +226 -0
- package/extension/substrate/config.ts +339 -0
- package/extension/substrate/miniYaml.ts +262 -0
- package/extension/substrate/prompts.ts +35 -0
- package/extension/substrate/providers.ts +177 -0
- package/extension/substrate/registry.ts +62 -0
- package/extension/substrate/resources.ts +41 -0
- package/extension/substrate/result.ts +72 -0
- package/extension/substrate/runId.ts +49 -0
- package/extension/substrate/sessionData.ts +229 -0
- package/extension/substrate/structuredOutput.ts +141 -0
- package/extension/substrate/toolGating.ts +400 -0
- package/extension/substrate/toolParams.ts +106 -0
- package/extension/substrate/workflowState.ts +233 -0
- package/extension/surfaces/footerProvider.ts +43 -0
- package/extension/surfaces/report.ts +34 -0
- package/extension/surfaces/surfaces.ts +460 -0
- package/extension/vendor/btw/btw.ts +964 -0
- package/extension/vendor/btw/core.ts +153 -0
- package/extension/vendor/whimsical/whimsical.ts +485 -0
- package/extension/worker/readOnlySession.ts +282 -0
- package/extension/worker/worker.ts +765 -0
- package/extension/workerMain.ts +150 -0
- package/package.json +55 -0
- package/prompts/README.md +15 -0
- package/prompts/_fixtures/cases.yaml +140 -0
- package/prompts/_fixtures/golden/address-action-model.txt +10 -0
- package/prompts/_fixtures/golden/address-action.txt +10 -0
- package/prompts/_fixtures/golden/address-preview-model.txt +6 -0
- package/prompts/_fixtures/golden/address-preview.txt +6 -0
- package/prompts/_fixtures/golden/hello.txt +1 -0
- package/prompts/_fixtures/golden/implement-github.txt +8 -0
- package/prompts/_fixtures/golden/learn-docs.txt +8 -0
- package/prompts/_fixtures/golden/learn-github.txt +11 -0
- package/prompts/_fixtures/golden/learn-linear.txt +11 -0
- package/prompts/_fixtures/golden/learn-no-ref.txt +8 -0
- package/prompts/_fixtures/golden/learn-other.txt +8 -0
- package/prompts/_fixtures/golden/objective-plan-guidance-linear.txt +8 -0
- package/prompts/_fixtures/golden/objective-plan-guidance.txt +8 -0
- package/prompts/_fixtures/golden/objective-plan-seed-linear.txt +20 -0
- package/prompts/_fixtures/golden/objective-plan-seed.txt +15 -0
- package/prompts/_fixtures/golden/objective-read-linear-nourl.txt +1 -0
- package/prompts/_fixtures/golden/objective-read-linear.txt +1 -0
- package/prompts/_fixtures/golden/plan-read-github.txt +1 -0
- package/prompts/_fixtures/golden/plan-read-linear.txt +1 -0
- package/prompts/_fixtures/golden/plan-read-other.txt +1 -0
- package/prompts/_fixtures/golden/with_include.txt +4 -0
- package/prompts/_fixtures/templates/_greeting.md +1 -0
- package/prompts/_fixtures/templates/hello.md +1 -0
- package/prompts/_fixtures/templates/with_include.md +4 -0
- package/prompts/common/objective-read/linear.md +1 -0
- package/prompts/common/plan-read/github.md +1 -0
- package/prompts/common/plan-read/linear.md +1 -0
- package/prompts/common/plan-read/other.md +1 -0
- package/prompts/stages/address/action.md +10 -0
- package/prompts/stages/address/preview.md +6 -0
- package/prompts/stages/implement.md +8 -0
- package/prompts/stages/learn-docs.md +8 -0
- package/prompts/stages/learn.md +21 -0
- package/prompts/stages/objective-plan/guidance.md +12 -0
- package/prompts/stages/objective-plan/seed.md +20 -0
- package/shared/README.md +29 -0
- package/shared/bindings.yaml +64 -0
- package/shared/contracts-history.md +403 -0
- package/shared/contracts.md +4172 -0
- package/shared/providers.yaml +221 -0
- package/shared/registry.yaml +199 -0
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
// The tool-gating primitive (the keystone). Structural read-only enforcement, NOT
|
|
2
|
+
// prompting. Mirrors pi's authoritative `examples/extensions/plan-mode/` recipe (the
|
|
3
|
+
// `setActiveTools` allowlist + `tool_call` bash sub-allowlist + `before_agent_start` injection +
|
|
4
|
+
// `context` strip-when-off) and `preset.ts`'s snapshot-then-restore. The gate attaches to the
|
|
5
|
+
// existing `perk:workflow-state.mode` field (`read-only`/`read-write`) — no new registry stage.
|
|
6
|
+
//
|
|
7
|
+
// Substrate only: perk-owned plan mode and the read-only CI executor are the consumers of the
|
|
8
|
+
// `enter`/`exit` surface; the allowlist-restore is wired into the existing
|
|
9
|
+
// `session_start`/`session_tree` rebuild points.
|
|
10
|
+
|
|
11
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
12
|
+
import { WORKFLOW_STATE_TYPE } from "./workflowState.ts";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Tools available while read-only mode is active (mirrors plan-mode's PLAN_MODE_TOOLS).
|
|
16
|
+
* `plan_review` is the backend-neutral review door (planReview.ts) — allowlisted so the model
|
|
17
|
+
* can request a human plan review INSIDE plan mode (review happens before the gate ever comes
|
|
18
|
+
* off); fail-open everywhere (headless / dismissed soft-skip), so it is safe on every path.
|
|
19
|
+
*/
|
|
20
|
+
export const READ_ONLY_TOOLS = [
|
|
21
|
+
"read",
|
|
22
|
+
"grep",
|
|
23
|
+
"find",
|
|
24
|
+
"ls",
|
|
25
|
+
"bash",
|
|
26
|
+
"ask_user_question",
|
|
27
|
+
"plan_review",
|
|
28
|
+
// The plan_draft carve-out: plan_draft is structurally limited to the one working-plan
|
|
29
|
+
// artifact in the run-scoped session data dir (gitignored scratch), so the read-only invariant
|
|
30
|
+
// (worktree untouched) holds; the `tool_call` edit/write/bash blocking below is unchanged.
|
|
31
|
+
"plan_draft",
|
|
32
|
+
// The objective_draft twin of the plan_draft carve-out: objective_draft writes only the one
|
|
33
|
+
// working-objective artifact in the session data dir (fixed artifact name, seam-derived
|
|
34
|
+
// path); the gate's edit/write/bash blocking is unchanged.
|
|
35
|
+
"objective_draft",
|
|
36
|
+
// The `web` seam providers' research tools: the UNION of all known web-provider tool
|
|
37
|
+
// names, allowlisted statically and inert when the package is absent (the plan_review precedent
|
|
38
|
+
// — setActiveTools simply has nothing to enable). None mutate the repo — fetch_content's
|
|
39
|
+
// GitHub-clone path writes only to its own cache outside the worktree, morally equivalent to the
|
|
40
|
+
// already-allowlisted curl. perk does NOT normalize names, so all three providers' divergent
|
|
41
|
+
// names are listed: pi-web-access (default: web_search/code_search/fetch_content/
|
|
42
|
+
// get_search_content), @ollama/pi-web-search (ollama_web_search/ollama_web_fetch), and
|
|
43
|
+
// @juicesharp/rpiv-web-tools (web_search shared, web_fetch).
|
|
44
|
+
"web_search",
|
|
45
|
+
"code_search",
|
|
46
|
+
"fetch_content",
|
|
47
|
+
"get_search_content",
|
|
48
|
+
"ollama_web_search",
|
|
49
|
+
"ollama_web_fetch",
|
|
50
|
+
"web_fetch",
|
|
51
|
+
// pi-mono-linear's read-only tools (the [issues] backend = "linear" selection):
|
|
52
|
+
// none mutate Linear or the repo. Foreign names are inert when the package is absent (the
|
|
53
|
+
// pi-web-access precedent above). The mutating/sensitive tools are deliberately excluded:
|
|
54
|
+
// linear_create_issue, linear_update_issue, linear_create_comment, linear_upload_file,
|
|
55
|
+
// linear_upload_file_to_issue_comment, linear_configure_auth (writes ~/.pi/agent/auth.json).
|
|
56
|
+
"linear_whoami",
|
|
57
|
+
"linear_workspace_metadata",
|
|
58
|
+
"linear_list_teams",
|
|
59
|
+
"linear_get_team",
|
|
60
|
+
"linear_list_users",
|
|
61
|
+
"linear_get_user",
|
|
62
|
+
"linear_list_issues",
|
|
63
|
+
"linear_get_issue",
|
|
64
|
+
"linear_search_issues",
|
|
65
|
+
"linear_list_my_issues",
|
|
66
|
+
"linear_list_projects",
|
|
67
|
+
"linear_get_project",
|
|
68
|
+
"linear_list_issue_statuses",
|
|
69
|
+
"linear_get_issue_status",
|
|
70
|
+
"linear_list_labels",
|
|
71
|
+
"linear_list_cycles",
|
|
72
|
+
"linear_list_documents",
|
|
73
|
+
"linear_get_document",
|
|
74
|
+
"linear_list_comments",
|
|
75
|
+
];
|
|
76
|
+
|
|
77
|
+
/** The read-only marker / custom-message type injected into context while active. */
|
|
78
|
+
const MODE_CONTEXT_TYPE = "perk:mode-context";
|
|
79
|
+
const READ_ONLY_MARKER = "[READ-ONLY MODE]";
|
|
80
|
+
|
|
81
|
+
/** Exported for tests: the injected read-only mode context (interpolates the allowlist). */
|
|
82
|
+
export const READ_ONLY_CONTEXT = `${READ_ONLY_MARKER}
|
|
83
|
+
You are in perk read-only mode — a structurally enforced exploration mode.
|
|
84
|
+
|
|
85
|
+
- You can only use: ${READ_ONLY_TOOLS.join(", ")}.
|
|
86
|
+
- You CANNOT use edit or write (file modifications are blocked).
|
|
87
|
+
- plan_draft is the sole sanctioned write: it writes only the working-plan artifact in the session data dir.
|
|
88
|
+
- bash is restricted to an allowlist of read-only commands.
|
|
89
|
+
- For GitHub data use read-only \`gh\` subcommands (view/list/diff/status/checks/search) — never raw curl/fetch against github.com (private repos reject unauthenticated requests).
|
|
90
|
+
|
|
91
|
+
These restrictions are enforced by perk, not advisory. Do not attempt to make changes.`;
|
|
92
|
+
|
|
93
|
+
// --- pure policy (copied from plan-mode/utils.ts so this primitive is self-contained; perk-owned
|
|
94
|
+
// so T2's eventual retirement of the borrowed pi-plan extension leaves no dangling import) -------
|
|
95
|
+
|
|
96
|
+
const DESTRUCTIVE_PATTERNS = [
|
|
97
|
+
/\brm\b/i,
|
|
98
|
+
/\brmdir\b/i,
|
|
99
|
+
/\bmv\b/i,
|
|
100
|
+
/\bcp\b/i,
|
|
101
|
+
/\bmkdir\b/i,
|
|
102
|
+
/\btouch\b/i,
|
|
103
|
+
/\bchmod\b/i,
|
|
104
|
+
/\bchown\b/i,
|
|
105
|
+
/\bchgrp\b/i,
|
|
106
|
+
/\bln\b/i,
|
|
107
|
+
/\btee\b/i,
|
|
108
|
+
/\btruncate\b/i,
|
|
109
|
+
/\bdd\b/i,
|
|
110
|
+
/\bshred\b/i,
|
|
111
|
+
/(^|[^<])>(?!>)/,
|
|
112
|
+
/>>/,
|
|
113
|
+
/\bnpm\s+(install|uninstall|update|ci|link|publish)/i,
|
|
114
|
+
/\byarn\s+(add|remove|install|publish)/i,
|
|
115
|
+
/\bpnpm\s+(add|remove|install|publish)/i,
|
|
116
|
+
/\bpip\s+(install|uninstall)/i,
|
|
117
|
+
/\bapt(-get)?\s+(install|remove|purge|update|upgrade)/i,
|
|
118
|
+
/\bbrew\s+(install|uninstall|upgrade)/i,
|
|
119
|
+
/\bgit\s+(add|commit|push|pull|merge|rebase|reset|checkout|branch\s+-[dD]|stash|cherry-pick|revert|tag|init|clone)/i,
|
|
120
|
+
/\bsudo\b/i,
|
|
121
|
+
/\bsu\b/i,
|
|
122
|
+
/\bkill\b/i,
|
|
123
|
+
/\bpkill\b/i,
|
|
124
|
+
/\bkillall\b/i,
|
|
125
|
+
/\breboot\b/i,
|
|
126
|
+
/\bshutdown\b/i,
|
|
127
|
+
/\bsystemctl\s+(start|stop|restart|enable|disable)/i,
|
|
128
|
+
/\bservice\s+\S+\s+(start|stop|restart)/i,
|
|
129
|
+
/\b(vim?|nano|emacs|code|subl)\b/i,
|
|
130
|
+
];
|
|
131
|
+
|
|
132
|
+
const SAFE_PATTERNS = [
|
|
133
|
+
// `cd` mutates nothing — it is the common prefix for scoping a read-only query
|
|
134
|
+
// (`cd repo && perk objective show …`). Safe under the per-segment model: every other
|
|
135
|
+
// segment is still independently validated and the whole-string destructive veto is unchanged.
|
|
136
|
+
/^\s*cd\b/,
|
|
137
|
+
/^\s*cat\b/,
|
|
138
|
+
/^\s*head\b/,
|
|
139
|
+
/^\s*tail\b/,
|
|
140
|
+
/^\s*less\b/,
|
|
141
|
+
/^\s*more\b/,
|
|
142
|
+
/^\s*grep\b/,
|
|
143
|
+
/^\s*find\b/,
|
|
144
|
+
/^\s*ls\b/,
|
|
145
|
+
/^\s*pwd\b/,
|
|
146
|
+
/^\s*echo\b/,
|
|
147
|
+
/^\s*printf\b/,
|
|
148
|
+
/^\s*wc\b/,
|
|
149
|
+
/^\s*sort\b/,
|
|
150
|
+
/^\s*uniq\b/,
|
|
151
|
+
/^\s*diff\b/,
|
|
152
|
+
/^\s*file\b/,
|
|
153
|
+
/^\s*stat\b/,
|
|
154
|
+
/^\s*du\b/,
|
|
155
|
+
/^\s*df\b/,
|
|
156
|
+
/^\s*tree\b/,
|
|
157
|
+
/^\s*which\b/,
|
|
158
|
+
/^\s*whereis\b/,
|
|
159
|
+
/^\s*type\b/,
|
|
160
|
+
/^\s*env\b/,
|
|
161
|
+
/^\s*printenv\b/,
|
|
162
|
+
/^\s*uname\b/,
|
|
163
|
+
/^\s*whoami\b/,
|
|
164
|
+
/^\s*id\b/,
|
|
165
|
+
/^\s*date\b/,
|
|
166
|
+
/^\s*cal\b/,
|
|
167
|
+
/^\s*uptime\b/,
|
|
168
|
+
/^\s*ps\b/,
|
|
169
|
+
/^\s*top\b/,
|
|
170
|
+
/^\s*htop\b/,
|
|
171
|
+
/^\s*free\b/,
|
|
172
|
+
/^\s*git\s+(status|log|diff|show|branch|remote|config\s+--get)/i,
|
|
173
|
+
/^\s*git\s+ls-/i,
|
|
174
|
+
/^\s*npm\s+(list|ls|view|info|search|outdated|audit)/i,
|
|
175
|
+
/^\s*yarn\s+(list|info|why|audit)/i,
|
|
176
|
+
/^\s*node\s+--version/i,
|
|
177
|
+
/^\s*python\s+--version/i,
|
|
178
|
+
/^\s*curl\s/i,
|
|
179
|
+
/^\s*wget\s+-O\s*-/i,
|
|
180
|
+
/^\s*jq\b/,
|
|
181
|
+
/^\s*sed\s+-n/i,
|
|
182
|
+
/^\s*awk\b/,
|
|
183
|
+
/^\s*rg\b/,
|
|
184
|
+
/^\s*fd\b/,
|
|
185
|
+
/^\s*ast-grep\b/,
|
|
186
|
+
// Browser-automation skill (.agents/skills/agent-browser): a command-keyed entry mirroring
|
|
187
|
+
// `ast-grep` — it gates the command, not its args. Two invocation forms: the bare global
|
|
188
|
+
// install on PATH, and the `npx` fallback anchored to `agent-browser` so bare `npx <anything>`
|
|
189
|
+
// stays blocked. Accepted known leniency: the leading-command model cannot inspect args, so
|
|
190
|
+
// agent-browser's own output flags (screenshot/video `--output`) can write files and its actions
|
|
191
|
+
// can mutate external sites — outside the gate's granularity. This is accepted and documented,
|
|
192
|
+
// consistent with the allowlisted `curl` / `fetch_content` GitHub-clone cache-write precedent
|
|
193
|
+
// (both write outside the gate). The whole-string `>`-redirect destructive veto still applies.
|
|
194
|
+
/^\s*agent-browser\b/,
|
|
195
|
+
/^\s*npx\s+agent-browser\b/,
|
|
196
|
+
/^\s*bat\b/,
|
|
197
|
+
/^\s*eza\b/,
|
|
198
|
+
// perk's own read-only objective queries (show/next + their s/n aliases). The trailing \b keeps
|
|
199
|
+
// the `n` alias from matching the mutating `node` subcommand; create/node/reconcile stay blocked.
|
|
200
|
+
/^\s*perk\s+(objective|obj)\s+(show|s|next|n)\b/i,
|
|
201
|
+
// Read-only `gh` queries — the guidance in the managed AGENTS block ("GitHub access goes
|
|
202
|
+
// through gh") must be followable in read-only sessions. Query-shaped subcommands only;
|
|
203
|
+
// `gh api` stays blocked (it can POST/PATCH), as do all mutating subcommands (create/edit/
|
|
204
|
+
// merge/close/comment/clone/...). Destructive-wins still blocks `> file` redirects.
|
|
205
|
+
/^\s*gh\s+(issue|pr|repo|run|release|label)\s+(view|list|diff|status|checks)\b/i,
|
|
206
|
+
/^\s*gh\s+search\s+(issues|prs|code|commits|repos)\b/i,
|
|
207
|
+
/^\s*gh\s+auth\s+status\b/i,
|
|
208
|
+
];
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Split a command into top-level shell segments for the per-segment safe check. Walks the string
|
|
212
|
+
* character by character tracking single- and double-quote state, splitting only on UNQUOTED
|
|
213
|
+
* sequencing operators `;`, `&&`, `||`, and `|` (`&&`/`||` are two-char operators; a lone `|` is
|
|
214
|
+
* the pipe). Quoted operators must not split — load-bearing: a `|` inside `grep -iE 'a|b'` stays
|
|
215
|
+
* in one segment. Segments are trimmed and empties dropped.
|
|
216
|
+
*
|
|
217
|
+
* Known limitation: backslash-escaped quote characters are not handled. This is acceptable — the
|
|
218
|
+
* whole-string destructive veto in isReadOnlyBashCommand remains the backstop.
|
|
219
|
+
*/
|
|
220
|
+
function splitTopLevelSegments(command: string): string[] {
|
|
221
|
+
const segments: string[] = [];
|
|
222
|
+
let current = "";
|
|
223
|
+
let quote: '"' | "'" | null = null;
|
|
224
|
+
for (let i = 0; i < command.length; i++) {
|
|
225
|
+
const ch = command[i];
|
|
226
|
+
if (quote) {
|
|
227
|
+
current += ch;
|
|
228
|
+
if (ch === quote) quote = null;
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
if (ch === '"' || ch === "'") {
|
|
232
|
+
quote = ch;
|
|
233
|
+
current += ch;
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
if (ch === ";" || ch === "|" || ch === "&") {
|
|
237
|
+
const next = command[i + 1];
|
|
238
|
+
if ((ch === "|" && next === "|") || (ch === "&" && next === "&")) {
|
|
239
|
+
// two-char operator (`||` / `&&`)
|
|
240
|
+
segments.push(current);
|
|
241
|
+
current = "";
|
|
242
|
+
i++;
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
if (ch === ";" || ch === "|") {
|
|
246
|
+
// single-char sequencing operator (`;` / `|`)
|
|
247
|
+
segments.push(current);
|
|
248
|
+
current = "";
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
// a lone `&` (background / part of `&>`): keep it in the segment so `&>` redirect detection
|
|
252
|
+
// and the destructive veto see it intact.
|
|
253
|
+
current += ch;
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
current += ch;
|
|
257
|
+
}
|
|
258
|
+
segments.push(current);
|
|
259
|
+
return segments.map((s) => s.trim()).filter((s) => s.length > 0);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Whether a bash command is allowed under read-only mode. Two independent checks:
|
|
264
|
+
* - NOT destructive: a WHOLE-STRING scan against DESTRUCTIVE_PATTERNS (destructive-wins — content
|
|
265
|
+
* anywhere in the string, incl. command substitutions, still vetoes). Two redirect carve-outs
|
|
266
|
+
* are neutralized first: FD duplications (`2>&1`, `1>&2`) and redirects to `/dev/null`
|
|
267
|
+
* (`>/dev/null`, `2>/dev/null`, `&>/dev/null`, `>>/dev/null`) — both discard output and write
|
|
268
|
+
* nothing to the filesystem. Redirects to a REAL path (`> file`, `&> file`, `>> file`) are NOT
|
|
269
|
+
* carved out and stay destructive.
|
|
270
|
+
* - SAFE per segment: split into quote-aware top-level segments (on `;`/`&&`/`||`/`|`) and require
|
|
271
|
+
* EVERY segment's leading command to match a SAFE_PATTERNS entry. This unblocks `cd`-prefixed
|
|
272
|
+
* chains and tightens the model — a non-safe command anywhere in a chain is now blocked, not
|
|
273
|
+
* just when it leads.
|
|
274
|
+
* Pure → unit-testable offline.
|
|
275
|
+
*/
|
|
276
|
+
export function isReadOnlyBashCommand(command: string): boolean {
|
|
277
|
+
const withoutFdRedirects = command
|
|
278
|
+
.replace(/\d*>&\d+/g, " ")
|
|
279
|
+
.replace(/(?:\d+|&)?>>?\s*\/dev\/null\b/g, " ");
|
|
280
|
+
const isDestructive = DESTRUCTIVE_PATTERNS.some((p) => p.test(withoutFdRedirects));
|
|
281
|
+
const segments = splitTopLevelSegments(command);
|
|
282
|
+
const isSafe =
|
|
283
|
+
segments.length > 0 && segments.every((seg) => SAFE_PATTERNS.some((p) => p.test(seg)));
|
|
284
|
+
return !isDestructive && isSafe;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// --- the controller -----------------------------------------------------------------------------
|
|
288
|
+
|
|
289
|
+
/** The API T2/T5 consume + the lifecycle hooks index.ts wires. */
|
|
290
|
+
export interface ToolGating {
|
|
291
|
+
/** Reapply the allowlist from a rebuilt `mode` (called on session_start AND session_tree). */
|
|
292
|
+
syncFromState(mode: string | undefined): void;
|
|
293
|
+
/** Enter read-only mode: persist `mode=read-only` + snapshot/restrict tools. (T2/T5 call site.) */
|
|
294
|
+
enter(ctx?: ExtensionContext): void;
|
|
295
|
+
/** Exit read-only mode: persist `mode=read-write` + restore tools. (T2/T5 call site.) */
|
|
296
|
+
exit(ctx?: ExtensionContext): void;
|
|
297
|
+
/** Whether the gate is currently active (in-memory source of truth for `tool_call`). */
|
|
298
|
+
isActive(): boolean;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function isReadOnlyMode(mode: string | undefined): boolean {
|
|
302
|
+
return mode === "read-only";
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
export function registerToolGating(pi: ExtensionAPI): ToolGating {
|
|
306
|
+
// In-memory gate (mirrors plan-mode's `planModeEnabled`): the authority `tool_call` consults.
|
|
307
|
+
// Fail-closed — a failed sync never opens this; tool_call blocks on any internal error.
|
|
308
|
+
let active = false;
|
|
309
|
+
// Pre-gate tool snapshot, taken once on the off→on transition (preset.ts discipline).
|
|
310
|
+
let snapshot: string[] | null = null;
|
|
311
|
+
|
|
312
|
+
function applyActive(next: boolean): void {
|
|
313
|
+
if (next && !active) {
|
|
314
|
+
// off → on: snapshot the current tool set, then restrict.
|
|
315
|
+
snapshot = pi.getActiveTools();
|
|
316
|
+
pi.setActiveTools(READ_ONLY_TOOLS);
|
|
317
|
+
} else if (!next && active) {
|
|
318
|
+
// on → off: restore the pre-gate snapshot. If none exists (near-unreachable — the off→on
|
|
319
|
+
// branch always snapshots first), fall back to the FULL configured tool set
|
|
320
|
+
// (pi.getAllTools()) like plan-mode, never a hardcoded list that would silently drop
|
|
321
|
+
// grep/find/ls and perk's custom tools (plan_save/submit/land/learn).
|
|
322
|
+
pi.setActiveTools(snapshot ?? pi.getAllTools().map((t) => t.name));
|
|
323
|
+
snapshot = null;
|
|
324
|
+
}
|
|
325
|
+
active = next;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// Structural backstop: block writes + non-allowlisted bash while active. Fail-closed on error.
|
|
329
|
+
pi.on("tool_call", async (event) => {
|
|
330
|
+
try {
|
|
331
|
+
if (!active) return;
|
|
332
|
+
if (event.toolName === "edit" || event.toolName === "write") {
|
|
333
|
+
return {
|
|
334
|
+
block: true,
|
|
335
|
+
reason: `perk read-only mode: ${event.toolName} is blocked (file modifications disabled).`,
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
if (event.toolName === "bash") {
|
|
339
|
+
const command = String((event.input as { command?: unknown }).command ?? "");
|
|
340
|
+
if (!isReadOnlyBashCommand(command)) {
|
|
341
|
+
return {
|
|
342
|
+
block: true,
|
|
343
|
+
reason: `perk read-only mode: command blocked (not allowlisted).\nCommand: ${command}`,
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
return;
|
|
348
|
+
} catch {
|
|
349
|
+
// Never let an internal error open the gate — fail closed.
|
|
350
|
+
return { block: true, reason: "perk read-only mode: blocked (internal gating error)." };
|
|
351
|
+
}
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
// Inject the hidden read-only mode context while active (display:false → not shown in transcript).
|
|
355
|
+
pi.on("before_agent_start", async () => {
|
|
356
|
+
if (!active) return;
|
|
357
|
+
return {
|
|
358
|
+
message: { customType: MODE_CONTEXT_TYPE, content: READ_ONLY_CONTEXT, display: false },
|
|
359
|
+
};
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
// Strip the stale read-only marker from context when the gate is off (so it never lingers).
|
|
363
|
+
pi.on("context", async (event) => {
|
|
364
|
+
if (active) return;
|
|
365
|
+
return {
|
|
366
|
+
messages: event.messages.filter((m) => {
|
|
367
|
+
const msg = m as { customType?: string; role?: string; content?: unknown };
|
|
368
|
+
if (msg.customType === MODE_CONTEXT_TYPE) return false;
|
|
369
|
+
if (msg.role !== "user") return true;
|
|
370
|
+
const content = msg.content;
|
|
371
|
+
if (typeof content === "string") return !content.includes(READ_ONLY_MARKER);
|
|
372
|
+
if (Array.isArray(content)) {
|
|
373
|
+
return !content.some(
|
|
374
|
+
(c) =>
|
|
375
|
+
(c as { type?: string; text?: string }).type === "text" &&
|
|
376
|
+
((c as { text?: string }).text ?? "").includes(READ_ONLY_MARKER),
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
return true;
|
|
380
|
+
}),
|
|
381
|
+
};
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
return {
|
|
385
|
+
syncFromState(mode: string | undefined): void {
|
|
386
|
+
applyActive(isReadOnlyMode(mode));
|
|
387
|
+
},
|
|
388
|
+
enter(_ctx?: ExtensionContext): void {
|
|
389
|
+
pi.appendEntry(WORKFLOW_STATE_TYPE, { mode: "read-only" });
|
|
390
|
+
applyActive(true);
|
|
391
|
+
},
|
|
392
|
+
exit(_ctx?: ExtensionContext): void {
|
|
393
|
+
pi.appendEntry(WORKFLOW_STATE_TYPE, { mode: "read-write" });
|
|
394
|
+
applyActive(false);
|
|
395
|
+
},
|
|
396
|
+
isActive(): boolean {
|
|
397
|
+
return active;
|
|
398
|
+
},
|
|
399
|
+
};
|
|
400
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// The tool-boundary decode seam — the sibling of coldDoor.ts's JSON-boundary decode. Registered
|
|
2
|
+
// tools receive their LLM-supplied `params` as `unknown` (the SDK's
|
|
3
|
+
// `Static<TSchema>` default); each handler NARROWS that unknown here instead of asserting a shape
|
|
4
|
+
// with `params as {…}`. The live LLM path is already schema-validated by pi's agent loop before
|
|
5
|
+
// `execute`, so these helpers are type-honesty plus defense-in-depth for the unvalidated direct
|
|
6
|
+
// paths (the test harness's `invokeTool`; any future programmatic caller).
|
|
7
|
+
//
|
|
8
|
+
// Tri-state by design: the uniform strict-fail policy needs ABSENT (decode to undefined — current
|
|
9
|
+
// optional-field behavior) distinguished from PRESENT-BUT-MISTYPED (the `null` invalid sentinel —
|
|
10
|
+
// fail the call with `bad_input`). That is exactly why coldDoor.ts's lenient two-state field
|
|
11
|
+
// helpers (absent OR mistyped → undefined) are not reused here — different boundary, different
|
|
12
|
+
// semantics.
|
|
13
|
+
|
|
14
|
+
/** A narrowed tool-call params object. */
|
|
15
|
+
export type ToolParams = Record<string, unknown>;
|
|
16
|
+
|
|
17
|
+
/** Narrow unknown tool-call params to a plain object; null when not one. */
|
|
18
|
+
export function paramsOf(params: unknown): ToolParams | null {
|
|
19
|
+
if (typeof params !== "object" || params === null || Array.isArray(params)) return null;
|
|
20
|
+
return params as ToolParams;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Tri-state string field: undefined = absent; null = present-but-mistyped. */
|
|
24
|
+
export function stringParam(p: ToolParams, key: string): string | undefined | null {
|
|
25
|
+
const value = p[key];
|
|
26
|
+
if (value === undefined) return undefined;
|
|
27
|
+
return typeof value === "string" ? value : null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Tri-state number field: undefined = absent; null = present-but-mistyped. */
|
|
31
|
+
export function numberParam(p: ToolParams, key: string): number | undefined | null {
|
|
32
|
+
const value = p[key];
|
|
33
|
+
if (value === undefined) return undefined;
|
|
34
|
+
return typeof value === "number" ? value : null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Tri-state string-array field: every element must be a string; null on any mismatch. */
|
|
38
|
+
export function stringArrayParam(p: ToolParams, key: string): string[] | undefined | null {
|
|
39
|
+
const value = p[key];
|
|
40
|
+
if (value === undefined) return undefined;
|
|
41
|
+
if (!Array.isArray(value)) return null;
|
|
42
|
+
const out: string[] = [];
|
|
43
|
+
for (const item of value) {
|
|
44
|
+
if (typeof item !== "string") return null;
|
|
45
|
+
out.push(item);
|
|
46
|
+
}
|
|
47
|
+
return out;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Tri-state number-array field: every element must be a number; null on any mismatch. */
|
|
51
|
+
export function numberArrayParam(p: ToolParams, key: string): number[] | undefined | null {
|
|
52
|
+
const value = p[key];
|
|
53
|
+
if (value === undefined) return undefined;
|
|
54
|
+
if (!Array.isArray(value)) return null;
|
|
55
|
+
const out: number[] = [];
|
|
56
|
+
for (const item of value) {
|
|
57
|
+
if (typeof item !== "number") return null;
|
|
58
|
+
out.push(item);
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Tri-state issue-id field: a string passes through; a number coerces via `String()` (models
|
|
65
|
+
* routinely echo numeric GitHub ids un-quoted); anything else → null. Issue ids are opaque
|
|
66
|
+
* strings at every boundary (contracts §8.21).
|
|
67
|
+
*/
|
|
68
|
+
export function idParam(p: ToolParams, key: string): string | undefined | null {
|
|
69
|
+
const value = p[key];
|
|
70
|
+
if (value === undefined) return undefined;
|
|
71
|
+
if (typeof value === "string") return value;
|
|
72
|
+
if (typeof value === "number") return String(value);
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Tri-state issue-id-array field: strings pass through; numbers coerce via `String()` (models
|
|
78
|
+
* are routinely shown bare numeric ids — e.g. the learn-docs guidance — and may echo them as
|
|
79
|
+
* numbers); anything else → null. Issue ids are opaque strings at every boundary (contracts
|
|
80
|
+
* §8.21).
|
|
81
|
+
*/
|
|
82
|
+
export function idArrayParam(p: ToolParams, key: string): string[] | undefined | null {
|
|
83
|
+
const value = p[key];
|
|
84
|
+
if (value === undefined) return undefined;
|
|
85
|
+
if (!Array.isArray(value)) return null;
|
|
86
|
+
const out: string[] = [];
|
|
87
|
+
for (const item of value) {
|
|
88
|
+
if (typeof item !== "string" && typeof item !== "number") return null;
|
|
89
|
+
out.push(String(item));
|
|
90
|
+
}
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Tri-state array field: any array passes (elements stay unknown); null on a non-array. */
|
|
95
|
+
export function arrayParam(p: ToolParams, key: string): unknown[] | undefined | null {
|
|
96
|
+
const value = p[key];
|
|
97
|
+
if (value === undefined) return undefined;
|
|
98
|
+
return Array.isArray(value) ? [...value] : null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Tri-state plain-object field: arrays/null/non-objects are invalid. */
|
|
102
|
+
export function objectParam(p: ToolParams, key: string): ToolParams | undefined | null {
|
|
103
|
+
const value = p[key];
|
|
104
|
+
if (value === undefined) return undefined;
|
|
105
|
+
return paramsOf(value) ?? null;
|
|
106
|
+
}
|