@gotgenes/pi-permission-system 20.7.3 → 20.9.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 +35 -0
- package/README.md +3 -0
- package/config/config.example.json +2 -0
- package/dist/public.d.ts +212 -75
- package/docs/configuration.md +45 -9
- package/package.json +1 -1
- package/schemas/permissions.schema.json +10 -0
- package/src/access-intent/input-normalizer.ts +34 -1
- package/src/authority/approval-escalator.ts +53 -23
- package/src/authority/authorizer-chain.ts +60 -0
- package/src/authority/authorizer-registry.ts +69 -0
- package/src/authority/authorizer-selection.ts +54 -8
- package/src/authority/authorizer.ts +31 -4
- package/src/authority/delegation-envelope.ts +52 -0
- package/src/authority/denying-authorizer.ts +2 -2
- package/src/authority/forwarded-request-server.ts +22 -32
- package/src/authority/forwarder-context.ts +7 -0
- package/src/authority/forwarding-io.ts +61 -0
- package/src/authority/local-user-authorizer.ts +2 -2
- package/src/authority/permission-forwarding.ts +46 -0
- package/src/authority/permission-prompter.ts +16 -5
- package/src/config-loader.ts +5 -4
- package/src/config-schema.ts +7 -0
- package/src/extension-config.ts +5 -0
- package/src/handlers/gates/bash-external-directory.ts +6 -0
- package/src/handlers/gates/bash-path.ts +2 -0
- package/src/handlers/gates/external-directory.ts +2 -0
- package/src/handlers/gates/helpers.ts +33 -0
- package/src/handlers/gates/path.ts +2 -0
- package/src/handlers/gates/skill-input.ts +2 -0
- package/src/handlers/gates/skill-read.ts +2 -0
- package/src/handlers/gates/tool.ts +19 -6
- package/src/index.ts +26 -13
- package/src/permission-resolver.ts +17 -2
- package/src/permissions-service.ts +10 -0
- package/src/service.ts +52 -14
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
import { isPermissionDecisionState } from "#src/authority/permission-dialog";
|
|
13
13
|
import {
|
|
14
14
|
createPermissionForwardingLocation,
|
|
15
|
+
type ForwardedAccessIntent,
|
|
15
16
|
type ForwardedPermissionRequest,
|
|
16
17
|
type ForwardedPermissionResponse,
|
|
17
18
|
type ForwardedSessionApproval,
|
|
@@ -67,6 +68,65 @@ function asForwardedSessionApproval(
|
|
|
67
68
|
return { surface: candidate.surface, patterns: [...candidate.patterns] };
|
|
68
69
|
}
|
|
69
70
|
|
|
71
|
+
/**
|
|
72
|
+
* Narrow an unknown value to a `ForwardedAccessIntent`, or `undefined`.
|
|
73
|
+
*
|
|
74
|
+
* Tolerant read: the child-fixed access intent is optional (absent on an older
|
|
75
|
+
* child) and only accepted when fully well-formed — a string `surface`, an
|
|
76
|
+
* all-string `matchValues` array, a `string | null` `boundaryValue`, a string
|
|
77
|
+
* `requesterCwd`, and a `principal` with string `sessionId`/`agentName`. Any
|
|
78
|
+
* malformed shape → `undefined`, so the serving node floors to `ask` (Step 3)
|
|
79
|
+
* rather than resolving against corrupt facts.
|
|
80
|
+
*/
|
|
81
|
+
function asForwardedAccessIntent(
|
|
82
|
+
value: unknown,
|
|
83
|
+
): ForwardedAccessIntent | undefined {
|
|
84
|
+
if (typeof value !== "object" || value === null) {
|
|
85
|
+
return undefined;
|
|
86
|
+
}
|
|
87
|
+
const candidate = value as {
|
|
88
|
+
surface?: unknown;
|
|
89
|
+
matchValues?: unknown;
|
|
90
|
+
boundaryValue?: unknown;
|
|
91
|
+
requesterCwd?: unknown;
|
|
92
|
+
principal?: unknown;
|
|
93
|
+
};
|
|
94
|
+
if (
|
|
95
|
+
typeof candidate.surface !== "string" ||
|
|
96
|
+
!Array.isArray(candidate.matchValues) ||
|
|
97
|
+
!candidate.matchValues.every((entry) => typeof entry === "string") ||
|
|
98
|
+
!(
|
|
99
|
+
candidate.boundaryValue === null ||
|
|
100
|
+
typeof candidate.boundaryValue === "string"
|
|
101
|
+
) ||
|
|
102
|
+
typeof candidate.requesterCwd !== "string" ||
|
|
103
|
+
typeof candidate.principal !== "object" ||
|
|
104
|
+
candidate.principal === null
|
|
105
|
+
) {
|
|
106
|
+
return undefined;
|
|
107
|
+
}
|
|
108
|
+
const principal = candidate.principal as {
|
|
109
|
+
sessionId?: unknown;
|
|
110
|
+
agentName?: unknown;
|
|
111
|
+
};
|
|
112
|
+
if (
|
|
113
|
+
typeof principal.sessionId !== "string" ||
|
|
114
|
+
typeof principal.agentName !== "string"
|
|
115
|
+
) {
|
|
116
|
+
return undefined;
|
|
117
|
+
}
|
|
118
|
+
return {
|
|
119
|
+
surface: candidate.surface,
|
|
120
|
+
matchValues: [...candidate.matchValues],
|
|
121
|
+
boundaryValue: candidate.boundaryValue,
|
|
122
|
+
requesterCwd: candidate.requesterCwd,
|
|
123
|
+
principal: {
|
|
124
|
+
sessionId: principal.sessionId,
|
|
125
|
+
agentName: principal.agentName,
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
70
130
|
export function formatUnknownErrorMessage(error: unknown): string {
|
|
71
131
|
if (error instanceof Error && error.message) {
|
|
72
132
|
return error.message;
|
|
@@ -350,6 +410,7 @@ export function readForwardedPermissionRequest(
|
|
|
350
410
|
surface: asNullableDisplayString(parsed.surface),
|
|
351
411
|
value: asNullableDisplayString(parsed.value),
|
|
352
412
|
sessionApproval: asForwardedSessionApproval(parsed.sessionApproval),
|
|
413
|
+
accessIntent: asForwardedAccessIntent(parsed.accessIntent),
|
|
353
414
|
};
|
|
354
415
|
} catch (error) {
|
|
355
416
|
logPermissionForwardingWarning(
|
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
type PermissionEventBus,
|
|
15
15
|
} from "#src/permission-events";
|
|
16
16
|
import { buildUiPrompt } from "#src/permission-ui-prompt";
|
|
17
|
-
import type {
|
|
17
|
+
import type { TerminalAuthorizer } from "./authorizer";
|
|
18
18
|
import type { PromptPermissionDetails } from "./permission-prompter";
|
|
19
19
|
|
|
20
20
|
/** Dependencies required by {@link LocalUserAuthorizer}. */
|
|
@@ -41,7 +41,7 @@ export interface LocalUserAuthorizerDeps {
|
|
|
41
41
|
* class renders (populated `forwarding` context + "(Subagent)" title) so the
|
|
42
42
|
* broadcast stays non-degraded (#292) without a second emission path.
|
|
43
43
|
*/
|
|
44
|
-
export class LocalUserAuthorizer implements
|
|
44
|
+
export class LocalUserAuthorizer implements TerminalAuthorizer {
|
|
45
45
|
constructor(private readonly deps: LocalUserAuthorizerDeps) {}
|
|
46
46
|
|
|
47
47
|
authorize(
|
|
@@ -66,6 +66,46 @@ export interface ForwardedSessionApproval {
|
|
|
66
66
|
patterns: readonly string[];
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
+
/**
|
|
70
|
+
* The child-fixed facts a gate emits: the surface it evaluated and the match
|
|
71
|
+
* set it computed. `requesterCwd` and `principal` are stamped at the escalation
|
|
72
|
+
* edge (`ParentAuthorizer`), so a gate carries only what it alone can produce.
|
|
73
|
+
*
|
|
74
|
+
* Strings only — an `AccessPath` never crosses onto the wire
|
|
75
|
+
* (`docs/decisions/0002-path-values-string-boundary.md`).
|
|
76
|
+
*/
|
|
77
|
+
export interface ForwardedAccessFacts {
|
|
78
|
+
/** Gate surface: `"path"`, `"external_directory"`, `"bash"`, a tool name, or a skill name. */
|
|
79
|
+
surface: string;
|
|
80
|
+
/**
|
|
81
|
+
* The child-fixed match set. Path surface: `AccessPath.matchValues()`
|
|
82
|
+
* (absolute ∪ cwd-relative ∪ canonical), computed at the child. Non-path
|
|
83
|
+
* surface: the already-portable single value as a one-element array.
|
|
84
|
+
*/
|
|
85
|
+
matchValues: string[];
|
|
86
|
+
/** `AccessPath.boundaryValue()` (canonical) for a path surface; `null` for a non-path surface. */
|
|
87
|
+
boundaryValue: string | null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The forwarded-wire access intent (ADR 0008 §2): the child-fixed access facts
|
|
92
|
+
* plus the requester identity the escalation edge stamps.
|
|
93
|
+
*
|
|
94
|
+
* The serving node resolves against this intent directly (Step 3, [#597]),
|
|
95
|
+
* using `matchValues` as-is — it never re-derives a path through its own
|
|
96
|
+
* `PathNormalizer`/cwd. See
|
|
97
|
+
* `docs/decisions/0008-cross-session-access-intent.md`.
|
|
98
|
+
*/
|
|
99
|
+
export interface ForwardedAccessIntent extends ForwardedAccessFacts {
|
|
100
|
+
/** The requester's cwd, for provenance/disclosure — never for parent re-derivation. */
|
|
101
|
+
requesterCwd: string;
|
|
102
|
+
/** Who is requesting. */
|
|
103
|
+
principal: {
|
|
104
|
+
sessionId: string;
|
|
105
|
+
agentName: string;
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
69
109
|
export type ForwardedPermissionRequest = {
|
|
70
110
|
id: string;
|
|
71
111
|
createdAt: number;
|
|
@@ -89,6 +129,12 @@ export type ForwardedPermissionRequest = {
|
|
|
89
129
|
* omits it, and the serving dialog then offers no scope choice).
|
|
90
130
|
*/
|
|
91
131
|
sessionApproval?: ForwardedSessionApproval;
|
|
132
|
+
/**
|
|
133
|
+
* The child-fixed access intent (ADR 0008 §2). Optional for version-skew
|
|
134
|
+
* tolerance: an older child omits it, and the serving node floors to `ask`
|
|
135
|
+
* (Step 3). Present on a current child's request for every gate surface.
|
|
136
|
+
*/
|
|
137
|
+
accessIntent?: ForwardedAccessIntent;
|
|
92
138
|
};
|
|
93
139
|
|
|
94
140
|
export type ForwardedPermissionResponse = {
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import type { PermissionPromptDecision } from "#src/authority/permission-dialog";
|
|
2
|
-
import type {
|
|
2
|
+
import type {
|
|
3
|
+
ForwardedAccessFacts,
|
|
4
|
+
ForwardedSessionApproval,
|
|
5
|
+
} from "#src/authority/permission-forwarding";
|
|
3
6
|
import type { ReviewLogger } from "#src/session-logger";
|
|
4
|
-
import type {
|
|
7
|
+
import type { TerminalAuthorizer } from "./authorizer";
|
|
5
8
|
|
|
6
9
|
export type PermissionReviewSource = "tool_call" | "skill_input" | "skill_read";
|
|
7
10
|
|
|
@@ -46,6 +49,14 @@ export interface PromptPermissionDetails {
|
|
|
46
49
|
* suggestion.
|
|
47
50
|
*/
|
|
48
51
|
sessionApproval?: ForwardedSessionApproval;
|
|
52
|
+
/**
|
|
53
|
+
* The child-fixed access facts the raising gate computed (surface + match
|
|
54
|
+
* set). Rides through the runner to the escalation edge, which completes
|
|
55
|
+
* them into a `ForwardedAccessIntent` by stamping `requesterCwd` and
|
|
56
|
+
* `principal`. Absent for a serving-node local prompt reconstructed from a
|
|
57
|
+
* forwarded request.
|
|
58
|
+
*/
|
|
59
|
+
accessIntent?: ForwardedAccessFacts;
|
|
49
60
|
}
|
|
50
61
|
|
|
51
62
|
/**
|
|
@@ -58,7 +69,7 @@ export interface PromptPermissionDetails {
|
|
|
58
69
|
*/
|
|
59
70
|
export interface PermissionPrompterApi {
|
|
60
71
|
prompt(
|
|
61
|
-
authorizer:
|
|
72
|
+
authorizer: TerminalAuthorizer,
|
|
62
73
|
details: PromptPermissionDetails,
|
|
63
74
|
): Promise<PermissionPromptDecision>;
|
|
64
75
|
}
|
|
@@ -71,7 +82,7 @@ export interface PermissionPrompterDeps {
|
|
|
71
82
|
|
|
72
83
|
/**
|
|
73
84
|
* Brackets the ask-path flow with review-log entries and delegates the
|
|
74
|
-
* live decision to the selected {@link
|
|
85
|
+
* live decision to the selected {@link TerminalAuthorizer}:
|
|
75
86
|
* 1. Review-log "waiting" entry.
|
|
76
87
|
* 2. `authorizer.authorize(details)`.
|
|
77
88
|
* 3. Review-log "approved" / "denied" entry.
|
|
@@ -89,7 +100,7 @@ export class PermissionPrompter implements PermissionPrompterApi {
|
|
|
89
100
|
constructor(private readonly deps: PermissionPrompterDeps) {}
|
|
90
101
|
|
|
91
102
|
async prompt(
|
|
92
|
-
authorizer:
|
|
103
|
+
authorizer: TerminalAuthorizer,
|
|
93
104
|
details: PromptPermissionDetails,
|
|
94
105
|
): Promise<PermissionPromptDecision> {
|
|
95
106
|
this.writeReviewEntry("permission_request.waiting", details);
|
package/src/config-loader.ts
CHANGED
|
@@ -231,10 +231,11 @@ export function mergeUnifiedConfigs(
|
|
|
231
231
|
}
|
|
232
232
|
|
|
233
233
|
// Array fields: override replaces base when defined
|
|
234
|
-
const piInfrastructureReadPaths
|
|
235
|
-
override
|
|
236
|
-
|
|
237
|
-
|
|
234
|
+
for (const key of ["piInfrastructureReadPaths", "authorizerChain"] as const) {
|
|
235
|
+
const value = override[key] ?? base[key];
|
|
236
|
+
if (value !== undefined) {
|
|
237
|
+
merged[key] = value;
|
|
238
|
+
}
|
|
238
239
|
}
|
|
239
240
|
|
|
240
241
|
// shellTools: shallow-merge by tool name so a project entry overrides a
|
package/src/config-schema.ts
CHANGED
|
@@ -206,6 +206,13 @@ export const unifiedConfigSchema = z
|
|
|
206
206
|
"Additional directories to auto-allow for reads as Pi infrastructure, bypassing the `external_directory` gate.\n\nThe extension auto-discovers the global node_modules root (walks up from the extension's install path; falls back to `npm root -g` from a dev checkout), Pi's own install directory (via the coding-agent `getPackageDir()` API), `agentDir`, `agentDir/git`, and project-local `.pi/npm/` and `.pi/git/`. Add entries here for edge cases where auto-discovery is insufficient (e.g. custom `npmCommand` pointing to pnpm).\n\nSupports `~`/`$HOME` expansion. Entries may be plain directory prefixes or wildcard patterns using `*` (matches any characters, including `/`) and `?` (matches exactly one character). `**` and `*` are equivalent — both cross directory boundaries.\n\nOn Windows, matching is case-insensitive and tolerant of either path separator.",
|
|
207
207
|
default: [],
|
|
208
208
|
}),
|
|
209
|
+
authorizerChain: z.array(z.string().min(1)).optional().meta({
|
|
210
|
+
description:
|
|
211
|
+
"Ordered names of registered live-authority chain links to consult before the terminal authorizer. Config order (not registration order) fixes the chain order; an unregistered name is skipped fail-safe (more prompting, never less); a link decides nothing until it is named here.",
|
|
212
|
+
markdownDescription:
|
|
213
|
+
"Ordered names of registered **live-authority chain links** (e.g. a model judge) to consult before the terminal authorizer (the human, or the subagent-forwarding / headless-deny fallback).\n\nA link reviews an `ask` and returns `allow` / `deny` (with an optional teaching reason) / `defer` to the next link. Three invariants govern the chain:\n\n- **Config order wins.** The order here \u2014 not the order extensions register in \u2014 fixes the security-relevant chain order.\n- **Fail-safe skip.** A name with no registered link is skipped with a warning; the `ask` still reaches the terminal (more prompting, never less).\n- **Opt-in activation.** Installing a judge extension grants it no authority; a link decides nothing until you name it here.\n\nThe chain owner caps every verdict with a bounded-delegation checkpoint: a link's `allow` on an excluded surface (`external_directory` or `path`) is downgraded to `defer`, so a link cannot exceed your policy.\n\nDefaults to an empty list (no links).",
|
|
214
|
+
default: [],
|
|
215
|
+
}),
|
|
209
216
|
permission: permissionSchema.optional(),
|
|
210
217
|
shellTools: shellToolsSchema.optional(),
|
|
211
218
|
})
|
package/src/extension-config.ts
CHANGED
|
@@ -23,6 +23,8 @@ export interface PermissionSystemExtensionConfig {
|
|
|
23
23
|
toolTextSummaryMaxLength?: number;
|
|
24
24
|
/** Non-bash tools that carry shell semantics, keyed by tool name. */
|
|
25
25
|
shellTools?: ShellToolsConfig;
|
|
26
|
+
/** Ordered names of registered live-authority chain links to consult before the terminal authorizer. */
|
|
27
|
+
authorizerChain?: string[];
|
|
26
28
|
}
|
|
27
29
|
|
|
28
30
|
export const DEFAULT_EXTENSION_CONFIG: PermissionSystemExtensionConfig = {
|
|
@@ -75,6 +77,9 @@ export function normalizePermissionSystemConfig(
|
|
|
75
77
|
if (raw.shellTools !== undefined) {
|
|
76
78
|
result.shellTools = raw.shellTools;
|
|
77
79
|
}
|
|
80
|
+
if (raw.authorizerChain !== undefined) {
|
|
81
|
+
result.authorizerChain = raw.authorizerChain;
|
|
82
|
+
}
|
|
78
83
|
return result;
|
|
79
84
|
}
|
|
80
85
|
|
|
@@ -5,6 +5,7 @@ import { deriveApprovalPattern } from "#src/session-rules";
|
|
|
5
5
|
import type { GateResult } from "./descriptor";
|
|
6
6
|
import { formatBashExternalDirectoryAskPrompt } from "./external-directory-messages";
|
|
7
7
|
import { selectUncoveredExternalPaths } from "./external-directory-policy";
|
|
8
|
+
import { accessFactsFromPath } from "./helpers";
|
|
8
9
|
import type { ToolCallContext } from "./types";
|
|
9
10
|
|
|
10
11
|
/**
|
|
@@ -65,6 +66,10 @@ export function describeBashExternalDirectoryGate(
|
|
|
65
66
|
// defined; the fallback keeps TypeScript happy across the early return. A
|
|
66
67
|
// config-level "deny" is preserved (not downgraded to the catch-all "ask").
|
|
67
68
|
const preCheck = worstCheck ?? uncoveredEntries[0].check;
|
|
69
|
+
// The AccessPath the decision was made against — its facts ride the wire.
|
|
70
|
+
const worstEntry =
|
|
71
|
+
uncoveredEntries.find(({ check }) => check === preCheck) ??
|
|
72
|
+
uncoveredEntries[0];
|
|
68
73
|
|
|
69
74
|
const disclosures = uncoveredEntries.map(({ path }) => ({
|
|
70
75
|
path: path.value(),
|
|
@@ -98,6 +103,7 @@ export function describeBashExternalDirectoryGate(
|
|
|
98
103
|
toolCallId: tcc.toolCallId,
|
|
99
104
|
toolName: tcc.toolName,
|
|
100
105
|
command,
|
|
106
|
+
accessIntent: accessFactsFromPath("external_directory", worstEntry.path),
|
|
101
107
|
},
|
|
102
108
|
logContext: {
|
|
103
109
|
source: "tool_call",
|
|
@@ -6,6 +6,7 @@ import { deriveApprovalPattern } from "#src/session-rules";
|
|
|
6
6
|
import type { PermissionCheckResult } from "#src/types";
|
|
7
7
|
import { pickMostRestrictive } from "./candidate-check";
|
|
8
8
|
import type { GateResult } from "./descriptor";
|
|
9
|
+
import { accessFactsFromPath } from "./helpers";
|
|
9
10
|
import { formatPathAskPrompt } from "./path";
|
|
10
11
|
import type { ToolCallContext } from "./types";
|
|
11
12
|
|
|
@@ -135,6 +136,7 @@ export function describeBashPathGate(
|
|
|
135
136
|
toolCallId: tcc.toolCallId,
|
|
136
137
|
toolName: tcc.toolName,
|
|
137
138
|
command,
|
|
139
|
+
accessIntent: accessFactsFromPath("path", worstEntry.path),
|
|
138
140
|
},
|
|
139
141
|
logContext: {
|
|
140
142
|
source: "tool_call",
|
|
@@ -7,6 +7,7 @@ import type { ToolAccessExtractorLookup } from "#src/tool-access-extractor-regis
|
|
|
7
7
|
import type { GateResult } from "./descriptor";
|
|
8
8
|
import { formatExternalDirectoryAskPrompt } from "./external-directory-messages";
|
|
9
9
|
import { resolveExternalDirectoryPolicy } from "./external-directory-policy";
|
|
10
|
+
import { accessFactsFromPath } from "./helpers";
|
|
10
11
|
import type { ToolCallContext } from "./types";
|
|
11
12
|
|
|
12
13
|
/**
|
|
@@ -104,6 +105,7 @@ export function describeExternalDirectoryGate(
|
|
|
104
105
|
toolCallId: tcc.toolCallId,
|
|
105
106
|
toolName: tcc.toolName,
|
|
106
107
|
path: externalDirectoryPath,
|
|
108
|
+
accessIntent: accessFactsFromPath("external_directory", accessPath),
|
|
107
109
|
},
|
|
108
110
|
logContext: {
|
|
109
111
|
source: "tool_call",
|
|
@@ -1,10 +1,43 @@
|
|
|
1
|
+
import type { AccessPath } from "#src/access-intent/access-path";
|
|
1
2
|
import { classifyToolKind } from "#src/access-intent/tool-kind";
|
|
3
|
+
import type { ForwardedAccessFacts } from "#src/authority/permission-forwarding";
|
|
2
4
|
import type {
|
|
3
5
|
PermissionDecisionEvent,
|
|
4
6
|
PermissionDecisionResolution,
|
|
5
7
|
} from "#src/permission-events";
|
|
6
8
|
import type { PermissionCheckResult } from "#src/types";
|
|
7
9
|
|
|
10
|
+
/**
|
|
11
|
+
* Build the child-fixed access facts for a path-shaped gate from its
|
|
12
|
+
* `AccessPath`.
|
|
13
|
+
*
|
|
14
|
+
* Converts the `AccessPath` to strings at the point of emission (ADR-0002: an
|
|
15
|
+
* `AccessPath` never crosses onto the wire), carrying the lexical ∪ canonical
|
|
16
|
+
* match set. An empty `boundaryValue()` (a literal-only path) becomes `null`,
|
|
17
|
+
* so the wire distinguishes "no canonical form" cleanly.
|
|
18
|
+
*/
|
|
19
|
+
export function accessFactsFromPath(
|
|
20
|
+
surface: string,
|
|
21
|
+
path: AccessPath,
|
|
22
|
+
): ForwardedAccessFacts {
|
|
23
|
+
return {
|
|
24
|
+
surface,
|
|
25
|
+
matchValues: path.matchValues(),
|
|
26
|
+
boundaryValue: path.boundaryValue() || null,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Build the child-fixed access facts for a non-path gate (bash command, MCP
|
|
32
|
+
* target, skill name, plain tool) from its already-portable single value.
|
|
33
|
+
*/
|
|
34
|
+
export function accessFactsFromValue(
|
|
35
|
+
surface: string,
|
|
36
|
+
value: string,
|
|
37
|
+
): ForwardedAccessFacts {
|
|
38
|
+
return { surface, matchValues: [value], boundaryValue: null };
|
|
39
|
+
}
|
|
40
|
+
|
|
8
41
|
/**
|
|
9
42
|
* Derive the human-readable value for a decision event from a check result.
|
|
10
43
|
* Bash → extracted command; MCP → qualified target;
|
|
@@ -5,6 +5,7 @@ import { SessionApproval } from "#src/session-approval";
|
|
|
5
5
|
import { deriveApprovalPattern } from "#src/session-rules";
|
|
6
6
|
import type { ToolAccessExtractorLookup } from "#src/tool-access-extractor-registry";
|
|
7
7
|
import type { GateDescriptor, GateResult } from "./descriptor";
|
|
8
|
+
import { accessFactsFromPath } from "./helpers";
|
|
8
9
|
import type { ToolCallContext } from "./types";
|
|
9
10
|
|
|
10
11
|
/**
|
|
@@ -67,6 +68,7 @@ export function describePathGate(
|
|
|
67
68
|
toolCallId: tcc.toolCallId,
|
|
68
69
|
toolName: tcc.toolName,
|
|
69
70
|
path: filePath,
|
|
71
|
+
accessIntent: accessFactsFromPath("path", accessPath),
|
|
70
72
|
},
|
|
71
73
|
logContext: {
|
|
72
74
|
source: "tool_call",
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { formatSkillAskPrompt } from "#src/permission-prompts";
|
|
2
2
|
import type { PermissionCheckResult } from "#src/types";
|
|
3
3
|
import type { GateDescriptor } from "./descriptor";
|
|
4
|
+
import { accessFactsFromValue } from "./helpers";
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Build a pure descriptor for the skill-input permission gate.
|
|
@@ -29,6 +30,7 @@ export function describeSkillInputGate(
|
|
|
29
30
|
agentName,
|
|
30
31
|
message,
|
|
31
32
|
skillName,
|
|
33
|
+
accessIntent: accessFactsFromValue("skill", skillName),
|
|
32
34
|
},
|
|
33
35
|
logContext: {
|
|
34
36
|
source: "skill_input",
|
|
@@ -4,6 +4,7 @@ import type { SkillPromptEntry } from "#src/skill-prompt-sanitizer";
|
|
|
4
4
|
import { findSkillPathMatch } from "#src/skill-prompt-sanitizer";
|
|
5
5
|
import { toRecord } from "#src/value-guards";
|
|
6
6
|
import type { GateDescriptor } from "./descriptor";
|
|
7
|
+
import { accessFactsFromValue } from "./helpers";
|
|
7
8
|
import type { ToolCallContext } from "./types";
|
|
8
9
|
|
|
9
10
|
/**
|
|
@@ -64,6 +65,7 @@ export function describeSkillReadGate(
|
|
|
64
65
|
toolName: tcc.toolName,
|
|
65
66
|
skillName: matchedSkill.name,
|
|
66
67
|
path,
|
|
68
|
+
accessIntent: accessFactsFromValue("skill", matchedSkill.name),
|
|
67
69
|
},
|
|
68
70
|
logContext: {
|
|
69
71
|
source: "skill_read",
|
|
@@ -11,7 +11,11 @@ import { SessionApproval } from "#src/session-approval";
|
|
|
11
11
|
import type { ToolPreviewFormatter } from "#src/tool-preview-formatter";
|
|
12
12
|
import type { PermissionCheckResult } from "#src/types";
|
|
13
13
|
import type { GateDescriptor } from "./descriptor";
|
|
14
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
accessFactsFromPath,
|
|
16
|
+
accessFactsFromValue,
|
|
17
|
+
deriveDecisionValue,
|
|
18
|
+
} from "./helpers";
|
|
15
19
|
import type { ToolCallContext } from "./types";
|
|
16
20
|
|
|
17
21
|
/**
|
|
@@ -75,6 +79,18 @@ export function describeToolGate(
|
|
|
75
79
|
formatter,
|
|
76
80
|
);
|
|
77
81
|
|
|
82
|
+
const decisionValue = deriveDecisionValue(
|
|
83
|
+
gateSurface,
|
|
84
|
+
check,
|
|
85
|
+
getPathBearingToolPath(tcc.toolName, tcc.input) ?? undefined,
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
// A path-bearing tool carries the AccessPath's alias set; every other surface
|
|
89
|
+
// (bash command, MCP target, plain tool) carries its already-portable value.
|
|
90
|
+
const accessIntent = accessPath
|
|
91
|
+
? accessFactsFromPath(gateSurface, accessPath)
|
|
92
|
+
: accessFactsFromValue(gateSurface, decisionValue);
|
|
93
|
+
|
|
78
94
|
return {
|
|
79
95
|
surface: gateSurface,
|
|
80
96
|
input: tcc.input,
|
|
@@ -95,6 +111,7 @@ export function describeToolGate(
|
|
|
95
111
|
toolCallId: tcc.toolCallId,
|
|
96
112
|
toolName: tcc.toolName,
|
|
97
113
|
sessionLabel: suggestion.label,
|
|
114
|
+
accessIntent,
|
|
98
115
|
...permissionLogContext,
|
|
99
116
|
},
|
|
100
117
|
logContext: {
|
|
@@ -106,11 +123,7 @@ export function describeToolGate(
|
|
|
106
123
|
},
|
|
107
124
|
decision: {
|
|
108
125
|
surface: gateSurface,
|
|
109
|
-
value:
|
|
110
|
-
gateSurface,
|
|
111
|
-
check,
|
|
112
|
-
getPathBearingToolPath(tcc.toolName, tcc.input) ?? undefined,
|
|
113
|
-
),
|
|
126
|
+
value: decisionValue,
|
|
114
127
|
},
|
|
115
128
|
};
|
|
116
129
|
}
|
package/src/index.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { getAgentDir, getPackageDir } from "@earendil-works/pi-coding-agent";
|
|
3
3
|
import { warmBashParser } from "./access-intent/bash/parser";
|
|
4
|
-
import {
|
|
4
|
+
import { buildResolvedIntentFromMatchValues } from "./access-intent/input-normalizer";
|
|
5
|
+
import { AuthorizerRegistry } from "./authority/authorizer-registry";
|
|
5
6
|
import { AuthorizerSelection } from "./authority/authorizer-selection";
|
|
6
7
|
import {
|
|
7
8
|
ForwardedRequestServer,
|
|
@@ -64,6 +65,10 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
64
65
|
const formatterRegistry = new ToolInputFormatterRegistry();
|
|
65
66
|
registerBuiltinToolInputFormatters(formatterRegistry);
|
|
66
67
|
const accessExtractorRegistry = new ToolAccessExtractorRegistry();
|
|
68
|
+
// One registry instance backs both the registerAuthorizer service surface and
|
|
69
|
+
// AuthorizerSelection's chain resolution, so a registration is visible to
|
|
70
|
+
// composition.
|
|
71
|
+
const authorizerRegistry = new AuthorizerRegistry();
|
|
67
72
|
|
|
68
73
|
// Both `configStore` and `session` are forward-declared so the logger's
|
|
69
74
|
// lazy thunks can close over them without a cast or null-init holder.
|
|
@@ -108,6 +113,15 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
108
113
|
registry: subagentRegistry,
|
|
109
114
|
logger,
|
|
110
115
|
prompter,
|
|
116
|
+
// The published service is the narrow, session-scoped PermissionQuery a
|
|
117
|
+
// chain link is handed (it routes bash/path at gate parity against the live
|
|
118
|
+
// session cwd). A thunk because `permissionsService` is constructed below;
|
|
119
|
+
// it resolves at session_start (activate), well after assignment.
|
|
120
|
+
getPermissionQuery: () => permissionsService,
|
|
121
|
+
// Same registry instance the registerAuthorizer service surface writes to,
|
|
122
|
+
// resolved in config order at activation.
|
|
123
|
+
authorizerRegistry,
|
|
124
|
+
getAuthorizerChain: () => configStore.current().authorizerChain ?? [],
|
|
111
125
|
});
|
|
112
126
|
|
|
113
127
|
// Resolver composes the manager + session ruleset and owns the
|
|
@@ -116,20 +130,18 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
116
130
|
// service and gates below share this one instance.
|
|
117
131
|
const resolver = new PermissionResolver(permissionManager, sessionRules);
|
|
118
132
|
|
|
119
|
-
// Serving a forwarded request is resolution:
|
|
120
|
-
// against the serving node's
|
|
121
|
-
//
|
|
122
|
-
//
|
|
123
|
-
//
|
|
124
|
-
// deferred-binding precedent as the logger notify sink below.
|
|
133
|
+
// Serving a forwarded request is resolution: resolve the child-fixed
|
|
134
|
+
// ForwardedAccessIntent (ADR 0008) directly against the serving node's
|
|
135
|
+
// composed ruleset, agent-scoped to the requester (§3) — the match values
|
|
136
|
+
// are used as fixed by the child, never re-derived through this session's
|
|
137
|
+
// PathNormalizer/cwd (#597).
|
|
125
138
|
const servingPolicy: ServingPolicy = {
|
|
126
|
-
|
|
139
|
+
resolve: (intent) =>
|
|
127
140
|
resolver.resolve(
|
|
128
|
-
|
|
129
|
-
surface,
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
undefined,
|
|
141
|
+
buildResolvedIntentFromMatchValues(
|
|
142
|
+
intent.surface,
|
|
143
|
+
intent.matchValues,
|
|
144
|
+
intent.principal.agentName,
|
|
133
145
|
),
|
|
134
146
|
),
|
|
135
147
|
};
|
|
@@ -176,6 +188,7 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
176
188
|
session,
|
|
177
189
|
formatterRegistry,
|
|
178
190
|
accessExtractorRegistry,
|
|
191
|
+
authorizerRegistry,
|
|
179
192
|
);
|
|
180
193
|
|
|
181
194
|
// Subscribe to @gotgenes/pi-subagents' child lifecycle events so child
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type {
|
|
2
2
|
AccessIntent,
|
|
3
|
+
PathValuesAccessIntent,
|
|
3
4
|
ResolvedAccessIntent,
|
|
4
5
|
} from "./access-intent/access-intent";
|
|
5
6
|
import type { ScopedPermissionManager } from "./permission-manager";
|
|
@@ -31,8 +32,14 @@ export interface ScopedPermissionResolver {
|
|
|
31
32
|
* canonical alias set (#418) is derived. Keeping it here (not in the manager)
|
|
32
33
|
* is the deliberate boundary formalized in ADR-0002
|
|
33
34
|
* (`docs/decisions/0002-path-values-string-boundary.md`).
|
|
35
|
+
*
|
|
36
|
+
* Also accepts an already-resolved {@link PathValuesAccessIntent} (the
|
|
37
|
+
* forwarded-serving wire's producer, #597) as a pure passthrough — it is
|
|
38
|
+
* already a `ResolvedAccessIntent`, so there is nothing to unwrap.
|
|
34
39
|
*/
|
|
35
|
-
function toResolvedIntent(
|
|
40
|
+
function toResolvedIntent(
|
|
41
|
+
intent: AccessIntent | PathValuesAccessIntent,
|
|
42
|
+
): ResolvedAccessIntent {
|
|
36
43
|
if (intent.kind === "access-path") {
|
|
37
44
|
return {
|
|
38
45
|
kind: "path-values",
|
|
@@ -66,8 +73,16 @@ export class PermissionResolver
|
|
|
66
73
|
* Answer a gate-emitted access intent, composing the current session ruleset
|
|
67
74
|
* so callers never thread it by hand. Unwraps the `access-path` variant via
|
|
68
75
|
* `matchValues()` before handing a string-based intent to the manager.
|
|
76
|
+
*
|
|
77
|
+
* Also accepts a pre-fixed `path-values` intent (the forwarded-serving wire,
|
|
78
|
+
* #597) — a passthrough, since it is already a `ResolvedAccessIntent`. The
|
|
79
|
+
* gate-facing {@link ScopedPermissionResolver} interface stays narrow
|
|
80
|
+
* (`AccessIntent` only); this wider acceptance is available only through the
|
|
81
|
+
* concrete `PermissionResolver` instance the composition root holds.
|
|
69
82
|
*/
|
|
70
|
-
resolve(
|
|
83
|
+
resolve(
|
|
84
|
+
intent: AccessIntent | PathValuesAccessIntent,
|
|
85
|
+
): PermissionCheckResult {
|
|
71
86
|
return this.permissionManager.check(
|
|
72
87
|
toResolvedIntent(intent),
|
|
73
88
|
this.sessionRules.getRuleset(),
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { AccessIntent } from "./access-intent/access-intent";
|
|
2
2
|
import { buildAccessIntentForSurface } from "./access-intent/input-normalizer";
|
|
3
|
+
import type { Authorizer } from "./authority/authorizer";
|
|
4
|
+
import type { AuthorizerRegistrar } from "./authority/authorizer-registry";
|
|
3
5
|
import { resolveBashAdvisoryCheck } from "./bash-advisory-check";
|
|
4
6
|
import type { PathNormalizer } from "./path-normalizer";
|
|
5
7
|
import type { PermissionsService } from "./service";
|
|
@@ -44,6 +46,7 @@ export class LocalPermissionsService implements PermissionsService {
|
|
|
44
46
|
private readonly session: PathNormalizerProvider,
|
|
45
47
|
private readonly formatterRegistry: ToolInputFormatterRegistrar,
|
|
46
48
|
private readonly accessExtractorRegistry: ToolAccessExtractorRegistrar,
|
|
49
|
+
private readonly authorizerRegistry: AuthorizerRegistrar,
|
|
47
50
|
) {}
|
|
48
51
|
|
|
49
52
|
checkPermission(
|
|
@@ -87,4 +90,11 @@ export class LocalPermissionsService implements PermissionsService {
|
|
|
87
90
|
): ReturnType<PermissionsService["registerToolAccessExtractor"]> {
|
|
88
91
|
return this.accessExtractorRegistry.register(toolName, extractor);
|
|
89
92
|
}
|
|
93
|
+
|
|
94
|
+
registerAuthorizer(
|
|
95
|
+
name: string,
|
|
96
|
+
authorize: Authorizer["authorize"],
|
|
97
|
+
): ReturnType<PermissionsService["registerAuthorizer"]> {
|
|
98
|
+
return this.authorizerRegistry.register(name, authorize);
|
|
99
|
+
}
|
|
90
100
|
}
|