@wernerbisschoff/pi-gatekeeper 0.1.1 โ†’ 0.1.3

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.
@@ -17,10 +17,10 @@
17
17
  */
18
18
  import { exec } from "node:child_process";
19
19
  import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
20
- import os from "node:os";
21
20
  import { dirname, join, resolve } from "node:path";
22
21
  import { fileURLToPath } from "node:url";
23
22
  import { promisify } from "node:util";
23
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
24
24
  import { Type } from "@sinclair/typebox";
25
25
  const execAsync = promisify(exec);
26
26
  /**
@@ -317,7 +317,7 @@ export function saveRules(filePath, payload, ctx) {
317
317
  * extension creates it from a clean slate.
318
318
  */
319
319
  export function getRulesPath() {
320
- const agentDir = join(os.homedir(), ".pi", "agent");
320
+ const agentDir = getAgentDir();
321
321
  mkdirSync(agentDir, { recursive: true, mode: 0o700 });
322
322
  return join(agentDir, "perm-rules.json");
323
323
  }
@@ -564,21 +564,27 @@ export const PERMISSION_STATUS_KEY = "perm";
564
564
  * TUI footer labels keyed by PermissionLevel. Specific emoji + label pairs are
565
565
  * documented in AC-010-01 / AC-010-02
566
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)
567
+ * - `low` โ†’ `LOW` (restricted/safe)
568
+ * - `medium` โ†’ `MEDIUM` (balanced)
569
+ * - `high` โ†’ `HIGH` (permissive)
570
570
  * The status key (`PERMISSION_STATUS_KEY`) is reserved by `architecture.md:157` โ€” no other extension
571
571
  * may write to it (otherwise the level indicator could be shadowed on the TUI footer).
572
572
  */
573
+ /**
574
+ * Plain-text level labels used by {@link updateStatus} to build the
575
+ * TUI footer indicator. The labels are intentionally simple โ€” the
576
+ * `/perm` hint is appended at render time so the indicator is
577
+ * self-documenting without coloured circles.
578
+ */
573
579
  export const MODE_LABELS = {
574
- low: "๐ŸŸข LOW",
575
- medium: "๐ŸŸก MEDIUM",
576
- high: "๐ŸŸ  HIGH",
580
+ low: "LOW",
581
+ medium: "MEDIUM",
582
+ high: "HIGH",
577
583
  };
578
584
  /**
579
585
  * Render the active permission level into the TUI footer via `ctx.ui.setStatus(...)` using the
580
586
  * 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
587
+ * `~/.pi/agent/perm-mode` file directly and overlays the level indicator in the
582
588
  * bottom powerline bar โ€” so the `setStatus` call here serves as the fallback for built-in
583
589
  * footer mode and as a data source for any extension that reads status keys directly.
584
590
  *
@@ -588,7 +594,11 @@ export const MODE_LABELS = {
588
594
  * define the primitive so it is independently testable.
589
595
  */
590
596
  export function updateStatus(ctx) {
591
- ctx.ui.setStatus(PERMISSION_STATUS_KEY, MODE_LABELS[currentMode]);
597
+ const label = MODE_LABELS[currentMode];
598
+ const text = `${label} ยท /perm`;
599
+ // Apply muted theme styling when available (supported in both Pi and OMP).
600
+ const styled = typeof ctx.ui.theme?.fg === "function" ? ctx.ui.theme.fg("muted", text) : text;
601
+ ctx.ui.setStatus(PERMISSION_STATUS_KEY, styled);
592
602
  // Broadcast the current mode on the shared extension event bus so the footer
593
603
  // extension (which may replace the built-in footer) can update its indicator
594
604
  // without polling the filesystem. Guarded with typeof checks since test mocks
@@ -1270,40 +1280,29 @@ export function matchSegmentAgainstList(segment, patterns) {
1270
1280
  return result.matched ? { matched: true, pattern: result.pattern } : { matched: false };
1271
1281
  }
1272
1282
  /**
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`).
1283
+ * Find the first matching pattern in `patterns`. Single walker primitive for first-match-wins
1284
+ * semantics โ€” {@link matchSegmentAgainstList}, {@link evaluateExplicit}, and the cascade walkers
1285
+ * delegate here so the public API, the explicit-pass evaluator, and the cascade walkers share
1286
+ * one iteration loop. Returns only the matched pattern name on hit (no index) because no
1287
+ * caller surfaces "earlier patterns did not match" diagnostic context โ€” first-match-wins is
1288
+ * expressed by the matched pattern alone (`data-model.md:61`).
1281
1289
  */
1282
1290
  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 };
1291
+ for (const pattern of patterns) {
1292
+ if (matchGlob(pattern, segment)) {
1293
+ return { matched: true, pattern };
1286
1294
  }
1287
1295
  }
1288
1296
  return { matched: false };
1289
1297
  }
