@gotgenes/pi-permission-system 27.0.0 → 27.1.0

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.
Files changed (38) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/README.md +26 -6
  3. package/config/config.example.json +3 -1
  4. package/dist/public.d.ts +21 -1
  5. package/docs/configuration.md +184 -5
  6. package/docs/troubleshooting.md +4 -1
  7. package/package.json +3 -1
  8. package/schemas/permissions.schema.json +52 -4
  9. package/src/access-intent/bash/bash-path-resolver.ts +104 -34
  10. package/src/access-intent/bash/command-effects.ts +305 -0
  11. package/src/access-intent/bash/command-enumeration.ts +116 -40
  12. package/src/access-intent/bash/program.ts +15 -11
  13. package/src/access-intent/bash/redirect-analysis.ts +98 -0
  14. package/src/access-intent/bash/token-collection.ts +102 -22
  15. package/src/access-intent/bash/wrapper-analysis.ts +101 -11
  16. package/src/access-intent/effect.ts +56 -0
  17. package/src/access-intent/input-normalizer.ts +2 -2
  18. package/src/access-intent/path-surfaces.ts +110 -4
  19. package/src/authority/delegation-envelope.ts +18 -7
  20. package/src/config-schema.ts +95 -6
  21. package/src/handlers/gates/bash-command.ts +53 -17
  22. package/src/handlers/gates/bash-external-directory.ts +41 -8
  23. package/src/handlers/gates/bash-path-extractor.ts +3 -3
  24. package/src/handlers/gates/bash-path.ts +31 -13
  25. package/src/handlers/gates/external-directory-policy.ts +37 -13
  26. package/src/handlers/gates/external-directory.ts +11 -4
  27. package/src/handlers/gates/path.ts +12 -5
  28. package/src/handlers/gates/tool.ts +22 -0
  29. package/src/normalize.ts +70 -1
  30. package/src/permission-manager.ts +3 -2
  31. package/src/permission-resolver.ts +23 -4
  32. package/src/presentation/path-ask-payload.ts +17 -6
  33. package/src/restrictiveness.ts +48 -0
  34. package/src/rule.ts +13 -13
  35. package/src/scope-merge.ts +8 -2
  36. package/src/session-rules.ts +19 -8
  37. package/src/types.ts +22 -1
  38. package/src/handlers/gates/candidate-check.ts +0 -32
@@ -1,13 +1,16 @@
1
1
  /**
2
2
  * Wrapper interpretation for a bash command unit: what kind of wrapper it is,
3
- * and — where it can be established — what it actually runs.
3
+ * what it actually runs, and whether its floor still has a reason to hold.
4
4
  *
5
5
  * Pure and word-based; the AST walk that produces the words lives in
6
- * `command-enumeration.ts`. Both questions live here together deliberately: the
7
- * shape that floors a unit to `ask` and the shape that names its inner command
8
- * must agree, and two classifiers over the same vocabulary would drift.
6
+ * `command-enumeration.ts`. The three questions live here together
7
+ * deliberately: the shape that floors a unit to `ask`, the shape that names its
8
+ * inner command, and the shape that exempts it must agree, and separate
9
+ * classifiers over the same vocabulary would drift.
9
10
  */
10
11
 
12
+ import { proveCommandEffect } from "#src/access-intent/bash/command-effects";
13
+
11
14
  /** One word of a command unit: its text, and its offset into the unit's text. */
