@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.
- package/dist/gatekeeper.d.ts +73 -38
- package/dist/gatekeeper.d.ts.map +1 -1
- package/dist/gatekeeper.js +418 -214
- package/dist/gatekeeper.js.map +1 -1
- package/package.json +1 -1
package/dist/gatekeeper.js
CHANGED
|
@@ -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 (
|
|
2078
|
-
*
|
|
2079
|
-
*
|
|
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
|
-
|
|
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 (
|
|
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 (
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
|
|
2099
|
-
|
|
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
|
-
|
|
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:
|
|
2131
|
-
|
|
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
|
|
2521
|
-
*
|
|
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
|
-
*
|
|
2524
|
-
*
|
|
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
|
|
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(
|
|
2530
|
-
|
|
2531
|
-
|
|
2532
|
-
|
|
2533
|
-
|
|
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
|
-
|
|
2536
|
-
|
|
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
|
-
|
|
2547
|
-
|
|
2548
|
-
|
|
2549
|
-
|
|
2550
|
-
|
|
2551
|
-
if (
|
|
2552
|
-
|
|
2553
|
-
|
|
2554
|
-
|
|
2555
|
-
if (
|
|
2556
|
-
|
|
2557
|
-
|
|
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
|
-
* (
|
|
2566
|
-
*
|
|
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
|
-
*
|
|
2569
|
-
*
|
|
2570
|
-
*
|
|
2571
|
-
* `
|
|
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
|
-
*
|
|
2574
|
-
*
|
|
2575
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
2589
|
-
|
|
2590
|
-
|
|
2591
|
-
|
|
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
|
|
2614
|
-
*
|
|
2615
|
-
*
|
|
2616
|
-
*
|
|
2617
|
-
*
|
|
2618
|
-
*
|
|
2619
|
-
*
|
|
2620
|
-
*
|
|
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: [],
|
|
2785
|
+
return { segments: [], cancelled: false };
|
|
2636
2786
|
}
|
|
2637
2787
|
if (segments.length === 0) {
|
|
2638
|
-
return { segments: [],
|
|
2788
|
+
return { segments: [], cancelled: false };
|
|
2639
2789
|
}
|
|
2640
2790
|
const resultSegments = [];
|
|
2641
2791
|
const promptSelect = ctx.ui.select;
|
|
2642
|
-
|
|
2643
|
-
|
|
2644
|
-
|
|
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: `
|
|
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,
|
|
2846
|
+
resultSegments.push({ segment, decision: "deny", scope: "persist", pattern: stem });
|
|
2847
|
+
return { segments: resultSegments, cancelled: false };
|
|
2658
2848
|
}
|
|
2659
2849
|
if (selected === undefined) {
|
|
2660
|
-
|
|
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
|
-
|
|
2668
|
-
|
|
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,
|
|
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.
|
|
2689
|
-
|
|
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) =>
|
|
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
|
-
|
|
2930
|
-
|
|
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
|
-
|
|
2954
|
-
|
|
2955
|
-
|
|
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
|
/**
|