@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
|
@@ -19,11 +19,17 @@ interface PathAskFacts {
|
|
|
19
19
|
pathValue: string;
|
|
20
20
|
agentName: string | null;
|
|
21
21
|
matchedPattern?: string;
|
|
22
|
+
/**
|
|
23
|
+
* The surface that decided — a directional member when the tool's identity
|
|
24
|
+
* proved a direction, the bare family otherwise. Distinct from the payload
|
|
25
|
+
* `kind`, which stays coarse so renderer dispatch is untouched.
|
|
26
|
+
*/
|
|
27
|
+
surface: string;
|
|
22
28
|
}
|
|
23
29
|
|
|
24
|
-
/** A tool ask gated by an explicit `path
|
|
30
|
+
/** A tool ask gated by an explicit `path`-family rule. */
|
|
25
31
|
export function buildPathAskPayload(facts: PathAskFacts): PromptPayload {
|
|
26
|
-
return pathPayload("path",
|
|
32
|
+
return pathPayload("path", facts, []);
|
|
27
33
|
}
|
|
28
34
|
|
|
29
35
|
/** The facts the external-directory gate adds: the boundary and the alias. */
|
|
@@ -38,7 +44,7 @@ interface ExternalDirectoryAskFacts extends PathAskFacts {
|
|
|
38
44
|
export function buildExternalDirectoryAskPayload(
|
|
39
45
|
facts: ExternalDirectoryAskFacts,
|
|
40
46
|
): PromptPayload {
|
|
41
|
-
return pathPayload("external_directory",
|
|
47
|
+
return pathPayload("external_directory", facts, [
|
|
42
48
|
...resolvedAliasEvidence(facts.resolvedPath),
|
|
43
49
|
workingDirectoryEvidence(facts.cwd),
|
|
44
50
|
]);
|
|
@@ -53,6 +59,12 @@ interface BashExternalDirectoryAskFacts {
|
|
|
53
59
|
agentName: string | null;
|
|
54
60
|
toolName: string;
|
|
55
61
|
matchedPattern?: string;
|
|
62
|
+
/**
|
|
63
|
+
* The surface that decided — a directional member when the deciding path's
|
|
64
|
+
* effect was proven, the bare family otherwise. Distinct from the payload
|
|
65
|
+
* `kind`, which stays coarse so renderer dispatch is untouched.
|
|
66
|
+
*/
|
|
67
|
+
surface: string;
|
|
56
68
|
}
|
|
57
69
|
|
|
58
70
|
/** A bash ask whose command references paths outside the working directory. */
|
|
@@ -63,7 +75,7 @@ export function buildBashExternalDirectoryAskPayload(
|
|
|
63
75
|
kind: "bash_external_directory",
|
|
64
76
|
request: {
|
|
65
77
|
requester: localRequester(facts.agentName),
|
|
66
|
-
surface:
|
|
78
|
+
surface: facts.surface,
|
|
67
79
|
toolName: facts.toolName,
|
|
68
80
|
invokedToolName: null,
|
|
69
81
|
value: facts.command,
|
|
@@ -87,7 +99,6 @@ export function buildBashExternalDirectoryAskPayload(
|
|
|
87
99
|
*/
|
|
88
100
|
function pathPayload(
|
|
89
101
|
kind: "path" | "external_directory",
|
|
90
|
-
surface: string,
|
|
91
102
|
facts: PathAskFacts,
|
|
92
103
|
evidence: PromptEvidence[],
|
|
93
104
|
): PromptPayload {
|
|
@@ -95,7 +106,7 @@ function pathPayload(
|
|
|
95
106
|
kind,
|
|
96
107
|
request: {
|
|
97
108
|
requester: localRequester(facts.agentName),
|
|
98
|
-
surface,
|
|
109
|
+
surface: facts.surface,
|
|
99
110
|
toolName: facts.toolName,
|
|
100
111
|
invokedToolName: null,
|
|
101
112
|
value: facts.pathValue,
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { PermissionCheckResult, PermissionState } from "#src/types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Select the most restrictive permission result from a possibly-empty list
|
|
5
|
+
* (deny > ask > allow).
|
|
6
|
+
*
|
|
7
|
+
* The first occurrence wins on ties, so a caller passing results in candidate
|
|
8
|
+
* order receives the earliest worst case. Returns `undefined` for an empty list.
|
|
9
|
+
*
|
|
10
|
+
* Shared by the bash gates (path, external-directory) to combine the per-candidate
|
|
11
|
+
* `checkPermission` results their tree-sitter token extraction produces.
|
|
12
|
+
*/
|
|
13
|
+
export function pickMostRestrictive(
|
|
14
|
+
results: readonly PermissionCheckResult[],
|
|
15
|
+
): PermissionCheckResult | undefined {
|
|
16
|
+
const first = results.at(0);
|
|
17
|
+
return first === undefined
|
|
18
|
+
? undefined
|
|
19
|
+
: mostRestrictiveOf([first, ...results.slice(1)]);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Select the most restrictive of a statically non-empty list of results
|
|
24
|
+
* (deny > ask > allow), first-wins on ties.
|
|
25
|
+
*
|
|
26
|
+
* Total by construction: the non-empty tuple parameter means the caller never
|
|
27
|
+
* handles an `undefined` branch. The winner is the losing member's **own**
|
|
28
|
+
* result, so `toolName`, `matchedPattern`, `origin`, and `source` all name the
|
|
29
|
+
* input that forced the verdict rather than a synthesized composite.
|
|
30
|
+
*/
|
|
31
|
+
export function mostRestrictiveOf(
|
|
32
|
+
results: readonly [PermissionCheckResult, ...PermissionCheckResult[]],
|
|
33
|
+
): PermissionCheckResult {
|
|
34
|
+
let worst = results[0];
|
|
35
|
+
for (const result of results) {
|
|
36
|
+
if (RESTRICTIVENESS[result.state] > RESTRICTIVENESS[worst.state]) {
|
|
37
|
+
worst = result;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return worst;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Restrictiveness ordering: deny is the most restrictive, allow the least. */
|
|
44
|
+
const RESTRICTIVENESS: Record<PermissionState, number> = {
|
|
45
|
+
allow: 0,
|
|
46
|
+
ask: 1,
|
|
47
|
+
deny: 2,
|
|
48
|
+
};
|
package/src/rule.ts
CHANGED
|
@@ -139,19 +139,6 @@ function ruleMatches(
|
|
|
139
139
|
);
|
|
140
140
|
}
|
|
141
141
|
|
|
142
|
-
/**
|
|
143
|
-
* Evaluate a surface against an ordered list of candidate values, stopping at
|
|
144
|
-
* the first candidate that matches a non-default rule (last-match-wins within
|
|
145
|
-
* each candidate, first-non-default-wins across candidates).
|
|
146
|
-
*
|
|
147
|
-
* Used by MCP (multi-candidate target list) and, uniformly, by all other
|
|
148
|
-
* surfaces (single-element candidate list).
|
|
149
|
-
*
|
|
150
|
-
* Returns the matched rule and the candidate value that produced it.
|
|
151
|
-
* When every candidate matches only the synthesized default, falls back to
|
|
152
|
-
* evaluating the first candidate so the caller always receives a concrete
|
|
153
|
-
* result.
|
|
154
|
-
*/
|
|
155
142
|
/**
|
|
156
143
|
* Evaluate a surface against multiple values, returning the most restrictive
|
|
157
144
|
* non-allow result (deny > ask > allow).
|
|
@@ -180,6 +167,19 @@ export function evaluateMostRestrictive(
|
|
|
180
167
|
return worst;
|
|
181
168
|
}
|
|
182
169
|
|
|
170
|
+
/**
|
|
171
|
+
* Evaluate a surface against an ordered list of candidate values, stopping at
|
|
172
|
+
* the first candidate that matches a non-default rule (last-match-wins within
|
|
173
|
+
* each candidate, first-non-default-wins across candidates).
|
|
174
|
+
*
|
|
175
|
+
* Used by MCP (multi-candidate target list) and, uniformly, by all other
|
|
176
|
+
* surfaces (single-element candidate list).
|
|
177
|
+
*
|
|
178
|
+
* Returns the matched rule and the candidate value that produced it.
|
|
179
|
+
* When every candidate matches only the synthesized default, falls back to
|
|
180
|
+
* evaluating the first candidate so the caller always receives a concrete
|
|
181
|
+
* result.
|
|
182
|
+
*/
|
|
183
183
|
export function evaluateFirst(
|
|
184
184
|
surface: string,
|
|
185
185
|
values: string[],
|
package/src/scope-merge.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { expandDirectionalSugar } from "#src/normalize";
|
|
1
2
|
import { mergeFlatPermissions } from "#src/permission-merge";
|
|
2
3
|
import type { RuleOrigin } from "#src/rule";
|
|
3
4
|
import type { FlatPermissionConfig, ScopeConfig } from "#src/types";
|
|
@@ -33,7 +34,12 @@ export function mergeScopesWithOrigins(
|
|
|
33
34
|
for (const [scopeName, scope] of scopes) {
|
|
34
35
|
if (!scope.permission) continue;
|
|
35
36
|
|
|
36
|
-
|
|
37
|
+
// Sugar expands per scope, before both the origin bookkeeping and the
|
|
38
|
+
// merge (ADR 0013 §9): origins are keyed by surface name, so expanding
|
|
39
|
+
// after composition would attribute every expanded rule to `builtin`.
|
|
40
|
+
const permission = expandDirectionalSugar(scope.permission);
|
|
41
|
+
|
|
42
|
+
for (const [surface, value] of Object.entries(permission)) {
|
|
37
43
|
const baseVal = mergedPermission[surface];
|
|
38
44
|
/* eslint-disable @typescript-eslint/no-unnecessary-condition -- defensive null/type checks; config values may differ at runtime */
|
|
39
45
|
const bothObjects =
|
|
@@ -65,7 +71,7 @@ export function mergeScopesWithOrigins(
|
|
|
65
71
|
}
|
|
66
72
|
}
|
|
67
73
|
|
|
68
|
-
mergedPermission = mergeFlatPermissions(mergedPermission,
|
|
74
|
+
mergedPermission = mergeFlatPermissions(mergedPermission, permission);
|
|
69
75
|
}
|
|
70
76
|
|
|
71
77
|
return { mergedPermission, origins };
|
package/src/session-rules.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { surfaceFamilyMembers } from "#src/access-intent/path-surfaces";
|
|
1
2
|
import type { Ruleset } from "./rule";
|
|
2
3
|
import type { SessionApproval } from "./session-approval";
|
|
3
4
|
import type { SessionApprovalRecorder } from "./session-approval-recorder";
|
|
@@ -13,15 +14,25 @@ import type { SessionApprovalRecorder } from "./session-approval-recorder";
|
|
|
13
14
|
export class SessionRules implements SessionApprovalRecorder {
|
|
14
15
|
private rules: Ruleset = [];
|
|
15
16
|
|
|
16
|
-
/**
|
|
17
|
+
/**
|
|
18
|
+
* Record a wildcard pattern as approved for the given surface.
|
|
19
|
+
*
|
|
20
|
+
* A session approval is a policy source under ADR 0013 §9, so it expands the
|
|
21
|
+
* same way a config key does: an approval on a bare family surface becomes
|
|
22
|
+
* one rule per directional member, and one on a directional surface stays a
|
|
23
|
+
* single rule. Without the expansion an approval would sit on a surface no
|
|
24
|
+
* query names, and the next ask for the same path would prompt again.
|
|
25
|
+
*/
|
|
17
26
|
approve(surface: string, pattern: string): void {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
27
|
+
for (const target of surfaceFamilyMembers(surface) ?? [surface]) {
|
|
28
|
+
this.rules.push({
|
|
29
|
+
surface: target,
|
|
30
|
+
pattern,
|
|
31
|
+
action: "allow",
|
|
32
|
+
layer: "session",
|
|
33
|
+
origin: "session",
|
|
34
|
+
});
|
|
35
|
+
}
|
|
25
36
|
}
|
|
26
37
|
|
|
27
38
|
/** Return a defensive copy of the current session ruleset. */
|
package/src/types.ts
CHANGED
|
@@ -41,6 +41,20 @@ export type BashCommandContext =
|
|
|
41
41
|
| "process_substitution"
|
|
42
42
|
| "subshell";
|
|
43
43
|
|
|
44
|
+
/**
|
|
45
|
+
* Why an indirection wrapper's floor did not apply after all (#803).
|
|
46
|
+
*
|
|
47
|
+
* `"core-reader"` — the command the wrapper runs is in the built-in pure-reader
|
|
48
|
+
* core, so it is read-only for any argument feed and the floor's reason (an
|
|
49
|
+
* unknown direction behind the wrapper) does not hold.
|
|
50
|
+
*
|
|
51
|
+
* A named reason rather than a boolean, so the review log states *why* a
|
|
52
|
+
* wrapper was let through, and so a later source (a chain verdict, a user
|
|
53
|
+
* declaration) is an added member rather than a second flag. ADR 0013 §11
|
|
54
|
+
* keeps v1 at the audited core alone.
|
|
55
|
+
*/
|
|
56
|
+
export type FloorExemption = "core-reader";
|
|
57
|
+
|
|
44
58
|
export interface PermissionCheckResult {
|
|
45
59
|
toolName: string;
|
|
46
60
|
state: PermissionState;
|
|
@@ -61,9 +75,16 @@ export interface PermissionCheckResult {
|
|
|
61
75
|
/**
|
|
62
76
|
* The command the winning bash unit actually runs, when it is a wrapper whose
|
|
63
77
|
* inner command differs from the unit text (#713). Display-only: the gate
|
|
64
|
-
*
|
|
78
|
+
* decides on `command`, and on `executedUnit`'s rules only when
|
|
79
|
+
* {@link floorExemption} says the inner command is a proven pure reader.
|
|
65
80
|
*/
|
|
66
81
|
executedUnit?: string;
|
|
82
|
+
/**
|
|
83
|
+
* Set when the winning bash unit is a wrapper the floor no longer covers,
|
|
84
|
+
* naming why (#803). Recorded in the review log so an allow the floor would
|
|
85
|
+
* once have prompted for is auditable to the reason that let it through.
|
|
86
|
+
*/
|
|
87
|
+
floorExemption?: FloorExemption;
|
|
67
88
|
}
|
|
68
89
|
|
|
69
90
|
export function isPermissionState(value: unknown): value is PermissionState {
|
|
@@ -1,32 +0,0 @@
|
|
|
1
|
-
import type { PermissionCheckResult, PermissionState } from "#src/types";
|
|
2
|
-
|
|
3
|
-
/** Restrictiveness ordering: deny is the most restrictive, allow the least. */
|
|
4
|
-
const RESTRICTIVENESS: Record<PermissionState, number> = {
|
|
5
|
-
allow: 0,
|
|
6
|
-
ask: 1,
|
|
7
|
-
deny: 2,
|
|
8
|
-
};
|
|
9
|
-
|
|
10
|
-
/**
|
|
11
|
-
* Select the most restrictive permission result from a list (deny > ask > allow).
|
|
12
|
-
*
|
|
13
|
-
* The first occurrence wins on ties, so a caller passing results in candidate
|
|
14
|
-
* order receives the earliest worst case. Returns `undefined` for an empty list.
|
|
15
|
-
*
|
|
16
|
-
* Shared by the bash gates (path, external-directory) to combine the per-candidate
|
|
17
|
-
* `checkPermission` results their tree-sitter token extraction produces.
|
|
18
|
-
*/
|
|
19
|
-
export function pickMostRestrictive(
|
|
20
|
-
results: readonly PermissionCheckResult[],
|
|
21
|
-
): PermissionCheckResult | undefined {
|
|
22
|
-
let worst: PermissionCheckResult | undefined;
|
|
23
|
-
for (const result of results) {
|
|
24
|
-
if (
|
|
25
|
-
worst === undefined ||
|
|
26
|
-
RESTRICTIVENESS[result.state] > RESTRICTIVENESS[worst.state]
|
|
27
|
-
) {
|
|
28
|
-
worst = result;
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
return worst;
|
|
32
|
-
}
|