@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
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Wrapper interpretation for a bash command unit: what kind of wrapper it is,
|
|
3
|
-
*
|
|
3
|
+
* what it actually runs, and whether its floor still has a reason to hold.
|
|
4
4
|
*
|
|
5
5
|
* Pure and word-based; the AST walk that produces the words lives in
|
|
6
|
-
* `command-enumeration.ts`.
|
|
7
|
-
* shape that floors a unit to `ask
|
|
8
|
-
*
|
|
6
|
+
* `command-enumeration.ts`. The three questions live here together
|
|
7
|
+
* deliberately: the shape that floors a unit to `ask`, the shape that names its
|
|
8
|
+
* inner command, and the shape that exempts it must agree, and separate
|
|
9
|
+
* classifiers over the same vocabulary would drift.
|
|
9
10
|
*/
|
|
10
11
|
|
|
12
|
+
import { proveCommandEffect } from "#src/access-intent/bash/command-effects";
|
|
13
|
+
|
|
11
14
|
/** One word of a command unit: its text, and its offset into the unit's text. */
|
|
12
15
|
export interface CommandWord {
|
|
13
16
|
readonly text: string;
|
|
@@ -62,11 +65,12 @@ export function classifyWrapperWords(
|
|
|
62
65
|
* The command a wrapper unit actually runs, or `null` when it cannot be
|
|
63
66
|
* established or adds nothing over the unit itself.
|
|
64
67
|
*
|
|
65
|
-
* Display-only (ADR 0011 §3.5, #713): the result
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
68
|
+
* Display-only (ADR 0011 §3.5, #713): the result names what runs, including
|
|
69
|
+
* the payload of an inline shell, so it deliberately looks *past* a `sh -c`
|
|
70
|
+
* layer that the gate must not look past. Because it is shown on a decision
|
|
71
|
+
* surface, the rule is to fail to `null` rather than to a guess — an
|
|
72
|
+
* unrecognized option shape yields nothing rather than a remainder that might
|
|
73
|
+
* name the wrong command.
|
|
70
74
|
*
|
|
71
75
|
* Nested wrappers unwrap to the innermost command (`sudo timeout 5 xargs grep
|
|
72
76
|
* foo` → `grep foo`), bounded by {@link MAX_UNWRAP_DEPTH}.
|
|
@@ -75,8 +79,93 @@ export function executedUnitOf(
|
|
|
75
79
|
unitText: string,
|
|
76
80
|
words: readonly CommandWord[],
|
|
77
81
|
): string | null {
|
|
82
|
+
const unwrapped = unwrapIndirection(words, unitText);
|
|
83
|
+
const text = unwrapped.kind === "opaque" ? unwrapped.payload : unwrapped.text;
|
|
84
|
+
return nothingNew(text, unitText);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* True when a wrapper unit's floor has no reason left to hold: the command it
|
|
89
|
+
* runs is in the pure-reader core, so its *direction* is provable however
|
|
90
|
+
* unknown its argument feed is (ADR 0013 §11, #803).
|
|
91
|
+
*
|
|
92
|
+
* The floor exists because a wrapper hides the command that should be gated,
|
|
93
|
+
* and the unknowability it guards is unknowability of scope — which stays the
|
|
94
|
+
* projection's and the path surfaces' job, for wrapped and unwrapped commands
|
|
95
|
+
* alike. Argument-independence is the core's admission bar, so there are no
|
|
96
|
+
* arguments that make `grep` write a file.
|
|
97
|
+
*
|
|
98
|
+
* Four things must hold, and each is a way the reason could still hold:
|
|
99
|
+
*
|
|
100
|
+
* 1. The unit is an indirection wrapper — an ordinary command has no floor.
|
|
101
|
+
* 2. Unwrapping reached the inner command without passing through an opaque
|
|
102
|
+
* payload. `sh -c '…'` carries an unparsed program whose first word says
|
|
103
|
+
* nothing about the rest of it, which is why this cannot consult
|
|
104
|
+
* {@link executedUnitOf}'s string.
|
|
105
|
+
* 3. The inner command *proves* a read — bare-basename core word, retraction
|
|
106
|
+
* guards applied, so `xargs sort -o /tmp/x` and `xargs find . -delete` are
|
|
107
|
+
* not transparent.
|
|
108
|
+
* 4. The unit writes no file through a redirect, which the caller reads off
|
|
109
|
+
* the parse tree and this module never sees.
|
|
110
|
+
*/
|
|
111
|
+
export function isTransparentWrapper(
|
|
112
|
+
words: readonly CommandWord[],
|
|
113
|
+
statement: { readonly writesViaRedirect: boolean },
|
|
114
|
+
): boolean {
|
|
115
|
+
if (statement.writesViaRedirect) return false;
|
|
116
|
+
if (classifyWrapperWords(words) !== "indirection") return false;
|
|
117
|
+
|
|
118
|
+
// Only the peeled words matter here, so the walk is handed no source span to
|
|
119
|
+
// cut from — the text slice is `executedUnitOf`'s product, not this one's.
|
|
120
|
+
const unwrapped = unwrapIndirection(words, "");
|
|
121
|
+
if (unwrapped.kind === "opaque" || unwrapped.layers === 0) return false;
|
|
122
|
+
|
|
123
|
+
const head = unwrapped.words.at(0)?.text ?? "";
|
|
124
|
+
const args = unwrapped.words.slice(1).map((word) => word.text);
|
|
125
|
+
return proveCommandEffect(head, args).effect === "read";
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ── Unwrapping ───────────────────────────────────────────────────────────────
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* How an unwrap ended: at the innermost command reachable by peeling
|
|
132
|
+
* indirection layers, or at an inline-shell payload.
|
|
133
|
+
*
|
|
134
|
+
* The two are kept apart because they are different kinds of answer. A peeled
|
|
135
|
+
* result is a slice of this command line, with words the caller can inspect;
|
|
136
|
+
* a payload is an inner *program* the enumerator never parsed, so it has text
|
|
137
|
+
* and nothing else. Collapsing them is how a core-looking first word inside
|
|
138
|
+
* `sh -c '…'` would come to stand for the whole payload (#803).
|
|
139
|
+
*/
|
|
140
|
+
type UnwrapResult =
|
|
141
|
+
| { readonly kind: "opaque"; readonly payload: string | null }
|
|
142
|
+
| {
|
|
143
|
+
readonly kind: "peeled";
|
|
144
|
+
readonly text: string;
|
|
145
|
+
readonly words: readonly CommandWord[];
|
|
146
|
+
/** How many indirection layers came off; `0` means none did. */
|
|
147
|
+
readonly layers: number;
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Peel indirection layers off a command unit until an ordinary command, an
|
|
152
|
+
* opaque payload, or an unrecognized option shape stops the walk.
|
|
153
|
+
*
|
|
154
|
+
* Stopping early is not an error: the words peeled so far are returned, and
|
|
155
|
+
* each caller decides what an incomplete peel is worth — `executedUnitOf`
|
|
156
|
+
* shows it, {@link isTransparentWrapper} declines it because the head word it
|
|
157
|
+
* would judge is the wrapper's own.
|
|
158
|
+
*
|
|
159
|
+
* `unitText` is the span the peeled `text` is cut from; a caller that wants
|
|
160
|
+
* only the words passes the empty string and ignores it.
|
|
161
|
+
*/
|
|
162
|
+
function unwrapIndirection(
|
|
163
|
+
words: readonly CommandWord[],
|
|
164
|
+
unitText: string,
|
|
165
|
+
): UnwrapResult {
|
|
78
166
|
let text = unitText;
|
|
79
167
|
let current = words;
|
|
168
|
+
let layers = 0;
|
|
80
169
|
|
|
81
170
|
for (let depth = 0; depth < MAX_UNWRAP_DEPTH; depth++) {
|
|
82
171
|
const kind = classifyWrapperWords(current);
|
|
@@ -85,7 +174,7 @@ export function executedUnitOf(
|
|
|
85
174
|
if (kind === "opaque-payload") {
|
|
86
175
|
// The payload is an inner *program*, not a slice of this command line, so
|
|
87
176
|
// it is unquoted and terminal — unwrapping it further would need a parse.
|
|
88
|
-
return
|
|
177
|
+
return { kind: "opaque", payload: opaquePayload(current) };
|
|
89
178
|
}
|
|
90
179
|
|
|
91
180
|
const start = innerCommandIndex(current);
|
|
@@ -93,9 +182,10 @@ export function executedUnitOf(
|
|
|
93
182
|
const end = execTerminatorIndex(current, start);
|
|
94
183
|
text = sliceWords(text, current, start, end).trimEnd();
|
|
95
184
|
current = rebase(current, start, end);
|
|
185
|
+
layers++;
|
|
96
186
|
}
|
|
97
187
|
|
|
98
|
-
return
|
|
188
|
+
return { kind: "peeled", text, words: current, layers };
|
|
99
189
|
}
|
|
100
190
|
|
|
101
191
|
/** How many wrapper layers to unwrap before giving up. */
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A filesystem effect an access can have.
|
|
3
|
+
*
|
|
4
|
+
* ADR 0013 §2 reserves a third value, `delete`, as strictly stronger than
|
|
5
|
+
* `write`; it is not shipped, so nothing can name it in a config or a proof.
|
|
6
|
+
*/
|
|
7
|
+
export type Effect = "read" | "write";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* An effect attribution, including the fail-closed base case.
|
|
11
|
+
*
|
|
12
|
+
* `unproven` is not a third effect — it is the absence of a proof, and ADR
|
|
13
|
+
* 0013 §10 makes it consult both directional surfaces, most-restrictive.
|
|
14
|
+
*/
|
|
15
|
+
export type AttributedEffect = Effect | "unproven";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* What established an attribution — the blame fact ADR 0013 §7 asks the review
|
|
19
|
+
* log to record.
|
|
20
|
+
*
|
|
21
|
+
* `retracted` is distinct from `unproven` on purpose: "nobody claimed anything
|
|
22
|
+
* about `pnpm`" and "`find` is a core reader but `-delete` withdrew the claim"
|
|
23
|
+
* are different diagnoses, and only the second names a line to read.
|
|
24
|
+
*/
|
|
25
|
+
export type EffectSource = "syntax" | "core" | "retracted" | "unproven";
|
|
26
|
+
|
|
27
|
+
/** A path token's attributed effect, paired with what established it. */
|
|
28
|
+
export interface TokenEffect {
|
|
29
|
+
readonly effect: AttributedEffect;
|
|
30
|
+
readonly source: EffectSource;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The fail-closed base case: no proof, and nothing to blame for its absence. */
|
|
34
|
+
export const UNPROVEN_EFFECT: TokenEffect = {
|
|
35
|
+
effect: "unproven",
|
|
36
|
+
source: "unproven",
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Combine two attributions of the same resolved path.
|
|
41
|
+
*
|
|
42
|
+
* Agreement keeps the effect, and prefers whichever source has something to
|
|
43
|
+
* blame — a `retracted` attribution survives a merge with a bare unproven
|
|
44
|
+
* whichever order the two tokens were collected in. Order-independence
|
|
45
|
+
* matters because collection order is an accident of the command's shape, and
|
|
46
|
+
* the blame line is the only reason the source is recorded at all.
|
|
47
|
+
*
|
|
48
|
+
* Disagreement falls to {@link UNPROVEN_EFFECT}, because proven-both and
|
|
49
|
+
* unproven-at-all consult the same two surfaces — ADR 0013's 2026-08-25
|
|
50
|
+
* amendment reads them as one mechanism, not two — which is what makes the
|
|
51
|
+
* fold honest rather than lossy.
|
|
52
|
+
*/
|
|
53
|
+
export function mergeTokenEffects(a: TokenEffect, b: TokenEffect): TokenEffect {
|
|
54
|
+
if (a.effect !== b.effect) return UNPROVEN_EFFECT;
|
|
55
|
+
return a.source === "unproven" ? b : a;
|
|
56
|
+
}
|
|
@@ -3,7 +3,7 @@ import type { PathNormalizer } from "#src/path-normalizer";
|
|
|
3
3
|
import { getNonEmptyString, toRecord } from "#src/value-guards";
|
|
4
4
|
import type { AccessIntent, ResolvedAccessIntent } from "./access-intent";
|
|
5
5
|
import { createMcpPermissionTargets } from "./mcp-targets";
|
|
6
|
-
import { PATH_SURFACES } from "./path-surfaces";
|
|
6
|
+
import { PATH_SURFACES, surfaceFamilyOf } from "./path-surfaces";
|
|
7
7
|
import { classifyToolKind } from "./tool-kind";
|
|
8
8
|
|
|
9
9
|
/**
|
|
@@ -95,7 +95,7 @@ function buildInputForSurface(
|
|
|
95
95
|
const v = value ?? "";
|
|
96
96
|
if (surface === "bash") return { command: v };
|
|
97
97
|
if (surface === "skill") return { name: v };
|
|
98
|
-
if (surface === "external_directory") return { path: v };
|
|
98
|
+
if (surfaceFamilyOf(surface) === "external_directory") return { path: v };
|
|
99
99
|
// MCP and tool surfaces: normalizeInput handles them from the surface alone.
|
|
100
100
|
return {};
|
|
101
101
|
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { AttributedEffect } from "#src/access-intent/effect";
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* File tools that only read — never write — the filesystem.
|
|
3
5
|
* Only these tools are eligible for the Pi infrastructure auto-allow.
|
|
@@ -18,13 +20,117 @@ export const PATH_BEARING_TOOLS = new Set([
|
|
|
18
20
|
"ls",
|
|
19
21
|
]);
|
|
20
22
|
|
|
23
|
+
/**
|
|
24
|
+
* The surface families that carry the read/write capability axis (ADR 0013 §3).
|
|
25
|
+
*
|
|
26
|
+
* A bare family name means "both directions": it is what an access whose
|
|
27
|
+
* direction is proven to be both, or cannot be proven at all, consults — the
|
|
28
|
+
* fail-closed base case of ADR 0013 §10.
|
|
29
|
+
*/
|
|
30
|
+
const DIRECTIONAL_FAMILIES: ReadonlySet<string> = new Set([
|
|
31
|
+
"path",
|
|
32
|
+
"external_directory",
|
|
33
|
+
]);
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The capability suffixes, in ADR 0013 §4's normative order: a family's
|
|
37
|
+
* sugar-expanded and folded members are always read-then-write.
|
|
38
|
+
*/
|
|
39
|
+
const CAPABILITY_SUFFIXES = ["_read", "_write"] as const;
|
|
40
|
+
|
|
21
41
|
/**
|
|
22
42
|
* Surfaces whose patterns are matched against filesystem paths and therefore
|
|
23
|
-
* fold case (and separators) on Windows: the path-bearing tools
|
|
24
|
-
* cross-cutting `path` gate
|
|
43
|
+
* fold case (and separators) on Windows: the path-bearing tools, the
|
|
44
|
+
* cross-cutting `path` gate, the `external_directory` boundary gate, and each
|
|
45
|
+
* gate's two directional members.
|
|
25
46
|
*/
|
|
26
47
|
export const PATH_SURFACES: ReadonlySet<string> = new Set([
|
|
27
48
|
...PATH_BEARING_TOOLS,
|
|
28
|
-
|
|
29
|
-
|
|
49
|
+
...DIRECTIONAL_FAMILIES,
|
|
50
|
+
...directionalSurfaceNames(),
|
|
30
51
|
]);
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The family a surface belongs to — itself, when it carries no capability
|
|
55
|
+
* suffix over a directional family.
|
|
56
|
+
*
|
|
57
|
+
* A surface that merely ends in `_read` without naming a directional family
|
|
58
|
+
* (an extension tool called `my_tool_read`) is its own family, so the relation
|
|
59
|
+
* never captures a name the axis does not own.
|
|
60
|
+
*/
|
|
61
|
+
export function surfaceFamilyOf(surface: string): string {
|
|
62
|
+
for (const suffix of CAPABILITY_SUFFIXES) {
|
|
63
|
+
if (!surface.endsWith(suffix)) continue;
|
|
64
|
+
const family = surface.slice(0, -suffix.length);
|
|
65
|
+
if (DIRECTIONAL_FAMILIES.has(family)) return family;
|
|
66
|
+
}
|
|
67
|
+
return surface;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* A family name's directional members, or `null` when `surface` is not a
|
|
72
|
+
* family name (a directional member itself, or any non-path surface).
|
|
73
|
+
*
|
|
74
|
+
* The non-empty tuple is what lets the resolver's family fold be total: it
|
|
75
|
+
* hands the members straight to `mostRestrictiveOf` with no empty branch.
|
|
76
|
+
*/
|
|
77
|
+
export function surfaceFamilyMembers(
|
|
78
|
+
surface: string,
|
|
79
|
+
): readonly [string, ...string[]] | null {
|
|
80
|
+
if (!DIRECTIONAL_FAMILIES.has(surface)) return null;
|
|
81
|
+
const [read, write] = CAPABILITY_SUFFIXES;
|
|
82
|
+
return [`${surface}${read}`, `${surface}${write}`];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The narrowest surface in `family` that an attributed effect names.
|
|
87
|
+
*
|
|
88
|
+
* An unproven attribution names the bare family, whose two members the
|
|
89
|
+
* resolver folds most-restrictive — the fail-closed base case of ADR 0013 §10,
|
|
90
|
+
* which is also where a proven-both access lands.
|
|
91
|
+
*/
|
|
92
|
+
export function capabilitySurfaceForEffect(
|
|
93
|
+
family: string,
|
|
94
|
+
effect: AttributedEffect,
|
|
95
|
+
): string {
|
|
96
|
+
const [read, write] = CAPABILITY_SUFFIXES;
|
|
97
|
+
switch (effect) {
|
|
98
|
+
case "read":
|
|
99
|
+
return `${family}${read}`;
|
|
100
|
+
case "write":
|
|
101
|
+
return `${family}${write}`;
|
|
102
|
+
case "unproven":
|
|
103
|
+
return family;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The narrowest surface in `family` that `toolName`'s identity proves.
|
|
109
|
+
*
|
|
110
|
+
* A tool's name is one of the three proof sources ADR 0013 §7 names, so it
|
|
111
|
+
* routes through the same effect-keyed selector every other source does:
|
|
112
|
+
* `edit` and an unproven bash token reach the bare family by the one path.
|
|
113
|
+
*/
|
|
114
|
+
export function capabilitySurfaceForTool(
|
|
115
|
+
family: string,
|
|
116
|
+
toolName: string,
|
|
117
|
+
): string {
|
|
118
|
+
return capabilitySurfaceForEffect(family, effectProvenByTool(toolName));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* The effect a tool's identity proves: the read-only file tools prove a read,
|
|
123
|
+
* `write` proves a write, and everything else — `edit` (which does both), an
|
|
124
|
+
* MCP tool, an extension tool — proves nothing.
|
|
125
|
+
*/
|
|
126
|
+
function effectProvenByTool(toolName: string): AttributedEffect {
|
|
127
|
+
if (READ_ONLY_PATH_BEARING_TOOLS.has(toolName)) return "read";
|
|
128
|
+
if (toolName === "write") return "write";
|
|
129
|
+
return "unproven";
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function directionalSurfaceNames(): string[] {
|
|
133
|
+
return [...DIRECTIONAL_FAMILIES].flatMap((family) =>
|
|
134
|
+
CAPABILITY_SUFFIXES.map((suffix) => `${family}${suffix}`),
|
|
135
|
+
);
|
|
136
|
+
}
|
|
@@ -7,17 +7,25 @@
|
|
|
7
7
|
* the terminal (a prompt) instead. The checkpoint only ever *tightens* a
|
|
8
8
|
* verdict — it never turns a `defer`/`deny` into an `allow`.
|
|
9
9
|
*
|
|
10
|
-
* The excluded set is the whole `path` surface plus
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
10
|
+
* The excluded set is the whole `path` surface **family** plus the
|
|
11
|
+
* `external_directory` family — membership is tested on the family a surface
|
|
12
|
+
* belongs to, so a directional member (`path_write`) is excluded by
|
|
13
|
+
* construction and a later capability suffix joins its family for free (ADR
|
|
14
|
+
* 0013 §4). That is a name-resolution rule, not a scope freeze: it says how a
|
|
15
|
+
* family name resolves to members, and leaves *which* families are excluded
|
|
16
|
+
* independently relaxable (#620).
|
|
17
|
+
*
|
|
18
|
+
* A finer secret-shaped-`path` exclusion (letting a link allow a non-secret
|
|
19
|
+
* path) is deferred to the allow-capable slice that needs it (#620); until then
|
|
20
|
+
* the conservative whole-family exclusion ships. The checkpoint is dormant
|
|
21
|
+
* while the only registered links are deny-first (they never `allow`).
|
|
15
22
|
*/
|
|
16
23
|
|
|
24
|
+
import { surfaceFamilyOf } from "#src/access-intent/path-surfaces";
|
|
17
25
|
import type { Authorizer } from "./authorizer";
|
|
18
26
|
import type { PromptPermissionDetails } from "./permission-prompter";
|
|
19
27
|
|
|
20
|
-
/**
|
|
28
|
+
/** Surface families on which a link may never grant an `allow` (ADR 0007 §5). */
|
|
21
29
|
export const DELEGATION_EXCLUDED_SURFACES: ReadonlySet<string> = new Set([
|
|
22
30
|
"external_directory",
|
|
23
31
|
"path",
|
|
@@ -49,5 +57,8 @@ export function encloseInDelegationEnvelope(
|
|
|
49
57
|
*/
|
|
50
58
|
function isExcludedSurface(details: PromptPermissionDetails): boolean {
|
|
51
59
|
const surface = details.accessIntent?.surface ?? details.surface ?? undefined;
|
|
52
|
-
return
|
|
60
|
+
return (
|
|
61
|
+
surface === undefined ||
|
|
62
|
+
DELEGATION_EXCLUDED_SURFACES.has(surfaceFamilyOf(surface))
|
|
63
|
+
);
|
|
53
64
|
}
|
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,
|