12
15
  export interface CommandWord {
13
16
  readonly text: string;
@@ -62,11 +65,12 @@ export function classifyWrapperWords(
62
65
  * The command a wrapper unit actually runs, or `null` when it cannot be
63
66
  * established or adds nothing over the unit itself.
64
67
  *
65
- * Display-only (ADR 0011 §3.5, #713): the result is never gated and never
66
- * becomes a `BashCommand`, so the wrapper floor is untouched. Because it is
67
- * shown on a decision surface, the rule is to fail to `null` rather than to a
68
- * guess — an unrecognized option shape yields nothing rather than a remainder
69
- * that might name the wrong command.
68
+ * Display-only (ADR 0011 §3.5, #713): the result names what runs, including
69
+ * the payload of an inline shell, so it deliberately looks *past* a `sh -c`
70
+ * layer that the gate must not look past. Because it is shown on a decision
71
+ * surface, the rule is to fail to `null` rather than to a guess — an
72
+ * unrecognized option shape yields nothing rather than a remainder that might
73
+ * name the wrong command.
70
74
  *
71
75
  * Nested wrappers unwrap to the innermost command (`sudo timeout 5 xargs grep
72
76
  * foo` → `grep foo`), bounded by {@link MAX_UNWRAP_DEPTH}.
@@ -75,8 +79,93 @@ export function executedUnitOf(
75
79
  unitText: string,
76
80
  words: readonly CommandWord[],
77
81
  ): string | null {
82
+ const unwrapped = unwrapIndirection(words, unitText);
83
+ const text = unwrapped.kind === "opaque" ? unwrapped.payload : unwrapped.text;
84
+ return nothingNew(text, unitText);
85
+ }
86
+
87
+ /**
88
+ * True when a wrapper unit's floor has no reason left to hold: the command it
89
+ * runs is in the pure-reader core, so its *direction* is provable however
90
+ * unknown its argument feed is (ADR 0013 §11, #803).
91
+ *
92
+ * The floor exists because a wrapper hides the command that should be gated,
93
+ * and the unknowability it guards is unknowability of scope — which stays the
94
+ * projection's and the path surfaces' job, for wrapped and unwrapped commands
95
+ * alike. Argument-independence is the core's admission bar, so there are no
96
+ * arguments that make `grep` write a file.
97
+ *
98
+ * Four things must hold, and each is a way the reason could still hold:
99
+ *
100
+ * 1. The unit is an indirection wrapper — an ordinary command has no floor.
101
+ * 2. Unwrapping reached the inner command without passing through an opaque
102
+ * payload. `sh -c '…'` carries an unparsed program whose first word says
103
+ * nothing about the rest of it, which is why this cannot consult
104
+ * {@link executedUnitOf}'s string.
105
+ * 3. The inner command *proves* a read — bare-basename core word, retraction
106
+ * guards applied, so `xargs sort -o /tmp/x` and `xargs find . -delete` are
107
+ * not transparent.
108
+ * 4. The unit writes no file through a redirect, which the caller reads off
109
+ * the parse tree and this module never sees.
110
+ */
111
+ export function isTransparentWrapper(
112
+ words: readonly CommandWord[],
113
+ statement: { readonly writesViaRedirect: boolean },
114
+ ): boolean {
115
+ if (statement.writesViaRedirect) return false;
116
+ if (classifyWrapperWords(words) !== "indirection") return false;
117
+
118
+ // Only the peeled words matter here, so the walk is handed no source span to
119
+ // cut from — the text slice is `executedUnitOf`'s product, not this one's.
120
+ const unwrapped = unwrapIndirection(words, "");
121
+ if (unwrapped.kind === "opaque" || unwrapped.layers === 0) return false;
122
+
123
+ const head = unwrapped.words.at(0)?.text ?? "";
124
+ const args = unwrapped.words.slice(1).map((word) => word.text);
125
+ return proveCommandEffect(head, args).effect === "read";
126
+ }
127
+
128
+ // ── Unwrapping ───────────────────────────────────────────────────────────────
129
+
130
+ /**
131
+ * How an unwrap ended: at the innermost command reachable by peeling
132
+ * indirection layers, or at an inline-shell payload.
133
+ *
134
+ * The two are kept apart because they are different kinds of answer. A peeled
135
+ * result is a slice of this command line, with words the caller can inspect;
136
+ * a payload is an inner *program* the enumerator never parsed, so it has text
137
+ * and nothing else. Collapsing them is how a core-looking first word inside
138
+ * `sh -c '…'` would come to stand for the whole payload (#803).
139
+ */
140
+ type UnwrapResult =
141
+ | { readonly kind: "opaque"; readonly payload: string | null }
142
+ | {
143
+ readonly kind: "peeled";
144
+ readonly text: string;
145
+ readonly words: readonly CommandWord[];
146
+ /** How many indirection layers came off; `0` means none did. */
147
+ readonly layers: number;
148
+ };
149
+
150
+ /**
151
+ * Peel indirection layers off a command unit until an ordinary command, an
152
+ * opaque payload, or an unrecognized option shape stops the walk.
153
+ *
154
+ * Stopping early is not an error: the words peeled so far are returned, and
155
+ * each caller decides what an incomplete peel is worth — `executedUnitOf`
156
+ * shows it, {@link isTransparentWrapper} declines it because the head word it
157
+ * would judge is the wrapper's own.
158
+ *
159
+ * `unitText` is the span the peeled `text` is cut from; a caller that wants
160
+ * only the words passes the empty string and ignores it.
161
+ */
162
+ function unwrapIndirection(
163
+ words: readonly CommandWord[],
164
+ unitText: string,
165
+ ): UnwrapResult {
78
166
  let text = unitText;
79
167
  let current = words;
168
+ let layers = 0;
80
169
 
81
170
  for (let depth = 0; depth < MAX_UNWRAP_DEPTH; depth++) {
82
171
  const kind = classifyWrapperWords(current);
@@ -85,7 +174,7 @@ export function executedUnitOf(
85
174
  if (kind === "opaque-payload") {
86
175
  // The payload is an inner *program*, not a slice of this command line, so
87
176
  // it is unquoted and terminal — unwrapping it further would need a parse.
88
- return nothingNew(opaquePayload(current), unitText);
177
+ return { kind: "opaque", payload: opaquePayload(current) };
89
178
  }
90
179
 
91
180
  const start = innerCommandIndex(current);
@@ -93,9 +182,10 @@ export function executedUnitOf(
93
182
  const end = execTerminatorIndex(current, start);
94
183
  text = sliceWords(text, current, start, end).trimEnd();
95
184
  current = rebase(current, start, end);
185
+ layers++;
96
186
  }
97
187
 
98
- return nothingNew(text, unitText);
188
+ return { kind: "peeled", text, words: current, layers };
99
189
  }
100
190
 
101
191
  /** How many wrapper layers to unwrap before giving up. */
@@ -0,0 +1,56 @@
1
+ /**
2
+ * A filesystem effect an access can have.
3
+ *
4
+ * ADR 0013 §2 reserves a third value, `delete`, as strictly stronger than
5
+ * `write`; it is not shipped, so nothing can name it in a config or a proof.
6
+ */
7
+ export type Effect = "read" | "write";
8
+
9
+ /**
10
+ * An effect attribution, including the fail-closed base case.
11
+ *
12
+ * `unproven` is not a third effect — it is the absence of a proof, and ADR
13
+ * 0013 §10 makes it consult both directional surfaces, most-restrictive.
14
+ */
15
+ export type AttributedEffect = Effect | "unproven";
16
+
17
+ /**
18
+ * What established an attribution — the blame fact ADR 0013 §7 asks the review
19
+ * log to record.
20
+ *
21
+ * `retracted` is distinct from `unproven` on purpose: "nobody claimed anything
22
+ * about `pnpm`" and "`find` is a core reader but `-delete` withdrew the claim"
23
+ * are different diagnoses, and only the second names a line to read.
24
+ */
25
+ export type EffectSource = "syntax" | "core" | "retracted" | "unproven";
26
+
27
+ /** A path token's attributed effect, paired with what established it. */
28
+ export interface TokenEffect {
29
+ readonly effect: AttributedEffect;
30
+ readonly source: EffectSource;
31
+ }
32
+
33
+ /** The fail-closed base case: no proof, and nothing to blame for its absence. */
34
+ export const UNPROVEN_EFFECT: TokenEffect = {
35
+ effect: "unproven",
36
+ source: "unproven",
37
+ };
38
+
39
+ /**
40
+ * Combine two attributions of the same resolved path.
41
+ *
42
+ * Agreement keeps the effect, and prefers whichever source has something to
43
+ * blame — a `retracted` attribution survives a merge with a bare unproven
44
+ * whichever order the two tokens were collected in. Order-independence
45
+ * matters because collection order is an accident of the command's shape, and
46
+ * the blame line is the only reason the source is recorded at all.
47
+ *
48
+ * Disagreement falls to {@link UNPROVEN_EFFECT}, because proven-both and
49
+ * unproven-at-all consult the same two surfaces — ADR 0013's 2026-08-25
50
+ * amendment reads them as one mechanism, not two — which is what makes the
51
+ * fold honest rather than lossy.
52
+ */
53
+ export function mergeTokenEffects(a: TokenEffect, b: TokenEffect): TokenEffect {
54
+ if (a.effect !== b.effect) return UNPROVEN_EFFECT;
55
+ return a.source === "unproven" ? b : a;
56
+ }
@@ -3,7 +3,7 @@ import type { PathNormalizer } from "#src/path-normalizer";
3
3
  import { getNonEmptyString, toRecord } from "#src/value-guards";
4
4
  import type { AccessIntent, ResolvedAccessIntent } from "./access-intent";
5
5
  import { createMcpPermissionTargets } from "./mcp-targets";
6
- import { PATH_SURFACES } from "./path-surfaces";
6
+ import { PATH_SURFACES, surfaceFamilyOf } from "./path-surfaces";
7
7
  import { classifyToolKind } from "./tool-kind";
8
8
 
9
9
  /**
@@ -95,7 +95,7 @@ function buildInputForSurface(
95
95
  const v = value ?? "";
96
96
  if (surface === "bash") return { command: v };
97
97
  if (surface === "skill") return { name: v };
98
- if (surface === "external_directory") return { path: v };
98
+ if (surfaceFamilyOf(surface) === "external_directory") return { path: v };
99
99
  // MCP and tool surfaces: normalizeInput handles them from the surface alone.
100
100
  return {};
101
101
  }
@@ -1,3 +1,5 @@
1
+ import type { AttributedEffect } from "#src/access-intent/effect";
2
+
1
3
  /**
2
4
  * File tools that only read — never write — the filesystem.
3
5
  * Only these tools are eligible for the Pi infrastructure auto-allow.
@@ -18,13 +20,117 @@ export const PATH_BEARING_TOOLS = new Set([
18
20
  "ls",
19
21
  ]);
20
22
 
23
+ /**
24
+ * The surface families that carry the read/write capability axis (ADR 0013 §3).
25
+ *
26
+ * A bare family name means "both directions": it is what an access whose
27
+ * direction is proven to be both, or cannot be proven at all, consults — the
28
+ * fail-closed base case of ADR 0013 §10.
29
+ */
30
+ const DIRECTIONAL_FAMILIES: ReadonlySet<string> = new Set([
31
+ "path",
32
+ "external_directory",
33
+ ]);
34
+
35
+ /**
36
+ * The capability suffixes, in ADR 0013 §4's normative order: a family's
37
+ * sugar-expanded and folded members are always read-then-write.
38
+ */
39
+ const CAPABILITY_SUFFIXES = ["_read", "_write"] as const;
40
+
21
41
  /**
22
42
  * Surfaces whose patterns are matched against filesystem paths and therefore
23
- * fold case (and separators) on Windows: the path-bearing tools plus the
24
- * cross-cutting `path` gate and the `external_directory` boundary gate.
43
+ * fold case (and separators) on Windows: the path-bearing tools, the
44
+ * cross-cutting `path` gate, the `external_directory` boundary gate, and each
45
+ * gate's two directional members.
25
46
  */
26
47
  export const PATH_SURFACES: ReadonlySet<string> = new Set([
27
48
  ...PATH_BEARING_TOOLS,
28
- "external_directory",
29
- "path",
49
+ ...DIRECTIONAL_FAMILIES,
50
+ ...directionalSurfaceNames(),
30
51
  ]);
52
+
53
+ /**
54
+ * The family a surface belongs to — itself, when it carries no capability
55
+ * suffix over a directional family.
56
+ *
57
+ * A surface that merely ends in `_read` without naming a directional family
58
+ * (an extension tool called `my_tool_read`) is its own family, so the relation
59
+ * never captures a name the axis does not own.
60
+ */
61
+ export function surfaceFamilyOf(surface: string): string {
62
+ for (const suffix of CAPABILITY_SUFFIXES) {
63
+ if (!surface.endsWith(suffix)) continue;
64
+ const family = surface.slice(0, -suffix.length);
65
+ if (DIRECTIONAL_FAMILIES.has(family)) return family;
66
+ }
67
+ return surface;
68
+ }
69
+
70
+ /**
71
+ * A family name's directional members, or `null` when `surface` is not a
72
+ * family name (a directional member itself, or any non-path surface).
73
+ *
74
+ * The non-empty tuple is what lets the resolver's family fold be total: it
75
+ * hands the members straight to `mostRestrictiveOf` with no empty branch.
76
+ */
77
+ export function surfaceFamilyMembers(
78
+ surface: string,
79
+ ): readonly [string, ...string[]] | null {
80
+ if (!DIRECTIONAL_FAMILIES.has(surface)) return null;
81
+ const [read, write] = CAPABILITY_SUFFIXES;
82
+ return [`${surface}${read}`, `${surface}${write}`];
83
+ }
84
+
85
+ /**
86
+ * The narrowest surface in `family` that an attributed effect names.
87
+ *
88
+ * An unproven attribution names the bare family, whose two members the
89
+ * resolver folds most-restrictive — the fail-closed base case of ADR 0013 §10,
90
+ * which is also where a proven-both access lands.
91
+ */
92
+ export function capabilitySurfaceForEffect(
93
+ family: string,
94
+ effect: AttributedEffect,
95
+ ): string {
96
+ const [read, write] = CAPABILITY_SUFFIXES;
97
+ switch (effect) {
98
+ case "read":
99
+ return `${family}${read}`;
100
+ case "write":
101
+ return `${family}${write}`;
102
+ case "unproven":
103
+ return family;
104
+ }
105
+ }
106
+
107
+ /**
108
+ * The narrowest surface in `family` that `toolName`'s identity proves.
109
+ *
110
+ * A tool's name is one of the three proof sources ADR 0013 §7 names, so it
111
+ * routes through the same effect-keyed selector every other source does:
112
+ * `edit` and an unproven bash token reach the bare family by the one path.
113
+ */
114
+ export function capabilitySurfaceForTool(
115
+ family: string,
116
+ toolName: string,
117
+ ): string {
118
+ return capabilitySurfaceForEffect(family, effectProvenByTool(toolName));
119
+ }
120
+
121
+ /**
122
+ * The effect a tool's identity proves: the read-only file tools prove a read,
123
+ * `write` proves a write, and everything else — `edit` (which does both), an
124
+ * MCP tool, an extension tool — proves nothing.
125
+ */
126
+ function effectProvenByTool(toolName: string): AttributedEffect {
127
+ if (READ_ONLY_PATH_BEARING_TOOLS.has(toolName)) return "read";
128
+ if (toolName === "write") return "write";
129
+ return "unproven";
130
+ }
131
+
132
+ function directionalSurfaceNames(): string[] {
133
+ return [...DIRECTIONAL_FAMILIES].flatMap((family) =>
134
+ CAPABILITY_SUFFIXES.map((suffix) => `${family}${suffix}`),
135
+ );
136
+ }
@@ -7,17 +7,25 @@
7
7
  * the terminal (a prompt) instead. The checkpoint only ever *tightens* a
8
8
  * verdict — it never turns a `defer`/`deny` into an `allow`.
9
9
  *
10
- * The excluded set is the whole `path` surface plus `external_directory`. A
11
- * finer secret-shaped-`path` exclusion (letting a link allow a non-secret path)
12
- * is deferred to the allow-capable slice that needs it (#620); until then the
13
- * conservative whole-surface exclusion ships. The checkpoint is dormant while
14
- * the only registered links are deny-first (they never `allow`).
10
+ * The excluded set is the whole `path` surface **family** plus the
11
+ * `external_directory` family — membership is tested on the family a surface
12
+ * belongs to, so a directional member (`path_write`) is excluded by
13
+ * construction and a later capability suffix joins its family for free (ADR
14
+ * 0013 §4). That is a name-resolution rule, not a scope freeze: it says how a
15
+ * family name resolves to members, and leaves *which* families are excluded
16
+ * independently relaxable (#620).
17
+ *
18
+ * A finer secret-shaped-`path` exclusion (letting a link allow a non-secret
19
+ * path) is deferred to the allow-capable slice that needs it (#620); until then
20
+ * the conservative whole-family exclusion ships. The checkpoint is dormant
21
+ * while the only registered links are deny-first (they never `allow`).
15
22
  */
16
23
 
24
+ import { surfaceFamilyOf } from "#src/access-intent/path-surfaces";
17
25
  import type { Authorizer } from "./authorizer";
18
26
  import type { PromptPermissionDetails } from "./permission-prompter";
19
27
 
20
- /** Surfaces on which a link may never grant an `allow` (ADR 0007 §5). */
28
+ /** Surface families on which a link may never grant an `allow` (ADR 0007 §5). */
21
29
  export const DELEGATION_EXCLUDED_SURFACES: ReadonlySet<string> = new Set([
22
30
  "external_directory",
23
31
  "path",
@@ -49,5 +57,8 @@ export function encloseInDelegationEnvelope(
49
57
  */
50
58
  function isExcludedSurface(details: PromptPermissionDetails): boolean {
51
59
  const surface = details.accessIntent?.surface ?? details.surface ?? undefined;
52
- return surface === undefined || DELEGATION_EXCLUDED_SURFACES.has(surface);
60
+ return (
61
+ surface === undefined ||
62
+ DELEGATION_EXCLUDED_SURFACES.has(surfaceFamilyOf(surface))
63
+ );
53
64
  }
@@ -73,13 +73,58 @@ const permissionMapSchema = z
73
73
  "A map of wildcard patterns to permission states.\n\nUse `*` for wildcard matching. When multiple patterns match, the **last matching rule wins** — put broad catch-alls first and specific overrides after them.\n\nPattern keys support home directory expansion:\n- `~/path` or `$HOME/path` — expanded to the OS home directory at match time.\n- `~` or `$HOME` alone — expands to the home directory itself.\n\nThe stored pattern is always shown in logs and approval dialogs as written (e.g. `~/dev/*`).",
74
74
  });
75
75
 
76
+ const surfaceValueSchema = z.union([
77
+ permissionStateSchema,
78
+ permissionMapSchema,
79
+ ]);
80
+
81
+ /**
82
+ * The `path` and `external_directory` families' directional members
83
+ * (ADR 0013 §3), named so editors offer autocomplete and hover documentation.
84
+ *
85
+ * Naming them as properties rather than leaving them to the catchall is also
86
+ * what lets the loader tell a legal directional key from a misspelled one —
87
+ * see {@link rejectMisspelledDirectionalKeys}.
88
+ */
89
+ const DIRECTIONAL_SURFACE_DESCRIPTIONS: Record<
90
+ string,
91
+ { description: string; markdownDescription: string }
92
+ > = {
93
+ path_read: {
94
+ description:
95
+ "Cross-cutting gate for reading a file, by path pattern. The useful directional grant.",
96
+ markdownDescription:
97
+ 'Cross-cutting gate for **reading** a file, matched by path pattern across all path-aware tools.\n\nThis is the directional key worth granting: `"path_read": { "~/dev/*": "allow" }` permits reads without permitting writes.\n\nA bare `"path"` key is sugar that expands into this key **and** `path_write`, with its entries placed first — so an explicit `path_read` entry always has the final say, whatever the key order in the file.',
98
+ },
99
+ path_write: {
100
+ description:
101
+ "Cross-cutting gate for writing a file, by path pattern. Earns its keep as a restriction.",
102
+ markdownDescription:
103
+ 'Cross-cutting gate for **writing** a file, matched by path pattern across all path-aware tools.\n\nThis key earns its keep as a *restriction* rather than a grant: `"path_write": { "*": "deny" }` is a coherent read-only-agent posture. A `"path_write": "allow"` on its own does not silence an `edit`, which also reads — grant `path_read` too, or use the bare `"path"` key.',
104
+ },
105
+ external_directory_read: {
106
+ description:
107
+ "Boundary gate for reading outside the working directory. The relief most asks want.",
108
+ markdownDescription:
109
+ 'Boundary gate for **reading** a path outside the session working directory.\n\nThe one-line grant for an external root: `"external_directory_read": { "~/dev/*": "allow" }` silences repeated read prompts on a directory outside the tree while a write to the same path still prompts. No parallel `path_read` entry is needed.',
110
+ },
111
+ external_directory_write: {
112
+ description: "Boundary gate for writing outside the working directory.",
113
+ markdownDescription:
114
+ 'Boundary gate for **writing** to a path outside the session working directory.\n\nA bare `"external_directory"` key is sugar that expands into this key and `external_directory_read`; write this one only to give the two directions different answers.',
115
+ },
116
+ };
117
+
76
118
  const permissionSchema = z
77
- .record(
78
- z.string().min(1).meta({
79
- description: "A surface name or the universal fallback key '*'.",
80
- }),
81
- z.union([permissionStateSchema, permissionMapSchema]),
119
+ .object(
120
+ Object.fromEntries(
121
+ Object.entries(DIRECTIONAL_SURFACE_DESCRIPTIONS).map(([key, meta]) => [
122
+ key,
123
+ surfaceValueSchema.optional().meta(meta),
124
+ ]),
125
+ ),
82
126
  )
127
+ .catchall(surfaceValueSchema)
83
128
  .meta({
84
129
  description:
85
130
  "Flat permission policy. Each key is a surface name; values are a PermissionState string (catch-all) or a pattern→action map.",
@@ -106,9 +151,53 @@ const permissionSchema = z
106
151
  mcp: { "*": "ask", mcp_status: "allow", "exa:*": "allow" },
107
152
  skill: { "*": "ask", librarian: "allow" },
108
153
  external_directory: { "*": "ask", "~/.cargo/registry/*": "allow" },
154
+ external_directory_read: { "~/dev/*": "allow" },
109
155
  },
110
156
  ],
111
- });
157
+ })
158
+ .superRefine(rejectUnusableSurfaceKeys);
159
+
160
+ /**
161
+ * Reject the two surface-key spellings that would otherwise sit inert.
162
+ *
163
+ * Neither check serializes into the JSON Schema, so an editor will not flag
164
+ * them; the loader will, fail-closed, with the offending key named.
165
+ *
166
+ * 1. A key shaped like a directional surface but misspelled
167
+ * (`path_wrote`, `external_directory_reed`). A typo in a *grant* fails safe
168
+ * — the rule never fires and the user just gets more prompts — but a typo
169
+ * in a *restriction* fails **open**: `path_wrote: {"*": "deny"}` enforces
170
+ * nothing at all. The false-positive population is an extension tool
171
+ * literally named `path_*` or `external_directory_*`.
172
+ * 2. An empty key, which `.catchall()` no longer rejects on its own the way
173
+ * the record form's `propertyNames: {minLength: 1}` did.
174
+ */
175
+ function rejectUnusableSurfaceKeys(
176
+ permission: Record<string, unknown>,
177
+ ctx: z.core.$RefinementCtx,
178
+ ): void {
179
+ const legalDirectionalKeys = Object.keys(DIRECTIONAL_SURFACE_DESCRIPTIONS);
180
+ for (const key of Object.keys(permission)) {
181
+ if (key === "") {
182
+ ctx.addIssue({
183
+ code: "custom",
184
+ path: [key],
185
+ message: "A surface key must not be empty.",
186
+ });
187
+ continue;
188
+ }
189
+ if (
190
+ /^(path|external_directory)_/.test(key) &&
191
+ !legalDirectionalKeys.includes(key)
192
+ ) {
193
+ ctx.addIssue({
194
+ code: "custom",
195
+ path: [key],
196
+ message: `Unknown directional surface key "${key}". The legal spellings are ${legalDirectionalKeys.join(", ")}.`,
197
+ });
198
+ }
199
+ }
200
+ }
112
201
 
113
202
  const shellToolAliasSchema = z
114
203
  .strictObject({
@@ -2,8 +2,8 @@ import type {
2
2
  BashCommand,
3
3
  WrapperKind,
4
4
  } from "#src/access-intent/bash/command-enumeration";
5
- import { pickMostRestrictive } from "#src/handlers/gates/candidate-check";
6
5
  import type { ScopedPermissionResolver } from "#src/permission-resolver";
6
+ import { pickMostRestrictive } from "#src/restrictiveness";
7
7
  import type { PermissionCheckResult } from "#src/types";
8
8
 
9
9
  /**
@@ -60,9 +60,9 @@ export function resolveBashCommandCheck(
60
60
  ): PermissionCheckResult {
61
61
  if (commands.length === 0) {
62
62
  if (isTriviallyEmptyCommand(command)) {
63
- return resolveWholeCommand(command, agentName, resolver);
63
+ return resolveOnBashSurface(command, agentName, resolver);
64
64
  }
65
- const whole = resolveWholeCommand(command, agentName, resolver);
65
+ const whole = resolveOnBashSurface(command, agentName, resolver);
66
66
  if (whole.state === "deny") {
67
67
  return whole;
68
68
  }
@@ -77,19 +77,10 @@ export function resolveBashCommandCheck(
77
77
  }
78
78
 
79
79
  const results = commands.map((cmd) => {
80
- const base = resolver.resolve({
81
- kind: "tool",
82
- surface: "bash",
83
- input: { command: cmd.text },
84
- agentName,
85
- });
80
+ const base = resolveOnBashSurface(cmd.text, agentName, resolver);
86
81
  const floored =
87
82
  cmd.wrapperKind && base.state === "allow"
88
- ? {
89
- ...base,
90
- state: "ask" as const,
91
- matchedPattern: WRAPPER_SENTINEL[cmd.wrapperKind],
92
- }
83
+ ? resolveWrapperUnit(cmd, cmd.wrapperKind, base, agentName, resolver)
93
84
  : base;
94
85
  const result = cmd.context
95
86
  ? { ...floored, commandContext: cmd.context }
@@ -100,10 +91,49 @@ export function resolveBashCommandCheck(
100
91
  });
101
92
  return (
102
93
  pickMostRestrictive(results) ??
103
- resolveWholeCommand(command, agentName, resolver)
94
+ resolveOnBashSurface(command, agentName, resolver)
104
95
  );
105
96
  }
106
97
 
98
+ /**
99
+ * Resolve a wrapper unit whose own text resolved to `allow`.
100
+ *
101
+ * A wrapper hides or indirects the command that should be gated, so its `allow`
102
+ * is clamped up to a synthetic `ask` naming the kind that caused it — unless
103
+ * the enumerator established that the floor has no reason left to hold, in
104
+ * which case the unit is resolved by the rules of the command it runs (ADR 0013
105
+ * §11, #803).
106
+ *
107
+ * Only an `allow` reaches here, which is what makes the exemption unable to
108
+ * weaken anything: an explicit `deny` or `ask` on the wrapper is decided before
109
+ * this function is consulted, and no inner rule is read at all.
110
+ */
111
+ function resolveWrapperUnit(
112
+ cmd: BashCommand,
113
+ wrapperKind: WrapperKind,
114
+ base: PermissionCheckResult,
115
+ agentName: string | undefined,
116
+ resolver: ScopedPermissionResolver,
117
+ ): PermissionCheckResult {
118
+ const inner = cmd.floorExemption && cmd.executedUnit;
119
+ if (!inner) {
120
+ return {
121
+ ...base,
122
+ state: "ask",
123
+ matchedPattern: WRAPPER_SENTINEL[wrapperKind],
124
+ };
125
+ }
126
+ // The inner command's rule decides, but the unit is still what runs: the
127
+ // prompt, the decision value, and the session-approval suggestion all read
128
+ // `command`, and naming a fragment of the command line there would offer a
129
+ // grant that does not cover what the user is looking at.
130
+ return {
131
+ ...resolveOnBashSurface(inner, agentName, resolver),
132
+ command: base.command,
133
+ floorExemption: cmd.floorExemption,
134
+ };
135
+ }
136
+
107
137
  /**
108
138
  * True when a command has genuinely nothing to gate: it is empty,
109
139
  * whitespace-only, or contains only comment lines (every non-blank line starts
@@ -118,8 +148,14 @@ function isTriviallyEmptyCommand(command: string): boolean {
118
148
  return lines.every((line) => line.startsWith("#"));
119
149
  }
120
150
 
121
- /** Resolve the whole command string as a single unit on the `bash` surface. */
122
- function resolveWholeCommand(
151
+ /**
152
+ * Resolve one command string against the `bash` surface's rules.
153
+ *
154
+ * Three callers share it: each command unit of the chain, the whole command
155
+ * when the chain yields no units, and the inner command of a wrapper the floor
156
+ * no longer covers.
157
+ */
158
+ function resolveOnBashSurface(
123
159
  command: string,
124
160
  agentName: string | undefined,
125
161
  resolver: ScopedPermissionResolver,