@gotgenes/pi-permission-system 25.3.0 → 26.0.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 +48 -0
- package/README.md +14 -12
- package/config/config.example.json +1 -2
- package/dist/public.d.ts +37 -10
- package/docs/configuration.md +23 -19
- package/docs/cross-extension-api.md +43 -12
- package/docs/migration/0745-prompt-payload-contracts.md +68 -0
- package/docs/migration/0746-review-log-fields.md +69 -0
- package/docs/troubleshooting.md +2 -1
- package/package.json +1 -1
- package/schemas/permissions.schema.json +14 -4
- package/src/access-intent/tool-kind.ts +1 -1
- package/src/authority/approval-escalator.ts +32 -5
- package/src/authority/authorizer.ts +3 -3
- package/src/authority/forwarded-request-server.ts +0 -2
- package/src/authority/forwarding-io.ts +7 -5
- package/src/authority/permission-forwarding.ts +12 -3
- package/src/authority/permission-prompter.ts +5 -4
- package/src/config-loader.ts +31 -0
- package/src/config-schema.ts +13 -4
- package/src/extension-config.ts +8 -9
- package/src/handlers/gates/bash-external-directory.ts +1 -12
- package/src/handlers/gates/bash-path.ts +1 -10
- package/src/handlers/gates/descriptor.ts +26 -6
- package/src/handlers/gates/external-directory.ts +1 -13
- package/src/handlers/gates/helpers.ts +6 -7
- package/src/handlers/gates/path.ts +1 -9
- package/src/handlers/gates/runner.ts +62 -31
- package/src/handlers/gates/skill-input-gate-pipeline.ts +3 -14
- package/src/handlers/gates/skill-input.ts +1 -10
- package/src/handlers/gates/skill-read.ts +2 -11
- package/src/handlers/gates/tool-call-gate-pipeline.ts +1 -5
- package/src/handlers/gates/tool.ts +1 -11
- package/src/handlers/tool-call-boundary.ts +30 -7
- package/src/log-field-cap.ts +82 -0
- package/src/logging.ts +24 -3
- package/src/permission-events.ts +21 -2
- package/src/permission-prompts.ts +4 -3
- package/src/permission-request-id.ts +17 -0
- package/src/permission-session.ts +1 -1
- package/src/permission-ui-prompt.ts +4 -2
- package/src/presentation/agent-renderer.ts +215 -0
- package/src/presentation/dialog-renderer.ts +8 -64
- package/src/presentation/fact-vocabulary.ts +103 -0
- package/src/presentation/forwarded-ask-payload.ts +42 -17
- package/src/presentation/path-ask-payload.ts +8 -1
- package/src/presentation/prompt-payload.ts +165 -4
- package/src/presentation/review-log-renderer.ts +51 -0
- package/src/service.ts +11 -0
- package/src/tool-input-preview.ts +0 -1
- package/src/tool-preview-formatter.ts +18 -33
- package/src/denial-messages.ts +0 -269
- package/src/presentation/legacy-message.ts +0 -117
|
@@ -1,16 +1,22 @@
|
|
|
1
1
|
import type { AskEscalator } from "#src/authority/authorizer-selection";
|
|
2
2
|
import type { PermissionPromptDecision } from "#src/authority/permission-dialog";
|
|
3
3
|
import type { DecisionReporter } from "#src/decision-reporter";
|
|
4
|
-
import {
|
|
5
|
-
formatDenyReason,
|
|
6
|
-
formatUnavailableReason,
|
|
7
|
-
formatUserDeniedReason,
|
|
8
|
-
} from "#src/denial-messages";
|
|
9
4
|
import { applyPermissionGate } from "#src/permission-gate";
|
|
5
|
+
import { createPermissionRequestId } from "#src/permission-request-id";
|
|
10
6
|
import type { ScopedPermissionResolver } from "#src/permission-resolver";
|
|
7
|
+
import {
|
|
8
|
+
renderPolicyDenial,
|
|
9
|
+
renderUnavailableDenial,
|
|
10
|
+
renderUserDenial,
|
|
11
|
+
} from "#src/presentation/agent-renderer";
|
|
12
|
+
import { renderReviewLogFacts } from "#src/presentation/review-log-renderer";
|
|
11
13
|
import type { SessionApprovalRecorder } from "#src/session-approval-recorder";
|
|
12
14
|
import type { PermissionCheckResult } from "#src/types";
|
|
13
|
-
import type {
|
|
15
|
+
import type {
|
|
16
|
+
DecisionEventFacts,
|
|
17
|
+
GateDescriptor,
|
|
18
|
+
GateResult,
|
|
19
|
+
} from "./descriptor";
|
|
14
20
|
import { isGateBypass } from "./descriptor";
|
|
15
21
|
import {
|
|
16
22
|
buildDecisionEvent,
|
|
@@ -46,33 +52,44 @@ export class GateRunner {
|
|
|
46
52
|
/**
|
|
47
53
|
* Execute a gate: null → allow; bypass → log/emit side effects then allow;
|
|
48
54
|
* descriptor → full check→log→emit→approve cycle.
|
|
55
|
+
*
|
|
56
|
+
* The request id is minted here, before the branch, so a request that never
|
|
57
|
+
* prompts is identified exactly as one that does.
|
|
49
58
|
*/
|
|
50
|
-
async run(
|
|
51
|
-
gate: GateResult,
|
|
52
|
-
agentName: string | null,
|
|
53
|
-
toolCallId: string,
|
|
54
|
-
): Promise<GateOutcome> {
|
|
59
|
+
async run(gate: GateResult, agentName: string | null): Promise<GateOutcome> {
|
|
55
60
|
if (!gate) {
|
|
56
61
|
return { action: "allow" };
|
|
57
62
|
}
|
|
63
|
+
const requestId = createPermissionRequestId();
|
|
58
64
|
if (isGateBypass(gate)) {
|
|
59
65
|
if (gate.log) {
|
|
60
|
-
this.reporter.writeReviewLog(gate.log.event,
|
|
66
|
+
this.reporter.writeReviewLog(gate.log.event, {
|
|
67
|
+
...gate.log.details,
|
|
68
|
+
requestId,
|
|
69
|
+
});
|
|
61
70
|
}
|
|
62
71
|
if (gate.decision) {
|
|
63
|
-
this.
|
|
72
|
+
this.emitDecision(requestId, gate.decision);
|
|
64
73
|
}
|
|
65
74
|
return { action: "allow" };
|
|
66
75
|
}
|
|
67
|
-
return this.runDescriptor(gate, agentName,
|
|
76
|
+
return this.runDescriptor(gate, agentName, requestId);
|
|
68
77
|
}
|
|
69
78
|
|
|
70
79
|
// ── Private helpers ──────────────────────────────────────────────────────
|
|
71
80
|
|
|
81
|
+
/**
|
|
82
|
+
* The one place a decision event acquires its request id, so no emit path
|
|
83
|
+
* can be added that forgets it.
|
|
84
|
+
*/
|
|
85
|
+
private emitDecision(requestId: string, facts: DecisionEventFacts): void {
|
|
86
|
+
this.reporter.emitDecision({ requestId, ...facts });
|
|
87
|
+
}
|
|
88
|
+
|
|
72
89
|
private async runDescriptor(
|
|
73
90
|
descriptor: GateDescriptor,
|
|
74
91
|
agentName: string | null,
|
|
75
|
-
|
|
92
|
+
requestId: string,
|
|
76
93
|
): Promise<GateOutcome> {
|
|
77
94
|
// 1. Resolve permission state — pre-check, pre-resolved, or via resolver
|
|
78
95
|
let check: PermissionCheckResult;
|
|
@@ -94,15 +111,27 @@ export class GateRunner {
|
|
|
94
111
|
});
|
|
95
112
|
}
|
|
96
113
|
|
|
114
|
+
// The fields every review-log write for this gate shares, whatever the
|
|
115
|
+
// resolution — built once so a field added here reaches all of them. The
|
|
116
|
+
// payload's request facts are stamped here rather than by each gate, for
|
|
117
|
+
// the same reason `requestId` is: a gate cannot forget what it never
|
|
118
|
+
// supplies (ADR 0011 §6).
|
|
119
|
+
const logContext = {
|
|
120
|
+
...descriptor.logContext,
|
|
121
|
+
...renderReviewLogFacts(descriptor.payload),
|
|
122
|
+
agentName,
|
|
123
|
+
requestId,
|
|
124
|
+
};
|
|
125
|
+
|
|
97
126
|
// 2. Session-hit fast path
|
|
98
127
|
if (check.source === "session") {
|
|
99
128
|
this.reporter.writeReviewLog("permission_request.session_approved", {
|
|
100
|
-
...
|
|
101
|
-
agentName,
|
|
129
|
+
...logContext,
|
|
102
130
|
resolution: "session_approved",
|
|
103
131
|
sessionApprovalPattern: check.matchedPattern,
|
|
104
132
|
});
|
|
105
|
-
this.
|
|
133
|
+
this.emitDecision(
|
|
134
|
+
requestId,
|
|
106
135
|
buildDecisionEvent(
|
|
107
136
|
descriptor.decision,
|
|
108
137
|
check,
|
|
@@ -121,11 +150,11 @@ export class GateRunner {
|
|
|
121
150
|
const yoloGrant = resolveYoloGrant(check, this.isYoloEnabled());
|
|
122
151
|
if (yoloGrant) {
|
|
123
152
|
this.reporter.writeReviewLog("permission_request.auto_approved", {
|
|
124
|
-
...
|
|
125
|
-
agentName,
|
|
153
|
+
...logContext,
|
|
126
154
|
resolution: "auto_approved",
|
|
127
155
|
});
|
|
128
|
-
this.
|
|
156
|
+
this.emitDecision(
|
|
157
|
+
requestId,
|
|
129
158
|
buildDecisionEvent(
|
|
130
159
|
descriptor.decision,
|
|
131
160
|
yoloGrant,
|
|
@@ -140,16 +169,16 @@ export class GateRunner {
|
|
|
140
169
|
// 3. Apply the deny/ask/allow gate — always escalate on ask; the selected
|
|
141
170
|
// Authorizer answers (the DenyingAuthorizer by denying with a marker).
|
|
142
171
|
|
|
143
|
-
//
|
|
172
|
+
// The agent-facing renders of this ask. The rule reason is the operator's
|
|
173
|
+
// deny-with-reason text, which lives on the resolved check rather than the
|
|
174
|
+
// payload: no human render wants it, because a deny never prompts.
|
|
175
|
+
const { payload } = descriptor;
|
|
144
176
|
const messages = {
|
|
145
|
-
denyReason:
|
|
177
|
+
denyReason: renderPolicyDenial(payload, check.reason ?? null),
|
|
146
178
|
unavailableReason: (decision: PermissionPromptDecision) =>
|
|
147
|
-
|
|
148
|
-
descriptor.denialContext,
|
|
149
|
-
decision.denialReason,
|
|
150
|
-
),
|
|
179
|
+
renderUnavailableDenial(payload, decision.denialReason ?? null),
|
|
151
180
|
userDeniedReason: (decision: PermissionPromptDecision) =>
|
|
152
|
-
|
|
181
|
+
renderUserDenial(payload, decision.denialReason ?? null),
|
|
153
182
|
};
|
|
154
183
|
|
|
155
184
|
let autoApproved = false;
|
|
@@ -159,7 +188,8 @@ export class GateRunner {
|
|
|
159
188
|
sessionApproval: descriptor.sessionApproval?.toGateApproval(),
|
|
160
189
|
promptForApproval: async () => {
|
|
161
190
|
const decision = await this.prompter.escalate({
|
|
162
|
-
requestId
|
|
191
|
+
requestId,
|
|
192
|
+
payload,
|
|
163
193
|
...descriptor.promptDetails,
|
|
164
194
|
...(descriptor.sessionApproval
|
|
165
195
|
? { sessionApproval: descriptor.sessionApproval.toForwardedData() }
|
|
@@ -171,7 +201,7 @@ export class GateRunner {
|
|
|
171
201
|
},
|
|
172
202
|
writeLog: (event, details) =>
|
|
173
203
|
this.reporter.writeReviewLog(event, details),
|
|
174
|
-
logContext
|
|
204
|
+
logContext,
|
|
175
205
|
messages,
|
|
176
206
|
});
|
|
177
207
|
|
|
@@ -180,7 +210,8 @@ export class GateRunner {
|
|
|
180
210
|
gateResult.action === "allow" && gateResult.sessionApproval !== undefined;
|
|
181
211
|
|
|
182
212
|
// 5. Emit decision event
|
|
183
|
-
this.
|
|
213
|
+
this.emitDecision(
|
|
214
|
+
requestId,
|
|
184
215
|
buildDecisionEvent(
|
|
185
216
|
descriptor.decision,
|
|
186
217
|
check,
|
|
@@ -40,7 +40,7 @@ export interface GateNotifier {
|
|
|
40
40
|
|
|
41
41
|
/**
|
|
42
42
|
* Owns the skill-input gate assembly: raw permission pre-check, deny notify,
|
|
43
|
-
* `describeSkillInputGate` descriptor,
|
|
43
|
+
* `describeSkillInputGate` descriptor, and `runner.run(...)`.
|
|
44
44
|
*
|
|
45
45
|
* Constructed once in the composition root and injected into
|
|
46
46
|
* `PermissionGateHandler`, mirroring `ToolCallGatePipeline` for the `input`
|
|
@@ -70,29 +70,18 @@ export class SkillInputGatePipeline {
|
|
|
70
70
|
return runner.run(
|
|
71
71
|
describeSkillInputGate(skillName, agentName, check),
|
|
72
72
|
agentName,
|
|
73
|
-
createSkillInputRequestId(),
|
|
74
73
|
);
|
|
75
74
|
}
|
|
76
75
|
}
|
|
77
76
|
|
|
78
77
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
79
78
|
|
|
80
|
-
/**
|
|
81
|
-
* Mint a unique id for a skill-input permission request.
|
|
82
|
-
*
|
|
83
|
-
* Format is `skill-input-<timestamp>-<random>-<pid>`, matching the
|
|
84
|
-
* `createPermissionRequestId("skill-input")` pattern it replaces (#330).
|
|
85
|
-
*/
|
|
86
|
-
export function createSkillInputRequestId(): string {
|
|
87
|
-
return `skill-input-${Date.now()}-${Math.random().toString(36).slice(2, 10)}-${process.pid}`;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
79
|
/**
|
|
91
80
|
* Format the deny warning shown in the UI when a skill is blocked.
|
|
92
81
|
*
|
|
93
82
|
* Intentionally untagged (no `[pi-permission-system]` prefix) — this is a
|
|
94
|
-
* UI notify distinct from the
|
|
95
|
-
* `
|
|
83
|
+
* UI notify distinct from the agent-facing deny reasons the runner routes
|
|
84
|
+
* through `renderPolicyDenial`.
|
|
96
85
|
*/
|
|
97
86
|
export function formatSkillDenyNotice(
|
|
98
87
|
skillName: string,
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { renderLegacyMessage } from "#src/presentation/legacy-message";
|
|
2
1
|
import { buildSkillAskPayload } from "#src/presentation/skill-ask-payload";
|
|
3
2
|
import type { PermissionCheckResult } from "#src/types";
|
|
4
3
|
import type { GateDescriptor } from "./descriptor";
|
|
@@ -17,21 +16,14 @@ export function describeSkillInputGate(
|
|
|
17
16
|
preCheck: PermissionCheckResult,
|
|
18
17
|
): GateDescriptor {
|
|
19
18
|
const payload = buildSkillAskPayload(skillName, agentName);
|
|
20
|
-
const message = renderLegacyMessage(payload);
|
|
21
19
|
return {
|
|
22
20
|
surface: "skill",
|
|
23
21
|
input: { name: skillName },
|
|
24
22
|
preCheck,
|
|
25
|
-
|
|
26
|
-
kind: "skill_input",
|
|
27
|
-
skillName,
|
|
28
|
-
agentName: agentName ?? undefined,
|
|
29
|
-
},
|
|
23
|
+
payload,
|
|
30
24
|
promptDetails: {
|
|
31
25
|
source: "skill_input",
|
|
32
26
|
agentName,
|
|
33
|
-
message,
|
|
34
|
-
payload,
|
|
35
27
|
skillName,
|
|
36
28
|
accessIntent: accessFactsFromValue("skill", skillName),
|
|
37
29
|
},
|
|
@@ -39,7 +31,6 @@ export function describeSkillInputGate(
|
|
|
39
31
|
source: "skill_input",
|
|
40
32
|
skillName,
|
|
41
33
|
agentName,
|
|
42
|
-
message,
|
|
43
34
|
},
|
|
44
35
|
decision: {
|
|
45
36
|
surface: "skill",
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import type { PathNormalizer } from "#src/path-normalizer";
|
|
2
|
-
import { renderLegacyMessage } from "#src/presentation/legacy-message";
|
|
3
2
|
import { buildSkillPathAskPayload } from "#src/presentation/skill-ask-payload";
|
|
4
3
|
import type { SkillPromptEntry } from "#src/skill-prompt-sanitizer";
|
|
5
4
|
import { findSkillPathMatch } from "#src/skill-prompt-sanitizer";
|
|
@@ -44,22 +43,14 @@ export function describeSkillReadGate(
|
|
|
44
43
|
}
|
|
45
44
|
|
|
46
45
|
const payload = buildSkillPathAskPayload(matchedSkill, path, tcc.agentName);
|
|
47
|
-
const skillReadMessage = renderLegacyMessage(payload);
|
|
48
46
|
|
|
49
47
|
return {
|
|
50
48
|
surface: "skill",
|
|
51
49
|
input: { name: matchedSkill.name },
|
|
52
|
-
|
|
53
|
-
kind: "skill_read",
|
|
54
|
-
skillName: matchedSkill.name,
|
|
55
|
-
readPath: path,
|
|
56
|
-
agentName: tcc.agentName ?? undefined,
|
|
57
|
-
},
|
|
50
|
+
payload,
|
|
58
51
|
promptDetails: {
|
|
59
52
|
source: "skill_read",
|
|
60
53
|
agentName: tcc.agentName,
|
|
61
|
-
message: skillReadMessage,
|
|
62
|
-
payload,
|
|
63
54
|
toolCallId: tcc.toolCallId,
|
|
64
55
|
toolName: tcc.toolName,
|
|
65
56
|
skillName: matchedSkill.name,
|
|
@@ -68,10 +59,10 @@ export function describeSkillReadGate(
|
|
|
68
59
|
},
|
|
69
60
|
logContext: {
|
|
70
61
|
source: "skill_read",
|
|
62
|
+
toolCallId: tcc.toolCallId,
|
|
71
63
|
skillName: matchedSkill.name,
|
|
72
64
|
agentName: tcc.agentName,
|
|
73
65
|
path,
|
|
74
|
-
message: skillReadMessage,
|
|
75
66
|
},
|
|
76
67
|
decision: {
|
|
77
68
|
surface: "skill",
|
|
@@ -137,11 +137,7 @@ export class ToolCallGatePipeline {
|
|
|
137
137
|
];
|
|
138
138
|
|
|
139
139
|
for (const produce of gateProducers) {
|
|
140
|
-
const outcome = await runner.run(
|
|
141
|
-
await produce(),
|
|
142
|
-
tcc.agentName,
|
|
143
|
-
tcc.toolCallId,
|
|
144
|
-
);
|
|
140
|
+
const outcome = await runner.run(await produce(), tcc.agentName);
|
|
145
141
|
if (outcome.action === "block") {
|
|
146
142
|
return outcome;
|
|
147
143
|
}
|
|
@@ -6,7 +6,6 @@ import {
|
|
|
6
6
|
type ShellInvocation,
|
|
7
7
|
} from "#src/access-intent/tool-kind";
|
|
8
8
|
import { suggestSessionPattern } from "#src/pattern-suggest";
|
|
9
|
-
import { renderLegacyMessage } from "#src/presentation/legacy-message";
|
|
10
9
|
import { buildToolAskPayload } from "#src/presentation/tool-ask-payload";
|
|
11
10
|
import { SessionApproval } from "#src/session-approval";
|
|
12
11
|
import type { ToolPreviewFormatter } from "#src/tool-preview-formatter";
|
|
@@ -81,7 +80,6 @@ export function describeToolGate(
|
|
|
81
80
|
input: tcc.input,
|
|
82
81
|
formatter,
|
|
83
82
|
});
|
|
84
|
-
const askMessage = renderLegacyMessage(payload);
|
|
85
83
|
|
|
86
84
|
const decisionValue = deriveDecisionValue(
|
|
87
85
|
gateSurface,
|
|
@@ -98,12 +96,7 @@ export function describeToolGate(
|
|
|
98
96
|
return {
|
|
99
97
|
surface: gateSurface,
|
|
100
98
|
input: tcc.input,
|
|
101
|
-
|
|
102
|
-
kind: "tool",
|
|
103
|
-
check,
|
|
104
|
-
agentName: tcc.agentName ?? undefined,
|
|
105
|
-
input: tcc.input,
|
|
106
|
-
},
|
|
99
|
+
payload,
|
|
107
100
|
sessionApproval: SessionApproval.single(
|
|
108
101
|
suggestion.surface,
|
|
109
102
|
suggestion.pattern,
|
|
@@ -111,8 +104,6 @@ export function describeToolGate(
|
|
|
111
104
|
promptDetails: {
|
|
112
105
|
source: "tool_call",
|
|
113
106
|
agentName: tcc.agentName,
|
|
114
|
-
message: askMessage,
|
|
115
|
-
payload,
|
|
116
107
|
toolCallId: tcc.toolCallId,
|
|
117
108
|
toolName: tcc.toolName,
|
|
118
109
|
sessionLabel: suggestion.label,
|
|
@@ -123,7 +114,6 @@ export function describeToolGate(
|
|
|
123
114
|
source: "tool_call",
|
|
124
115
|
toolCallId: tcc.toolCallId,
|
|
125
116
|
toolName: tcc.toolName,
|
|
126
|
-
message: askMessage,
|
|
127
117
|
...permissionLogContext,
|
|
128
118
|
},
|
|
129
119
|
decision: {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import type { DecisionRecorder } from "#src/decision-audit";
|
|
3
3
|
import type { DecisionReporter } from "#src/decision-reporter";
|
|
4
|
+
import { createPermissionRequestId } from "#src/permission-request-id";
|
|
4
5
|
import { toRecord } from "#src/value-guards";
|
|
5
6
|
import type { GateOutcome } from "./gates/types";
|
|
6
7
|
|
|
@@ -53,18 +54,40 @@ export function createFailClosedToolCall(
|
|
|
53
54
|
? { block: true, reason: outcome.reason }
|
|
54
55
|
: {};
|
|
55
56
|
} catch (error) {
|
|
56
|
-
audit
|
|
57
|
-
reporter.writeReviewLog("permission_request.blocked", {
|
|
58
|
-
toolName: bestEffortToolName(event),
|
|
59
|
-
command: bestEffortCommand(event),
|
|
60
|
-
resolution: "gate_error",
|
|
61
|
-
error: errorMessage(error),
|
|
62
|
-
});
|
|
57
|
+
recordGateError(reporter, audit, event, error);
|
|
63
58
|
return { block: true, reason: formatGateErrorReason(error) };
|
|
64
59
|
}
|
|
65
60
|
};
|
|
66
61
|
}
|
|
67
62
|
|
|
63
|
+
/**
|
|
64
|
+
* Record a gate error without ever throwing.
|
|
65
|
+
*
|
|
66
|
+
* The block below this must be reached: the SDK does not catch a throwing
|
|
67
|
+
* handler, so an exception escaping the recording work would leave the command
|
|
68
|
+
* ungated. The request id is minted here rather than borrowed — the throw may
|
|
69
|
+
* have come from anywhere in the pipeline, so no gate's id is available.
|
|
70
|
+
*/
|
|
71
|
+
function recordGateError(
|
|
72
|
+
reporter: DecisionReporter,
|
|
73
|
+
audit: DecisionRecorder,
|
|
74
|
+
event: unknown,
|
|
75
|
+
error: unknown,
|
|
76
|
+
): void {
|
|
77
|
+
try {
|
|
78
|
+
audit.recordError();
|
|
79
|
+
reporter.writeReviewLog("permission_request.blocked", {
|
|
80
|
+
requestId: createPermissionRequestId(),
|
|
81
|
+
toolName: bestEffortToolName(event),
|
|
82
|
+
command: bestEffortCommand(event),
|
|
83
|
+
resolution: "gate_error",
|
|
84
|
+
error: errorMessage(error),
|
|
85
|
+
});
|
|
86
|
+
} catch {
|
|
87
|
+
// The block is the guarantee; its bookkeeping is not.
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
68
91
|
// ── Defensive event readers (never throw) ──────────────────────────────────
|
|
69
92
|
|
|
70
93
|
/** Best-effort tool name from a raw event; never throws. */
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The permission review log's width bound (ADR 0011 §6).
|
|
3
|
+
*
|
|
4
|
+
* The log renders the prompt payload under its own configured limits, and this
|
|
5
|
+
* is the limit: every string it writes is narrowed to a configured width. The
|
|
6
|
+
* bound is applied at `writeLine`, the single place a log line is produced, so
|
|
7
|
+
* a write path cannot be added that escapes it — the same discipline redaction
|
|
8
|
+
* already has there.
|
|
9
|
+
*
|
|
10
|
+
* A cap is not redaction, and the two must not be conflated
|
|
11
|
+
* (`docs/decisions/0010-permission-log-secret-exposure.md`). This narrows by
|
|
12
|
+
* length alone and never reads a value to decide what to shorten; redaction
|
|
13
|
+
* masks a value because of the key name it is bound to, and still does, so a
|
|
14
|
+
* sensitive-keyed value is masked whole however long it was.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The width when the operator configures none.
|
|
19
|
+
*
|
|
20
|
+
* Not a new number: it is the bound that already governed `toolInputPreview`,
|
|
21
|
+
* promoted from one field to every field so the log has one limit rather than
|
|
22
|
+
* one limit and an unbounded remainder.
|
|
23
|
+
*/
|
|
24
|
+
export const DEFAULT_REVIEW_LOG_FIELD_MAX_WIDTH = 1000;
|
|
25
|
+
|
|
26
|
+
/** The two-field shape this module reads off the extension config. */
|
|
27
|
+
export interface ReviewLogWidthConfig {
|
|
28
|
+
readonly reviewLogFieldMaxWidth?: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The configured review-log field width, or the built-in default. */
|
|
32
|
+
export function resolveReviewLogFieldWidth(
|
|
33
|
+
config: ReviewLogWidthConfig,
|
|
34
|
+
): number {
|
|
35
|
+
return config.reviewLogFieldMaxWidth ?? DEFAULT_REVIEW_LOG_FIELD_MAX_WIDTH;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Narrow every string in a log-detail record to `maxWidth`.
|
|
40
|
+
*
|
|
41
|
+
* Recurses through plain objects and arrays so a nested detail is bounded too,
|
|
42
|
+
* and touches strings only — a number, a boolean, or a null passes through as
|
|
43
|
+
* it was. A shortened value is marked with a bare ellipsis, the same marker the
|
|
44
|
+
* dialog uses: a character count is a number the reader cannot act on
|
|
45
|
+
* (ADR 0011 §4).
|
|
46
|
+
*/
|
|
47
|
+
export function capLogFieldWidths<T>(details: T, maxWidth: number): T {
|
|
48
|
+
return capValue(details, maxWidth) as T;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function capValue(value: unknown, maxWidth: number): unknown {
|
|
52
|
+
if (typeof value === "string") {
|
|
53
|
+
return value.length <= maxWidth
|
|
54
|
+
? value
|
|
55
|
+
: `${value.slice(0, maxWidth)}\u2026`;
|
|
56
|
+
}
|
|
57
|
+
if (Array.isArray(value)) {
|
|
58
|
+
return value.map((entry) => capValue(entry, maxWidth));
|
|
59
|
+
}
|
|
60
|
+
if (isPlainObject(value)) {
|
|
61
|
+
return Object.fromEntries(
|
|
62
|
+
Object.entries(value).map(([key, entry]) => [
|
|
63
|
+
key,
|
|
64
|
+
capValue(entry, maxWidth),
|
|
65
|
+
]),
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
return value;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Whether a value is a record this cap should descend into.
|
|
73
|
+
*
|
|
74
|
+
* A class instance (a `Date`, an `Error`) is left alone: rebuilding it as a
|
|
75
|
+
* plain object would change what the writer serializes, and the cap's job is
|
|
76
|
+
* to shorten strings, not to reshape a value.
|
|
77
|
+
*/
|
|
78
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
79
|
+
if (typeof value !== "object" || value === null) return false;
|
|
80
|
+
const prototype: unknown = Object.getPrototypeOf(value);
|
|
81
|
+
return prototype === Object.prototype || prototype === null;
|
|
82
|
+
}
|
package/src/logging.ts
CHANGED
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
EXTENSION_ID,
|
|
5
5
|
type PermissionSystemExtensionConfig,
|
|
6
6
|
} from "./extension-config";
|
|
7
|
+
import { capLogFieldWidths, resolveReviewLogFieldWidth } from "./log-field-cap";
|
|
7
8
|
import {
|
|
8
9
|
OWNER_ONLY_FILE_MODE,
|
|
9
10
|
restrictExistingPathToOwner,
|
|
@@ -37,11 +38,20 @@ export function createPermissionSystemLogger(
|
|
|
37
38
|
// re-invoked per session, unlike module scope, which now outlives one.
|
|
38
39
|
const hardened = new Set<string>();
|
|
39
40
|
|
|
41
|
+
/**
|
|
42
|
+
* The only place a log line is produced.
|
|
43
|
+
*
|
|
44
|
+
* `maxFieldWidth` bounds every string the line carries; it is supplied for
|
|
45
|
+
* the review stream and withheld for the debug stream, which is opt-in and
|
|
46
|
+
* exists to be read in full. Capping happens before redaction, which masks
|
|
47
|
+
* by key name and so still masks a sensitive value whole.
|
|
48
|
+
*/
|
|
40
49
|
const writeLine = (
|
|
41
50
|
stream: "debug" | "review",
|
|
42
51
|
path: string,
|
|
43
52
|
event: string,
|
|
44
53
|
details: Record<string, unknown>,
|
|
54
|
+
maxFieldWidth?: number,
|
|
45
55
|
): string | undefined => {
|
|
46
56
|
const directoryError = ensureLogsDirectory();
|
|
47
57
|
if (directoryError) {
|
|
@@ -49,12 +59,16 @@ export function createPermissionSystemLogger(
|
|
|
49
59
|
}
|
|
50
60
|
|
|
51
61
|
try {
|
|
62
|
+
const bounded =
|
|
63
|
+
maxFieldWidth === undefined
|
|
64
|
+
? details
|
|
65
|
+
: capLogFieldWidths(details, maxFieldWidth);
|
|
52
66
|
const line = redactedJsonStringify({
|
|
53
67
|
timestamp: new Date().toISOString(),
|
|
54
68
|
extension: EXTENSION_ID,
|
|
55
69
|
stream,
|
|
56
70
|
event,
|
|
57
|
-
...
|
|
71
|
+
...bounded,
|
|
58
72
|
});
|
|
59
73
|
if (!line) {
|
|
60
74
|
return `Failed to write permission-system ${stream} log '${path}': event could not be serialized.`;
|
|
@@ -89,11 +103,18 @@ export function createPermissionSystemLogger(
|
|
|
89
103
|
event: string,
|
|
90
104
|
details: Record<string, unknown> = {},
|
|
91
105
|
): string | undefined => {
|
|
92
|
-
|
|
106
|
+
const config = options.getConfig();
|
|
107
|
+
if (!config.permissionReviewLog) {
|
|
93
108
|
return undefined;
|
|
94
109
|
}
|
|
95
110
|
|
|
96
|
-
return writeLine(
|
|
111
|
+
return writeLine(
|
|
112
|
+
"review",
|
|
113
|
+
reviewLogPath,
|
|
114
|
+
event,
|
|
115
|
+
details,
|
|
116
|
+
resolveReviewLogFieldWidth(config),
|
|
117
|
+
);
|
|
97
118
|
};
|
|
98
119
|
|
|
99
120
|
return { debug, review };
|
package/src/permission-events.ts
CHANGED
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
* removed or renamed without a semver-major version bump.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
+
import type { PromptRequestFacts } from "#src/presentation/prompt-payload";
|
|
12
|
+
|
|
11
13
|
/** Minimal event bus interface required by the emit helpers. */
|
|
12
14
|
export interface PermissionEventBus {
|
|
13
15
|
emit(channel: string, data: unknown): void;
|
|
@@ -79,8 +81,19 @@ export interface PermissionUiPromptEvent {
|
|
|
79
81
|
value: string | null;
|
|
80
82
|
/** Agent name (when known). */
|
|
81
83
|
agentName: string | null;
|
|
82
|
-
/**
|
|
83
|
-
|
|
84
|
+
/**
|
|
85
|
+
* The ask's invariant core (ADR 0011 §3), verbatim from the prompt payload.
|
|
86
|
+
*
|
|
87
|
+
* Nested rather than flattened so the event and the payload share one shape:
|
|
88
|
+
* a fact added to `PromptRequestFacts` reaches the bus without a second
|
|
89
|
+
* hand-maintained declaration. Carries no evidence and no annotations — the
|
|
90
|
+
* bus is the narrowest renderer (ADR 0011 §6), observable by any loaded
|
|
91
|
+
* extension without the operator having named it.
|
|
92
|
+
*
|
|
93
|
+
* `request.surface` is the *gate* surface the rule fired on; the top-level
|
|
94
|
+
* `surface` is the display projection. Both are here on purpose.
|
|
95
|
+
*/
|
|
96
|
+
request: PromptRequestFacts;
|
|
84
97
|
/** Forwarding context, or null for a direct prompt. */
|
|
85
98
|
forwarding: ForwardedPromptContext | null;
|
|
86
99
|
}
|
|
@@ -101,6 +114,12 @@ export type PermissionDecisionResolution =
|
|
|
101
114
|
|
|
102
115
|
/** Payload emitted on `permissions:decision`. */
|
|
103
116
|
export interface PermissionDecisionEvent {
|
|
117
|
+
/**
|
|
118
|
+
* Identifies the permission request this decision resolves, minted when the
|
|
119
|
+
* request was created. Distinct from the host's tool-call id: one tool call
|
|
120
|
+
* runs several gates and so raises several requests.
|
|
121
|
+
*/
|
|
122
|
+
requestId: string;
|
|
104
123
|
/** Permission surface: "bash", "read", "mcp", "skill", "external_directory", etc. */
|
|
105
124
|
surface: string;
|
|
106
125
|
/** The value that was evaluated (command, tool name, skill name, path). */
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { classifyToolKind } from "./access-intent/tool-kind";
|
|
2
2
|
|
|
3
|
-
// NOTE: the ask prompts are now payload builders under src/presentation
|
|
4
|
-
// denial text
|
|
5
|
-
// pre-check reasons,
|
|
3
|
+
// NOTE: the ask prompts are now payload builders under src/presentation/, and
|
|
4
|
+
// denial text is a render over the payload (presentation/agent-renderer.ts).
|
|
5
|
+
// This module retains only the pre-check reasons, refused before any payload
|
|
6
|
+
// exists to render.
|
|
6
7
|
|
|
7
8
|
export function formatMissingToolNameReason(): string {
|
|
8
9
|
return "Tool call was blocked because no tool name was provided. Use a registered tool name from pi.getAllTools().";
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Mint the identifier for one permission request, at the moment the request is
|
|
5
|
+
* created rather than at the moment it prompts.
|
|
6
|
+
*
|
|
7
|
+
* Distinct from the host's `toolCallId`, which keeps flowing alongside it as
|
|
8
|
+
* the join back to the Pi transcript: a single tool call runs several gates and
|
|
9
|
+
* therefore raises several permission requests, so the SDK's id cannot identify
|
|
10
|
+
* one of them.
|
|
11
|
+
*
|
|
12
|
+
* The `perm-` prefix keeps the id self-identifying in a review log that also
|
|
13
|
+
* carries SDK tool-call ids.
|
|
14
|
+
*/
|
|
15
|
+
export function createPermissionRequestId(): string {
|
|
16
|
+
return `perm-${randomUUID()}`;
|
|
17
|
+
}
|
|
@@ -219,7 +219,7 @@ export class PermissionSession implements ToolCallGateInputs {
|
|
|
219
219
|
* so the pipeline reads a clean value rather than pulling raw config.
|
|
220
220
|
*/
|
|
221
221
|
getToolPreviewLimits(): ToolPreviewFormatterOptions {
|
|
222
|
-
return resolveToolPreviewLimits(
|
|
222
|
+
return resolveToolPreviewLimits();
|
|
223
223
|
}
|
|
224
224
|
|
|
225
225
|
/**
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
* prompter or forwarding modules (no import cycles, correct layering).
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
+
import type { PromptPayload } from "#src/presentation/prompt-payload";
|
|
14
15
|
import type {
|
|
15
16
|
ForwardedPromptContext,
|
|
16
17
|
PermissionUiPromptEvent,
|
|
@@ -21,7 +22,8 @@ export interface DirectPromptInput {
|
|
|
21
22
|
requestId: string;
|
|
22
23
|
source: "tool_call" | "skill_input" | "skill_read";
|
|
23
24
|
agentName: string | null;
|
|
24
|
-
|
|
25
|
+
/** The ask's complete payload; the event carries its invariant core alone. */
|
|
26
|
+
payload: PromptPayload;
|
|
25
27
|
toolName?: string;
|
|
26
28
|
skillName?: string;
|
|
27
29
|
path?: string;
|
|
@@ -62,7 +64,7 @@ export function buildUiPrompt(input: UiPromptInput): PermissionUiPromptEvent {
|
|
|
62
64
|
surface: input.surface !== undefined ? input.surface : directSurface(input),
|
|
63
65
|
value: input.value !== undefined ? input.value : directValue(input),
|
|
64
66
|
agentName: input.agentName,
|
|
65
|
-
|
|
67
|
+
request: input.payload.request,
|
|
66
68
|
forwarding: input.forwarding ?? null,
|
|
67
69
|
};
|
|
68
70
|
}
|