@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.
Files changed (38) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/README.md +26 -6
  3. package/config/config.example.json +3 -1
  4. package/dist/public.d.ts +21 -1
  5. package/docs/configuration.md +184 -5
  6. package/docs/troubleshooting.md +4 -1
  7. package/package.json +3 -1
  8. package/schemas/permissions.schema.json +52 -4
  9. package/src/access-intent/bash/bash-path-resolver.ts +104 -34
  10. package/src/access-intent/bash/command-effects.ts +305 -0
  11. package/src/access-intent/bash/command-enumeration.ts +116 -40
  12. package/src/access-intent/bash/program.ts +15 -11
  13. package/src/access-intent/bash/redirect-analysis.ts +98 -0
  14. package/src/access-intent/bash/token-collection.ts +102 -22
  15. package/src/access-intent/bash/wrapper-analysis.ts +101 -11
  16. package/src/access-intent/effect.ts +56 -0
  17. package/src/access-intent/input-normalizer.ts +2 -2
  18. package/src/access-intent/path-surfaces.ts +110 -4
  19. package/src/authority/delegation-envelope.ts +18 -7
  20. package/src/config-schema.ts +95 -6
  21. package/src/handlers/gates/bash-command.ts +53 -17
  22. package/src/handlers/gates/bash-external-directory.ts +41 -8
  23. package/src/handlers/gates/bash-path-extractor.ts +3 -3
  24. package/src/handlers/gates/bash-path.ts +31 -13
  25. package/src/handlers/gates/external-directory-policy.ts +37 -13
  26. package/src/handlers/gates/external-directory.ts +11 -4
  27. package/src/handlers/gates/path.ts +12 -5
  28. package/src/handlers/gates/tool.ts +22 -0
  29. package/src/normalize.ts +70 -1
  30. package/src/permission-manager.ts +3 -2
  31. package/src/permission-resolver.ts +23 -4
  32. package/src/presentation/path-ask-payload.ts +17 -6
  33. package/src/restrictiveness.ts +48 -0
  34. package/src/rule.ts +13 -13
  35. package/src/scope-merge.ts +8 -2
  36. package/src/session-rules.ts +19 -8
  37. package/src/types.ts +22 -1
  38. package/src/handlers/gates/candidate-check.ts +0 -32
@@ -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` rule. */
30
+ /** A tool ask gated by an explicit `path`-family rule. */
25
31
  export function buildPathAskPayload(facts: PathAskFacts): PromptPayload {
26
- return pathPayload("path", "path", facts, []);
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", "external_directory", facts, [
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: "external_directory",
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[],
@@ -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
- for (const [surface, value] of Object.entries(scope.permission)) {
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, scope.permission);
74
+ mergedPermission = mergeFlatPermissions(mergedPermission, permission);
69
75
  }
70
76
 
71
77
  return { mergedPermission, origins };
@@ -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
- /** Record a wildcard pattern as approved for the given surface. */
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
- this.rules.push({
19
- surface,
20
- pattern,
21
- action: "allow",
22
- layer: "session",
23
- origin: "session",
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
- * still decides on `command`, so this never widens or narrows a decision.
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
- }