@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.
- package/CHANGELOG.md +36 -0
- package/README.md +20 -2
- package/config/config.example.json +3 -1
- package/dist/public.d.ts +21 -1
- package/docs/configuration.md +184 -5
- package/package.json +3 -1
- package/schemas/permissions.schema.json +52 -4
- package/src/access-intent/bash/bash-path-resolver.ts +104 -34
- package/src/access-intent/bash/command-effects.ts +305 -0
- package/src/access-intent/bash/command-enumeration.ts +116 -40
- package/src/access-intent/bash/program.ts +15 -11
- package/src/access-intent/bash/redirect-analysis.ts +98 -0
- package/src/access-intent/bash/token-classification.ts +14 -13
- package/src/access-intent/bash/token-collection.ts +102 -22
- package/src/access-intent/bash/wrapper-analysis.ts +101 -11
- package/src/access-intent/effect.ts +56 -0
- package/src/access-intent/input-normalizer.ts +2 -2
- package/src/access-intent/path-surfaces.ts +110 -4
- package/src/authority/delegation-envelope.ts +18 -7
- package/src/config-schema.ts +95 -6
- package/src/handlers/gates/bash-command.ts +53 -17
- package/src/handlers/gates/bash-external-directory.ts +41 -8
- package/src/handlers/gates/bash-path-extractor.ts +3 -3
- package/src/handlers/gates/bash-path.ts +31 -13
- package/src/handlers/gates/external-directory-policy.ts +37 -13
- package/src/handlers/gates/external-directory.ts +11 -4
- package/src/handlers/gates/path.ts +12 -5
- package/src/handlers/gates/tool.ts +22 -0
- package/src/normalize.ts +70 -1
- package/src/permission-manager.ts +3 -2
- package/src/permission-resolver.ts +23 -4
- package/src/presentation/path-ask-payload.ts +17 -6
- package/src/restrictiveness.ts +48 -0
- package/src/rule.ts +13 -13
- package/src/scope-merge.ts +8 -2
- package/src/session-rules.ts +19 -8
- package/src/types.ts +22 -1
- package/src/handlers/gates/candidate-check.ts +0 -32
package/src/config-schema.ts
CHANGED
|
@@ -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
|
-
.
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
|
63
|
+
return resolveOnBashSurface(command, agentName, resolver);
|
|
64
64
|
}
|
|
65
|
-
const whole =
|
|
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
|
|
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
|
-
|
|
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
|
-
/**
|
|
122
|
-
|
|
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
|
|
34
|
-
if (
|
|
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
|
-
|
|
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:
|
|
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
|
|
112
|
+
surface,
|
|
104
113
|
input: {},
|
|
105
114
|
payload,
|
|
106
|
-
sessionApproval: SessionApproval.multiple(
|
|
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(
|
|
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
|
|
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.
|
|
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
|
-
.
|
|
22
|
-
.map((
|
|
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
|
|
19
|
-
*
|
|
20
|
-
*
|
|
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
|
|
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
|
|
147
|
+
surface,
|
|
134
148
|
input: { path: worstToken },
|
|
135
149
|
payload,
|
|
136
|
-
sessionApproval: SessionApproval.single(
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
47
|
+
surface,
|
|
36
48
|
path,
|
|
37
49
|
agentName,
|
|
38
50
|
});
|
|
39
51
|
}
|
|
40
52
|
|
|
41
53
|
/**
|
|
42
|
-
* Resolve a set of external
|
|
54
|
+
* Resolve a set of external accesses and select those not already allowed.
|
|
43
55
|
*
|
|
44
|
-
* Each
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
* "
|
|
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
|
-
|
|
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
|
|
57
|
-
const
|
|
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
|
|
99
|
+
surface,
|
|
93
100
|
input: {},
|
|
94
101
|
preCheck,
|
|
95
102
|
payload,
|
|
96
|
-
sessionApproval: SessionApproval.single(
|
|
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(
|
|
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
|
|
120
|
+
surface,
|
|
114
121
|
value: externalDirectoryPath,
|
|
115
122
|
},
|
|
116
123
|
};
|