@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
@@ -17,6 +17,13 @@ import type { ToolCallContext } from "./types";
17
17
  * Returns a `GateBypass` when all paths are allowed (by config or session rule).
18
18
  * Returns a `GateDescriptor` with multi-pattern sessionApproval for uncovered paths.
19
19
  *
20
+ * Each path is resolved on the narrowest `external_directory`-family surface
21
+ * its own attributed effect names. The session approval holds one surface for
22
+ * all its patterns, so it narrows only when every uncovered path agrees; a
23
+ * mixed-direction ask falls back to the bare family, which is exactly today's
24
+ * width. Closing that last gap needs `(surface, pattern)` pairs on the
25
+ * approval and its forwarded wire form (#810).
26
+ *
20
27
  * The shell command (native `bash` or an aliased shell tool) is read from the
21
28
  * injected `BashProgram`, which owns the source text it was parsed from, so
22
29
  * this gate does not re-derive the input field name (#574).
@@ -30,8 +37,8 @@ export function describeBashExternalDirectoryGate(
30
37
  if (!bashProgram) return null;
31
38
  const command = bashProgram.commandText();
32
39
 
33
- const externalPaths = bashProgram.externalPaths();
34
- if (externalPaths.length === 0) return null;
40
+ const externalAccesses = bashProgram.externalAccesses();
41
+ if (externalAccesses.length === 0) return null;
35
42
 
36
43
  // Resolve every external path on the external_directory surface and keep the
37
44
  // ones not already allowed (config-level allows suppress the prompt just as
@@ -39,7 +46,7 @@ export function describeBashExternalDirectoryGate(
39
46
  // matching and the worst-uncovered selection.
40
47
  const { uncovered: uncoveredEntries, worstCheck } =
41
48
  selectUncoveredExternalPaths(
42
- externalPaths,
49
+ externalAccesses,
43
50
  resolver,
44
51
  tcc.agentName ?? undefined,
45
52
  );
@@ -65,7 +72,7 @@ export function describeBashExternalDirectoryGate(
65
72
  toolName: tcc.toolName,
66
73
  agentName: tcc.agentName,
67
74
  command,
68
- externalPaths: externalPaths.map((p) => p.value()),
75
+ externalPaths: externalAccesses.map(({ path }) => path.value()),
69
76
  resolution: "session_approved",
70
77
  },
71
78
  },
@@ -86,6 +93,7 @@ export function describeBashExternalDirectoryGate(
86
93
  resolvedPath: path.resolvedAlias(),
87
94
  }));
88
95
 
96
+ const surface = worstEntry.surface;
89
97
  const payload = buildBashExternalDirectoryAskPayload({
90
98
  command,
91
99
  externalPaths: disclosures,
@@ -93,6 +101,7 @@ export function describeBashExternalDirectoryGate(
93
101
  agentName: tcc.agentName,
94
102
  toolName: tcc.toolName,
95
103
  matchedPattern: preCheck.matchedPattern,
104
+ surface,
96
105
  });
97
106
 
98
107
  const patterns = uncoveredEntries.map(({ path }) =>
@@ -100,17 +109,20 @@ export function describeBashExternalDirectoryGate(
100
109
  );
101
110
 
102
111
  return {
103
- surface: "external_directory",
112
+ surface,
104
113
  input: {},
105
114
  payload,
106
- sessionApproval: SessionApproval.multiple("external_directory", patterns),
115
+ sessionApproval: SessionApproval.multiple(
116
+ approvalSurfaceFor(uncoveredEntries),
117
+ patterns,
118
+ ),
107
119
  promptDetails: {
108
120
  source: "tool_call",
109
121
  agentName: tcc.agentName,
110
122
  toolCallId: tcc.toolCallId,
111
123
  toolName: tcc.toolName,
112
124
  command,
113
- accessIntent: accessFactsFromPath("external_directory", worstEntry.path),
125
+ accessIntent: accessFactsFromPath(surface, worstEntry.path),
114
126
  },
115
127
  logContext: {
116
128
  source: "tool_call",
@@ -119,11 +131,32 @@ export function describeBashExternalDirectoryGate(
119
131
  agentName: tcc.agentName,
120
132
  command,
121
133
  externalPaths: uncoveredPaths,
134
+ // The blame line ADR 0013 §7 asks for: `request.surface` already records
135
+ // the direction, and these two record what established it.
136
+ effect: worstEntry.effect.effect,
137
+ effectSource: worstEntry.effect.source,
122
138
  },
123
139
  decision: {
124
- surface: "external_directory",
140
+ surface,
125
141
  value: command,
126
142
  },
127
143
  preCheck,
128
144
  };
129
145
  }
146
+
147
+ /**
148
+ * The surface one session approval can carry for every uncovered path at once.
149
+ *
150
+ * A {@link SessionApproval} holds one surface for all its patterns, so it can
151
+ * narrow only when the whole ask agrees on a direction. The bare family is the
152
+ * fallback because it sugar-expands onto both members — exactly the width a
153
+ * mixed-direction command is granted today, never wider.
154
+ */
155
+ function approvalSurfaceFor(
156
+ uncoveredEntries: readonly { readonly surface: string }[],
157
+ ): string {
158
+ const surfaces = new Set(uncoveredEntries.map(({ surface }) => surface));
159
+ return surfaces.size === 1
160
+ ? [...surfaces][0]
161
+ : ("external_directory" as const);
162
+ }
@@ -4,7 +4,7 @@ import type { PathNormalizer } from "#src/path-normalizer";
4
4
  /**
5
5
  * Extract paths from a bash command that resolve outside CWD.
6
6
  *
7
- * Thin facade over {@link BashProgram.externalPaths}; parses the command
7
+ * Thin facade over {@link BashProgram.externalAccesses}; parses the command
8
8
  * through the injected {@link PathNormalizer} (platform + cwd baked in) and
9
9
  * returns the cd-aware external paths in their lexical (as-typed) string form.
10
10
  * See `BashProgram` for the parsing and resolution semantics.
@@ -18,6 +18,6 @@ export async function extractExternalPathsFromBashCommand(
18
18
  normalizer: PathNormalizer,
19
19
  ): Promise<string[]> {
20
20
  return (await BashProgram.parse(command, normalizer))
21
- .externalPaths()
22
- .map((p) => p.value());
21
+ .externalAccesses()
22
+ .map(({ path }) => path.value());
23
23
  }
@@ -1,11 +1,13 @@
1
1
  import type { AccessPath } from "#src/access-intent/access-path";
2
2
  import type { BashProgram } from "#src/access-intent/bash/program";
3
+ import type { TokenEffect } from "#src/access-intent/effect";
4
+ import { capabilitySurfaceForEffect } from "#src/access-intent/path-surfaces";
3
5
  import type { PathNormalizer } from "#src/path-normalizer";
4
6
  import type { ScopedPermissionResolver } from "#src/permission-resolver";
5
7
  import { buildPathAskPayload } from "#src/presentation/path-ask-payload";
8
+ import { pickMostRestrictive } from "#src/restrictiveness";
6
9
  import { SessionApproval } from "#src/session-approval";
7
10
  import type { PermissionCheckResult } from "#src/types";
8
- import { pickMostRestrictive } from "./candidate-check";
9
11
  import type { GateResult } from "./descriptor";
10
12
  import { accessFactsFromPath } from "./helpers";
11
13
  import type { ToolCallContext } from "./types";
@@ -15,10 +17,17 @@ import type { ToolCallContext } from "./types";
15
17
  *
16
18
  * Reads path-rule candidates from the injected `BashProgram` (the broader
17
19
  * `path`-rule filter, accepting dot-files and relative paths). Each candidate
18
- * pairs the raw token with cd-aware policy values; the gate evaluates those
19
- * values against the `path` permission surface and returns the most
20
- * restrictive result, while prompts, logs, and session approvals use the raw
21
- * token.
20
+ * pairs the raw token with cd-aware policy values and the effect its position
21
+ * proved; the gate evaluates those values against the narrowest `path`-family
22
+ * surface that effect names and returns the most restrictive result, while
23
+ * prompts, logs, and session approvals use the raw token.
24
+ *
25
+ * A proven read resolves on `path_read`, a proven write on `path_write`, and
26
+ * an unproven token on the bare family, whose two members the resolver folds
27
+ * most-restrictive (ADR 0013 §10). The deciding token's surface is the one the
28
+ * descriptor, the payload, the access facts, the decision, and the session
29
+ * approval all carry — a session grant is never wider than what the gate
30
+ * proved.
22
31
  *
23
32
  * Returns `null` when the gate does not apply (not a shell invocation, no
24
33
  * command, no tokens extracted, or all tokens evaluate to `allow`).
@@ -48,14 +57,17 @@ export function describeBashPathGate(
48
57
  const uncovered: Array<{
49
58
  token: string;
50
59
  path: AccessPath;
60
+ surface: string;
61
+ effect: TokenEffect;
51
62
  check: PermissionCheckResult;
52
63
  }> = [];
53
64
  let allSessionCovered = true;
54
65
 
55
- for (const { token, path } of candidates) {
66
+ for (const { token, path, effect } of candidates) {
67
+ const surface = capabilitySurfaceForEffect("path", effect.effect);
56
68
  const check = resolver.resolve({
57
69
  kind: "access-path",
58
- surface: "path",
70
+ surface,
59
71
  path,
60
72
  agentName: tcc.agentName ?? undefined,
61
73
  });
@@ -73,11 +85,11 @@ export function describeBashPathGate(
73
85
  }
74
86
 
75
87
  if (check.state === "deny") {
76
- uncovered.push({ token, path, check });
88
+ uncovered.push({ token, path, surface, effect, check });
77
89
  break; // Short-circuit on deny.
78
90
  }
79
91
  if (check.state === "ask") {
80
- uncovered.push({ token, path, check });
92
+ uncovered.push({ token, path, surface, effect, check });
81
93
  }
82
94
  }
83
95
 
@@ -122,25 +134,27 @@ export function describeBashPathGate(
122
134
  // path), so it matches the values a later call produces. For an unknown base
123
135
  // (`forLiteral`) `value()` is the raw token.
124
136
  const pattern = normalizer.approvalPatternFor(worstEntry.path);
137
+ const surface = worstEntry.surface;
125
138
  const payload = buildPathAskPayload({
126
139
  toolName: tcc.toolName,
127
140
  pathValue: worstToken,
128
141
  agentName: tcc.agentName,
129
142
  matchedPattern: worstCheck.matchedPattern,
143
+ surface,
130
144
  });
131
145
 
132
146
  return {
133
- surface: "path",
147
+ surface,
134
148
  input: { path: worstToken },
135
149
  payload,
136
- sessionApproval: SessionApproval.single("path", pattern),
150
+ sessionApproval: SessionApproval.single(surface, pattern),
137
151
  promptDetails: {
138
152
  source: "tool_call",
139
153
  agentName: tcc.agentName,
140
154
  toolCallId: tcc.toolCallId,
141
155
  toolName: tcc.toolName,
142
156
  command,
143
- accessIntent: accessFactsFromPath("path", worstEntry.path),
157
+ accessIntent: accessFactsFromPath(surface, worstEntry.path),
144
158
  },
145
159
  logContext: {
146
160
  source: "tool_call",
@@ -149,9 +163,13 @@ export function describeBashPathGate(
149
163
  agentName: tcc.agentName,
150
164
  command,
151
165
  path: worstToken,
166
+ // The blame line ADR 0013 §7 asks for: `request.surface` already records
167
+ // the direction, and these two record what established it.
168
+ effect: worstEntry.effect.effect,
169
+ effectSource: worstEntry.effect.source,
152
170
  },
153
171
  decision: {
154
- surface: "path",
172
+ surface,
155
173
  value: worstToken,
156
174
  },
157
175
  preCheck: worstCheck,
@@ -1,11 +1,18 @@
1
1
  import type { AccessPath } from "#src/access-intent/access-path";
2
+ import type { BashExternalPath } from "#src/access-intent/bash/bash-path-resolver";
3
+ import type { TokenEffect } from "#src/access-intent/effect";
4
+ import { capabilitySurfaceForEffect } from "#src/access-intent/path-surfaces";
2
5
  import type { ScopedPermissionResolver } from "#src/permission-resolver";
6
+ import { pickMostRestrictive } from "#src/restrictiveness";
3
7
  import type { PermissionCheckResult } from "#src/types";
4
- import { pickMostRestrictive } from "./candidate-check";
5
8
 
6
9
  /** An external path whose resolved `external_directory` state is not "allow". */
7
10
  export interface UncoveredExternalPath {
8
11
  path: AccessPath;
12
+ /** The family member the path's own effect named, and the check answered. */
13
+ surface: string;
14
+ /** What the effect was, and what established it — the review log's blame. */
15
+ effect: TokenEffect;
9
16
  check: PermissionCheckResult;
10
17
  }
11
18
 
@@ -17,7 +24,11 @@ export interface UncoveredExternalPaths {
17
24
  }
18
25
 
19
26
  /**
20
- * Resolve one external path's policy on the `external_directory` surface.
27
+ * Resolve one external path's policy on an `external_directory`-family surface.
28
+ *
29
+ * `surface` is the narrowest family member the caller can prove — the bare
30
+ * family name when it can prove nothing narrower, which the resolver folds
31
+ * over both directions (ADR 0013 §10).
21
32
  *
22
33
  * Emits an `access-path` {@link AccessIntent}; the resolver unwraps it via
23
34
  * {@link AccessPath.matchValues} so a config pattern on either the typed or
@@ -28,35 +39,48 @@ export interface UncoveredExternalPaths {
28
39
  export function resolveExternalDirectoryPolicy(
29
40
  path: AccessPath,
30
41
  resolver: ScopedPermissionResolver,
42
+ surface: string,
31
43
  agentName: string | undefined,
32
44
  ): PermissionCheckResult {
33
45
  return resolver.resolve({
34
46
  kind: "access-path",
35
- surface: "external_directory",
47
+ surface,
36
48
  path,
37
49
  agentName,
38
50
  });
39
51
  }
40
52
 
41
53
  /**
42
- * Resolve a set of external paths and select those not already allowed.
54
+ * Resolve a set of external accesses and select those not already allowed.
43
55
  *
44
- * Each path is resolved via {@link resolveExternalDirectoryPolicy}; entries
45
- * whose state is not "allow" are collected (filtering on state, not source, so
46
- * config-level allow rules suppress the prompt just as session-level allow
47
- * rules do), and the most restrictive uncovered check is returned so a config
48
- * "deny" is not downgraded to the catch-all "ask".
56
+ * Each access is resolved via {@link resolveExternalDirectoryPolicy} on the
57
+ * narrowest family member its own effect names — a proven read on
58
+ * `external_directory_read`, an unproven one on the bare family, which the
59
+ * resolver folds over both directions (ADR 0013 §10). Entries whose state is
60
+ * not "allow" are collected (filtering on state, not source, so config-level
61
+ * allow rules suppress the prompt just as session-level allow rules do), and
62
+ * the most restrictive uncovered check is returned so a config "deny" is not
63
+ * downgraded to the catch-all "ask".
49
64
  */
50
65
  export function selectUncoveredExternalPaths(
51
- paths: readonly AccessPath[],
66
+ accesses: readonly BashExternalPath[],
52
67
  resolver: ScopedPermissionResolver,
53
68
  agentName: string | undefined,
54
69
  ): UncoveredExternalPaths {
55
70
  const uncovered: UncoveredExternalPath[] = [];
56
- for (const path of paths) {
57
- const check = resolveExternalDirectoryPolicy(path, resolver, agentName);
71
+ for (const { path, effect } of accesses) {
72
+ const surface = capabilitySurfaceForEffect(
73
+ "external_directory",
74
+ effect.effect,
75
+ );
76
+ const check = resolveExternalDirectoryPolicy(
77
+ path,
78
+ resolver,
79
+ surface,
80
+ agentName,
81
+ );
58
82
  if (check.state !== "allow") {
59
- uncovered.push({ path, check });
83
+ uncovered.push({ path, surface, effect, check });
60
84
  }
61
85
  }
62
86
  return {
@@ -1,3 +1,4 @@
1
+ import { capabilitySurfaceForTool } from "#src/access-intent/path-surfaces";
1
2
  import { getToolInputPath } from "#src/access-intent/tool-input-path";
2
3
  import type { PathNormalizer } from "#src/path-normalizer";
3
4
  import type { ScopedPermissionResolver } from "#src/permission-resolver";
@@ -71,10 +72,15 @@ export function describeExternalDirectoryGate(
71
72
  // ── Build descriptor for permission check ───────────────────────────────
72
73
  const resolvedAlias = accessPath.resolvedAlias();
73
74
 
75
+ // The narrowest `external_directory`-family surface this tool's identity
76
+ // proves; the bare family name folds both directions (ADR 0013 §10).
77
+ const surface = capabilitySurfaceForTool("external_directory", tcc.toolName);
78
+
74
79
  // The runner consumes this preCheck and skips its own resolve.
75
80
  const preCheck = resolveExternalDirectoryPolicy(
76
81
  accessPath,
77
82
  resolver,
83
+ surface,
78
84
  tcc.agentName ?? undefined,
79
85
  );
80
86
  const pattern = normalizer.approvalPatternFor(accessPath);
@@ -86,21 +92,22 @@ export function describeExternalDirectoryGate(
86
92
  cwd: tcc.cwd,
87
93
  agentName: tcc.agentName,
88
94
  matchedPattern: preCheck.matchedPattern,
95
+ surface,
89
96
  });
90
97
 
91
98
  return {
92
- surface: "external_directory",
99
+ surface,
93
100
  input: {},
94
101
  preCheck,
95
102
  payload,
96
- sessionApproval: SessionApproval.single("external_directory", pattern),
103
+ sessionApproval: SessionApproval.single(surface, pattern),
97
104
  promptDetails: {
98
105
  source: "tool_call",
99
106
  agentName: tcc.agentName,
100
107
  toolCallId: tcc.toolCallId,
101
108
  toolName: tcc.toolName,
102
109
  path: externalDirectoryPath,
103
- accessIntent: accessFactsFromPath("external_directory", accessPath),
110
+ accessIntent: accessFactsFromPath(surface, accessPath),
104
111
  },
105
112
  logContext: {
106
113
  source: "tool_call",
@@ -110,7 +117,7 @@ export function describeExternalDirectoryGate(
110
117
  path: externalDirectoryPath,
111
118
  },
112
119
  decision: {
113
- surface: "external_directory",
120
+ surface,
114
121
  value: externalDirectoryPath,
115
122
  },
116
123
  };
@@ -1,3 +1,4 @@
1
+ import { capabilitySurfaceForTool } from "#src/access-intent/path-surfaces";
1
2
  import { getToolInputPath } from "#src/access-intent/tool-input-path";
2
3
  import type { PathNormalizer } from "#src/path-normalizer";
3
4
  import type { ScopedPermissionResolver } from "#src/permission-resolver";
@@ -25,13 +26,18 @@ export function describePathGate(
25
26
  const filePath = getToolInputPath(tcc.toolName, tcc.input, extractors);
26
27
  if (!filePath) return null;
27
28
 
29
+ // The narrowest `path`-family surface this tool's identity proves. A tool
30
+ // that proves nothing narrower emits the bare family name, which the
31
+ // resolver folds over both directional members (ADR 0013 §10).
32
+ const surface = capabilitySurfaceForTool("path", tcc.toolName);
33
+
28
34
  // Emit an access-path intent so the resolver matches the lexical aliases
29
35
  // *and* the canonical (symlink-resolved) form, the same set
30
36
  // `external_directory` matches (#418, #486).
31
37
  const accessPath = normalizer.forPath(filePath);
32
38
  const check = resolver.resolve({
33
39
  kind: "access-path",
34
- surface: "path",
40
+ surface,
35
41
  path: accessPath,
36
42
  agentName: tcc.agentName ?? undefined,
37
43
  });
@@ -52,20 +58,21 @@ export function describePathGate(
52
58
  pathValue: filePath,
53
59
  agentName: tcc.agentName,
54
60
  matchedPattern: check.matchedPattern,
61
+ surface,
55
62
  });
56
63
 
57
64
  const descriptor: GateDescriptor = {
58
- surface: "path",
65
+ surface,
59
66
  input: { path: filePath },
60
67
  payload,
61
- sessionApproval: SessionApproval.single("path", pattern),
68
+ sessionApproval: SessionApproval.single(surface, pattern),
62
69
  promptDetails: {
63
70
  source: "tool_call",
64
71
  agentName: tcc.agentName,
65
72
  toolCallId: tcc.toolCallId,
66
73
  toolName: tcc.toolName,
67
74
  path: filePath,
68
- accessIntent: accessFactsFromPath("path", accessPath),
75
+ accessIntent: accessFactsFromPath(surface, accessPath),
69
76
  },
70
77
  logContext: {
71
78
  source: "tool_call",
@@ -75,7 +82,7 @@ export function describePathGate(
75
82
  path: filePath,
76
83
  },
77
84
  decision: {
78
- surface: "path",
85
+ surface,
79
86
  value: filePath,
80
87
  },
81
88
  preCheck: check,
@@ -132,6 +132,7 @@ export function describeToolGate(
132
132
  toolCallId: tcc.toolCallId,
133
133
  toolName: tcc.toolName,
134
134
  ...permissionLogContext,
135
+ ...floorExemptionFact(check),
135
136
  },
136
137
  decision: {
137
138
  surface: gateSurface,
@@ -139,3 +140,24 @@ export function describeToolGate(
139
140
  },
140
141
  };
141
142
  }
143
+
144
+ /**
145
+ * Why a bash wrapper's floor did not apply, when one did not (#803).
146
+ *
147
+ * The blame line ADR 0013 §11 asks the review log to record: `matchedPattern`
148
+ * names the rule that decided and `executedUnit` the command it decided about,
149
+ * and this names why that rule was consulted instead of the floor. Absent for
150
+ * every other decision, so a line states what was true rather than enumerating
151
+ * what was not.
152
+ *
153
+ * It rides the gate's `logContext` rather than the prompt payload because an
154
+ * exempt unit's usual outcome is that no prompt happens at all — the same
155
+ * routing `effect`/`effectSource` take on the bash path gates.
156
+ */
157
+ function floorExemptionFact(
158
+ check: PermissionCheckResult,
159
+ ): Record<string, unknown> {
160
+ return check.floorExemption === undefined
161
+ ? {}
162
+ : { floorExemption: check.floorExemption };
163
+ }
package/src/normalize.ts CHANGED
@@ -1,7 +1,76 @@
1
+ import { surfaceFamilyMembers } from "#src/access-intent/path-surfaces";
1
2
  import type { Rule, Ruleset } from "./rule";
2
- import type { FlatPermissionConfig } from "./types";
3
+ import type { FlatPermissionConfig, PatternValue } from "./types";
3
4
  import { isDenyWithReason, isPermissionState } from "./types";
4
5
 
6
+ /**
7
+ * A surface's value in a flat permission config: a catch-all or a pattern map.
8
+ * `NonNullable` because the four named directional properties are optional.
9
+ */
10
+ type SurfaceValue = NonNullable<FlatPermissionConfig[string]>;
11
+
12
+ /** A surface's pattern → action map. */
13
+ type PatternMap = Record<string, PatternValue>;
14
+
15
+ /**
16
+ * Rewrite one scope's flat permission object so the bare `path` and
17
+ * `external_directory` sugar keys are replaced by their directional members
18
+ * (ADR 0013 §4).
19
+ *
20
+ * After expansion **no rule lives on a bare family surface at all** — that is
21
+ * what makes `PermissionResolver`'s family fold the read path.
22
+ *
23
+ * The intra-surface merge order is normative: sugar-derived entries come first
24
+ * and explicit directional entries append after them, whatever the keys'
25
+ * textual order in the file, so a config and its key-order-swapped twin mean
26
+ * the same thing. A pattern the explicit entry redefines is emitted once, at
27
+ * the explicit entry's position, so last-match-wins gives it the final say.
28
+ *
29
+ * Called per scope at load, before composition — so origins stay attributed to
30
+ * the authoring scope rather than collapsing to `builtin`.
31
+ */
32
+ export function expandDirectionalSugar(
33
+ permission: FlatPermissionConfig,
34
+ ): FlatPermissionConfig {
35
+ const expanded: FlatPermissionConfig = {};
36
+ for (const [surface, value] of Object.entries(permission)) {
37
+ // A key present with an explicit `undefined` value carries no rules.
38
+ if (value === undefined) continue;
39
+ const members = surfaceFamilyMembers(surface);
40
+ if (members === null) {
41
+ // A directional key the sugar already absorbed keeps the merged value.
42
+ if (!Object.hasOwn(expanded, surface)) expanded[surface] = value;
43
+ continue;
44
+ }
45
+ for (const member of members) {
46
+ expanded[member] = appendExplicitEntries(value, permission[member]);
47
+ }
48
+ }
49
+ return expanded;
50
+ }
51
+
52
+ /** The sugar-derived entries, followed by the explicit directional entries. */
53
+ function appendExplicitEntries(
54
+ sugar: SurfaceValue,
55
+ explicit: SurfaceValue | undefined,
56
+ ): SurfaceValue {
57
+ if (explicit === undefined) {
58
+ return typeof sugar === "string" ? sugar : { ...sugar };
59
+ }
60
+ const explicitPatterns = toPatternMap(explicit);
61
+ const sugarPatterns = Object.fromEntries(
62
+ Object.entries(toPatternMap(sugar)).filter(
63
+ ([pattern]) => !Object.hasOwn(explicitPatterns, pattern),
64
+ ),
65
+ );
66
+ return { ...sugarPatterns, ...explicitPatterns };
67
+ }
68
+
69
+ /** A catch-all string is shorthand for `{ "*": action }` (see `normalizeFlatConfig`). */
70
+ function toPatternMap(value: SurfaceValue): PatternMap {
71
+ return typeof value === "string" ? { "*": value } : value;
72
+ }
73
+
5
74
  /**
6
75
  * Convert a flat permission config into a Ruleset.
7
76
  *
@@ -1,7 +1,7 @@
1
1
  import { join } from "node:path";
2
2
  import type { ResolvedAccessIntent } from "./access-intent/access-intent";
3
3
  import { normalizeInput } from "./access-intent/input-normalizer";
4
- import { PATH_SURFACES } from "./access-intent/path-surfaces";
4
+ import { PATH_SURFACES, surfaceFamilyOf } from "./access-intent/path-surfaces";
5
5
  import { classifyToolKind } from "./access-intent/tool-kind";
6
6
  import {
7
7
  getGlobalConfigPath,
@@ -408,7 +408,8 @@ function deriveSource(
408
408
  toolName: string,
409
409
  ): PermissionCheckResult["source"] {
410
410
  if (rule.layer === "session") return "session";
411
- if (SPECIAL_PERMISSION_KEYS.has(toolName)) return "special";
411
+ // Family membership, so a directional surface keeps reporting "special".
412
+ if (SPECIAL_PERMISSION_KEYS.has(surfaceFamilyOf(toolName))) return "special";
412
413
 
413
414
  switch (classifyToolKind(toolName)) {
414
415
  case "mcp":
@@ -3,7 +3,9 @@ import type {
3
3
  PathValuesAccessIntent,
4
4
  ResolvedAccessIntent,
5
5
  } from "./access-intent/access-intent";
6
+ import { surfaceFamilyMembers } from "./access-intent/path-surfaces";
6
7
  import type { ScopedPermissionManager } from "./permission-manager";
8
+ import { mostRestrictiveOf } from "./restrictiveness";
7
9
  import type { Rule } from "./rule";
8
10
  import type { SessionRules } from "./session-rules";
9
11
  import type { SkillPermissionChecker } from "./skill-prompt-sanitizer";
@@ -79,14 +81,31 @@ export class PermissionResolver
79
81
  * gate-facing {@link ScopedPermissionResolver} interface stays narrow
80
82
  * (`AccessIntent` only); this wider acceptance is available only through the
81
83
  * concrete `PermissionResolver` instance the composition root holds.
84
+ *
85
+ * An intent naming a bare surface family (`path`, `external_directory`) is
86
+ * folded over the family's directional members, most-restrictive (ADR 0013
87
+ * §10's fail-closed base case). The fold lives here rather than in the gates
88
+ * because this is the one entry point the gates, `LocalPermissionsService`,
89
+ * and `ServingPolicy` all share — a serving node resolving a forwarded child
90
+ * request against an emptied bare surface would stop hard-denying what the
91
+ * parent's config denies (the #712 defect class).
82
92
  */
83
93
  resolve(
84
94
  intent: AccessIntent | PathValuesAccessIntent,
85
95
  ): PermissionCheckResult {
86
- return this.permissionManager.check(
87
- toResolvedIntent(intent),
88
- this.sessionRules.getRuleset(),
89
- );
96
+ const resolved = toResolvedIntent(intent);
97
+ const sessionRuleset = this.sessionRules.getRuleset();
98
+ const members = surfaceFamilyMembers(resolved.surface);
99
+ if (members === null) {
100
+ return this.permissionManager.check(resolved, sessionRuleset);
101
+ }
102
+ const [first, ...rest] = members;
103
+ const checkMember = (surface: string): PermissionCheckResult =>
104
+ this.permissionManager.check({ ...resolved, surface }, sessionRuleset);
105
+ return mostRestrictiveOf([
106
+ checkMember(first),
107
+ ...rest.map((surface) => checkMember(surface)),
108
+ ]);
90
109
  }
91
110
 
92
111
  /**