@gotgenes/pi-permission-system 20.2.0 → 20.4.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 +28 -0
- package/docs/cross-extension-api.md +4 -0
- package/docs/subagent-integration.md +4 -2
- package/package.json +1 -1
- package/src/access-intent/access-path.ts +7 -5
- package/src/access-intent/bash/bash-path-resolver.ts +1 -2
- package/src/access-intent/bash/msys-bash-tokens.ts +2 -2
- package/src/access-intent/bash/parser.ts +39 -0
- package/src/access-intent/bash/sync-commands.ts +31 -0
- package/src/access-intent/bash/token-classification.ts +18 -32
- package/src/access-intent/path-normalization.ts +24 -43
- package/src/access-intent/tool-kind.ts +57 -0
- package/src/authority/approval-escalator.ts +18 -8
- package/src/authority/authorizer-selection.ts +1 -1
- package/src/authority/authorizer.ts +2 -2
- package/src/authority/denying-authorizer.ts +1 -1
- package/src/authority/forwarded-request-server.ts +52 -4
- package/src/authority/forwarder-context.ts +1 -1
- package/src/authority/forwarding-io.ts +30 -3
- package/src/{forwarding-manager.ts → authority/forwarding-manager.ts} +2 -2
- package/src/authority/local-user-authorizer.ts +27 -2
- package/src/{permission-dialog.ts → authority/permission-dialog.ts} +26 -0
- package/src/{permission-forwarding.ts → authority/permission-forwarding.ts} +22 -2
- package/src/authority/permission-prompter.ts +9 -1
- package/src/authority/subagent-context.ts +11 -14
- package/src/authority/subagent-detection.ts +5 -4
- package/src/bash-advisory-check.ts +38 -0
- package/src/denial-messages.ts +6 -9
- package/src/handlers/before-agent-start.ts +8 -0
- package/src/handlers/gates/helpers.ts +12 -4
- package/src/handlers/gates/runner.ts +4 -1
- package/src/handlers/gates/tool-call-gate-pipeline.ts +3 -2
- package/src/handlers/gates/tool.ts +9 -4
- package/src/index.ts +27 -12
- package/src/input-normalizer.ts +53 -48
- package/src/{canonicalize-path.ts → path/canonicalize-path.ts} +4 -3
- package/src/path/path-containment.ts +24 -0
- package/src/path/path-flavor.ts +114 -0
- package/src/{pi-infrastructure-read.ts → path/pi-infrastructure-read.ts} +12 -17
- package/src/path-normalizer.ts +35 -59
- package/src/pattern-suggest.ts +29 -0
- package/src/permission-gate.ts +1 -1
- package/src/permission-manager.ts +36 -56
- package/src/permission-prompts.ts +4 -3
- package/src/permission-session.ts +6 -5
- package/src/permissions-service.ts +8 -0
- package/src/rule.ts +19 -21
- package/src/session-approval.ts +11 -0
- package/src/tool-input-path.ts +17 -19
- package/src/tool-preview-formatter.ts +2 -5
- package/src/path-containment.ts +0 -56
- /package/src/{subagent-lifecycle-events.ts → authority/subagent-lifecycle-events.ts} +0 -0
- /package/src/{subagent-registry.ts → authority/subagent-registry.ts} +0 -0
|
@@ -3,15 +3,17 @@ import {
|
|
|
3
3
|
type ForwarderContext,
|
|
4
4
|
getSessionId,
|
|
5
5
|
} from "#src/authority/forwarder-context";
|
|
6
|
-
import type { PermissionPromptDecision } from "#src/permission-dialog";
|
|
6
|
+
import type { PermissionPromptDecision } from "#src/authority/permission-dialog";
|
|
7
7
|
import {
|
|
8
8
|
type ForwardedPermissionRequest,
|
|
9
9
|
type ForwardedPermissionResponse,
|
|
10
10
|
isForwardedPermissionRequestForSession,
|
|
11
11
|
type PermissionForwardingLocation,
|
|
12
|
-
} from "#src/permission-forwarding";
|
|
12
|
+
} from "#src/authority/permission-forwarding";
|
|
13
|
+
import type { SubagentSessionRegistry } from "#src/authority/subagent-registry";
|
|
14
|
+
import { SessionApproval } from "#src/session-approval";
|
|
15
|
+
import type { SessionApprovalRecorder } from "#src/session-approval-recorder";
|
|
13
16
|
import type { DebugReviewLogger } from "#src/session-logger";
|
|
14
|
-
import type { SubagentSessionRegistry } from "#src/subagent-registry";
|
|
15
17
|
import type { PermissionCheckResult } from "#src/types";
|
|
16
18
|
import type { AskEscalator } from "./authorizer-selection";
|
|
17
19
|
import {
|
|
@@ -61,6 +63,11 @@ export interface ForwardedRequestServerDeps {
|
|
|
61
63
|
policy: ServingPolicy;
|
|
62
64
|
/** Escalation seam to the serving session's selected `Authorizer` on `ask`. */
|
|
63
65
|
escalator: AskEscalator;
|
|
66
|
+
/**
|
|
67
|
+
* The serving session's `SessionRules`. Records a whole-session grant when a
|
|
68
|
+
* human approves a forwarded request for the entire serving session.
|
|
69
|
+
*/
|
|
70
|
+
recorder: SessionApprovalRecorder;
|
|
64
71
|
/** In-process subagent registry, read only by the one-hop canary. */
|
|
65
72
|
registry?: SubagentSessionRegistry;
|
|
66
73
|
}
|
|
@@ -115,6 +122,11 @@ function buildForwardedAskDetails(
|
|
|
115
122
|
requesterAgentName: request.requesterAgentName || null,
|
|
116
123
|
requesterSessionId: request.requesterSessionId || null,
|
|
117
124
|
},
|
|
125
|
+
// Carries the child's suggestion so LocalUserAuthorizer can offer the
|
|
126
|
+
// whole-session grant scope; absent for a legacy/version-skew request.
|
|
127
|
+
...(request.sessionApproval
|
|
128
|
+
? { sessionApproval: request.sessionApproval }
|
|
129
|
+
: {}),
|
|
118
130
|
};
|
|
119
131
|
}
|
|
120
132
|
|
|
@@ -132,6 +144,7 @@ export class ForwardedRequestServer implements InboxProcessor {
|
|
|
132
144
|
private readonly logger: DebugReviewLogger;
|
|
133
145
|
private readonly policy: ServingPolicy;
|
|
134
146
|
private readonly escalator: AskEscalator;
|
|
147
|
+
private readonly recorder: SessionApprovalRecorder;
|
|
135
148
|
private readonly registry: SubagentSessionRegistry | undefined;
|
|
136
149
|
|
|
137
150
|
constructor(deps: ForwardedRequestServerDeps) {
|
|
@@ -139,6 +152,7 @@ export class ForwardedRequestServer implements InboxProcessor {
|
|
|
139
152
|
this.logger = deps.logger;
|
|
140
153
|
this.policy = deps.policy;
|
|
141
154
|
this.escalator = deps.escalator;
|
|
155
|
+
this.recorder = deps.recorder;
|
|
142
156
|
this.registry = deps.registry;
|
|
143
157
|
}
|
|
144
158
|
|
|
@@ -237,10 +251,44 @@ export class ForwardedRequestServer implements InboxProcessor {
|
|
|
237
251
|
location,
|
|
238
252
|
requestPath,
|
|
239
253
|
currentSessionId,
|
|
240
|
-
decision,
|
|
254
|
+
this.applyGrantScope(request, decision, forwardedPermissionLogDetails),
|
|
241
255
|
);
|
|
242
256
|
}
|
|
243
257
|
|
|
258
|
+
/**
|
|
259
|
+
* Apply the human's grant-scope choice on a forwarded approval.
|
|
260
|
+
*
|
|
261
|
+
* A whole-session grant (`approved_for_serving_session`) records the child's
|
|
262
|
+
* suggested pattern into this serving node's `SessionRules` — the single
|
|
263
|
+
* source of truth for the scope — and is then translated to a plain
|
|
264
|
+
* `approved` so the child records nothing (its next identical action
|
|
265
|
+
* re-forwards and resolves as recorded authority). Every other decision
|
|
266
|
+
* passes through unchanged (`approved_for_session` → the child records).
|
|
267
|
+
*/
|
|
268
|
+
private applyGrantScope(
|
|
269
|
+
request: ForwardedPermissionRequest,
|
|
270
|
+
decision: PermissionPromptDecision,
|
|
271
|
+
logDetails: Record<string, unknown>,
|
|
272
|
+
): PermissionPromptDecision {
|
|
273
|
+
if (decision.state !== "approved_for_serving_session") {
|
|
274
|
+
return decision;
|
|
275
|
+
}
|
|
276
|
+
if (request.sessionApproval) {
|
|
277
|
+
this.recorder.recordSessionApproval(
|
|
278
|
+
SessionApproval.multiple(
|
|
279
|
+
request.sessionApproval.surface,
|
|
280
|
+
request.sessionApproval.patterns,
|
|
281
|
+
),
|
|
282
|
+
);
|
|
283
|
+
this.logger.review("forwarded_permission.session_recorded", {
|
|
284
|
+
...logDetails,
|
|
285
|
+
surface: request.sessionApproval.surface,
|
|
286
|
+
patterns: request.sessionApproval.patterns,
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
return { approved: true, state: "approved" };
|
|
290
|
+
}
|
|
291
|
+
|
|
244
292
|
/**
|
|
245
293
|
* Persist the served decision: write the response file the child polls for,
|
|
246
294
|
* log the outcome, and delete the drained request. The symmetric "respond"
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { SessionEntryView } from "#src/active-agent";
|
|
2
|
-
import type { PermissionDecisionUi } from "#src/permission-dialog";
|
|
2
|
+
import type { PermissionDecisionUi } from "#src/authority/permission-dialog";
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Narrow context the forwarding subsystem reads: the UI gate (`hasUI`), the
|
|
@@ -9,14 +9,15 @@ import {
|
|
|
9
9
|
writeFileSync,
|
|
10
10
|
} from "node:fs";
|
|
11
11
|
|
|
12
|
-
import { isPermissionDecisionState } from "#src/permission-dialog";
|
|
13
|
-
import type { PermissionUiPromptSource } from "#src/permission-events";
|
|
12
|
+
import { isPermissionDecisionState } from "#src/authority/permission-dialog";
|
|
14
13
|
import {
|
|
15
14
|
createPermissionForwardingLocation,
|
|
16
15
|
type ForwardedPermissionRequest,
|
|
17
16
|
type ForwardedPermissionResponse,
|
|
17
|
+
type ForwardedSessionApproval,
|
|
18
18
|
type PermissionForwardingLocation,
|
|
19
|
-
} from "#src/permission-forwarding";
|
|
19
|
+
} from "#src/authority/permission-forwarding";
|
|
20
|
+
import type { PermissionUiPromptSource } from "#src/permission-events";
|
|
20
21
|
import type { DebugReviewLogger } from "#src/session-logger";
|
|
21
22
|
|
|
22
23
|
/** Valid `permissions:ui_prompt` source values, for tolerant request reads. */
|
|
@@ -41,6 +42,31 @@ function asNullableDisplayString(value: unknown): string | null | undefined {
|
|
|
41
42
|
return undefined;
|
|
42
43
|
}
|
|
43
44
|
|
|
45
|
+
/**
|
|
46
|
+
* Narrow an unknown value to a `ForwardedSessionApproval`, or `undefined`.
|
|
47
|
+
*
|
|
48
|
+
* Tolerant read: the child's session-approval suggestion is optional (absent
|
|
49
|
+
* on an older child) and only accepted when well-formed — a non-empty surface
|
|
50
|
+
* and an all-string patterns array.
|
|
51
|
+
*/
|
|
52
|
+
function asForwardedSessionApproval(
|
|
53
|
+
value: unknown,
|
|
54
|
+
): ForwardedSessionApproval | undefined {
|
|
55
|
+
if (typeof value !== "object" || value === null) {
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
const candidate = value as Partial<ForwardedSessionApproval>;
|
|
59
|
+
if (
|
|
60
|
+
typeof candidate.surface !== "string" ||
|
|
61
|
+
candidate.surface.length === 0 ||
|
|
62
|
+
!Array.isArray(candidate.patterns) ||
|
|
63
|
+
!candidate.patterns.every((pattern) => typeof pattern === "string")
|
|
64
|
+
) {
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
return { surface: candidate.surface, patterns: [...candidate.patterns] };
|
|
68
|
+
}
|
|
69
|
+
|
|
44
70
|
export function formatUnknownErrorMessage(error: unknown): string {
|
|
45
71
|
if (error instanceof Error && error.message) {
|
|
46
72
|
return error.message;
|
|
@@ -323,6 +349,7 @@ export function readForwardedPermissionRequest(
|
|
|
323
349
|
source: asUiPromptSource(parsed.source),
|
|
324
350
|
surface: asNullableDisplayString(parsed.surface),
|
|
325
351
|
value: asNullableDisplayString(parsed.value),
|
|
352
|
+
sessionApproval: asForwardedSessionApproval(parsed.sessionApproval),
|
|
326
353
|
};
|
|
327
354
|
} catch (error) {
|
|
328
355
|
logPermissionForwardingWarning(
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import type { InboxProcessor } from "./
|
|
3
|
-
import type { SubagentDetector } from "./authority/subagent-detection";
|
|
2
|
+
import type { InboxProcessor } from "./forwarded-request-server";
|
|
4
3
|
import { PERMISSION_FORWARDING_POLL_INTERVAL_MS } from "./permission-forwarding";
|
|
4
|
+
import type { SubagentDetector } from "./subagent-detection";
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* Narrow interface for the forwarding lifecycle used by `PermissionSession`.
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import type {
|
|
2
2
|
PermissionDecisionUi,
|
|
3
3
|
PermissionPromptDecision,
|
|
4
|
+
RequestPermissionOptions,
|
|
4
5
|
requestPermissionDecisionFromUi,
|
|
5
|
-
} from "#src/permission-dialog";
|
|
6
|
+
} from "#src/authority/permission-dialog";
|
|
7
|
+
import { buildForwardedScopeLabels } from "#src/pattern-suggest";
|
|
6
8
|
import {
|
|
7
9
|
emitUiPromptEvent,
|
|
8
10
|
type PermissionEventBus,
|
|
@@ -45,7 +47,30 @@ export class LocalUserAuthorizer implements Authorizer {
|
|
|
45
47
|
? "Permission Required (Subagent)"
|
|
46
48
|
: "Permission Required",
|
|
47
49
|
details.message,
|
|
48
|
-
details
|
|
50
|
+
buildRequestOptions(details),
|
|
49
51
|
);
|
|
50
52
|
}
|
|
51
53
|
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* A forwarded ask carrying a session-approval suggestion offers the scope
|
|
57
|
+
* choice (subagent vs whole session); any other ask keeps its single
|
|
58
|
+
* "for this session" option (custom label when the gate supplied one).
|
|
59
|
+
*/
|
|
60
|
+
function buildRequestOptions(
|
|
61
|
+
details: PromptPermissionDetails,
|
|
62
|
+
): RequestPermissionOptions | undefined {
|
|
63
|
+
const pattern = details.sessionApproval?.patterns[0];
|
|
64
|
+
if (details.forwarding && details.sessionApproval && pattern) {
|
|
65
|
+
return {
|
|
66
|
+
sessionScope: buildForwardedScopeLabels(
|
|
67
|
+
details.forwarding.requesterAgentName,
|
|
68
|
+
details.sessionApproval.surface,
|
|
69
|
+
pattern,
|
|
70
|
+
),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
return details.sessionLabel
|
|
74
|
+
? { sessionLabel: details.sessionLabel }
|
|
75
|
+
: undefined;
|
|
76
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export type PermissionDecisionState =
|
|
2
2
|
| "approved"
|
|
3
3
|
| "approved_for_session"
|
|
4
|
+
| "approved_for_serving_session"
|
|
4
5
|
| "denied"
|
|
5
6
|
| "denied_with_reason";
|
|
6
7
|
|
|
@@ -67,6 +68,7 @@ export function isPermissionDecisionState(
|
|
|
67
68
|
return (
|
|
68
69
|
value === "approved" ||
|
|
69
70
|
value === "approved_for_session" ||
|
|
71
|
+
value === "approved_for_serving_session" ||
|
|
70
72
|
value === "denied" ||
|
|
71
73
|
value === "denied_with_reason"
|
|
72
74
|
);
|
|
@@ -75,6 +77,15 @@ export function isPermissionDecisionState(
|
|
|
75
77
|
export interface RequestPermissionOptions {
|
|
76
78
|
/** Override the "for this session" option label (e.g. to show the suggested pattern). */
|
|
77
79
|
sessionLabel?: string;
|
|
80
|
+
/**
|
|
81
|
+
* Forwarded asks only: when set, choosing the "for this session" option opens
|
|
82
|
+
* a second select asking whether the grant applies to the requesting subagent
|
|
83
|
+
* only (the least-privilege default) or the whole serving session.
|
|
84
|
+
*/
|
|
85
|
+
sessionScope?: {
|
|
86
|
+
subagentLabel: string;
|
|
87
|
+
servingSessionLabel: string;
|
|
88
|
+
};
|
|
78
89
|
}
|
|
79
90
|
|
|
80
91
|
export async function requestPermissionDecisionFromUi(
|
|
@@ -103,6 +114,21 @@ export async function requestPermissionDecisionFromUi(
|
|
|
103
114
|
}
|
|
104
115
|
|
|
105
116
|
if (selected === sessionOption) {
|
|
117
|
+
if (options?.sessionScope) {
|
|
118
|
+
const scope = await ui.select(`${title}\nApply this session grant to:`, [
|
|
119
|
+
options.sessionScope.subagentLabel,
|
|
120
|
+
options.sessionScope.servingSessionLabel,
|
|
121
|
+
]);
|
|
122
|
+
return {
|
|
123
|
+
approved: true,
|
|
124
|
+
// A cancelled scope select (undefined) falls back to the
|
|
125
|
+
// least-privilege subagent scope.
|
|
126
|
+
state:
|
|
127
|
+
scope === options.sessionScope.servingSessionLabel
|
|
128
|
+
? "approved_for_serving_session"
|
|
129
|
+
: "approved_for_session",
|
|
130
|
+
};
|
|
131
|
+
}
|
|
106
132
|
return {
|
|
107
133
|
approved: true,
|
|
108
134
|
state: "approved_for_session",
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { join } from "node:path";
|
|
2
|
-
|
|
2
|
+
import type { PermissionUiPromptSource } from "#src/permission-events";
|
|
3
3
|
import type { PermissionDecisionState } from "./permission-dialog";
|
|
4
|
-
import type { PermissionUiPromptSource } from "./permission-events";
|
|
5
4
|
import type { SubagentSessionRegistry } from "./subagent-registry";
|
|
6
5
|
|
|
7
6
|
export const PERMISSION_FORWARDING_POLL_INTERVAL_MS = 250;
|
|
@@ -53,6 +52,20 @@ export interface ForwardedPromptDisplay {
|
|
|
53
52
|
value: string | null;
|
|
54
53
|
}
|
|
55
54
|
|
|
55
|
+
/**
|
|
56
|
+
* The child's session-approval suggestion, relayed to the serving node so a
|
|
57
|
+
* human who grants "the whole session" records the same pattern the child
|
|
58
|
+
* would have recorded locally.
|
|
59
|
+
*
|
|
60
|
+
* A plain data shape (not the `SessionApproval` value object) so it serializes
|
|
61
|
+
* onto the forwarded request; the serving node rebuilds a `SessionApproval`
|
|
62
|
+
* from it via `SessionApproval.multiple`.
|
|
63
|
+
*/
|
|
64
|
+
export interface ForwardedSessionApproval {
|
|
65
|
+
surface: string;
|
|
66
|
+
patterns: readonly string[];
|
|
67
|
+
}
|
|
68
|
+
|
|
56
69
|
export type ForwardedPermissionRequest = {
|
|
57
70
|
id: string;
|
|
58
71
|
createdAt: number;
|
|
@@ -69,6 +82,13 @@ export type ForwardedPermissionRequest = {
|
|
|
69
82
|
source?: PermissionUiPromptSource;
|
|
70
83
|
surface?: string | null;
|
|
71
84
|
value?: string | null;
|
|
85
|
+
/**
|
|
86
|
+
* The child's session-approval suggestion. Present when the child computed a
|
|
87
|
+
* "for this session" pattern for the ask; lets the serving node record a
|
|
88
|
+
* whole-session grant. Optional for version-skew tolerance (an older child
|
|
89
|
+
* omits it, and the serving dialog then offers no scope choice).
|
|
90
|
+
*/
|
|
91
|
+
sessionApproval?: ForwardedSessionApproval;
|
|
72
92
|
};
|
|
73
93
|
|
|
74
94
|
export type ForwardedPermissionResponse = {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import type { PermissionPromptDecision } from "#src/permission-dialog";
|
|
1
|
+
import type { PermissionPromptDecision } from "#src/authority/permission-dialog";
|
|
2
|
+
import type { ForwardedSessionApproval } from "#src/authority/permission-forwarding";
|
|
2
3
|
import type { ReviewLogger } from "#src/session-logger";
|
|
3
4
|
import type { Authorizer } from "./authorizer";
|
|
4
5
|
|
|
@@ -38,6 +39,13 @@ export interface PromptPermissionDetails {
|
|
|
38
39
|
value?: string | null;
|
|
39
40
|
/** Present iff this ask was forwarded from a subagent; drives the non-degraded broadcast + "(Subagent)" title. */
|
|
40
41
|
forwarding?: ForwardedAskProvenance;
|
|
42
|
+
/**
|
|
43
|
+
* The session-approval suggestion for this ask. On the child's escalation it
|
|
44
|
+
* rides into the forwarded request; on the serving node it lets the dialog
|
|
45
|
+
* offer a whole-session grant scope. Absent when the gate computed no
|
|
46
|
+
* suggestion.
|
|
47
|
+
*/
|
|
48
|
+
sessionApproval?: ForwardedSessionApproval;
|
|
41
49
|
}
|
|
42
50
|
|
|
43
51
|
/**
|
|
@@ -1,7 +1,6 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
import {
|
|
4
|
-
import type { SubagentSessionRegistry } from "#src/subagent-registry";
|
|
1
|
+
import { SUBAGENT_ENV_HINT_KEYS } from "#src/authority/permission-forwarding";
|
|
2
|
+
import type { SubagentSessionRegistry } from "#src/authority/subagent-registry";
|
|
3
|
+
import type { PathFlavor } from "#src/path/path-flavor";
|
|
5
4
|
|
|
6
5
|
/**
|
|
7
6
|
* Narrow context for subagent detection — the only session-manager readers
|
|
@@ -17,17 +16,15 @@ export interface SubagentDetectionContext {
|
|
|
17
16
|
|
|
18
17
|
export function normalizeFilesystemPath(
|
|
19
18
|
pathValue: string,
|
|
20
|
-
|
|
19
|
+
flavor: PathFlavor,
|
|
21
20
|
): string {
|
|
22
|
-
|
|
23
|
-
const normalizedPath = impl.normalize(pathValue);
|
|
24
|
-
return platform === "win32" ? normalizedPath.toLowerCase() : normalizedPath;
|
|
21
|
+
return flavor.fold(flavor.impl.normalize(pathValue));
|
|
25
22
|
}
|
|
26
23
|
|
|
27
24
|
function isPathWithinDirectoryForSubagent(
|
|
28
25
|
pathValue: string,
|
|
29
26
|
directory: string,
|
|
30
|
-
|
|
27
|
+
flavor: PathFlavor,
|
|
31
28
|
): boolean {
|
|
32
29
|
if (!pathValue || !directory) {
|
|
33
30
|
return false;
|
|
@@ -37,7 +34,7 @@ function isPathWithinDirectoryForSubagent(
|
|
|
37
34
|
return true;
|
|
38
35
|
}
|
|
39
36
|
|
|
40
|
-
const sep =
|
|
37
|
+
const sep = flavor.impl.sep;
|
|
41
38
|
const prefix = directory.endsWith(sep) ? directory : `${directory}${sep}`;
|
|
42
39
|
return pathValue.startsWith(prefix);
|
|
43
40
|
}
|
|
@@ -71,7 +68,7 @@ export function isRegisteredSubagentChild(
|
|
|
71
68
|
export function isSubagentExecutionContext(
|
|
72
69
|
ctx: SubagentDetectionContext,
|
|
73
70
|
subagentSessionsDir: string,
|
|
74
|
-
|
|
71
|
+
flavor: PathFlavor,
|
|
75
72
|
registry?: SubagentSessionRegistry,
|
|
76
73
|
): boolean {
|
|
77
74
|
// 1. Explicit registry — in-process subagent extensions register by child
|
|
@@ -99,14 +96,14 @@ export function isSubagentExecutionContext(
|
|
|
99
96
|
return false;
|
|
100
97
|
}
|
|
101
98
|
|
|
102
|
-
const normalizedSessionDir = normalizeFilesystemPath(sessionDir,
|
|
99
|
+
const normalizedSessionDir = normalizeFilesystemPath(sessionDir, flavor);
|
|
103
100
|
const normalizedSubagentRoot = normalizeFilesystemPath(
|
|
104
101
|
subagentSessionsDir,
|
|
105
|
-
|
|
102
|
+
flavor,
|
|
106
103
|
);
|
|
107
104
|
return isPathWithinDirectoryForSubagent(
|
|
108
105
|
normalizedSessionDir,
|
|
109
106
|
normalizedSubagentRoot,
|
|
110
|
-
|
|
107
|
+
flavor,
|
|
111
108
|
);
|
|
112
109
|
}
|
|
@@ -3,7 +3,8 @@ import {
|
|
|
3
3
|
isSubagentExecutionContext,
|
|
4
4
|
type SubagentDetectionContext,
|
|
5
5
|
} from "#src/authority/subagent-context";
|
|
6
|
-
import type { SubagentSessionRegistry } from "#src/subagent-registry";
|
|
6
|
+
import type { SubagentSessionRegistry } from "#src/authority/subagent-registry";
|
|
7
|
+
import type { PathFlavor } from "#src/path/path-flavor";
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* Narrow seam for the ask-path consumers: "is the current session a subagent?"
|
|
@@ -30,7 +31,7 @@ export interface RegisteredChildDetector {
|
|
|
30
31
|
/** Composition-root inputs for {@link SubagentDetection}. */
|
|
31
32
|
export interface SubagentDetectionDeps {
|
|
32
33
|
subagentSessionsDir: string;
|
|
33
|
-
|
|
34
|
+
flavor: PathFlavor;
|
|
34
35
|
registry?: SubagentSessionRegistry;
|
|
35
36
|
}
|
|
36
37
|
|
|
@@ -38,7 +39,7 @@ export interface SubagentDetectionDeps {
|
|
|
38
39
|
* Single owner of subagent detection.
|
|
39
40
|
*
|
|
40
41
|
* Constructed once in the composition root with the detection inputs
|
|
41
|
-
* (`subagentSessionsDir`, `
|
|
42
|
+
* (`subagentSessionsDir`, `flavor`, `registry`) and shared across every
|
|
42
43
|
* consumer, replacing the dep triple those consumers previously threaded
|
|
43
44
|
* individually. Delegates to the pure detection functions in
|
|
44
45
|
* {@link ./subagent-context}, holding only the deps.
|
|
@@ -52,7 +53,7 @@ export class SubagentDetection
|
|
|
52
53
|
return isSubagentExecutionContext(
|
|
53
54
|
ctx,
|
|
54
55
|
this.deps.subagentSessionsDir,
|
|
55
|
-
this.deps.
|
|
56
|
+
this.deps.flavor,
|
|
56
57
|
this.deps.registry,
|
|
57
58
|
);
|
|
58
59
|
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { parseBashCommandsSync } from "#src/access-intent/bash/sync-commands";
|
|
2
|
+
import { resolveBashCommandCheck } from "#src/handlers/gates/bash-command";
|
|
3
|
+
import type { ScopedPermissionResolver } from "#src/permission-resolver";
|
|
4
|
+
import type { PermissionCheckResult } from "#src/types";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Resolve an advisory bash query at the gate's decomposed fidelity.
|
|
8
|
+
*
|
|
9
|
+
* When the tree-sitter parser is warm, the command is decomposed into its
|
|
10
|
+
* command-pattern units and routed through the same shared orchestrator the
|
|
11
|
+
* enforcement gate uses (`resolveBashCommandCheck`) — so a chained/nested
|
|
12
|
+
* command returns the most-restrictive decision (`deny > ask > allow`) and
|
|
13
|
+
* inherits the opaque-wrapper floor (#481) and the fail-closed
|
|
14
|
+
* `<unparseable-bash-command>` sentinel (#452), at parity with the gate.
|
|
15
|
+
*
|
|
16
|
+
* In the pre-warm window (`parseBashCommandsSync` returns `null`) it falls back
|
|
17
|
+
* to the pre-#309 whole-string match, so the advisory answer is never *weaker*
|
|
18
|
+
* than before — only strengthened once warm.
|
|
19
|
+
*
|
|
20
|
+
* Synchronous, preserving `PermissionsService.checkPermission`'s sync contract:
|
|
21
|
+
* the only async step (parser init) happens earlier, at `before_agent_start`.
|
|
22
|
+
*/
|
|
23
|
+
export function resolveBashAdvisoryCheck(
|
|
24
|
+
command: string,
|
|
25
|
+
agentName: string | undefined,
|
|
26
|
+
resolver: ScopedPermissionResolver,
|
|
27
|
+
): PermissionCheckResult {
|
|
28
|
+
const commands = parseBashCommandsSync(command);
|
|
29
|
+
if (commands === null) {
|
|
30
|
+
return resolver.resolve({
|
|
31
|
+
kind: "tool",
|
|
32
|
+
surface: "bash",
|
|
33
|
+
input: { command },
|
|
34
|
+
agentName,
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
return resolveBashCommandCheck(command, commands, agentName, resolver);
|
|
38
|
+
}
|
package/src/denial-messages.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { classifyToolKind, isMcpCheck } from "./access-intent/tool-kind";
|
|
1
2
|
import { EXTENSION_ID } from "./extension-config";
|
|
2
3
|
import type { BashCommandContext, PermissionCheckResult } from "./types";
|
|
3
4
|
|
|
@@ -127,7 +128,7 @@ function buildToolDenyBody(
|
|
|
127
128
|
parts.push(`Agent '${agentName}'`);
|
|
128
129
|
}
|
|
129
130
|
|
|
130
|
-
if (isMcpCheck(check)) {
|
|
131
|
+
if (isMcpCheck(check) && check.target) {
|
|
131
132
|
parts.push(`is not permitted to run MCP target '${check.target}'`);
|
|
132
133
|
} else {
|
|
133
134
|
parts.push(`is not permitted to run '${check.toolName}'`);
|
|
@@ -190,10 +191,10 @@ function buildUnavailableBody(ctx: DenialContext): string {
|
|
|
190
191
|
switch (ctx.kind) {
|
|
191
192
|
case "tool": {
|
|
192
193
|
const { check } = ctx;
|
|
193
|
-
if (check.toolName === "bash" && check.command) {
|
|
194
|
+
if (classifyToolKind(check.toolName) === "bash" && check.command) {
|
|
194
195
|
return `Running bash command '${check.command}' requires approval, but no interactive UI is available.`;
|
|
195
196
|
}
|
|
196
|
-
if (isMcpCheck(check)) {
|
|
197
|
+
if (isMcpCheck(check) && check.target) {
|
|
197
198
|
return "Using tool 'mcp' requires approval, but no interactive UI is available.";
|
|
198
199
|
}
|
|
199
200
|
return `Using tool '${check.toolName}' requires approval, but no interactive UI is available.`;
|
|
@@ -220,10 +221,10 @@ function buildUserDeniedBody(
|
|
|
220
221
|
switch (ctx.kind) {
|
|
221
222
|
case "tool": {
|
|
222
223
|
const { check } = ctx;
|
|
223
|
-
if (isMcpCheck(check)) {
|
|
224
|
+
if (isMcpCheck(check) && check.target) {
|
|
224
225
|
return `User denied MCP target '${check.target}'.${reasonSuffix(denialReason)}`;
|
|
225
226
|
}
|
|
226
|
-
if (check.toolName === "bash" && check.command) {
|
|
227
|
+
if (classifyToolKind(check.toolName) === "bash" && check.command) {
|
|
227
228
|
return `User denied bash command '${check.command}'.${reasonSuffix(denialReason)}`;
|
|
228
229
|
}
|
|
229
230
|
return `User denied tool '${check.toolName}'.${reasonSuffix(denialReason)}`;
|
|
@@ -243,10 +244,6 @@ function buildUserDeniedBody(
|
|
|
243
244
|
}
|
|
244
245
|
}
|
|
245
246
|
|
|
246
|
-
function isMcpCheck(check: PermissionCheckResult): boolean {
|
|
247
|
-
return (check.source === "mcp" || check.toolName === "mcp") && !!check.target;
|
|
248
|
-
}
|
|
249
|
-
|
|
250
247
|
/** Render an external-path disclosure list for the bash deny body's path clause. */
|
|
251
248
|
function formatExternalPathList(paths: ExternalPathDisclosure[]): string {
|
|
252
249
|
return paths
|
|
@@ -40,12 +40,16 @@ export function shouldExposeTool(
|
|
|
40
40
|
* - `session` — encapsulates all mutable session state and lifecycle operations
|
|
41
41
|
* - `resolver` — owns permission-query surface: `getToolPermission`, skill check
|
|
42
42
|
* - `toolRegistry` — Pi tool API subset (getActive + setActive)
|
|
43
|
+
* - `warmParser` — warms the tree-sitter parser so the synchronous advisory
|
|
44
|
+
* bash path can decompose at gate parity; `before_agent_start` precedes any
|
|
45
|
+
* tool call, so triggering it here closes the pre-warm window (#309)
|
|
43
46
|
*/
|
|
44
47
|
export class AgentPrepHandler {
|
|
45
48
|
constructor(
|
|
46
49
|
private readonly session: PermissionSession,
|
|
47
50
|
private readonly resolver: PermissionResolver,
|
|
48
51
|
private readonly toolRegistry: ToolRegistry,
|
|
52
|
+
private readonly warmParser: () => void,
|
|
49
53
|
) {}
|
|
50
54
|
|
|
51
55
|
// eslint-disable-next-line @typescript-eslint/require-await
|
|
@@ -53,6 +57,10 @@ export class AgentPrepHandler {
|
|
|
53
57
|
event: BeforeAgentStartPayload,
|
|
54
58
|
ctx: ExtensionContext,
|
|
55
59
|
): Promise<BeforeAgentStartEventResult> {
|
|
60
|
+
// Fire-and-forget: warming is idempotent and best-effort, so it never
|
|
61
|
+
// delays agent start. A bash advisory query before it completes falls back
|
|
62
|
+
// to whole-string matching.
|
|
63
|
+
this.warmParser();
|
|
56
64
|
this.session.activate(ctx);
|
|
57
65
|
this.session.refreshConfig(ctx);
|
|
58
66
|
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { classifyToolKind } from "#src/access-intent/tool-kind";
|
|
1
2
|
import type {
|
|
2
3
|
PermissionDecisionEvent,
|
|
3
4
|
PermissionDecisionResolution,
|
|
@@ -14,10 +15,17 @@ export function deriveDecisionValue(
|
|
|
14
15
|
check: Pick<PermissionCheckResult, "command" | "target">,
|
|
15
16
|
path?: string,
|
|
16
17
|
): string {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
18
|
+
switch (classifyToolKind(toolName)) {
|
|
19
|
+
case "bash":
|
|
20
|
+
return check.command ?? toolName;
|
|
21
|
+
case "mcp":
|
|
22
|
+
return check.target ?? toolName;
|
|
23
|
+
case "path":
|
|
24
|
+
case "skill":
|
|
25
|
+
case "extension":
|
|
26
|
+
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- || intentional: an empty path falls through to toolName (the original `if (path)` truthiness)
|
|
27
|
+
return path || toolName;
|
|
28
|
+
}
|
|
21
29
|
}
|
|
22
30
|
|
|
23
31
|
/**
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import type { AskEscalator } from "#src/authority/authorizer-selection";
|
|
2
|
+
import type { PermissionPromptDecision } from "#src/authority/permission-dialog";
|
|
2
3
|
import type { DecisionReporter } from "#src/decision-reporter";
|
|
3
4
|
import {
|
|
4
5
|
formatDenyReason,
|
|
5
6
|
formatUnavailableReason,
|
|
6
7
|
formatUserDeniedReason,
|
|
7
8
|
} from "#src/denial-messages";
|
|
8
|
-
import type { PermissionPromptDecision } from "#src/permission-dialog";
|
|
9
9
|
import { applyPermissionGate } from "#src/permission-gate";
|
|
10
10
|
import type { ScopedPermissionResolver } from "#src/permission-resolver";
|
|
11
11
|
import type { SessionApprovalRecorder } from "#src/session-approval-recorder";
|
|
@@ -147,6 +147,9 @@ export class GateRunner {
|
|
|
147
147
|
const decision = await this.prompter.escalate({
|
|
148
148
|
requestId: toolCallId,
|
|
149
149
|
...descriptor.promptDetails,
|
|
150
|
+
...(descriptor.sessionApproval
|
|
151
|
+
? { sessionApproval: descriptor.sessionApproval.toForwardedData() }
|
|
152
|
+
: {}),
|
|
150
153
|
});
|
|
151
154
|
autoApproved = decision.autoApproved === true;
|
|
152
155
|
confirmationUnavailable = decision.confirmationUnavailable === true;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { AccessPath } from "#src/access-intent/access-path";
|
|
2
2
|
import { BashProgram } from "#src/access-intent/bash/program";
|
|
3
|
+
import { classifyToolKind } from "#src/access-intent/tool-kind";
|
|
3
4
|
import type { PathNormalizer } from "#src/path-normalizer";
|
|
4
5
|
import type { ScopedPermissionResolver } from "#src/permission-resolver";
|
|
5
6
|
import type { SkillPromptEntry } from "#src/skill-prompt-sanitizer";
|
|
@@ -77,7 +78,7 @@ export class ToolCallGatePipeline {
|
|
|
77
78
|
const command = getNonEmptyString(toRecord(tcc.input).command);
|
|
78
79
|
const normalizer = this.inputs.getPathNormalizer();
|
|
79
80
|
const bashProgram =
|
|
80
|
-
tcc.toolName === "bash" && command
|
|
81
|
+
classifyToolKind(tcc.toolName) === "bash" && command
|
|
81
82
|
? await BashProgram.parse(
|
|
82
83
|
command,
|
|
83
84
|
normalizer,
|
|
@@ -159,7 +160,7 @@ export class ToolCallGatePipeline {
|
|
|
159
160
|
command: string | null,
|
|
160
161
|
normalizer: PathNormalizer,
|
|
161
162
|
): { toolCheck: PermissionCheckResult; accessPath?: AccessPath } {
|
|
162
|
-
if (tcc.toolName === "bash" && bashProgram) {
|
|
163
|
+
if (classifyToolKind(tcc.toolName) === "bash" && bashProgram) {
|
|
163
164
|
return {
|
|
164
165
|
toolCheck: resolveBashCommandCheck(
|
|
165
166
|
command ?? "",
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { AccessPath } from "#src/access-intent/access-path";
|
|
2
|
+
import { classifyToolKind } from "#src/access-intent/tool-kind";
|
|
2
3
|
import { PATH_BEARING_TOOLS } from "#src/path-surfaces";
|
|
3
4
|
import { suggestSessionPattern } from "#src/pattern-suggest";
|
|
4
5
|
import { formatAskPrompt } from "#src/permission-prompts";
|
|
@@ -23,10 +24,14 @@ function deriveSuggestionValue(
|
|
|
23
24
|
check: PermissionCheckResult,
|
|
24
25
|
accessPath?: AccessPath,
|
|
25
26
|
): string {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
27
|
+
switch (classifyToolKind(tcc.toolName)) {
|
|
28
|
+
case "bash":
|
|
29
|
+
return check.command ?? "";
|
|
30
|
+
case "mcp":
|
|
31
|
+
return check.target ?? "mcp";
|
|
32
|
+
default:
|
|
33
|
+
return accessPath ? accessPath.value() : "*";
|
|
34
|
+
}
|
|
30
35
|
}
|
|
31
36
|
|
|
32
37
|
/**
|