1290
1298
  /**
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.
1299
+ * Build the reason string for an explicit-pass match at the current level. Single-line form:
1300
+ * `${action} at ${level} matched '<pattern>'`. First-match-wins is expressed by the matched
1301
+ * pattern name alone โ€” no earlier-patterns parenthetical is appended, so the FLOW-07 prompt
1302
+ * preview stays decluttered and only the winning pattern reaches the LLM.
1297
1303
  */
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)`;
1304
+ function explicitReason(action, level, matched) {
1305
+ return `${action} at ${level} matched '${matched}'`;
1307
1306
  }
1308
1307
  /**
1309
1308
  * Build the reason string for a cascade-inherited match. Mirrors {@link explicitReason}'s
@@ -1333,7 +1332,7 @@ function evaluateExplicit(segment, mode, levelRules) {
1333
1332
  if (match.matched) {
1334
1333
  return {
1335
1334
  decision: action,
1336
- reason: explicitReason(action, mode, match.pattern, match.index, levelRules[action]),
1335
+ reason: explicitReason(action, mode, match.pattern),
1337
1336
  };
1338
1337
  }
1339
1338
  }
@@ -1529,9 +1528,17 @@ export function classifyCommand(command, mode, rules) {
1529
1528
  if (segments.length === 0) {
1530
1529
  return { decision: "ask", reason: "empty command" };
1531
1530
  }
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,
1531
+ // (c) Strip leading env-var assignments from each segment โ€” shell semantics treat
1532
+ // `VAR=value cmd` as an environment override for `cmd`, so the env-var prefix is not
1533
+ // part of the executable command. This is handled for the full command by
1534
+ // `runPermissionGate`'s `stripLeadingEnvAssignments`, but compound commands split by
1535
+ // `&&`, `||`, `;`, or `|` produce segments where the env var is no longer at position 0
1536
+ // of the original input. Stripping per-segment here ensures classification (allow/deny/ask
1537
+ // rules) and cascade matching work against the bare command in every segment.
1538
+ const strippedSegments = segments.map((seg) => stripLeadingEnvAssignments(seg));
1539
+ // (d) Per-segment classification โ€” delegate to classifySegment for each surviving segment.
1540
+ const perSegment = strippedSegments.map((seg) => classifySegment(seg, mode, rules));
1541
+ // (e) Combined precedence โ€” deny > ask > allow. The first segment with each decision wins,
1535
1542
  // and the combined reason concatenates per-segment reasons so FLOW-07's prompt preview and
1536
1543
  // FLOW-08's audit trail can attribute the decision to the offending segment(s).
1537
1544
  const combinedReason = perSegment.map((d, idx) => `segment ${idx + 1}: ${d.reason}`).join("; ");
@@ -2033,14 +2040,13 @@ export function stripLeadingEnvAssignments(command) {
2033
2040
  * segment (e.g. `echo hello` โ†’ `["echo hello"]`).
2034
2041
  *
2035
2042
  * **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)
2043
+ * - `getAllowListEntries("grep -l pattern")` โ†’ `["grep"]` (command name extracted)
2044
+ * - `getAllowListEntries("ls && grep pattern")` โ†’ `["ls", "grep"]` (one command per segment)
2045
+ * - `getAllowListEntries("npm install express && npm test")` โ†’ `["npm", "npm test"]`
2046
+ * (command names; duplicates collapsed; `npm test` is a different command from `npm install`
2047
+ * but both share `npm` as command name; kept only once)
2048
+ * - `getAllowListEntries("cat /tmp/output.txt")` โ†’ `["cat"]`
2049
+ * - `getAllowListEntries("echo hello")` โ†’ `["echo"]` (single segment command name)
2044
2050
  *
2045
2051
  * Contract anchors:
2046
2052
  * - `data-model.md:177-228` (FR-008: allowlist entry computation)
@@ -2051,38 +2057,206 @@ export function stripLeadingEnvAssignments(command) {
2051
2057
  * empty-input early-return keeps dedup logic clean and matches PRD's "empty command โ†’ empty array"
2052
2058
  * exception strategy.
2053
2059
  */
2060
+ /**
2061
+ * Extract the command stem from a command string โ€” the command name plus any
2062
+ * fixed subcommand (e.g. `git diff`, `docker compose`). Everything before the
2063
+ * first flag argument (word starting with `-`) or path argument (word starting
2064
+ * with `/`, `~`, `.`). This produces entries that, when persisted with ` *`,
2065
+ * match the same subcommand pattern without being overly broad (`git diff *`
2066
+ * rather than `git *`).
2067
+ *
2068
+ * For single-word commands without subcommands (e.g. `grep`, `ls`), returns
2069
+ * just the command name.
2070
+ */
2071
+ function extractCommandStem(segment) {
2072
+ const cleaned = stripLeadingEnvAssignments(segment);
2073
+ const words = splitShellWords(cleaned);
2074
+ if (words.length === 0)
2075
+ return cleaned;
2076
+ // Take words until we hit something that looks like a flag or path.
2077
+ const stem = [];
2078
+ for (const w of words) {
2079
+ if (w.startsWith("-"))
2080
+ break; // flag argument starts here
2081
+ if (w.startsWith("/") || w.startsWith("~") || w.startsWith(".")) {
2082
+ // Bare path โ€” stop before it UNLESS this is the very first word
2083
+ // (a bare-path command like `/usr/bin/ls`).
2084
+ if (stem.length > 0)
2085
+ break;
2086
+ }
2087
+ stem.push(w);
2088
+ }
2089
+ if (stem.length === 0)
2090
+ return words[0];
2091
+ return stem.join(" ");
2092
+ }
2093
+ /**
2094
+ * Compute allowlist entries for a command โ€” returns the FULL parsed segments
2095
+ * (not command stems). These entries are used by:
2096
+ * - `promptForPermission` for the "Will allow" preview (which further filters
2097
+ * to show only the "ask" segments' command stems)
2098
+ * - `appendStemsToAllowList` for persistence (each entry is already a command stem
2099
+ * extracted by the caller โ€” this function does NOT do the stem extraction)
2100
+ * - "allow-session" and "allow-once" for session/ephemeral caching of the
2101
+ * full resolved command
2102
+ *
2103
+ * Returns full segments so the caller can decide how to display/persist them.
2104
+ */
2054
2105
  export function getAllowListEntries(command) {
2055
2106
  const trimmed = command.trim();
2056
2107
  if (trimmed.length === 0)
2057
2108
  return [];
2058
2109
  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;
2110
+ // Strip leading env-var assignments from each segment so transient `VAR=val`
2111
+ // prefixes do not leak into entries.
2112
+ const cleanSegments = rawSegments.map((s) => stripLeadingEnvAssignments(s));
2113
+ // Filter bare paths.
2114
+ const segments = cleanSegments.filter((s) => !isBarePath(s));
2066
2115
  if (segments.length === 0) {
2067
- candidates = [trimmed];
2068
- }
2069
- else {
2070
- candidates = [segments[0], trimmed, ...segments.slice(1)];
2116
+ // Fallback: no non-bare segments.
2117
+ return isBarePath(trimmed) ? [] : [trimmed];
2071
2118
  }
2119
+ // Dedup full segments (preserving order).
2072
2120
  const seen = new Set();
2073
2121
  const out = [];
2074
- for (const entry of candidates) {
2075
- if (entry.length === 0)
2122
+ for (const seg of segments) {
2123
+ if (seen.has(seg))
2076
2124
  continue;
2077
- if (isBarePath(entry))
2125
+ seen.add(seg);
2126
+ out.push(seg);
2127
+ }
2128
+ return out;
2129
+ }
2130
+ /**
2131
+ * Redirect-operator prefix anchor: a token is a redirect when it begins with `<` / `>`
2132
+ * (including `<<` heredoc, `<<<` herestring, bare `>`), with `&<` / `&>`, or with a digit
2133
+ * fd-prefix followed by a redirect (`2>`, `1<`, `2>>`, โ€ฆ). Single-shot module-scope RegExp
2134
+ * so the three alternative branches don't recompile per call.
2135
+ */
2136
+ const REDIRECT_OP_PREFIX_RE = /^(?:[<>]|&[<>]|\d+[<>])/;
2137
+ /**
2138
+ * Bare (operator-only) redirect shape: the token is EXACTLY the operator with no embedded
2139
+ * target โ€” `>`, `>>`, `<<`, `<<<`, `&>`, `&>>`, `2>`, `2>>`, โ€ฆ Self-contained forms like
2140
+ * `2>&1`, `2>file`, or `&>out` have non-redirect trailing chars and therefore do NOT match.
2141
+ * Drives {@link stripRedirectsFromTokens}'s target-consumption decision.
2142
+ */
2143
+ const BARE_REDIRECT_RE = /^&?[0-9]*[<>]+$/;
2144
+ /**
2145
+ * Filter redirect operators and their targets from an already-tokenized word list. Returns the
2146
+ * surviving executable words in their original order.
2147
+ *
2148
+ * Algorithm mirrors `splitShellSegments`'s redirect-tracking state machine
2149
+ * (`src/gatekeeper.ts:1382-1396`) but at the WORD level โ€” a bare redirect operator
2150
+ * (`>`, `>>`, `<`, `<<`, `2>`, `2>>`, โ€ฆ) consumes the next token as its target; self-contained
2151
+ * forms like `2>&1`, `2>file`, `&>out` are recognized by {@link REDIRECT_OP_PREFIX_RE} but do
2152
+ * NOT match {@link BARE_REDIRECT_RE}, so they consume nothing further.
2153
+ */
2154
+ function stripRedirectsFromTokens(tokens) {
2155
+ const out = [];
2156
+ let consumeNext = false;
2157
+ for (const token of tokens) {
2158
+ if (consumeNext) {
2159
+ consumeNext = false;
2078
2160
  continue;
2079
- if (seen.has(entry))
2161
+ }
2162
+ if (token.length > 0 && REDIRECT_OP_PREFIX_RE.test(token)) {
2163
+ if (BARE_REDIRECT_RE.test(token)) {
2164
+ consumeNext = true;
2165
+ }
2080
2166
  continue;
2081
- seen.add(entry);
2082
- out.push(entry);
2167
+ }
2168
+ out.push(token);
2083
2169
  }
2084
2170
  return out;
2085
2171
  }
2172
+ /**
2173
+ * Compute the list of positional wildcard candidates for a single shell segment. Used by
2174
+ * FLOW-12's segment-by-segment prompt (TSK-009-04) to build `<candidate> * This Session` and
2175
+ * `<candidate> * Always` options from the segment's executable token chain.
2176
+ *
2177
+ * Pipeline:
2178
+ * 1. {@link stripLeadingEnvAssignments} โ€” drop transient `VAR=val` prefixes so they do not
2179
+ * leak into the candidate list.
2180
+ * 2. {@link splitShellWords} โ€” tokenize on whitespace, respecting single/double quotes so
2181
+ * `"hello world"` is one token, not two.
2182
+ * 3. {@link stripRedirectsFromTokens} โ€” drop redirect operators (`>`, `>>`, `<`, `<<`,
2183
+ * `2>`, `2>&1`, โ€ฆ) and their targets so `pnpm exec biome check test/ 2>&1` produces the
2184
+ * same candidates as `pnpm exec biome check test/`.
2185
+ * 4. Prefix-depth unfolding โ€” emit one candidate per prefix depth `k`:
2186
+ * `<tokens[0..k).join(" ") + " *">`. The wildcard is always on the LAST emitted token,
2187
+ * never between tokens.
2188
+ *
2189
+ * Contract (pinned by `test/prompt.test.ts` FLOW-12 sub-describe):
2190
+ * - `unfoldPositionalCandidates("pnpm exec biome check test/")` โ†’
2191
+ * `["pnpm *", "pnpm exec *", "pnpm exec biome *", "pnpm exec biome check *", "pnpm exec biome check test/ *"]`
2192
+ * - `unfoldPositionalCandidates("pnpm exec biome check test/ 2>&1")` โ†’ identical to above
2193
+ * (the `2>&1` redirect contributes nothing).
2194
+ * - `unfoldPositionalCandidates("> /dev/null")` โ†’ `[]` (bare redirect consumes its target).
2195
+ * - `unfoldPositionalCandidates("NODE_ENV=test pnpm test")` โ†’ `["pnpm *", "pnpm test *"]`
2196
+ * (env-var prefix stripped, then unfolded).
2197
+ * - `unfoldPositionalCandidates("head")` โ†’ `["head *"]` (single-word segment).
2198
+ * - `unfoldPositionalCandidates("")` โ†’ `[]` (empty / whitespace-only inputs never throw).
2199
+ *
2200
+ * Pure function โ€” no I/O, no side effects, no module-level state. O(n) over the segment's
2201
+ * character count.
2202
+ *
2203
+ * Source anchor: AC-ADHOC-012-01 / AC-ADHOC-012-02 (positional wildcard unfolding).
2204
+ */
2205
+ export function unfoldPositionalCandidates(segment) {
2206
+ const cleaned = stripLeadingEnvAssignments(segment);
2207
+ if (cleaned.trim().length === 0)
2208
+ return [];
2209
+ const tokens = stripRedirectsFromTokens(splitShellWords(cleaned));
2210
+ if (tokens.length === 0)
2211
+ return [];
2212
+ const candidates = [];
2213
+ for (let k = 1; k <= tokens.length; k += 1) {
2214
+ candidates.push(`${tokens.slice(0, k).join(" ")} *`);
2215
+ }
2216
+ return candidates;
2217
+ }
2218
+ /**
2219
+ * Validate that every `*` wildcard in `pattern` is preceded by either a whitespace character or
2220
+ * the start of the pattern. Used by FLOW-12 as the write-time guard inside
2221
+ * {@link appendStemsToAllowList} (defense-in-depth) and by the ingest-time check on any
2222
+ * programmatically-built wildcard before it lands in `perm-rules.json`.
2223
+ *
2224
+ * Rule (Issue 009 Hard Inclusion #2, AC-ADHOC-012-04):
2225
+ * - Every `*` in `pattern` MUST be at position 0 OR immediately preceded by whitespace
2226
+ * (` `, `\t`, or `\n`).
2227
+ * - Patterns without a `*` are vacuously valid.
2228
+ *
2229
+ * Why: a pattern like `git*` (no space before the wildcard) compiles to the regex
2230
+ * `^git.*$`, which matches `gitlog`, `git-fetch`, and any other input that merely STARTS
2231
+ * with `git`. The full-token-boundary rule prevents this class of accidental over-match
2232
+ * โ€” `git *` is the correct pattern for "git followed by anything" and matches only inputs
2233
+ * where `git` is its own whitespace-delimited token.
2234
+ *
2235
+ * Pure predicate โ€” single linear scan, O(n) over `pattern.length`. Sub-microsecond per call.
2236
+ *
2237
+ * Contract (pinned by `test/prompt.test.ts` FLOW-12 sub-describe):
2238
+ * - `validateFullTokenBoundary("git *")` โ†’ `true`
2239
+ * - `validateFullTokenBoundary("git*")` โ†’ `false`
2240
+ * - `validateFullTokenBoundary("*")` โ†’ `true` (position 0)
2241
+ * - `validateFullTokenBoundary("git diff *")` โ†’ `true`
2242
+ * - `validateFullTokenBoundary("npm install express")` โ†’ `true` (no `*`, vacuously valid)
2243
+ * - `validateFullTokenBoundary("")` โ†’ `true` (vacuously valid)
2244
+ */
2245
+ export function validateFullTokenBoundary(pattern) {
2246
+ for (let i = 0; i < pattern.length; i += 1) {
2247
+ if (pattern[i] !== "*")
2248
+ continue;
2249
+ // Position-0 wildcard is the "start of pattern" boundary โ€” a leading `*` is a suffix
2250
+ // wildcard ("anything ending with X"), which is acceptable.
2251
+ if (i === 0)
2252
+ continue;
2253
+ const prev = pattern[i - 1];
2254
+ if (prev === " " || prev === "\t" || prev === "\n")
2255
+ continue;
2256
+ return false;
2257
+ }
2258
+ return true;
2259
+ }
2086
2260
  /**
2087
2261
  * Module-level ephemeral allow set โ€” survives across `runPermissionGate` invocations within a
2088
2262
  * single session, cleared by {@link sessionStartHandler} on the next session start so long-lived
@@ -2147,9 +2321,52 @@ export function sessionAllow(command) {
2147
2321
  export function isSessionAllowed(command) {
2148
2322
  return sessionAllowList.has(command);
2149
2323
  }
2324
+ /* โ”€โ”€โ”€ Allow All Once โ€” ephemeral flag for compound commands (FLOW-12) โ”€โ”€โ”€
2325
+ *
2326
+ * Module-level boolean that when set causes `runPermissionGate` to return
2327
+ * `{ allow: true }` for remaining segments without prompting. Cleared after
2328
+ * execution completes (does NOT persist across bash tool invocations).
2329
+ *
2330
+ * Lifecycle: setAllowAllOnce โ†’ consumeAllowAllOnce (read-and-clear,
2331
+ * one-shot, returns true once) โ†’ resetAllowAllOnce (unconditional clear).
2332
+ *
2333
+ * Flow references: FLOW-12 Step 3 + Step 8 + FLOW-08 Step 3. */
2334
+ let allowAllOnceActive = false;
2335
+ /**
2336
+ * Set the Allow All Once flag. After this call, the next
2337
+ * {@link consumeAllowAllOnce} returns `true` and clears the flag.
2338
+ * Exported so the TSK-009-03 lifecycle tests can drive it directly
2339
+ * without going through {@link promptForCompoundCommand}.
2340
+ */
2341
+ export function setAllowAllOnce() {
2342
+ allowAllOnceActive = true;
2343
+ }
2344
+ /**
2345
+ * Consume (read-and-clear) the Allow All Once flag. Returns `true`
2346
+ * exactly once when the flag was set by a prior {@link setAllowAllOnce}
2347
+ * call; subsequent calls return `false` until the flag is re-set.
2348
+ * This one-shot semantics mirrors the existing {@link isEphemeralAllowed}
2349
+ * pattern.
2350
+ */
2351
+ export function consumeAllowAllOnce() {
2352
+ if (allowAllOnceActive) {
2353
+ allowAllOnceActive = false;
2354
+ return true;
2355
+ }
2356
+ return false;
2357
+ }
2358
+ /**
2359
+ * Unconditionally clear the Allow All Once flag. Idempotent โ€” calling
2360
+ * when the flag is already `false` is a no-op. Primed as the test-teardown
2361
+ * seam (per-test `beforeEach` in the FLOW-12 lifecycle describe); intended
2362
+ * for cleanup after compound command execution resolves (FLOW-12 Step 8 โ€”
2363
+ * integration pending).
2364
+ */
2365
+ export function resetAllowAllOnce() {
2366
+ allowAllOnceActive = false;
2367
+ }
2150
2368
  /**
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
2369
+ * shared by {@link appendStemsToAllowList} and {@link appendToDenyList} โ€” both writers push
2153
2370
  * entries onto a per-level pattern list and MUST preserve the
2154
2371
  * "clicking-twice-doesn't-duplicate" contract (AC-005-04 / AC-005-05 first-match-wins).
2155
2372
  */
@@ -2163,45 +2380,42 @@ function appendUnique(list, entries) {
2163
2380
  }
2164
2381
  }
2165
2382
  /**
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:
2383
+ * Persist command stems from "ask" segments to the current level's allow list.
2384
+ * Each stem is written in two forms โ€” bare (`"git diff"`) and wildcard (`"git diff *"`)
2385
+ * โ€” matching the default-rules convention without being overly broad (`"git *"`
2386
+ * would allow all subcommands). Dedup via {@link appendUnique} so re-clicking
2387
+ * "Allow Always" on the same compound does NOT duplicate entries.
2169
2388
  *
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).
2389
+ * Defense-in-depth (Issue 009 ยงHard Inclusion #2, AC-ADHOC-012-04): each candidate
2390
+ * is filtered through {@link validateFullTokenBoundary}, silently dropping
2391
+ * glued-wildcard forms (`"git*"`) so future programmatic callers (FLOW-12's
2392
+ * `promptForCompoundCommand`) cannot seed `perm-rules.json` with an over-broad
2393
+ * pattern that would match `gitlog` / `git-fetch`. Empty input is a no-op (no write).
2190
2394
  */
2191
- function appendToAllowList(entries, ctx) {
2395
+ export function appendStemsToAllowList(stems, ctx) {
2192
2396
  const rules = loadRules(ctx);
2193
2397
  const mode = getCurrentMode();
2194
- appendUnique(rules.rules[mode].allow, entries);
2398
+ const expanded = [];
2399
+ for (const stem of stems) {
2400
+ const wild = `${stem} *`;
2401
+ if (validateFullTokenBoundary(stem))
2402
+ expanded.push(stem);
2403
+ if (validateFullTokenBoundary(wild))
2404
+ expanded.push(wild);
2405
+ }
2406
+ if (expanded.length === 0)
2407
+ return;
2408
+ appendUnique(rules.rules[mode].allow, expanded);
2195
2409
  saveRules(getRulesPath(), rules, ctx);
2196
2410
  }
2197
2411
  /**
2198
2412
  * 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}.
2413
+ * read-then-write + dedup + safety-floor-preservation contract from {@link appendStemsToAllowList}.
2200
2414
  * Cascade direction (FLOW-10) is a READ-time concern in {@link classifySegment}; this writer
2201
2415
  * mutates only the CURRENT level, so the developer-facing "Deny" choice is scoped exactly to
2202
2416
  * the active permission level.
2203
- *
2204
- * **API asymmetry note** (see {@link appendToAllowList} for the full rationale): this writer
2417
+
2418
+ * **API asymmetry note** (see {@link appendStemsToAllowList} for the full rationale): this writer
2205
2419
  * takes a single `command` string rather than a `readonly string[]`. "Deny" persists only the
2206
2420
  * literal compound command โ€” splitting a deny into per-segment entries would be ambiguous
2207
2421
  * (a `rm x && cat /tmp/junk` deny would create two rules that future explicit-level denials
@@ -2215,7 +2429,7 @@ function appendToDenyList(command, ctx) {
2215
2429
  saveRules(getRulesPath(), rules, ctx);
2216
2430
  }
2217
2431
  /**
2218
- * Headless-safety short-circuit helper shared by {@link promptForPermission} and
2432
+ * Headless-safety short-circuit helper shared by {@link ensureSelectAvailable} and
2219
2433
  * {@link runPermissionGate}. Returns `true` when the caller should treat the request as
2220
2434
  * auto-denied because `ctx.hasUI === false`; in that case surfaces the {@link BLOCKED_HEADLESS_MESSAGE}
2221
2435
  * notification through the (optional) `ctx.ui` surface โ€” guarded through an `if (ctx.ui)`
@@ -2229,6 +2443,30 @@ function checkHeadlessShortCircuit(ctx) {
2229
2443
  ctx.ui.notify(BLOCKED_HEADLESS_MESSAGE, "warning");
2230
2444
  return true;
2231
2445
  }
2446
+ /**
2447
+ * Shared prompt-availability guard used by both {@link promptForPermission} and
2448
+ * {@link promptForCompoundCommand}. Returns `true` when the caller should proceed with
2449
+ * the prompt; `false` when a UI issue (headless mode, missing select function) prevents
2450
+ * showing the prompt and the caller should fall back to a conservative deny.
2451
+ *
2452
+ * Combines two independent checks that are always used together:
2453
+ * 1. {@link checkHeadlessShortCircuit} โ€” headless mode (ctx.hasUI === false)
2454
+ * 2. `ctx.ui.select` availability โ€” runtime-absent UI surface
2455
+ *
2456
+ * Each check emits its own notification on failure so the developer has a clear signal
2457
+ * about why the prompt was skipped. Centralizing them ensures both prompt paths share
2458
+ * the same failure UX without duplicating the guard logic.
2459
+ */
2460
+ function ensureSelectAvailable(ctx) {
2461
+ if (checkHeadlessShortCircuit(ctx))
2462
+ return false;
2463
+ if (typeof ctx.ui?.select !== "function") {
2464
+ if (ctx.ui)
2465
+ ctx.ui.notify("Prompt unavailable; defaulting to deny", "warning");
2466
+ return false;
2467
+ }
2468
+ return true;
2469
+ }
2232
2470
  /**
2233
2471
  * Three-choice `ctx.ui.select` prompt rendered when the classifier yields `ask`. Surfaces the
2234
2472
  * FLOW-07 "developer saw every distinct command" preview verbatim by inlining
@@ -2241,17 +2479,10 @@ function checkHeadlessShortCircuit(ctx) {
2241
2479
  * from "prompt blew up, deny conservatively without persistence" (Constitution ยง1.6 โ€” a failed
2242
2480
  * prompt MUST NOT silently persist anything to `perm-rules.json`).
2243
2481
  */
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");
2482
+ async function promptForPermission(reason, command, askPreview, ctx) {
2483
+ if (!ensureSelectAvailable(ctx))
2250
2484
  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}`;
