@gotgenes/pi-permission-system 27.0.1 → 27.1.1

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 +20 -2
  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/package.json +3 -1
  7. package/schemas/permissions.schema.json +52 -4
  8. package/src/access-intent/bash/bash-path-resolver.ts +104 -34
  9. package/src/access-intent/bash/command-effects.ts +305 -0
  10. package/src/access-intent/bash/command-enumeration.ts +116 -40
  11. package/src/access-intent/bash/program.ts +15 -11
  12. package/src/access-intent/bash/redirect-analysis.ts +98 -0
  13. package/src/access-intent/bash/token-classification.ts +14 -13
  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,4 +1,5 @@
1
1
  import { basename } from "node:path";
2
+ import { proveCommandEffect } from "#src/access-intent/bash/command-effects";
2
3
  import {
3
4
  EXECUTION_HOST_TYPES,
4
5
  forEachNestedExecution,
@@ -10,6 +11,20 @@ import {
10
11
  SKIP_SUBTREE_TYPES,
11
12
  } from "#src/access-intent/bash/node-text";
12
13
  import type { TSNode } from "#src/access-intent/bash/parser";
14
+ import { redirectEffectForDestination } from "#src/access-intent/bash/redirect-analysis";
15
+ import type { TokenEffect } from "#src/access-intent/effect";
16
+
17
+ /**
18
+ * A collected path-candidate token paired with the effect its position proved.
19
+ *
20
+ * The pairing is made where the token is *produced*, never by mapping a whole
21
+ * result: a nested execution's tokens carry their own command's attribution
22
+ * and must not be overwritten by the enclosing one.
23
+ */
24
+ export interface PathToken {
25
+ readonly token: string;
26
+ readonly effect: TokenEffect;
27
+ }
13
28
 
14
29
  // ── Public surface ─────────────────────────────────────────────────────────
15
30
 
@@ -29,7 +44,7 @@ import type { TSNode } from "#src/access-intent/bash/parser";
29
44
  * as path candidates. For all other commands, collects all
30
45
  * arguments generically.
31
46
  */
32
- export function collectPathCandidateTokens(node: TSNode): string[] {
47
+ export function collectPathCandidateTokens(node: TSNode): PathToken[] {
33
48
  if (node.type === "command") return collectCommandTokens(node);
34
49
  if (node.type === "file_redirect") return collectRedirectTokens(node);
35
50
  if (EXECUTION_HOST_TYPES.has(node.type)) {
@@ -37,7 +52,7 @@ export function collectPathCandidateTokens(node: TSNode): string[] {
37
52
  }
38
53
  if (SKIP_SUBTREE_TYPES.has(node.type)) return [];
39
54
 
40
- const tokens: string[] = [];
55
+ const tokens: PathToken[] = [];
41
56
  for (let i = 0; i < node.childCount; i++) {
42
57
  const child = node.child(i);
43
58
  if (child) tokens.push(...collectPathCandidateTokens(child));
@@ -49,16 +64,25 @@ export function collectPathCandidateTokens(node: TSNode): string[] {
49
64
  * Select the collection strategy for a `command` node: pattern-first
50
65
  * commands use `collectPatternCommandTokens`; all others use
51
66
  * `collectGenericCommandTokens`.
67
+ *
68
+ * Every token the command owns carries the effect its head word proves — the
69
+ * pure-reader core, read through the *raw* head word so a path-qualified
70
+ * spelling proves nothing. A nested execution collected along the way keeps
71
+ * its own command's attribution instead.
52
72
  */
53
- export function collectCommandTokens(node: TSNode): string[] {
73
+ export function collectCommandTokens(node: TSNode): PathToken[] {
74
+ const effect = proveCommandEffect(
75
+ extractCommandWord(node) ?? "",
76
+ commandArgumentWords(node),
77
+ );
54
78
  const commandName = extractCommandName(node);
55
79
  const config = commandName
56
80
  ? PATTERN_FIRST_COMMANDS.get(commandName)
57
81
  : undefined;
58
82
  const tokens = config
59
- ? collectPatternCommandTokens(node, config)
60
- : collectGenericCommandTokens(node);
61
- return [...tokens, ...collectEmbeddedOptionValues(node)];
83
+ ? collectPatternCommandTokens(node, config, effect)
84
+ : collectGenericCommandTokens(node, effect);
85
+ return [...tokens, ...collectEmbeddedOptionValues(node, effect)];
62
86
  }
63
87
 
64
88
  /**
@@ -72,14 +96,24 @@ export function collectCommandTokens(node: TSNode): string[] {
72
96
  * Both passes are needed: a substitution can be the destination outright, or be
73
97
  * concatenated into it (`> ${DIR}/$(cmd)`), and a `concatenation` is itself an
74
98
  * argument node.
99
+ *
100
+ * The operator proves the destination's effect outright, and that proof is
101
+ * absolute: it overrides whatever the redirected command's own head word
102
+ * proved, because `> out.txt` writes `out.txt` however read-only the command
103
+ * in front of it is. A destination the operator names as a file descriptor
104
+ * (`2>&1`) contributes no token at all.
105
+ *
106
+ * Reading the redirect node itself belongs to `redirect-analysis.ts`, which
107
+ * the command enumerator consults for the same fact (#803).
75
108
  */
76
- export function collectRedirectTokens(node: TSNode): string[] {
77
- const tokens: string[] = [];
109
+ export function collectRedirectTokens(node: TSNode): PathToken[] {
110
+ const tokens: PathToken[] = [];
78
111
  for (let i = 0; i < node.childCount; i++) {
79
112
  const child = node.child(i);
80
113
  if (!child) continue;
81
114
  if (ARG_NODE_TYPES.has(child.type)) {
82
- tokens.push(resolveNodeText(child));
115
+ const effect = redirectEffectForDestination(node, child);
116
+ if (effect) tokens.push({ token: resolveNodeText(child), effect });
83
117
  }
84
118
  tokens.push(...collectHostedExecutionTokens(child));
85
119
  }
@@ -97,11 +131,11 @@ export function collectRedirectTokens(node: TSNode): string[] {
97
131
  * (`> ${DIR}/$(cmd)`); `forEachNestedExecution` searches strictly within a
98
132
  * subtree, so the first case is checked here.
99
133
  */
100
- function collectHostedExecutionTokens(node: TSNode): string[] {
134
+ function collectHostedExecutionTokens(node: TSNode): PathToken[] {
101
135
  if (NESTED_EXECUTION_CONTEXTS.has(node.type)) {
102
136
  return collectPathCandidateTokens(node);
103
137
  }
104
- const tokens: string[] = [];
138
+ const tokens: PathToken[] = [];
105
139
  forEachNestedExecution(node, (contextNode) => {
106
140
  tokens.push(...collectPathCandidateTokens(contextNode));
107
141
  });
@@ -112,14 +146,34 @@ function collectHostedExecutionTokens(node: TSNode): string[] {
112
146
  * Extract the command name from a `command` node.
113
147
  * Returns the basename (e.g. `/usr/bin/sed` → `sed`), or undefined
114
148
  * if the command name cannot be determined (e.g. variable expansion).
149
+ *
150
+ * The basename is what {@link PATTERN_FIRST_COMMANDS} needs: `/usr/bin/sed`
151
+ * parses its arguments exactly as `sed` does. It is the wrong question for a
152
+ * capability claim, where the directory prefix is the whole point — use
153
+ * {@link extractCommandWord} there.
115
154
  */
116
155
  export function extractCommandName(node: TSNode): string | undefined {
156
+ const word = extractCommandWord(node);
157
+ return word === undefined ? undefined : basename(word);
158
+ }
159
+
160
+ /**
161
+ * Extract the head word of a `command` node exactly as it was written, or
162
+ * undefined when it cannot be determined (e.g. variable expansion).
163
+ *
164
+ * Unbasenamed on purpose: `./grep` and `/tmp/evil/grep` name programs the
165
+ * pure-reader core's audit never saw, so an effect proof must be able to tell
166
+ * them from a bare `grep` — the Codex lesson the bare-basename rule encodes.
167
+ * Documented against {@link extractCommandName}, which answers the other
168
+ * question.
169
+ */
170
+ export function extractCommandWord(node: TSNode): string | undefined {
117
171
  for (let i = 0; i < node.childCount; i++) {
118
172
  const child = node.child(i);
119
173
  if (!child) continue;
120
174
  if (child.type === "command_name") {
121
175
  const text = resolveNodeText(child);
122
- return text ? basename(text) : undefined;
176
+ return text === "" ? undefined : text;
123
177
  }
124
178
  }
125
179
  return undefined;
@@ -127,6 +181,25 @@ export function extractCommandName(node: TSNode): string | undefined {
127
181
 
128
182
  // ── Private helpers and config ─────────────────────────────────────────────
129
183
 
184
+ /**
185
+ * The command's own argument words, which the retraction guards read.
186
+ *
187
+ * Reads the argument nodes directly rather than the collected tokens, because
188
+ * a guard fires on an *option* (`find -delete`) and no collector emits one.
189
+ */
190
+ function commandArgumentWords(node: TSNode): string[] {
191
+ const words: string[] = [];
192
+ for (let i = 0; i < node.childCount; i++) {
193
+ const child = node.child(i);
194
+ if (!child) continue;
195
+ if (child.type === "command_name" || child.type === "variable_assignment")
196
+ continue;
197
+ if (!ARG_NODE_TYPES.has(child.type)) continue;
198
+ words.push(resolveNodeText(child));
199
+ }
200
+ return words;
201
+ }
202
+
130
203
  /**
131
204
  * A long or short option carrying its value inline: one or two leading dashes,
132
205
  * a name containing no `=` or whitespace, then `=` and a non-empty value.
@@ -148,8 +221,11 @@ const OPTION_VALUE_PATTERN = /^-{1,2}[^=\s]+=(.+)$/;
148
221
  * here is what lets the projection see option-embedded paths without per-command
149
222
  * option tables (ADR 0009, #645).
150
223
  */
151
- function collectEmbeddedOptionValues(node: TSNode): string[] {
152
- const values: string[] = [];
224
+ function collectEmbeddedOptionValues(
225
+ node: TSNode,
226
+ effect: TokenEffect,
227
+ ): PathToken[] {
228
+ const values: PathToken[] = [];
153
229
  for (let i = 0; i < node.childCount; i++) {
154
230
  const child = node.child(i);
155
231
  if (!child) continue;
@@ -158,7 +234,7 @@ function collectEmbeddedOptionValues(node: TSNode): string[] {
158
234
  if (!ARG_NODE_TYPES.has(child.type)) continue;
159
235
 
160
236
  const value = OPTION_VALUE_PATTERN.exec(resolveNodeText(child))?.[1];
161
- if (value !== undefined) values.push(value);
237
+ if (value !== undefined) values.push({ token: value, effect });
162
238
  }
163
239
  return values;
164
240
  }
@@ -322,13 +398,14 @@ function classifyPatternCommandFlag(
322
398
  function collectPatternCommandTokens(
323
399
  node: TSNode,
324
400
  config: PatternCommandConfig,
325
- ): string[] {
401
+ effect: TokenEffect,
402
+ ): PathToken[] {
326
403
  const patternPositionals = config.patternPositionals ?? 1;
327
404
  let hasExplicitScript = false;
328
405
  let positionalsSeen = 0;
329
406
  let nextArgAction: "skip" | "extract" | null = null;
330
407
  let pastEndOfFlags = false;
331
- const tokens: string[] = [];
408
+ const tokens: PathToken[] = [];
332
409
 
333
410
  for (let i = 0; i < node.childCount; i++) {
334
411
  const child = node.child(i);
@@ -353,7 +430,7 @@ function collectPatternCommandTokens(
353
430
  continue;
354
431
  }
355
432
  if (nextArgAction === "extract") {
356
- tokens.push(text);
433
+ tokens.push({ token: text, effect });
357
434
  nextArgAction = null;
358
435
  continue;
359
436
  }
@@ -387,7 +464,7 @@ function collectPatternCommandTokens(
387
464
  }
388
465
 
389
466
  // File argument — collect as path candidate.
390
- tokens.push(text);
467
+ tokens.push({ token: text, effect });
391
468
  }
392
469
 
393
470
  return tokens;
@@ -397,8 +474,11 @@ function collectPatternCommandTokens(
397
474
  * Collect all argument tokens from a generic (non-pattern-first) command node,
398
475
  * skipping the command name and variable assignments.
399
476
  */
400
- function collectGenericCommandTokens(node: TSNode): string[] {
401
- const tokens: string[] = [];
477
+ function collectGenericCommandTokens(
478
+ node: TSNode,
479
+ effect: TokenEffect,
480
+ ): PathToken[] {
481
+ const tokens: PathToken[] = [];
402
482
  let seenCommandName = false;
403
483
 
404
484
  for (let i = 0; i < node.childCount; i++) {
@@ -421,7 +501,7 @@ function collectGenericCommandTokens(node: TSNode): string[] {
421
501
 
422
502
  // Argument nodes: resolve their text and collect.
423
503
  if (ARG_NODE_TYPES.has(child.type)) {
424
- tokens.push(resolveNodeText(child));
504
+ tokens.push({ token: resolveNodeText(child), effect });
425
505
  continue;
426
506
  }
427
507
 
@@ -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
  }