@wernerbisschoff/pi-gatekeeper 0.1.4 → 0.1.6

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.
@@ -609,7 +609,7 @@ const MODE_COLORS = {
609
609
  */
610
610
  export function updateStatus(ctx) {
611
611
  const label = MODE_LABELS[currentMode];
612
- const text = `● ${label} /perm`;
612
+ const text = `● ${label} · /perm`;
613
613
  const colorKey = MODE_COLORS[currentMode];
614
614
  const styled = typeof ctx.ui.theme?.fg === "function" ? ctx.ui.theme.fg(colorKey, text) : text;
615
615
  ctx.ui.setStatus(PERMISSION_STATUS_KEY, styled);
@@ -952,11 +952,19 @@ function commitSubstitution(wrapper, inner, segments) {
952
952
  * position `i` consumes the longest matching operator first — otherwise `|` would consume the first
953
953
  * char of `||` and leave a stray `|` in the next segment.
954
954
  *
955
+ * The newline operator (`\n`) is intentionally listed LAST so the multi-character forms still take
956
+ * precedence. Per shell semantics (Bash Reference Manual §3.2.1 "Shell Syntax"), an unquoted newline
957
+ * is a command separator equivalent to `;` — `cmd1\ncmd2` runs both commands sequentially. The
958
+ * pending-heredoc-delimiter check in the main loop runs BEFORE the operator check, so a `\n` that
959
+ * activates a heredoc body is consumed by the body walker instead of triggering a split. Likewise
960
+ * `tryMatchOperator` returns `null` inside any quote context, so newlines inside `"..."` / `'...'`
961
+ * strings stay literal.
962
+ *
955
963
  * Adding a new split operator is a single array append here plus a contract update; the parser loop
956
964
  * body stays declarative. Future phases (TSK-002-03..06) extend the operator set with heredoc openings,
957
965
  * subshell/process-substitution boundaries, etc.
958
966
  */
959
- const SPLIT_OPERATORS = ["&&", "||", ";", "|"];
967
+ const SPLIT_OPERATORS = ["&&", "||", ";", "|", "\n"];
960
968
  /**
961
969
  * Peek ahead from index `i` and report whether a split operator starts there. Returns `null` when
962
970
  * `i` is inside a quoted region so the caller stays a pure operator-table lookup without quote-state
@@ -1922,7 +1930,10 @@ const BARE_PATH_METACHAR_RE = /[|&;<>()$`\\\s'"*?{}\[\]!#]/;
1922
1930
  * candidate is a pure file-system path rather than a compound command fragment. Used by
1923
1931
  * {@link getAllowListEntries} to drop `/tmp/out` / `~/notes.md`-style entries from the prompt
1924
1932
  * preview and the on-disk allowlist so a developer cannot accidentally approve only the file
1925
- * portion of `cat /tmp/output.txt`.
1933
+ * portion of `cat /tmp/output.txt`. Composed of (a) the `isBarePath` predicate used by
1934
+ * {@link isFilePath} for the bare-prefix case and (b) the {@link isFileOnlySegment} check
1935
+ * applied to the trimmed entry to also drop single-token relative paths and known-extension
1936
+ * filenames that survive the multiline split.
1926
1937
  *
1927
1938
  * Order of checks matters: the empty-string short-circuit returns `false` BEFORE the regex
1928
1939
  * matches, so an empty entry never collides with the `/^[/~].*$/` anchor; the leading-`/` or
@@ -1940,6 +1951,122 @@ export function isBarePath(entry) {
1940
1951
  return false;
1941
1952
  return true;
1942
1953
  }
1954
+ /**
1955
+ * Known file extensions that mark a single token as a file path rather than a command name. The
1956
+ * list is intentionally narrow — covering shell/JS/Python/Ruby sources, common data formats
1957
+ * (JSON/YAML/TOML/TXT/MD/LOG), and a handful of well-known security/credential extensions
1958
+ * (.pem/.key/.crt/.env) — so that a bare `script.sh` token is correctly identified as a file
1959
+ * even though it contains no path separator. A segment is only filtered as "file-only" if it
1960
+ * consists of a SINGLE such token (see {@link isFileOnlySegment}); compound segments like
1961
+ * `rm foo.sh` are unaffected.
1962
+ */
1963
+ const FILE_EXTENSION_RE = /\.(sh|bash|zsh|ts|js|mjs|cjs|tsx|jsx|py|rb|json|ya?ml|toml|txt|md|log|pem|key|crt|env|cfg|conf|ini|out)$/i;
1964
+ /**
1965
+ * Set of command interpreters that EXECUTE a script file as their first non-flag argument. When
1966
+ * the first word of a segment is in this set, {@link extractCommandStem} preserves the second
1967
+ * word (the script file) in the stem so `bash script.sh` is persisted as `bash script.sh *`
1968
+ * (not just `bash *`, which would be overly broad). Direct invocations (`./script.sh`,
1969
+ * `../script.sh`) are recognized by {@link isDirectScriptInvocation} on the first word itself.
1970
+ */
1971
+ const SCRIPT_INVOKERS = {
1972
+ bash: true,
1973
+ sh: true,
1974
+ zsh: true,
1975
+ dash: true,
1976
+ ksh: true,
1977
+ fish: true,
1978
+ node: true,
1979
+ deno: true,
1980
+ bun: true,
1981
+ tsx: true,
1982
+ "ts-node": true,
1983
+ python: true,
1984
+ python2: true,
1985
+ python3: true,
1986
+ py: true,
1987
+ ruby: true,
1988
+ rb: true,
1989
+ perl: true,
1990
+ php: true,
1991
+ };
1992
+ /**
1993
+ * True when `word` is a direct script-invocation token: `./script.sh`, `../bin/run.sh`,
1994
+ * `~/bin/run.sh`. These are the user-facing forms that "invoke a file as a script" — distinct
1995
+ * from `script.sh` (which is a bare token the shell may or may not resolve via PATH). Used by
1996
+ * {@link isScriptInvocation} and {@link extractCommandStem} to recognize the "INVOKING the
1997
+ * file as a script" exception that keeps the script argument in the persisted stem.
1998
+ */
1999
+ function isDirectScriptInvocation(word) {
2000
+ // `./script.sh`, `../bin/run.sh`, `~/bin/run.sh`. The `~user/...` form is
2001
+ // intentionally NOT matched here — it expands to another user's home
2002
+ // directory and is semantically a bare path, not an explicit invocation.
2003
+ return /^\.{1,2}\//.test(word) || /^~\//.test(word);
2004
+ }
2005
+ /**
2006
+ * True when the first word of a segment is a script invocation — either a known interpreter
2007
+ * from {@link SCRIPT_INVOKERS} (`bash`, `node`, `python`, …) or a direct invocation token
2008
+ * (`./script.sh`, `../script.sh`). The script-invocation segment form is the single exception
2009
+ * to the "files are not in the allow list" rule: the WHOLE segment is a command (the user is
2010
+ * intentionally executing the file), so the file argument is preserved in the stem.
2011
+ */
2012
+ function isScriptInvocation(words) {
2013
+ if (words.length === 0)
2014
+ return false;
2015
+ const first = words[0];
2016
+ if (first === undefined)
2017
+ return false;
2018
+ return SCRIPT_INVOKERS[first] === true || isDirectScriptInvocation(first);
2019
+ }
2020
+ /**
2021
+ * Broader file-path detector than {@link isBarePath}. Catches:
2022
+ * - Bare absolute / home-relative paths (`/tmp/x`, `~/x`) — delegated to {@link isBarePath}
2023
+ * - Relative paths with separators (`build/config.json`, `src/index.ts`)
2024
+ * - Standalone filenames with a known script/data extension (`script.sh`, `config.json`,
2025
+ * `foo.txt`)
2026
+ *
2027
+ * Used by {@link isFileOnlySegment} (single-token filter) and by {@link extractCommandStem}
2028
+ * to stop stripping a command's file-argument tail. The empty-string short-circuit matches
2029
+ * {@link isBarePath}'s ordering so the two predicates compose cleanly.
2030
+ */
2031
+ export function isFilePath(entry) {
2032
+ if (entry.length === 0)
2033
+ return false;
2034
+ if (isBarePath(entry))
2035
+ return true;
2036
+ if (entry.includes("/"))
2037
+ return true;
2038
+ if (FILE_EXTENSION_RE.test(entry))
2039
+ return true;
2040
+ return false;
2041
+ }
2042
+ /**
2043
+ * True when `entry` is a "file-only" segment — after stripping leading env-var assignments,
2044
+ * the segment is a SINGLE file-path token with no command word and no shell-operator
2045
+ * punctuation (e.g., `script.sh`, `./run.sh`, `build/config.json`). Used by
2046
+ * {@link getAllowListEntries} and {@link appendStemsToAllowList} to drop segments that point
2047
+ * at a file rather than execute a command, so a multi-line input like
2048
+ * echo hello
2049
+ * script.sh
2050
+ * does not produce an allowlist entry for `script.sh` (the user has to invoke the file
2051
+ * explicitly via `bash script.sh` or `./script.sh` to land it in the allow list).
2052
+ *
2053
+ * Multi-token segments (`rm foo.sh`, `cat /tmp/x`) are NOT file-only — the first word is a
2054
+ * command, the rest are arguments. The BARE_PATH_METACHAR_RE scan filters out shell-operator
2055
+ * fragments and multi-word entries in one pass.
2056
+ */
2057
+ export function isFileOnlySegment(entry) {
2058
+ const cleaned = stripLeadingEnvAssignments(entry).trim();
2059
+ if (cleaned.length === 0)
2060
+ return false;
2061
+ if (BARE_PATH_METACHAR_RE.test(cleaned))
2062
+ return false;
2063
+ // Direct script invocations (`./script.sh`, `../script.sh`, `~/bin/run.sh`)
2064
+ // are commands, not file-only references — the user is explicitly invoking
2065
+ // the file, so it should land in the allow list like any other command.
2066
+ if (isDirectScriptInvocation(cleaned))
2067
+ return false;
2068
+ return isFilePath(cleaned);
2069
+ }
1943
2070
  /**
1944
2071
  * Regex matching a leading shell environment-variable assignment (`VAR=value`, `VAR="value"`,
1945
2072
  * etc.). The identifier must be a valid shell identifier (`[a-zA-Z_][a-zA-Z0-9_]*`) immediately
@@ -2044,59 +2171,56 @@ export function stripLeadingEnvAssignments(command) {
2044
2171
  return command;
2045
2172
  return words.slice(firstNonEnv).join(" ");
2046
2173
  }
2047
- /**
2048
- * Compute the list of entries that "Allow Always" will persist for `command`, in the
2049
- * AC-008-01-mandated order: **first non-bare segment, then full trimmed command, then remaining
2050
- * non-bare segments in parser order**. Bare-path entries are filtered by
2051
- * {@link isBarePath} so `/tmp/out` / `~/notes.md`-style tokens do not escape as standalone
2052
- * allowlist entries. Duplicates are removed with an order-preserving Set-then-spread pass so the
2053
- * "full + segments" formula collapses to a single entry when the full command equals the first
2054
- * segment (e.g. `echo hello` → `["echo hello"]`).
2055
- *
2056
- * **Ordering contract** (PRD AC-008-01 verbatim — pinned by `test/allowlist.test.ts`):
2057
- * - `getAllowListEntries("grep -l pattern")` → `["grep"]` (command name extracted)
2058
- * - `getAllowListEntries("ls && grep pattern")` → `["ls", "grep"]` (one command per segment)
2059
- * - `getAllowListEntries("npm install express && npm test")` → `["npm", "npm test"]`
2060
- * (command names; duplicates collapsed; `npm test` is a different command from `npm install`
2061
- * but both share `npm` as command name; kept only once)
2062
- * - `getAllowListEntries("cat /tmp/output.txt")` → `["cat"]`
2063
- * - `getAllowListEntries("echo hello")` → `["echo"]` (single segment command name)
2064
- *
2065
- * Contract anchors:
2066
- * - `data-model.md:177-228` (FR-008: allowlist entry computation)
2067
- * - PRD AC-008-01 / AC-008-02 / AC-008-03 (`["npm install express", "npm install express && npm test", "npm test"]`)
2068
- * - `architecture.md:152-163` (prompt preview ordering)
2069
- *
2070
- * Pure data transformation — no I/O, no `ExtensionContext`, no `os.homedir()` access. The
2071
- * empty-input early-return keeps dedup logic clean and matches PRD's "empty command → empty array"
2072
- * exception strategy.
2073
- */
2074
2174
  /**
2075
2175
  * Extract the command stem from a command string — the command name plus any
2076
2176
  * fixed subcommand (e.g. `git diff`, `docker compose`). Everything before the
2077
- * first flag argument (word starting with `-`) or path argument (word starting
2078
- * with `/`, `~`, `.`). This produces entries that, when persisted with ` *`,
2079
- * match the same subcommand pattern without being overly broad (`git diff *`
2080
- * rather than `git *`).
2177
+ * first flag argument (word starting with `-`) or file-path argument (anything
2178
+ * {@link isFilePath} recognizes, e.g. `/tmp/x`, `build/y.json`, `script.sh`).
2179
+ * This produces entries that, when persisted with ` *`, match the same
2180
+ * subcommand pattern without being overly broad (`git diff *` rather than `git *`).
2181
+ *
2182
+ * **Script-invocation exception** (ISS-002 follow-up — see `SCRIPT_INVOKERS`):
2183
+ * when the first word is a known interpreter (`bash`, `node`, `python`, …) or a
2184
+ * direct script-invocation token (`./script.sh`, `../script.sh`), the FIRST file
2185
+ * argument (the script) is preserved in the stem so `bash script.sh` produces
2186
+ * `bash script.sh` (not just `bash`). This matches the "INVOKING the file as a
2187
+ * script" carve-out and keeps the persisted rule from over-allowing every
2188
+ * invocation of the interpreter.
2081
2189
  *
2082
2190
  * For single-word commands without subcommands (e.g. `grep`, `ls`), returns
2083
- * just the command name.
2191
+ * just the command name. Single-token file paths (`script.sh`, `./run.sh`)
2192
+ * return themselves as the stem — the file IS the command in that case; the
2193
+ * downstream {@link getAllowListEntries} / {@link appendStemsToAllowList}
2194
+ * filter is responsible for keeping them out of the allow list.
2084
2195
  */
2085
2196
  function extractCommandStem(segment) {
2086
2197
  const cleaned = stripLeadingEnvAssignments(segment);
2087
2198
  const words = splitShellWords(cleaned);
2088
2199
  if (words.length === 0)
2089
2200
  return cleaned;
2090
- // Take words until we hit something that looks like a flag or path.
2201
+ const scriptInv = isScriptInvocation(words);
2202
+ // Take words until we hit something that looks like a flag or a file path.
2091
2203
  const stem = [];
2092
- for (const w of words) {
2204
+ for (let i = 0; i < words.length; i++) {
2205
+ const w = words[i];
2206
+ if (w === undefined)
2207
+ continue;
2093
2208
  if (w.startsWith("-"))
2094
2209
  break; // flag argument starts here
2095
- if (w.startsWith("/") || w.startsWith("~") || w.startsWith(".")) {
2096
- // Bare path stop before it UNLESS this is the very first word
2097
- // (a bare-path command like `/usr/bin/ls`).
2098
- if (stem.length > 0)
2099
- break;
2210
+ if (isFilePath(w)) {
2211
+ if (stem.length === 0) {
2212
+ // First word is a file path (e.g., `/usr/bin/python`, `./script.sh`,
2213
+ // `/tmp/run.sh`) keep it as the command.
2214
+ stem.push(w);
2215
+ continue;
2216
+ }
2217
+ // File-path argument after the command — stop UNLESS this is a script
2218
+ // invocation and the file is the script being invoked.
2219
+ if (scriptInv && i === 1) {
2220
+ stem.push(w);
2221
+ continue;
2222
+ }
2223
+ break;
2100
2224
  }
2101
2225
  stem.push(w);
2102
2226
  }
@@ -2124,11 +2248,29 @@ export function getAllowListEntries(command) {
2124
2248
  // Strip leading env-var assignments from each segment so transient `VAR=val`
2125
2249
  // prefixes do not leak into entries.
2126
2250
  const cleanSegments = rawSegments.map((s) => stripLeadingEnvAssignments(s));
2127
- // Filter bare paths.
2128
- const segments = cleanSegments.filter((s) => !isBarePath(s));
2251
+ // Filter bare paths AND file-only segments (e.g., a multi-line input where one
2252
+ // line is just `script.sh` — that's a file, not a command). Multi-token
2253
+ // segments like `rm foo.sh` pass through unchanged because {@link isFileOnlySegment}
2254
+ // only matches single-token file paths; the file argument inside a command is
2255
+ // the caller's responsibility (see {@link extractCommandStem}). Direct script
2256
+ // invocations (`./script.sh`, `../bin/run.sh`, `~/bin/run.sh`) bypass the
2257
+ // filter — the user is intentionally executing the file, so it belongs in
2258
+ // the allowlist like any other command.
2259
+ const segments = cleanSegments.filter((s) => isDirectScriptInvocation(s) || (!isBarePath(s) && !isFileOnlySegment(s)));
2129
2260
  if (segments.length === 0) {
2130
- // Fallback: no non-bare segments.
2131
- return isBarePath(trimmed) ? [] : [trimmed];
2261
+ // Fallback: every parsed segment was filtered. The whole command is
2262
+ // either a single direct invocation (preserved), a single file path
2263
+ // (dropped), or — in the multi-line "all file paths" case — the
2264
+ // trimmed input is a newline-joined string that {@link isFileOnlySegment}
2265
+ // refuses to classify as file-only because of the embedded `\n`. Walk
2266
+ // the original rawSegments and check EACH one individually so a
2267
+ // `script.sh\n/tmp/output.txt` input correctly resolves to `[]`.
2268
+ if (isDirectScriptInvocation(trimmed))
2269
+ return [trimmed];
2270
+ const allFiltered = cleanSegments.every((s) => !isDirectScriptInvocation(s) && (isBarePath(s) || isFileOnlySegment(s)));
2271
+ if (allFiltered)
2272
+ return [];
2273
+ return [trimmed];
2132
2274
  }
2133
2275
  // Dedup full segments (preserving order).
2134
2276
  const seen = new Set();
@@ -2335,50 +2477,6 @@ export function sessionAllow(command) {
2335
2477
  export function isSessionAllowed(command) {
2336
2478
  return sessionAllowList.has(command);
2337
2479
  }
2338
- /* ─── Allow All Once — ephemeral flag for compound commands (FLOW-12) ───
2339
- *
2340
- * Module-level boolean that when set causes `runPermissionGate` to return
2341
- * `{ allow: true }` for remaining segments without prompting. Cleared after
2342
- * execution completes (does NOT persist across bash tool invocations).
2343
- *
2344
- * Lifecycle: setAllowAllOnce → consumeAllowAllOnce (read-and-clear,
2345
- * one-shot, returns true once) → resetAllowAllOnce (unconditional clear).
2346
- *
2347
- * Flow references: FLOW-12 Step 3 + Step 8 + FLOW-08 Step 3. */
2348
- let allowAllOnceActive = false;
2349
- /**
2350
- * Set the Allow All Once flag. After this call, the next
2351
- * {@link consumeAllowAllOnce} returns `true` and clears the flag.
2352
- * Exported so the TSK-009-03 lifecycle tests can drive it directly
2353
- * without going through {@link promptForCompoundCommand}.
2354
- */
2355
- export function setAllowAllOnce() {
2356
- allowAllOnceActive = true;
2357
- }
2358
- /**
2359
- * Consume (read-and-clear) the Allow All Once flag. Returns `true`
2360
- * exactly once when the flag was set by a prior {@link setAllowAllOnce}
2361
- * call; subsequent calls return `false` until the flag is re-set.
2362
- * This one-shot semantics mirrors the existing {@link isEphemeralAllowed}
2363
- * pattern.
2364
- */
2365
- export function consumeAllowAllOnce() {
2366
- if (allowAllOnceActive) {
2367
- allowAllOnceActive = false;
2368
- return true;
2369
- }
2370
- return false;
2371
- }
2372
- /**
2373
- * Unconditionally clear the Allow All Once flag. Idempotent — calling
2374
- * when the flag is already `false` is a no-op. Primed as the test-teardown
2375
- * seam (per-test `beforeEach` in the FLOW-12 lifecycle describe); intended
2376
- * for cleanup after compound command execution resolves (FLOW-12 Step 8 —
2377
- * integration pending).
2378
- */
2379
- export function resetAllowAllOnce() {
2380
- allowAllOnceActive = false;
2381
- }
2382
2480
  /**
2383
2481
  * shared by {@link appendStemsToAllowList} and {@link appendToDenyList} — both writers push
2384
2482
  * entries onto a per-level pattern list and MUST preserve the
@@ -2405,12 +2503,31 @@ function appendUnique(list, entries) {
2405
2503
  * glued-wildcard forms (`"git*"`) so future programmatic callers (FLOW-12's
2406
2504
  * `promptForCompoundCommand`) cannot seed `perm-rules.json` with an over-broad
2407
2505
  * pattern that would match `gitlog` / `git-fetch`. Empty input is a no-op (no write).
2506
+ *
2507
+ * Scope parameter:
2508
+ * - `"session"` — in-memory only; adds each stem to {@link sessionAllowList} so it
2509
+ * survives the rest of the session without ever touching disk. No file-only
2510
+ * filtering or pattern expansion (matches the existing `sessionAllow` contract).
2511
+ * - `"always"` (default) — persist to `perm-rules.json` per the original contract.
2408
2512
  */
2409
- export function appendStemsToAllowList(stems, ctx) {
2513
+ export function appendStemsToAllowList(stems, ctx, scope = "always") {
2514
+ if (scope === "session") {
2515
+ for (const stem of stems) {
2516
+ sessionAllow(stem);
2517
+ }
2518
+ return;
2519
+ }
2410
2520
  const rules = loadRules(ctx);
2411
2521
  const mode = getCurrentMode();
2412
2522
  const expanded = [];
2413
2523
  for (const stem of stems) {
2524
+ // Drop file-only segments BEFORE persistence — a stem that is just a file
2525
+ // path (e.g., `script.sh` from a multi-line input) must never end up in
2526
+ // `perm-rules.json`. Script invocations (`bash script.sh`, `./script.sh`)
2527
+ // have their first word as a real command so the stem is multi-word and
2528
+ // passes through this filter.
2529
+ if (isFileOnlySegment(stem))
2530
+ continue;
2414
2531
  const wild = `${stem} *`;
2415
2532
  if (validateFullTokenBoundary(stem))
2416
2533
  expanded.push(stem);
@@ -2517,44 +2634,75 @@ async function promptForPermission(reason, command, askPreview, ctx) {
2517
2634
  }
2518
2635
  }
2519
2636
  /**
2520
- * Build the option list for the compound command prompt from positional wildcard
2521
- * candidates. Pure function — no I/O, no side effects.
2637
+ * Build the option list for the compound command prompt from the segment's stem
2638
+ * and (if present) parent stem. Pure function — no I/O, no side effects.
2639
+ *
2640
+ * Single-word segments emit a flat 4-option list:
2641
+ * Allow "{stem}" once
2642
+ * Allow "{stem}" this session
2643
+ * Allow "{stem}" always
2644
+ * Deny "{stem}"
2522
2645
  *
2523
- * Order: Allow All Once, then each candidate with This Session / Always suffixes,
2524
- * then Deny as the final entry.
2646
+ * Multi-word segments (e.g. `git diff`) emit a 6-option list that adds the
2647
+ * parent-stem expansion (e.g. `Allow "git" this session`, `Allow "git" always`)
2648
+ * so the developer can grant just the parent tool without locking in the
2649
+ * specific subcommand.
2525
2650
  *
2526
- * @param candidatesoutput of {@link unfoldPositionalCandidates}
2651
+ * @param stem`extractCommandStem(segment)`, the command without arguments
2652
+ * @param parent — null for single-word stems, else the first word of `stem`
2527
2653
  * @returns a string array suitable for `ctx.ui.select({ options })`
2528
2654
  */
2529
- function buildCompoundOptions(candidates) {
2530
- const options = ["Allow All Once"];
2531
- for (const c of candidates) {
2532
- options.push(`${c} This Session`);
2533
- options.push(`${c} Always`);
2655
+ function buildCompoundOptions(stem, parent) {
2656
+ if (parent !== null) {
2657
+ return [
2658
+ `Allow "${stem}" once`,
2659
+ `Allow "${parent}" this session`,
2660
+ `Allow "${stem}" this session`,
2661
+ `Allow "${parent}" always`,
2662
+ `Allow "${stem}" always`,
2663
+ `Deny "${stem}"`,
2664
+ ];
2534
2665
  }
2535
- options.push("Deny");
2536
- return options;
2666
+ return [
2667
+ `Allow "${stem}" once`,
2668
+ `Allow "${stem}" this session`,
2669
+ `Allow "${stem}" always`,
2670
+ `Deny "${stem}"`,
2671
+ ];
2537
2672
  }
2538
2673
  /**
2539
2674
  * Parse a selected option string from the compound command prompt back into
2540
2675
  * a structured {@link CompoundSelection}. Pure function — no I/O, no side effects.
2541
2676
  *
2677
+ * The shape is unambiguous because each option has a unique label suffix:
2678
+ * - ` <something>" once` → once
2679
+ * - `Deny "<something>"` → deny
2680
+ * - `Allow "<something>" this session` → session
2681
+ * - `Allow "<something>" always` → always
2682
+ * Both parent and self options share the `"Allow "<stem>" this session"`
2683
+ * shape, but their `stem` values are different — that's OK, since the parent
2684
+ * is the FIRST whitespace token of the multi-word self and is never itself
2685
+ * ambiguous.
2686
+ *
2542
2687
  * Returns `null` for unrecognised selections (the caller treats this as a
2543
2688
  * conservative deny per Constitution §1.6).
2544
2689
  */
2545
2690
  function parseCompoundSelection(selected) {
2546
- if (selected === "Allow All Once")
2547
- return { type: "allow-all-once" };
2548
- if (selected === "Deny")
2549
- return { type: "deny" };
2550
- // Must be a candidate option — determine if This Session or Always.
2551
- if (selected.endsWith(" This Session")) {
2552
- const pattern = selected.slice(0, -" This Session".length);
2553
- return { type: "candidate", pattern, isSession: true };
2554
- }
2555
- if (selected.endsWith(" Always")) {
2556
- const pattern = selected.slice(0, -" Always".length);
2557
- return { type: "candidate", pattern, isSession: false };
2691
+ const onceMatch = /^Allow "(.+)" once$/.exec(selected);
2692
+ if (onceMatch) {
2693
+ return { type: "once", stem: onceMatch[1] ?? "" };
2694
+ }
2695
+ const denyMatch = /^Deny "(.+)"$/.exec(selected);
2696
+ if (denyMatch) {
2697
+ return { type: "deny", stem: denyMatch[1] ?? "" };
2698
+ }
2699
+ const sessionMatch = /^Allow "(.+)" this session$/.exec(selected);
2700
+ if (sessionMatch) {
2701
+ return { type: "session", stem: sessionMatch[1] ?? "" };
2702
+ }
2703
+ const alwaysMatch = /^Allow "(.+)" always$/.exec(selected);
2704
+ if (alwaysMatch) {
2705
+ return { type: "always", stem: alwaysMatch[1] ?? "" };
2558
2706
  }
2559
2707
  // Unexpected selection.
2560
2708
  return null;
@@ -2562,62 +2710,65 @@ function parseCompoundSelection(selected) {
2562
2710
  /**
2563
2711
  * Apply a parsed compound selection decision: construct the decision entry,
2564
2712
  * append it to `resultSegments`, and execute any persistence side effects
2565
- * (session allow, allow-always persistence). Intended as the inner workhorse
2566
- * of {@link promptForCompoundCommand}'s segment loop.
2713
+ * (`appendStemsToAllowList` for session/always, `ephemeralAllow` for once,
2714
+ * `appendToDenyList` for deny). Intended as the inner workhorse of
2715
+ * {@link promptForCompoundCommand}'s segment loop.
2567
2716
  *
2568
- * Returns `true` when the caller should short-circuit (Allow All Once selected
2569
- * the flag has been set and the function has already returned early by
2570
- * returning the compound decision with `allowAllOnceUsed: true`); returns
2571
- * `false` to continue to the next segment.
2717
+ * Side effects:
2718
+ * - `once` → `ephemeralAllow(stem)` (consume-on-match)
2719
+ * - `session` `appendStemsToAllowList([stem], ctx, "session")` (in-memory only)
2720
+ * - `always` `appendStemsToAllowList([stem], ctx, "always")` (persists `stem` and `stem *`)
2721
+ * - `deny` → `appendToDenyList(stem, ctx)` (persists stem to current level)
2572
2722
  *
2573
- * Pure-ish: writes to `resultSegments` (mutating push), calls `setAllowAllOnce`,
2574
- * `sessionAllow`, or `appendStemsToAllowList` as side effects. The caller owns
2575
- * `resultSegments` and must not reuse it after this returns `true` (the return
2576
- * already contains the final compound decision).
2723
+ * Mutation: pushes onto `resultSegments` in place, including `pattern: parsed.stem`
2724
+ * on every pushed entry so downstream consumers can reconstruct the chosen stem
2725
+ * (the parent-stem session/always selections arrive here with `stem === parent`).
2577
2726
  */
2578
2727
  function applyCompoundSegmentDecision(segment, parsed, resultSegments, ctx) {
2579
- if (parsed.type === "allow-all-once") {
2580
- setAllowAllOnce();
2581
- resultSegments.push({ segment, decision: "allow", scope: "ephemeral" });
2582
- return true;
2583
- }
2584
2728
  if (parsed.type === "deny") {
2585
- resultSegments.push({ segment, decision: "deny", scope: "persist" });
2729
+ appendToDenyList(parsed.stem, ctx);
2730
+ resultSegments.push({ segment, decision: "deny", scope: "persist", pattern: parsed.stem });
2731
+ return false;
2732
+ }
2733
+ if (parsed.type === "once") {
2734
+ ephemeralAllow(parsed.stem);
2735
+ resultSegments.push({ segment, decision: "allow", scope: "ephemeral", pattern: parsed.stem });
2586
2736
  return false;
2587
2737
  }
2588
- // Candidate — extract the stem (remove trailing ` *` for persistence).
2589
- const stem = parsed.pattern.endsWith(" *") ? parsed.pattern.slice(0, -2) : parsed.pattern;
2590
- if (parsed.isSession) {
2591
- sessionAllow(stem);
2592
- resultSegments.push({
2593
- segment,
2594
- decision: "allow",
2595
- scope: "session",
2596
- pattern: parsed.pattern,
2597
- });
2598
- }
2599
- else {
2600
- appendStemsToAllowList([stem], ctx);
2601
- resultSegments.push({
2602
- segment,
2603
- decision: "allow",
2604
- scope: "persist",
2605
- pattern: parsed.pattern,
2606
- });
2738
+ if (parsed.type === "session") {
2739
+ appendStemsToAllowList([parsed.stem], ctx, "session");
2740
+ resultSegments.push({ segment, decision: "allow", scope: "session", pattern: parsed.stem });
2741
+ return false;
2607
2742
  }
2743
+ // "always"
2744
+ appendStemsToAllowList([parsed.stem], ctx, "always");
2745
+ resultSegments.push({ segment, decision: "allow", scope: "always", pattern: parsed.stem });
2608
2746
  return false;
2609
2747
  }
2610
2748
  /**
2611
2749
  * Segment-by-segment interactive prompt for compound commands (FLOW-12).
2612
2750
  *
2613
- * For each "ask" segment, computes positional wildcard candidates via
2614
- * {@link unfoldPositionalCandidates}, presents them in a `ctx.ui.select()`
2615
- * dialog with Allow All Once / This Session / Always / Deny options, and
2616
- * returns a {@link CompoundDecision} mapping each segment to its scoped result.
2617
- *
2618
- * The Allow All Once option short-circuits remaining segments without prompting
2619
- * and sets the Allow All Once flag so {@link runPermissionGate} can skip
2620
- * re-prompting for subsequent segments of the same compound execution.
2751
+ * For each "ask" segment, computes the command stem (and parent stem for
2752
+ * multi-word commands), presents them in a `ctx.ui.select()` dialog with
2753
+ * a per-segment menu (Allow "{stem}" once, Allow "{stem}" this session,
2754
+ * Allow "{stem}" always, Deny "{stem}"; multi-word segments also expose
2755
+ * parent-stem session/always variants), and returns a {@link CompoundDecision}
2756
+ * mapping each segment to its scoped result.
2757
+ *
2758
+ * After applying a per-segment decision, the function short-circuits when
2759
+ * the user commits to a session or always scope (remaining segments are
2760
+ * auto-allowed through that scope without further prompting); "once" and
2761
+ * "deny" continue iterating so each segment is decided individually.
2762
+ *
2763
+ * **Classifier-aware pre-pass** (FLOW-12 Step 2 enhancement): segments
2764
+ * whose per-segment classifier verdict is `allow` (e.g. cascade-matched
2765
+ * `git diff *` for a `cd /repo; git diff HEAD` chain) are recorded as
2766
+ * `decision: "allow"` in the result WITHOUT prompting — the cascade rule
2767
+ * already gates them, so re-prompting wastes UI cycles and trains the user
2768
+ * to deny out of habit. Per-segment `deny` is unreachable here because
2769
+ * `runPermissionGate` routes denies through `classifyCommand`'s combined
2770
+ * precedence before reaching this prompt (the compound branch only fires
2771
+ * when combined decision is `ask`).
2621
2772
  *
2622
2773
  * Headless short-circuit (FLOW-11): when `ctx.hasUI === false`, returns an empty
2623
2774
  * `CompoundDecision` immediately without calling `ctx.ui.select` — the caller
@@ -2627,25 +2778,64 @@ function applyCompoundSegmentDecision(segment, parsed, resultSegments, ctx) {
2627
2778
  *
2628
2779
  * Flow references:
2629
2780
  * - FLOW-12 Step 3 ("segment-by-segment prompt loop")
2630
- * - FLOW-12 Step 8 ("Allow All Once flag lifecycle")
2631
2781
  * - FLOW-08 Step 3 ("scope-based persistence routing")
2632
2782
  */
2633
2783
  export async function promptForCompoundCommand(segments, ctx) {
2634
2784
  if (!ensureSelectAvailable(ctx)) {
2635
- return { segments: [], allowAllOnceUsed: false };
2785
+ return { segments: [], cancelled: false };
2636
2786
  }
2637
2787
  if (segments.length === 0) {
2638
- return { segments: [], allowAllOnceUsed: false };
2788
+ return { segments: [], cancelled: false };
2639
2789
  }
2640
2790
  const resultSegments = [];
2641
2791
  const promptSelect = ctx.ui.select;
2642
- for (const segment of segments) {
2643
- const candidates = unfoldPositionalCandidates(segment);
2644
- const options = buildCompoundOptions(candidates);
2792
+ // Pre-classify every segment so cascade-allowed pieces of a compound
2793
+ // command (`cd /repo; git diff HEAD; echo done` at medium mode where
2794
+ // `cd *`, `git diff *`, and `echo *` all cascade-allow) never reach the
2795
+ // per-segment prompt. The classifier runs in a single pipeline (no I/O)
2796
+ // and mirrors `classifyCommand`'s per-segment walker at
2797
+ // `src/gatekeeper.ts:1799-1858`.
2798
+ const mode = getCurrentMode();
2799
+ const rules = loadRules(ctx);
2800
+ const strippedSegments = segments.map((seg) => stripLeadingEnvAssignments(seg));
2801
+ const perSegment = strippedSegments.map((seg) => classifySegment(seg, mode, rules));
2802
+ for (let i = 0; i < segments.length; i += 1) {
2803
+ const segment = segments[i];
2804
+ const segmentClassifier = perSegment[i];
2805
+ // Cascade-allowed segments are recorded but not prompted — the rule
2806
+ // that already gates them (e.g. `git diff *` matched via cascade-down)
2807
+ // is the authoritative allow signal; re-prompting would only confirm
2808
+ // what the rule already says and creates the false-deny surface the
2809
+ // user kept tripping on ("Blocked: All segments denied" out of habit).
2810
+ if (segmentClassifier.decision === "allow") {
2811
+ resultSegments.push({ segment, decision: "allow", scope: "ephemeral" });
2812
+ continue;
2813
+ }
2814
+ // Per-segment `deny` is unreachable through `runPermissionGate`
2815
+ // (combined precedence is `deny > ask > allow`), but if a future
2816
+ // caller routes a deny-yielding compound here we surface it as a
2817
+ // hard deny without prompting. `pattern` is omitted because the
2818
+ // upstream deny was decided at the full-command classifier level,
2819
+ // not from this per-segment prompt.
2820
+ if (segmentClassifier.decision === "deny") {
2821
+ resultSegments.push({ segment, decision: "deny", scope: "persist" });
2822
+ return { segments: resultSegments, cancelled: false };
2823
+ }
2824
+ // Compute the full command stem (e.g., `pnpm exec biome check test/`,
2825
+ // `git diff HEAD`) via `extractCommandStem`. The menu only needs the
2826
+ // first two tokens — the bare command plus its first subcommand — to
2827
+ // stay readable; longer stems add no meaningful choice granularity.
2828
+ // `parent` is the first token of that two-token menu stem, used for
2829
+ // the parent-stem "this session" / "always" expansion entries.
2830
+ const stem = extractCommandStem(segment);
2831
+ const tokens = stem.split(" ");
2832
+ const menuStem = tokens.slice(0, 2).join(" ");
2833
+ const parent = tokens.length > 1 ? (tokens[0] ?? null) : null;
2834
+ const options = buildCompoundOptions(menuStem, parent);
2645
2835
  let selected;
2646
2836
  try {
2647
2837
  selected = await promptSelect({
2648
- message: `Segment: ${segment}`,
2838
+ message: `Allow command "${segment}"`,
2649
2839
  options,
2650
2840
  });
2651
2841
  }
@@ -2653,22 +2843,31 @@ export async function promptForCompoundCommand(segments, ctx) {
2653
2843
  // Fail closed per Constitution §1.6 — a thrown prompt must never silently
2654
2844
  // allow. The denial is recorded in the CompoundDecision and surfaced by
2655
2845
  // finalizeCompoundGateResult's notification, avoiding a duplicate warning.
2656
- resultSegments.push({ segment, decision: "deny", scope: "persist" });
2657
- return { segments: resultSegments, allowAllOnceUsed: false };
2846
+ resultSegments.push({ segment, decision: "deny", scope: "persist", pattern: stem });
2847
+ return { segments: resultSegments, cancelled: false };
2658
2848
  }
2659
2849
  if (selected === undefined) {
2660
- return { segments: resultSegments, allowAllOnceUsed: false };
2850
+ // User dismissed the prompt (Escape, focus loss, TUI auto-dismiss) without
2851
+ // choosing any option. Mark `cancelled: true` so finalizeCompoundGateResult
2852
+ // can emit "Blocked: Prompt dismissed" — distinct from the misleading
2853
+ // "Blocked: All segments denied" that an empty `resultSegments` would otherwise produce.
2854
+ return { segments: resultSegments, cancelled: true };
2661
2855
  }
2662
2856
  const parsed = parseCompoundSelection(selected);
2663
2857
  if (parsed === null) {
2664
- resultSegments.push({ segment, decision: "deny", scope: "persist" });
2858
+ resultSegments.push({ segment, decision: "deny", scope: "persist", pattern: stem });
2665
2859
  continue;
2666
2860
  }
2667
- if (applyCompoundSegmentDecision(segment, parsed, resultSegments, ctx)) {
2668
- return { segments: resultSegments, allowAllOnceUsed: true };
2861
+ applyCompoundSegmentDecision(segment, parsed, resultSegments, ctx);
2862
+ // Short-circuit: when the user commits to a session or always scope on
2863
+ // the current segment, remaining segments are auto-allowed through that
2864
+ // scope without further prompting. "once" and "deny" continue to the
2865
+ // next segment so each is decided individually.
2866
+ if (parsed.type === "session" || parsed.type === "always") {
2867
+ return { segments: resultSegments, cancelled: false };
2669
2868
  }
2670
2869
  }
2671
- return { segments: resultSegments, allowAllOnceUsed: false };
2870
+ return { segments: resultSegments, cancelled: false };
2672
2871
  }
2673
2872
  /**
2674
2873
  * Interpret a {@link CompoundDecision} returned by
@@ -2685,8 +2884,17 @@ export async function promptForCompoundCommand(segments, ctx) {
2685
2884
  * - No segment allowed → notify and deny.
2686
2885
  */
2687
2886
  function finalizeCompoundGateResult(compoundDecision, ctx) {
2688
- if (compoundDecision.allowAllOnceUsed) {
2689
- return { allow: true };
2887
+ if (compoundDecision.cancelled) {
2888
+ // User dismissed the prompt (Escape, focus loss) without choosing. This is
2889
+ // semantically distinct from "explicitly denied every segment" — the user did
2890
+ // not actively choose Deny, so the message must reflect that. Cancellation also
2891
+ // MUST NOT persist anything to perm-rules.json (Constitution §1.6 — the user never
2892
+ // opted in to a deny rule, and an unwanted "deny" entry would silently widen the
2893
+ // deny surface for future invocations).
2894
+ if (ctx.hasUI && ctx.ui) {
2895
+ ctx.ui.notify("Blocked: Prompt dismissed", "warning");
2896
+ }
2897
+ return { allow: false };
2690
2898
  }
2691
2899
  const anyAllowed = compoundDecision.segments.some((s) => s.decision === "allow");
2692
2900
  if (anyAllowed) {
@@ -2745,15 +2953,6 @@ export async function runPermissionGate(command, ctx) {
2745
2953
  if (runtimeMode === "off") {
2746
2954
  return { allow: true };
2747
2955
  }
2748
- // (1.5) Allow All Once check — FLOW-12 Step 7/8. When the flag is set by
2749
- // promptForCompoundCommand for a preceding segment in the same compound
2750
- // execution, skip all processing (classifier, prompt) and return allow.
2751
- // The flag is consumed on first access (single-shot), so a subsequent
2752
- // runPermissionGate invocation without re-setting the flag goes through the
2753
- // normal classifier/prompt path.
2754
- if (consumeAllowAllOnce()) {
2755
- return { allow: true };
2756
- }
2757
2956
  // (2) Session allow check — persists across the session without consuming on match.
2758
2957
  if (isSessionAllowed(resolvedCommand)) {
2759
2958
  return { allow: true };
@@ -2802,7 +3001,22 @@ export async function runPermissionGate(command, ctx) {
2802
3001
  const strippedSegments = segments.map((seg) => stripLeadingEnvAssignments(seg));
2803
3002
  const perSegment = strippedSegments.map((seg) => classifySegment(seg, mode, rules));
2804
3003
  const askStems = perSegment
2805
- .map((d, i) => d.decision === "ask" ? extractCommandStem(strippedSegments[i]) : null)
3004
+ .map((d, i) => {
3005
+ if (d.decision !== "ask")
3006
+ return null;
3007
+ const seg = strippedSegments[i];
3008
+ if (seg === undefined)
3009
+ return null;
3010
+ // File-only segments (a single-token file path like `script.sh`) are
3011
+ // filtered from the preview AND from the allow-list persistence. The
3012
+ // full command is still shown in the prompt's `Command:` line, so the
3013
+ // user can see what is being run; the developer must invoke the file
3014
+ // explicitly (`bash script.sh`, `./script.sh`) to land it in the
3015
+ // allow list.
3016
+ if (isFileOnlySegment(seg))
3017
+ return null;
3018
+ return extractCommandStem(seg);
3019
+ })
2806
3020
  .filter((s) => s !== null);
2807
3021
  const askPreview = askStems.length > 0 ? askStems.join(", ") : "(no segments)";
2808
3022
  const choice = await promptForPermission(decision.reason, resolvedCommand, askPreview, ctx);
@@ -2925,38 +3139,28 @@ function buildBashResult(text, isError) {
2925
3139
  * Source anchor: `tasks.md TSK-005-03` (bash tool registration + execute handler).
2926
3140
  */
2927
3141
  async function bashExecute(_toolCallId, params, _signal, _onUpdate, ctx) {
3142
+ // (1) Defensive non-string guard — a malformed tool payload (number, object, null,
3143
+ // undefined) never reaches the gate. Returns BLOCKED with isError: true so the LLM
3144
+ // surfaces a structured error to the developer.
3145
+ if (typeof params.command !== "string") {
3146
+ return buildBashResult("BLOCKED: bash tool command must be a string", true);
3147
+ }
3148
+ // (2) Permission gate — classifier + prompt + persistence routing. The gate runs BEFORE
3149
+ // shell dispatch so a denied command never reaches `child_process.exec`.
3150
+ const decision = await runPermissionGate(params.command, ctx);
3151
+ if (!decision.allow) {
3152
+ return buildBashResult(`BLOCKED: ${params.command}`, true);
3153
+ }
3154
+ // (3) Shell dispatch — gate approved; run the command in `ctx.cwd` and surface the
3155
+ // merged stdout/stderr payload. A spawn failure (ENOENT, EACCES, etc.) is caught and
3156
+ // returned as a structured error result so the LLM sees a normalized shape.
2928
3157
  try {
2929
- // (1) Defensive non-string guard a malformed tool payload (number, object, null,
2930
- // undefined) never reaches the gate. Returns BLOCKED with isError: true so the LLM
2931
- // surfaces a structured error to the developer.
2932
- if (typeof params.command !== "string") {
2933
- return buildBashResult("BLOCKED: bash tool command must be a string", true);
2934
- }
2935
- // (2) Permission gate — classifier + prompt + persistence routing. The gate runs BEFORE
2936
- // shell dispatch so a denied command never reaches `child_process.exec`.
2937
- const decision = await runPermissionGate(params.command, ctx);
2938
- if (!decision.allow) {
2939
- return buildBashResult(`BLOCKED: ${params.command}`, true);
2940
- }
2941
- // (3) Shell dispatch — gate approved; run the command in `ctx.cwd` and surface the
2942
- // merged stdout/stderr payload. A spawn failure (ENOENT, EACCES, etc.) is caught and
2943
- // returned as a structured error result so the LLM sees a normalized shape.
2944
- try {
2945
- const { stdout, stderr } = await execBash(params.command, ctx.cwd);
2946
- return buildBashResult(stderr && stderr.length > 0 ? `${stdout}${stdout ? "\n" : ""}${stderr}` : stdout, false);
2947
- }
2948
- catch (err) {
2949
- const message = err instanceof Error ? err.message : String(err);
2950
- return buildBashResult(`BLOCKED: shell exec failed: ${message}`, true);
2951
- }
3158
+ const { stdout, stderr } = await execBash(params.command, ctx.cwd);
3159
+ return buildBashResult(stderr && stderr.length > 0 ? `${stdout}${stdout ? "\n" : ""}${stderr}` : stdout, false);
2952
3160
  }
2953
- finally {
2954
- // FLOW-12 Step 8 clearance: after any compound execution completes,
2955
- // unconditionally clear the Allow All Once flag so it does not persist
2956
- // across separate bash tool invocations. The outer try/finally guarantees
2957
- // this runs even if runPermissionGate throws unexpectedly, making the
2958
- // safety contract unbreakable (FLOW-12 Step 8, FLOW-08 Step 5).
2959
- resetAllowAllOnce();
3161
+ catch (err) {
3162
+ const message = err instanceof Error ? err.message : String(err);
3163
+ return buildBashResult(`BLOCKED: shell exec failed: ${message}`, true);
2960
3164
  }
2961
3165
  }
2962
3166
  /**