2485
+ const message = `[${reason}]\n\nCommand: ${command}\n\nWill allow: ${askPreview}`;
2255
2486
  try {
2256
2487
  const items = ["Allow Once", "Allow This Session", "Allow Always", "Deny"];
2257
2488
  const selected = await ctx.ui.select(message, items);
@@ -2271,6 +2502,187 @@ async function promptForPermission(reason, command, ctx) {
2271
2502
  return null;
2272
2503
  }
2273
2504
  }
2505
+ /**
2506
+ * Build the option list for the compound command prompt from positional wildcard
2507
+ * candidates. Pure function โ€” no I/O, no side effects.
2508
+ *
2509
+ * Order: Allow All Once, then each candidate with This Session / Always suffixes,
2510
+ * then Deny as the final entry.
2511
+ *
2512
+ * @param candidates โ€” output of {@link unfoldPositionalCandidates}
2513
+ * @returns a string array suitable for `ctx.ui.select({ options })`
2514
+ */
2515
+ function buildCompoundOptions(candidates) {
2516
+ const options = ["Allow All Once"];
2517
+ for (const c of candidates) {
2518
+ options.push(`${c} This Session`);
2519
+ options.push(`${c} Always`);
2520
+ }
2521
+ options.push("Deny");
2522
+ return options;
2523
+ }
2524
+ /**
2525
+ * Parse a selected option string from the compound command prompt back into
2526
+ * a structured {@link CompoundSelection}. Pure function โ€” no I/O, no side effects.
2527
+ *
2528
+ * Returns `null` for unrecognised selections (the caller treats this as a
2529
+ * conservative deny per Constitution ยง1.6).
2530
+ */
2531
+ function parseCompoundSelection(selected) {
2532
+ if (selected === "Allow All Once")
2533
+ return { type: "allow-all-once" };
2534
+ if (selected === "Deny")
2535
+ return { type: "deny" };
2536
+ // Must be a candidate option โ€” determine if This Session or Always.
2537
+ if (selected.endsWith(" This Session")) {
2538
+ const pattern = selected.slice(0, -" This Session".length);
2539
+ return { type: "candidate", pattern, isSession: true };
2540
+ }
2541
+ if (selected.endsWith(" Always")) {
2542
+ const pattern = selected.slice(0, -" Always".length);
2543
+ return { type: "candidate", pattern, isSession: false };
2544
+ }
2545
+ // Unexpected selection.
2546
+ return null;
2547
+ }
2548
+ /**
2549
+ * Apply a parsed compound selection decision: construct the decision entry,
2550
+ * append it to `resultSegments`, and execute any persistence side effects
2551
+ * (session allow, allow-always persistence). Intended as the inner workhorse
2552
+ * of {@link promptForCompoundCommand}'s segment loop.
2553
+ *
2554
+ * Returns `true` when the caller should short-circuit (Allow All Once selected
2555
+ * โ€” the flag has been set and the function has already returned early by
2556
+ * returning the compound decision with `allowAllOnceUsed: true`); returns
2557
+ * `false` to continue to the next segment.
2558
+ *
2559
+ * Pure-ish: writes to `resultSegments` (mutating push), calls `setAllowAllOnce`,
2560
+ * `sessionAllow`, or `appendStemsToAllowList` as side effects. The caller owns
2561
+ * `resultSegments` and must not reuse it after this returns `true` (the return
2562
+ * already contains the final compound decision).
2563
+ */
2564
+ function applyCompoundSegmentDecision(segment, parsed, resultSegments, ctx) {
2565
+ if (parsed.type === "allow-all-once") {
2566
+ setAllowAllOnce();
2567
+ resultSegments.push({ segment, decision: "allow", scope: "ephemeral" });
2568
+ return true;
2569
+ }
2570
+ if (parsed.type === "deny") {
2571
+ resultSegments.push({ segment, decision: "deny", scope: "persist" });
2572
+ return false;
2573
+ }
2574
+ // Candidate โ€” extract the stem (remove trailing ` *` for persistence).
2575
+ const stem = parsed.pattern.endsWith(" *") ? parsed.pattern.slice(0, -2) : parsed.pattern;
2576
+ if (parsed.isSession) {
2577
+ sessionAllow(stem);
2578
+ resultSegments.push({
2579
+ segment,
2580
+ decision: "allow",
2581
+ scope: "session",
2582
+ pattern: parsed.pattern,
2583
+ });
2584
+ }
2585
+ else {
2586
+ appendStemsToAllowList([stem], ctx);
2587
+ resultSegments.push({
2588
+ segment,
2589
+ decision: "allow",
2590
+ scope: "persist",
2591
+ pattern: parsed.pattern,
2592
+ });
2593
+ }
2594
+ return false;
2595
+ }
2596
+ /**
2597
+ * Segment-by-segment interactive prompt for compound commands (FLOW-12).
2598
+ *
2599
+ * For each "ask" segment, computes positional wildcard candidates via
2600
+ * {@link unfoldPositionalCandidates}, presents them in a `ctx.ui.select()`
2601
+ * dialog with Allow All Once / This Session / Always / Deny options, and
2602
+ * returns a {@link CompoundDecision} mapping each segment to its scoped result.
2603
+ *
2604
+ * The Allow All Once option short-circuits remaining segments without prompting
2605
+ * and sets the Allow All Once flag so {@link runPermissionGate} can skip
2606
+ * re-prompting for subsequent segments of the same compound execution.
2607
+ *
2608
+ * Headless short-circuit (FLOW-11): when `ctx.hasUI === false`, returns an empty
2609
+ * `CompoundDecision` immediately without calling `ctx.ui.select` โ€” the caller
2610
+ * should treat this as a deny per the existing headless auto-deny contract.
2611
+ * Similarly, when `ctx.ui.select` is not a function (UI is absent at runtime),
2612
+ * returns an empty result conservatively.
2613
+ *
2614
+ * Flow references:
2615
+ * - FLOW-12 Step 3 ("segment-by-segment prompt loop")
2616
+ * - FLOW-12 Step 8 ("Allow All Once flag lifecycle")
2617
+ * - FLOW-08 Step 3 ("scope-based persistence routing")
2618
+ */
2619
+ export async function promptForCompoundCommand(segments, ctx) {
2620
+ if (!ensureSelectAvailable(ctx)) {
2621
+ return { segments: [], allowAllOnceUsed: false };
2622
+ }
2623
+ if (segments.length === 0) {
2624
+ return { segments: [], allowAllOnceUsed: false };
2625
+ }
2626
+ const resultSegments = [];
2627
+ const promptSelect = ctx.ui.select;
2628
+ for (const segment of segments) {
2629
+ const candidates = unfoldPositionalCandidates(segment);
2630
+ const options = buildCompoundOptions(candidates);
2631
+ let selected;
2632
+ try {
2633
+ selected = await promptSelect({
2634
+ message: `Segment: ${segment}`,
2635
+ options,
2636
+ });
2637
+ }
2638
+ catch {
2639
+ // Fail closed per Constitution ยง1.6 โ€” a thrown prompt must never silently
2640
+ // allow. The denial is recorded in the CompoundDecision and surfaced by
2641
+ // finalizeCompoundGateResult's notification, avoiding a duplicate warning.
2642
+ resultSegments.push({ segment, decision: "deny", scope: "persist" });
2643
+ return { segments: resultSegments, allowAllOnceUsed: false };
2644
+ }
2645
+ if (selected === undefined) {
2646
+ return { segments: resultSegments, allowAllOnceUsed: false };
2647
+ }
2648
+ const parsed = parseCompoundSelection(selected);
2649
+ if (parsed === null) {
2650
+ resultSegments.push({ segment, decision: "deny", scope: "persist" });
2651
+ continue;
2652
+ }
2653
+ if (applyCompoundSegmentDecision(segment, parsed, resultSegments, ctx)) {
2654
+ return { segments: resultSegments, allowAllOnceUsed: true };
2655
+ }
2656
+ }
2657
+ return { segments: resultSegments, allowAllOnceUsed: false };
2658
+ }
2659
+ /**
2660
+ * Interpret a {@link CompoundDecision} returned by
2661
+ * {@link promptForCompoundCommand} into a gate result suitable for
2662
+ * {@link runPermissionGate}'s compound branch (5b). Pure logic โ€” no I/O,
2663
+ * no side effects, no persistence.
2664
+ *
2665
+ * Resolution rules:
2666
+ * - Allow All Once โ†’ allow all segments (flag already consumed by
2667
+ * `promptForCompoundCommand`).
2668
+ * - Any segment allowed โ†’ allow the compound command (permissive default:
2669
+ * when the classifier yielded "ask" for every segment, gates that pass
2670
+ * at least one segment's prompt allow the chain to proceed).
2671
+ * - No segment allowed โ†’ notify and deny.
2672
+ */
2673
+ function finalizeCompoundGateResult(compoundDecision, ctx) {
2674
+ if (compoundDecision.allowAllOnceUsed) {
2675
+ return { allow: true };
2676
+ }
2677
+ const anyAllowed = compoundDecision.segments.some((s) => s.decision === "allow");
2678
+ if (anyAllowed) {
2679
+ return { allow: true };
2680
+ }
2681
+ if (ctx.hasUI && ctx.ui) {
2682
+ ctx.ui.notify("Blocked: All segments denied", "warning");
2683
+ }
2684
+ return { allow: false };
2685
+ }
2274
2686
  /**
2275
2687
  * Four-branch permission gate โ€” the user-visible keystone for FLOW-07 (interactive prompt),
2276
2688
  * FLOW-08 (scope: ephemeral / persist-allow / persist-deny), and FLOW-11 (headless auto-deny).
@@ -2319,6 +2731,15 @@ export async function runPermissionGate(command, ctx) {
2319
2731
  if (runtimeMode === "off") {
2320
2732
  return { allow: true };
2321
2733
  }
2734
+ // (1.5) Allow All Once check โ€” FLOW-12 Step 7/8. When the flag is set by
2735
+ // promptForCompoundCommand for a preceding segment in the same compound
2736
+ // execution, skip all processing (classifier, prompt) and return allow.
2737
+ // The flag is consumed on first access (single-shot), so a subsequent
2738
+ // runPermissionGate invocation without re-setting the flag goes through the
2739
+ // normal classifier/prompt path.
2740
+ if (consumeAllowAllOnce()) {
2741
+ return { allow: true };
2742
+ }
2322
2743
  // (2) Session allow check โ€” persists across the session without consuming on match.
2323
2744
  if (isSessionAllowed(resolvedCommand)) {
2324
2745
  return { allow: true };
@@ -2327,7 +2748,7 @@ export async function runPermissionGate(command, ctx) {
2327
2748
  if (isEphemeralAllowed(resolvedCommand)) {
2328
2749
  return { allow: true };
2329
2750
  }
2330
- // (3) Classifier routing.
2751
+ // (4) Classifier routing.
2331
2752
  const mode = getCurrentMode();
2332
2753
  const rules = loadRules(ctx);
2333
2754
  const decision = classifyCommand(resolvedCommand, mode, rules);
@@ -2341,11 +2762,36 @@ export async function runPermissionGate(command, ctx) {
2341
2762
  return { allow: false };
2342
2763
  }
2343
2764
  // (5) Prompt branch โ€” classifier yielded "ask".
2344
- // (4a) Headless short-circuit (FLOW-11).
2765
+ // (5a) Headless short-circuit (FLOW-11).
2345
2766
  if (checkHeadlessShortCircuit(ctx)) {
2346
2767
  return { allow: false };
2347
2768
  }
2348
- const choice = await promptForPermission(decision.reason, resolvedCommand, ctx);
2769
+ // (5b) Compound command routing โ€” FLOW-12 Step 2. Split the command into
2770
+ // shell segments; when there is more than one non-empty segment, route
2771
+ // through the segment-by-segment compound prompt instead of the
2772
+ // single-segment prompt.
2773
+ const rawSegments = splitShellSegments(resolvedCommand);
2774
+ const segments = rawSegments.filter((s) => s.trim() !== "");
2775
+ if (segments.length > 1) {
2776
+ // Compound command: delegate to segment-by-segment prompt, then interpret
2777
+ // the CompoundDecision through the shared finalizeCompoundGateResult helper.
2778
+ // Note: promptForCompoundCommand โ†’ unfoldPositionalCandidates handles env
2779
+ // stripping internally, so we pass raw segments here.
2780
+ const compoundDecision = await promptForCompoundCommand(segments, ctx);
2781
+ return finalizeCompoundGateResult(compoundDecision, ctx);
2782
+ }
2783
+ // (5c) Single-segment (or collapsed-to-one) ask path. Build the "Will allow"
2784
+ // preview from only the segments that triggered the "ask" decision. Split
2785
+ // the command, re-classify each segment, and extract command stems from the
2786
+ // ask segments. This avoids showing already-allowed entries (e.g. `git diff`
2787
+ // when `git diff *` is already in the low allow list) in the preview.
2788
+ const strippedSegments = segments.map((seg) => stripLeadingEnvAssignments(seg));
2789
+ const perSegment = strippedSegments.map((seg) => classifySegment(seg, mode, rules));
2790
+ const askStems = perSegment
2791
+ .map((d, i) => d.decision === "ask" ? extractCommandStem(strippedSegments[i]) : null)
2792
+ .filter((s) => s !== null);
2793
+ const askPreview = askStems.length > 0 ? askStems.join(", ") : "(no segments)";
2794
+ const choice = await promptForPermission(decision.reason, resolvedCommand, askPreview, ctx);
2349
2795
  if (choice === null) {
2350
2796
  // Prompt could not be shown (missing UI, thrown `ctx.ui.select`, unexpected return value).
2351
2797
  // Fail closed per Constitution ยง1.6 ("never block waiting for interactive input") WITHOUT
@@ -2354,10 +2800,12 @@ export async function runPermissionGate(command, ctx) {
2354
2800
  return { allow: false };
2355
2801
  }
2356
2802
  if (choice === "allow-session") {
2357
- const entries = getAllowListEntries(resolvedCommand);
2358
- for (const entry of entries) {
2359
- sessionAllow(entry);
2360
- }
2803
+ // Cache the full resolved command directly (not the command-name entries)
2804
+ // so the exact-string `isSessionAllowed(resolvedCommand)` check on the next
2805
+ // invocation finds a match. Entries from `getAllowListEntries` are now just
2806
+ // command names (for display and "Allow Always" persistence), not the full
2807
+ // command string that session matching needs.
2808
+ sessionAllow(resolvedCommand);
2361
2809
  return { allow: true };
2362
2810
  }
2363
2811
  if (choice === "allow-once") {
@@ -2365,8 +2813,12 @@ export async function runPermissionGate(command, ctx) {
2365
2813
  return { allow: true };
2366
2814
  }
2367
2815
  if (choice === "allow-always") {
2368
- const entries = getAllowListEntries(resolvedCommand);
2369
- appendToAllowList(entries, ctx);
2816
+ // Persist the command stem + wildcard (e.g. `grep *`, `git diff *`) for
2817
+ // each "ask" segment. This matches the default-rules convention (`grep *`,
2818
+ // `git diff *`) without being overly broad (`git *` would allow ALL git
2819
+ // subcommands). The full-resolved-command pattern is also returned for
2820
+ // the `persistWrite` audit trail.
2821
+ appendStemsToAllowList(askStems, ctx);
2370
2822
  return {
2371
2823
  allow: true,
2372
2824
  persistWrite: { level: mode, action: "allow", pattern: resolvedCommand },
@@ -2459,29 +2911,38 @@ function buildBashResult(text, isError) {
2459
2911
  * Source anchor: `tasks.md TSK-005-03` (bash tool registration + execute handler).
2460
2912
  */
