@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.
- package/CHANGELOG.md +36 -0
- package/README.md +26 -6
- package/config/config.example.json +3 -1
- package/dist/public.d.ts +21 -1
- package/docs/configuration.md +184 -5
- package/docs/troubleshooting.md +4 -1
- 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-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
|
@@ -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
|
};
|
|
@@ -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
|
|
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
|
|
65
|
+
surface,
|
|
59
66
|
input: { path: filePath },
|
|
60
67
|
payload,
|
|
61
|
-
sessionApproval: SessionApproval.single(
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
/**
|