@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
@@ -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,
@@ -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
  };