2461
2913
  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
2914
  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);
2915
+ // (1) Defensive non-string guard โ€” a malformed tool payload (number, object, null,
2916
+ // undefined) never reaches the gate. Returns BLOCKED with isError: true so the LLM
2917
+ // surfaces a structured error to the developer.
2918
+ if (typeof params.command !== "string") {
2919
+ return buildBashResult("BLOCKED: bash tool command must be a string", true);
2920
+ }
2921
+ // (2) Permission gate โ€” classifier + prompt + persistence routing. The gate runs BEFORE
2922
+ // shell dispatch so a denied command never reaches `child_process.exec`.
2923
+ const decision = await runPermissionGate(params.command, ctx);
2924
+ if (!decision.allow) {
2925
+ return buildBashResult(`BLOCKED: ${params.command}`, true);
2926
+ }
2927
+ // (3) Shell dispatch โ€” gate approved; run the command in `ctx.cwd` and surface the
2928
+ // merged stdout/stderr payload. A spawn failure (ENOENT, EACCES, etc.) is caught and
2929
+ // returned as a structured error result so the LLM sees a normalized shape.
2930
+ try {
2931
+ const { stdout, stderr } = await execBash(params.command, ctx.cwd);
2932
+ return buildBashResult(stderr && stderr.length > 0 ? `${stdout}${stdout ? "\n" : ""}${stderr}` : stdout, false);
2933
+ }
2934
+ catch (err) {
2935
+ const message = err instanceof Error ? err.message : String(err);
2936
+ return buildBashResult(`BLOCKED: shell exec failed: ${message}`, true);
2937
+ }
2481
2938
  }
2482
- catch (err) {
2483
- const message = err instanceof Error ? err.message : String(err);
2484
- return buildBashResult(`BLOCKED: shell exec failed: ${message}`, true);
2939
+ finally {
2940
+ // FLOW-12 Step 8 clearance: after any compound execution completes,
2941
+ // unconditionally clear the Allow All Once flag so it does not persist
2942
+ // across separate bash tool invocations. The outer try/finally guarantees
2943
+ // this runs even if runPermissionGate throws unexpectedly, making the
2944
+ // safety contract unbreakable (FLOW-12 Step 8, FLOW-08 Step 5).
2945
+ resetAllowAllOnce();
2485
2946
  }
2486
2947
  }
2487
2948
  /**