@gotgenes/pi-permission-system 25.4.0 → 26.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +49 -0
- package/README.md +14 -12
- package/config/config.example.json +1 -2
- package/dist/public.d.ts +31 -10
- package/docs/configuration.md +23 -19
- package/docs/cross-extension-api.md +30 -4
- package/docs/migration/0745-prompt-payload-contracts.md +68 -0
- package/docs/migration/0746-review-log-fields.md +69 -0
- package/docs/subagent-integration.md +14 -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 +38 -5
- package/src/authority/authorizer-chain.ts +39 -11
- package/src/authority/authorizer-selection.ts +5 -5
- package/src/authority/authorizer.ts +16 -3
- package/src/authority/decision-source.ts +235 -0
- package/src/authority/denying-authorizer.ts +4 -0
- package/src/authority/forwarded-request-server.ts +44 -14
- package/src/authority/forwarding-io.ts +12 -5
- package/src/authority/permission-dialog.ts +23 -2
- package/src/authority/permission-forwarding.ts +25 -3
- package/src/authority/permission-prompt-component.ts +34 -10
- package/src/authority/permission-prompt-decision.ts +7 -3
- package/src/authority/permission-prompter.ts +13 -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 +10 -12
- package/src/handlers/gates/bash-path.ts +9 -10
- package/src/handlers/gates/descriptor.ts +25 -5
- package/src/handlers/gates/external-directory.ts +3 -13
- package/src/handlers/gates/path.ts +1 -9
- package/src/handlers/gates/runner.ts +44 -14
- package/src/handlers/gates/skill-input-gate-pipeline.ts +2 -2
- package/src/handlers/gates/skill-input.ts +1 -10
- package/src/handlers/gates/skill-read.ts +1 -11
- package/src/handlers/gates/tool.ts +1 -11
- package/src/handlers/tool-call-boundary.ts +4 -1
- package/src/log-field-cap.ts +82 -0
- package/src/logging.ts +24 -3
- package/src/permission-events.ts +15 -2
- package/src/permission-gate.ts +11 -0
- package/src/permission-prompts.ts +4 -3
- 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,7 +1,6 @@
|
|
|
1
1
|
import { getToolInputPath } from "#src/access-intent/tool-input-path";
|
|
2
2
|
import type { PathNormalizer } from "#src/path-normalizer";
|
|
3
3
|
import type { ScopedPermissionResolver } from "#src/permission-resolver";
|
|
4
|
-
import { renderLegacyMessage } from "#src/presentation/legacy-message";
|
|
5
4
|
import { buildExternalDirectoryAskPayload } from "#src/presentation/path-ask-payload";
|
|
6
5
|
import { SessionApproval } from "#src/session-approval";
|
|
7
6
|
import { deriveApprovalPattern } from "#src/session-rules";
|
|
@@ -46,6 +45,8 @@ export function describeExternalDirectoryGate(
|
|
|
46
45
|
if (normalizer.isInfrastructureRead(tcc.toolName, accessPath, infraDirs)) {
|
|
47
46
|
return {
|
|
48
47
|
action: "allow",
|
|
48
|
+
// Containment allowed this, not a rule the operator wrote.
|
|
49
|
+
decidedBy: { kind: "infrastructure_read" },
|
|
49
50
|
log: {
|
|
50
51
|
event: "permission_request.infrastructure_auto_allowed",
|
|
51
52
|
details: {
|
|
@@ -87,26 +88,16 @@ export function describeExternalDirectoryGate(
|
|
|
87
88
|
agentName: tcc.agentName,
|
|
88
89
|
matchedPattern: preCheck.matchedPattern,
|
|
89
90
|
});
|
|
90
|
-
const extDirMessage = renderLegacyMessage(payload);
|
|
91
91
|
|
|
92
92
|
return {
|
|
93
93
|
surface: "external_directory",
|
|
94
94
|
input: {},
|
|
95
95
|
preCheck,
|
|
96
|
-
|
|
97
|
-
kind: "external_directory",
|
|
98
|
-
toolName: tcc.toolName,
|
|
99
|
-
pathValue: externalDirectoryPath,
|
|
100
|
-
resolvedPath: resolvedAlias,
|
|
101
|
-
cwd: tcc.cwd,
|
|
102
|
-
agentName: tcc.agentName ?? undefined,
|
|
103
|
-
},
|
|
96
|
+
payload,
|
|
104
97
|
sessionApproval: SessionApproval.single("external_directory", pattern),
|
|
105
98
|
promptDetails: {
|
|
106
99
|
source: "tool_call",
|
|
107
100
|
agentName: tcc.agentName,
|
|
108
|
-
message: extDirMessage,
|
|
109
|
-
payload,
|
|
110
101
|
toolCallId: tcc.toolCallId,
|
|
111
102
|
toolName: tcc.toolName,
|
|
112
103
|
path: externalDirectoryPath,
|
|
@@ -118,7 +109,6 @@ export function describeExternalDirectoryGate(
|
|
|
118
109
|
toolName: tcc.toolName,
|
|
119
110
|
agentName: tcc.agentName,
|
|
120
111
|
path: externalDirectoryPath,
|
|
121
|
-
message: extDirMessage,
|
|
122
112
|
},
|
|
123
113
|
decision: {
|
|
124
114
|
surface: "external_directory",
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { getToolInputPath } from "#src/access-intent/tool-input-path";
|
|
2
2
|
import type { PathNormalizer } from "#src/path-normalizer";
|
|
3
3
|
import type { ScopedPermissionResolver } from "#src/permission-resolver";
|
|
4
|
-
import { renderLegacyMessage } from "#src/presentation/legacy-message";
|
|
5
4
|
import { buildPathAskPayload } from "#src/presentation/path-ask-payload";
|
|
6
5
|
import { SessionApproval } from "#src/session-approval";
|
|
7
6
|
import { deriveApprovalPattern } from "#src/session-rules";
|
|
@@ -59,18 +58,11 @@ export function describePathGate(
|
|
|
59
58
|
const descriptor: GateDescriptor = {
|
|
60
59
|
surface: "path",
|
|
61
60
|
input: { path: filePath },
|
|
62
|
-
|
|
63
|
-
kind: "path",
|
|
64
|
-
toolName: tcc.toolName,
|
|
65
|
-
pathValue: filePath,
|
|
66
|
-
agentName: tcc.agentName ?? undefined,
|
|
67
|
-
},
|
|
61
|
+
payload,
|
|
68
62
|
sessionApproval: SessionApproval.single("path", pattern),
|
|
69
63
|
promptDetails: {
|
|
70
64
|
source: "tool_call",
|
|
71
65
|
agentName: tcc.agentName,
|
|
72
|
-
message: renderLegacyMessage(payload),
|
|
73
|
-
payload,
|
|
74
66
|
toolCallId: tcc.toolCallId,
|
|
75
67
|
toolName: tcc.toolName,
|
|
76
68
|
path: filePath,
|
|
@@ -1,14 +1,15 @@
|
|
|
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";
|
|
10
5
|
import { createPermissionRequestId } from "#src/permission-request-id";
|
|
11
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";
|
|
12
13
|
import type { SessionApprovalRecorder } from "#src/session-approval-recorder";
|
|
13
14
|
import type { PermissionCheckResult } from "#src/types";
|
|
14
15
|
import type {
|
|
@@ -65,6 +66,7 @@ export class GateRunner {
|
|
|
65
66
|
this.reporter.writeReviewLog(gate.log.event, {
|
|
66
67
|
...gate.log.details,
|
|
67
68
|
requestId,
|
|
69
|
+
decidedBy: gate.decidedBy,
|
|
68
70
|
});
|
|
69
71
|
}
|
|
70
72
|
if (gate.decision) {
|
|
@@ -111,8 +113,21 @@ export class GateRunner {
|
|
|
111
113
|
}
|
|
112
114
|
|
|
113
115
|
// The fields every review-log write for this gate shares, whatever the
|
|
114
|
-
// resolution — built once so a field added here reaches all of them.
|
|
115
|
-
|
|
116
|
+
// resolution — built once so a field added here reaches all of them. The
|
|
117
|
+
// payload's request facts are stamped here rather than by each gate, for
|
|
118
|
+
// the same reason `requestId` is: a gate cannot forget what it never
|
|
119
|
+
// supplies (ADR 0011 §6).
|
|
120
|
+
const logContext = {
|
|
121
|
+
...descriptor.logContext,
|
|
122
|
+
...renderReviewLogFacts(descriptor.payload),
|
|
123
|
+
agentName,
|
|
124
|
+
requestId,
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
// Each resolution below states its own decider. The provenance is built
|
|
128
|
+
// at the branch that decides rather than merged into `logContext`: that
|
|
129
|
+
// context holds what every resolution of this gate shares, and who decided
|
|
130
|
+
// is by definition not shared (#726).
|
|
116
131
|
|
|
117
132
|
// 2. Session-hit fast path
|
|
118
133
|
if (check.source === "session") {
|
|
@@ -120,6 +135,11 @@ export class GateRunner {
|
|
|
120
135
|
...logContext,
|
|
121
136
|
resolution: "session_approved",
|
|
122
137
|
sessionApprovalPattern: check.matchedPattern,
|
|
138
|
+
decidedBy: {
|
|
139
|
+
kind: "session_approval",
|
|
140
|
+
surface: descriptor.surface,
|
|
141
|
+
pattern: check.matchedPattern ?? null,
|
|
142
|
+
},
|
|
123
143
|
});
|
|
124
144
|
this.emitDecision(
|
|
125
145
|
requestId,
|
|
@@ -143,6 +163,9 @@ export class GateRunner {
|
|
|
143
163
|
this.reporter.writeReviewLog("permission_request.auto_approved", {
|
|
144
164
|
...logContext,
|
|
145
165
|
resolution: "auto_approved",
|
|
166
|
+
// The pattern that raised the ask, sentinel included: "yolo allowed
|
|
167
|
+
// it" alone does not say why it was asked in the first place.
|
|
168
|
+
decidedBy: { kind: "yolo", pattern: check.matchedPattern ?? null },
|
|
146
169
|
});
|
|
147
170
|
this.emitDecision(
|
|
148
171
|
requestId,
|
|
@@ -160,16 +183,16 @@ export class GateRunner {
|
|
|
160
183
|
// 3. Apply the deny/ask/allow gate — always escalate on ask; the selected
|
|
161
184
|
// Authorizer answers (the DenyingAuthorizer by denying with a marker).
|
|
162
185
|
|
|
163
|
-
//
|
|
186
|
+
// The agent-facing renders of this ask. The rule reason is the operator's
|
|
187
|
+
// deny-with-reason text, which lives on the resolved check rather than the
|
|
188
|
+
// payload: no human render wants it, because a deny never prompts.
|
|
189
|
+
const { payload } = descriptor;
|
|
164
190
|
const messages = {
|
|
165
|
-
denyReason:
|
|
191
|
+
denyReason: renderPolicyDenial(payload, check.reason ?? null),
|
|
166
192
|
unavailableReason: (decision: PermissionPromptDecision) =>
|
|
167
|
-
|
|
168
|
-
descriptor.denialContext,
|
|
169
|
-
decision.denialReason,
|
|
170
|
-
),
|
|
193
|
+
renderUnavailableDenial(payload, decision.denialReason ?? null),
|
|
171
194
|
userDeniedReason: (decision: PermissionPromptDecision) =>
|
|
172
|
-
|
|
195
|
+
renderUserDenial(payload, decision.denialReason ?? null),
|
|
173
196
|
};
|
|
174
197
|
|
|
175
198
|
let autoApproved = false;
|
|
@@ -180,6 +203,7 @@ export class GateRunner {
|
|
|
180
203
|
promptForApproval: async () => {
|
|
181
204
|
const decision = await this.prompter.escalate({
|
|
182
205
|
requestId,
|
|
206
|
+
payload,
|
|
183
207
|
...descriptor.promptDetails,
|
|
184
208
|
...(descriptor.sessionApproval
|
|
185
209
|
? { sessionApproval: descriptor.sessionApproval.toForwardedData() }
|
|
@@ -192,6 +216,12 @@ export class GateRunner {
|
|
|
192
216
|
writeLog: (event, details) =>
|
|
193
217
|
this.reporter.writeReviewLog(event, details),
|
|
194
218
|
logContext,
|
|
219
|
+
decidedByRule: {
|
|
220
|
+
kind: "rule",
|
|
221
|
+
surface: descriptor.surface,
|
|
222
|
+
pattern: check.matchedPattern ?? null,
|
|
223
|
+
origin: check.origin,
|
|
224
|
+
},
|
|
195
225
|
messages,
|
|
196
226
|
});
|
|
197
227
|
|
|
@@ -80,8 +80,8 @@ export class SkillInputGatePipeline {
|
|
|
80
80
|
* Format the deny warning shown in the UI when a skill is blocked.
|
|
81
81
|
*
|
|
82
82
|
* Intentionally untagged (no `[pi-permission-system]` prefix) — this is a
|
|
83
|
-
* UI notify distinct from the
|
|
84
|
-
* `
|
|
83
|
+
* UI notify distinct from the agent-facing deny reasons the runner routes
|
|
84
|
+
* through `renderPolicyDenial`.
|
|
85
85
|
*/
|
|
86
86
|
export function formatSkillDenyNotice(
|
|
87
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,
|
|
@@ -72,7 +63,6 @@ export function describeSkillReadGate(
|
|
|
72
63
|
skillName: matchedSkill.name,
|
|
73
64
|
agentName: tcc.agentName,
|
|
74
65
|
path,
|
|
75
|
-
message: skillReadMessage,
|
|
76
66
|
},
|
|
77
67
|
decision: {
|
|
78
68
|
surface: "skill",
|
|
@@ -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: {
|
|
@@ -76,12 +76,15 @@ function recordGateError(
|
|
|
76
76
|
): void {
|
|
77
77
|
try {
|
|
78
78
|
audit.recordError();
|
|
79
|
+
const reason = errorMessage(error);
|
|
79
80
|
reporter.writeReviewLog("permission_request.blocked", {
|
|
80
81
|
requestId: createPermissionRequestId(),
|
|
81
82
|
toolName: bestEffortToolName(event),
|
|
82
83
|
command: bestEffortCommand(event),
|
|
83
84
|
resolution: "gate_error",
|
|
84
|
-
error:
|
|
85
|
+
error: reason,
|
|
86
|
+
// The boundary decided, by failing closed -- no rule and no human did.
|
|
87
|
+
decidedBy: { kind: "gate_error", reason },
|
|
85
88
|
});
|
|
86
89
|
} catch {
|
|
87
90
|
// The block is the guarantee; its bookkeeping is not.
|
|
@@ -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
|
}
|
package/src/permission-gate.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { DecisionSource } from "#src/authority/decision-source";
|
|
1
2
|
import type { PermissionPromptDecision } from "#src/authority/permission-dialog";
|
|
2
3
|
|
|
3
4
|
/** Result of applying the permission gate. */
|
|
@@ -30,6 +31,15 @@ export interface PermissionGateParams {
|
|
|
30
31
|
/** Log context fields shared across all log calls for this gate. */
|
|
31
32
|
logContext: Record<string, unknown>;
|
|
32
33
|
|
|
34
|
+
/**
|
|
35
|
+
* The rule that resolved this gate, for the deny arm's review entry.
|
|
36
|
+
*
|
|
37
|
+
* A sibling of `logContext` rather than a member of it: the context holds
|
|
38
|
+
* what every resolution of this gate shares, and the decider is by
|
|
39
|
+
* definition not shared (#726).
|
|
40
|
+
*/
|
|
41
|
+
decidedByRule: DecisionSource;
|
|
42
|
+
|
|
33
43
|
/** Message strings/factories for each outcome. */
|
|
34
44
|
messages: {
|
|
35
45
|
denyReason: string;
|
|
@@ -52,6 +62,7 @@ export async function applyPermissionGate(
|
|
|
52
62
|
writeLog("permission_request.blocked", {
|
|
53
63
|
...logContext,
|
|
54
64
|
resolution: "policy_denied",
|
|
65
|
+
decidedBy: params.decidedByRule,
|
|
55
66
|
});
|
|
56
67
|
return { action: "block", reason: messages.denyReason };
|
|
57
68
|
}
|
|
@@ -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().";
|
|
@@ -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
|
}
|