@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.
- package/dist/default-rules.json +2 -0
- package/dist/gatekeeper.d.ts +161 -30
- package/dist/gatekeeper.d.ts.map +1 -1
- package/dist/gatekeeper.js +597 -136
- package/dist/gatekeeper.js.map +1 -1
- package/package.json +14 -4
package/dist/gatekeeper.js
CHANGED
|
@@ -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 =
|
|
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` โ
|
|
568
|
-
* - `medium` โ
|
|
569
|
-
* - `high` โ
|
|
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: "
|
|
575
|
-
medium: "
|
|
576
|
-
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
|
|
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
|
-
|
|
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
|
|
1274
|
-
*
|
|
1275
|
-
*
|
|
1276
|
-
*
|
|
1277
|
-
*
|
|
1278
|
-
*
|
|
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 (
|
|
1284
|
-
if (matchGlob(
|
|
1285
|
-
return { matched: true, pattern
|
|
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.
|
|
1292
|
-
*
|
|
1293
|
-
*
|
|
1294
|
-
*
|
|
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
|
|
1299
|
-
|
|
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
|
|
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)
|
|
1533
|
-
|
|
1534
|
-
//
|
|
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("
|
|
2037
|
-
*
|
|
2038
|
-
* - `getAllowListEntries("
|
|
2039
|
-
*
|
|
2040
|
-
*
|
|
2041
|
-
* - `getAllowListEntries("
|
|
2042
|
-
* - `getAllowListEntries("
|
|
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
|
-
|
|
2060
|
-
//
|
|
2061
|
-
|
|
2062
|
-
//
|
|
2063
|
-
|
|
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
|
-
|
|
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
|
|
2075
|
-
if (
|
|
2122
|
+
for (const seg of segments) {
|
|
2123
|
+
if (seen.has(seg))
|
|
2076
2124
|
continue;
|
|
2077
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2082
|
-
out.push(
|
|
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
|
-
*
|
|
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
|
-
*
|
|
2167
|
-
*
|
|
2168
|
-
*
|
|
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
|
-
*
|
|
2171
|
-
*
|
|
2172
|
-
*
|
|
2173
|
-
*
|
|
2174
|
-
*
|
|
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
|
|
2395
|
+
export function appendStemsToAllowList(stems, ctx) {
|
|
2192
2396
|
const rules = loadRules(ctx);
|
|
2193
2397
|
const mode = getCurrentMode();
|
|
2194
|
-
|
|
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
|
|
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
|
|
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
|
|
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 (
|
|
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
|
-
// (
|
|
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
|
-
// (
|
|
2765
|
+
// (5a) Headless short-circuit (FLOW-11).
|
|
2345
2766
|
if (checkHeadlessShortCircuit(ctx)) {
|
|
2346
2767
|
return { allow: false };
|
|
2347
2768
|
}
|
|
2348
|
-
|
|
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
|
-
|
|
2358
|
-
|
|
2359
|
-
|
|
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
|
-
|
|
2369
|
-
|
|
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
|
-
|
|
2479
|
-
|
|
2480
|
-
|
|
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
|
-
|
|
2483
|
-
|
|
2484
|
-
|
|
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
|
/**
|