@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
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
writeFileSync,
|
|
10
10
|
} from "node:fs";
|
|
11
11
|
|
|
12
|
+
import { asDecisionSource } from "#src/authority/decision-source";
|
|
12
13
|
import { isPermissionDecisionState } from "#src/authority/permission-dialog";
|
|
13
14
|
import {
|
|
14
15
|
createPermissionForwardingLocation,
|
|
@@ -23,6 +24,7 @@ import {
|
|
|
23
24
|
OWNER_ONLY_FILE_MODE,
|
|
24
25
|
} from "#src/log-file-permissions";
|
|
25
26
|
import type { PermissionUiPromptSource } from "#src/permission-events";
|
|
27
|
+
import { asPromptPayload } from "#src/presentation/prompt-payload";
|
|
26
28
|
import type { DebugReviewLogger } from "#src/session-logger";
|
|
27
29
|
|
|
28
30
|
/** Valid `permissions:ui_prompt` source values, for tolerant request reads. */
|
|
@@ -396,8 +398,7 @@ export function readForwardedPermissionRequest(
|
|
|
396
398
|
typeof parsed.createdAt !== "number" ||
|
|
397
399
|
typeof parsed.requesterSessionId !== "string" ||
|
|
398
400
|
typeof parsed.targetSessionId !== "string" ||
|
|
399
|
-
typeof parsed.requesterAgentName !== "string"
|
|
400
|
-
typeof parsed.message !== "string"
|
|
401
|
+
typeof parsed.requesterAgentName !== "string"
|
|
401
402
|
) {
|
|
402
403
|
logPermissionForwardingWarning(
|
|
403
404
|
logger,
|
|
@@ -412,9 +413,11 @@ export function readForwardedPermissionRequest(
|
|
|
412
413
|
requesterSessionId: parsed.requesterSessionId,
|
|
413
414
|
targetSessionId: parsed.targetSessionId,
|
|
414
415
|
requesterAgentName: parsed.requesterAgentName,
|
|
415
|
-
|
|
416
|
-
//
|
|
417
|
-
// child
|
|
416
|
+
// Tolerant read: the payload and display fields are optional and may be
|
|
417
|
+
// absent (older child) or malformed; reconstruct only the well-formed
|
|
418
|
+
// ones. An older child's `message` is deliberately not salvaged — a
|
|
419
|
+
// skewed ask renders from the fields it does carry (ADR 0011 §9).
|
|
420
|
+
payload: asPromptPayload(parsed.payload),
|
|
418
421
|
source: asUiPromptSource(parsed.source),
|
|
419
422
|
surface: asNullableDisplayString(parsed.surface),
|
|
420
423
|
value: asNullableDisplayString(parsed.value),
|
|
@@ -464,6 +467,10 @@ export function readForwardedPermissionResponse(
|
|
|
464
467
|
typeof parsed.respondedAt === "number"
|
|
465
468
|
? parsed.respondedAt
|
|
466
469
|
: Date.now(),
|
|
470
|
+
// Tolerant like the request's `accessIntent`: an unusable provenance
|
|
471
|
+
// record is dropped, but the decision itself still has to reach the
|
|
472
|
+
// requester, so it never rejects the response.
|
|
473
|
+
decidedBy: asDecisionSource(parsed.decidedBy),
|
|
467
474
|
};
|
|
468
475
|
} catch (error) {
|
|
469
476
|
logPermissionForwardingWarning(
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { DecisionSource } from "#src/authority/decision-source";
|
|
2
|
+
|
|
1
3
|
export type PermissionDecisionState =
|
|
2
4
|
| "approved"
|
|
3
5
|
| "approved_for_session"
|
|
@@ -26,8 +28,27 @@ export type PermissionPromptDecision = {
|
|
|
26
28
|
* denial — a user who was never asked denied nothing (#719).
|
|
27
29
|
*/
|
|
28
30
|
confirmationUnavailable?: true;
|
|
31
|
+
/**
|
|
32
|
+
* What decided this request, stamped by the site that decided it.
|
|
33
|
+
*
|
|
34
|
+
* Required: every decision names its decider, and the type is what
|
|
35
|
+
* guarantees it rather than a convention each producer has to remember — the
|
|
36
|
+
* same discipline `PromptPermissionDetails.payload` carries (#726).
|
|
37
|
+
*/
|
|
38
|
+
decidedBy: DecisionSource;
|
|
29
39
|
};
|
|
30
40
|
|
|
41
|
+
/**
|
|
42
|
+
* A decision before its decider is known.
|
|
43
|
+
*
|
|
44
|
+
* The inner producers — the dialog's decision model, the `select`/`input`
|
|
45
|
+
* fallback, the verdict mapper — state the outcome; which decider to attribute
|
|
46
|
+
* it to is settled one layer up, at the site that chose the producer. The same
|
|
47
|
+
* shape `GateBypass.decision` uses for the request id: a producer emits only
|
|
48
|
+
* what it knows.
|
|
49
|
+
*/
|
|
50
|
+
export type UnattributedDecision = Omit<PermissionPromptDecision, "decidedBy">;
|
|
51
|
+
|
|
31
52
|
export interface PermissionDecisionUi {
|
|
32
53
|
select(title: string, options: string[]): Promise<string | undefined>;
|
|
33
54
|
input(title: string, placeholder?: string): Promise<string | undefined>;
|
|
@@ -51,7 +72,7 @@ export function normalizePermissionDenialReason(
|
|
|
51
72
|
|
|
52
73
|
export function createDeniedPermissionDecision(
|
|
53
74
|
denialReason?: string,
|
|
54
|
-
):
|
|
75
|
+
): UnattributedDecision {
|
|
55
76
|
const normalizedReason = normalizePermissionDenialReason(denialReason);
|
|
56
77
|
return normalizedReason
|
|
57
78
|
? {
|
|
@@ -96,7 +117,7 @@ export async function requestPermissionDecisionFromUi(
|
|
|
96
117
|
title: string,
|
|
97
118
|
message: string,
|
|
98
119
|
options?: RequestPermissionOptions,
|
|
99
|
-
): Promise<
|
|
120
|
+
): Promise<UnattributedDecision> {
|
|
100
121
|
const sessionOption = options?.sessionLabel ?? APPROVE_FOR_SESSION_OPTION;
|
|
101
122
|
const decisionOptions = [
|
|
102
123
|
APPROVE_OPTION,
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { join } from "node:path";
|
|
2
|
+
import type { DecisionSource } from "#src/authority/decision-source";
|
|
2
3
|
import type { PermissionUiPromptSource } from "#src/permission-events";
|
|
4
|
+
import type { PromptPayload } from "#src/presentation/prompt-payload";
|
|
3
5
|
import type { PermissionDecisionState } from "./permission-dialog";
|
|
4
6
|
import type { SubagentSessionRegistry } from "./subagent-registry";
|
|
5
7
|
|
|
@@ -53,9 +55,9 @@ const SESSION_FORWARDING_RESPONSES_DIRECTORY_NAME = "responses";
|
|
|
53
55
|
* Display fields relayed from a forwarding child to the parent UI so the parent
|
|
54
56
|
* can emit a non-degraded `permissions:ui_prompt` event.
|
|
55
57
|
*
|
|
56
|
-
* Carried separately from the prompt
|
|
58
|
+
* Carried separately from the prompt payload because the parent reconstructs
|
|
57
59
|
* the original event from the escalated ask's details (`buildUiPrompt`), not
|
|
58
|
-
* from the
|
|
60
|
+
* from the payload's own facts.
|
|
59
61
|
*/
|
|
60
62
|
export interface ForwardedPromptDisplay {
|
|
61
63
|
source: PermissionUiPromptSource;
|
|
@@ -123,7 +125,15 @@ export type ForwardedPermissionRequest = {
|
|
|
123
125
|
requesterSessionId: string;
|
|
124
126
|
targetSessionId: string;
|
|
125
127
|
requesterAgentName: string;
|
|
126
|
-
|
|
128
|
+
/**
|
|
129
|
+
* The child's complete prompt payload (ADR 0011 §2), so the serving node
|
|
130
|
+
* renders the child's own facts under the *parent's* budget rather than
|
|
131
|
+
* relaying a sentence the child assembled under its own configuration.
|
|
132
|
+
*
|
|
133
|
+
* Optional for version-skew tolerance: an older child omits it, and the
|
|
134
|
+
* serving node renders from the display fields it does carry (ADR 0011 §9).
|
|
135
|
+
*/
|
|
136
|
+
payload?: PromptPayload;
|
|
127
137
|
/**
|
|
128
138
|
* Original prompt display fields, persisted so the parent emits a
|
|
129
139
|
* non-degraded event. Optional for version-skew tolerance: a parent on a
|
|
@@ -154,6 +164,18 @@ export type ForwardedPermissionResponse = {
|
|
|
154
164
|
denialReason?: string;
|
|
155
165
|
responderSessionId: string;
|
|
156
166
|
respondedAt: number;
|
|
167
|
+
/**
|
|
168
|
+
* What decided, inside the responding session (#726).
|
|
169
|
+
*
|
|
170
|
+
* `responderSessionId` names *where* the decision was made; this names
|
|
171
|
+
* *what* made it, which is the difference between a human at the parent's
|
|
172
|
+
* dialog and the parent's policy answering on their behalf.
|
|
173
|
+
*
|
|
174
|
+
* Optional for version-skew tolerance: an older responder omits it, and the
|
|
175
|
+
* requester records the hop with a `null` inner decision rather than
|
|
176
|
+
* rejecting the answer.
|
|
177
|
+
*/
|
|
178
|
+
decidedBy?: DecisionSource;
|
|
157
179
|
};
|
|
158
180
|
|
|
159
181
|
export type PermissionForwardingLocation = {
|
|
@@ -4,10 +4,15 @@ import type {
|
|
|
4
4
|
KeybindingsManager,
|
|
5
5
|
} from "@earendil-works/pi-coding-agent";
|
|
6
6
|
import { type Component, matchesKey } from "@earendil-works/pi-tui";
|
|
7
|
+
import type {
|
|
8
|
+
DecisionSource,
|
|
9
|
+
UserDecisionSurface,
|
|
10
|
+
} from "#src/authority/decision-source";
|
|
7
11
|
import {
|
|
8
12
|
type PermissionPromptDecision,
|
|
9
13
|
type RequestPermissionOptions,
|
|
10
14
|
requestPermissionDecisionFromUi,
|
|
15
|
+
type UnattributedDecision,
|
|
11
16
|
} from "#src/authority/permission-dialog";
|
|
12
17
|
import {
|
|
13
18
|
initialPromptState,
|
|
@@ -64,15 +69,23 @@ export interface PromptPreferences {
|
|
|
64
69
|
*
|
|
65
70
|
* The single entry the `LocalUserAuthorizer` calls; keeps the mode dispatch in
|
|
66
71
|
* one place so the fallback and the inline component never both render.
|
|
72
|
+
*
|
|
73
|
+
* It is therefore also the one place that knows which surface the human
|
|
74
|
+
* answered on, so it is where the decision is attributed to that surface
|
|
75
|
+
* (#726). Having the dialog model and the fallback each name themselves would
|
|
76
|
+
* be two sites that must agree with this branch.
|
|
67
77
|
*/
|
|
68
|
-
export function requestPermissionDecision(
|
|
78
|
+
export async function requestPermissionDecision(
|
|
69
79
|
view: PermissionPromptView,
|
|
70
80
|
title: string,
|
|
71
81
|
payload: PromptPayload,
|
|
72
82
|
options?: RequestPermissionOptions,
|
|
73
83
|
): Promise<PermissionPromptDecision> {
|
|
74
84
|
if (view.mode === "tui") {
|
|
75
|
-
return
|
|
85
|
+
return attributeToHuman(
|
|
86
|
+
await presentInlinePermissionPrompt(view, title, payload, options),
|
|
87
|
+
"dialog",
|
|
88
|
+
);
|
|
76
89
|
}
|
|
77
90
|
// The fallback renders once and cannot re-render, so it neither paints nor
|
|
78
91
|
// offers an expansion; it substitutes a nominal width for the terminal size
|
|
@@ -81,14 +94,25 @@ export function requestPermissionDecision(
|
|
|
81
94
|
...view.budget,
|
|
82
95
|
width: FALLBACK_RENDER_WIDTH,
|
|
83
96
|
});
|
|
84
|
-
return
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
97
|
+
return attributeToHuman(
|
|
98
|
+
await requestPermissionDecisionFromUi(
|
|
99
|
+
view.ui,
|
|
100
|
+
title,
|
|
101
|
+
rendered.lines.join("\n"),
|
|
102
|
+
options,
|
|
103
|
+
),
|
|
104
|
+
"select",
|
|
89
105
|
);
|
|
90
106
|
}
|
|
91
107
|
|
|
108
|
+
function attributeToHuman(
|
|
109
|
+
decision: UnattributedDecision,
|
|
110
|
+
via: UserDecisionSurface,
|
|
111
|
+
): PermissionPromptDecision {
|
|
112
|
+
const decidedBy: DecisionSource = { kind: "user", via };
|
|
113
|
+
return { ...decision, decidedBy };
|
|
114
|
+
}
|
|
115
|
+
|
|
92
116
|
/** The width the `select`/`input` fallback renders against. */
|
|
93
117
|
const FALLBACK_RENDER_WIDTH = 80;
|
|
94
118
|
|
|
@@ -113,13 +137,13 @@ export function presentInlinePermissionPrompt(
|
|
|
113
137
|
title: string,
|
|
114
138
|
payload: PromptPayload,
|
|
115
139
|
options?: RequestPermissionOptions,
|
|
116
|
-
): Promise<
|
|
140
|
+
): Promise<UnattributedDecision> {
|
|
117
141
|
const config: PromptModelConfig = {
|
|
118
142
|
doublePressToConfirm: view.doublePressToConfirm,
|
|
119
143
|
sessionLabel: options?.sessionLabel ?? DEFAULT_SESSION_LABEL,
|
|
120
144
|
sessionScope: options?.sessionScope,
|
|
121
145
|
};
|
|
122
|
-
return view.ui.custom<
|
|
146
|
+
return view.ui.custom<UnattributedDecision>(
|
|
123
147
|
(tui, theme, keybindings, done) =>
|
|
124
148
|
new PermissionPromptComponent(
|
|
125
149
|
theme,
|
|
@@ -176,7 +200,7 @@ class PermissionPromptComponent implements Component {
|
|
|
176
200
|
private readonly budget: RenderBudget,
|
|
177
201
|
private readonly handleAppAction: (data: string) => boolean,
|
|
178
202
|
private readonly requestRender: () => void,
|
|
179
|
-
private readonly done: (decision:
|
|
203
|
+
private readonly done: (decision: UnattributedDecision) => void,
|
|
180
204
|
) {
|
|
181
205
|
this.state = initialPromptState(config);
|
|
182
206
|
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import {
|
|
2
2
|
createDeniedPermissionDecision,
|
|
3
3
|
normalizePermissionDenialReason,
|
|
4
|
-
type PermissionPromptDecision,
|
|
5
4
|
type RequestPermissionOptions,
|
|
5
|
+
type UnattributedDecision,
|
|
6
6
|
} from "#src/authority/permission-dialog";
|
|
7
7
|
|
|
8
8
|
/**
|
|
@@ -70,7 +70,7 @@ export type PromptEvent =
|
|
|
70
70
|
/** Either a re-render or a terminal decision. */
|
|
71
71
|
export type PromptOutcome =
|
|
72
72
|
| { kind: "render"; state: PromptViewState }
|
|
73
|
-
| { kind: "decision"; decision:
|
|
73
|
+
| { kind: "decision"; decision: UnattributedDecision };
|
|
74
74
|
|
|
75
75
|
export function initialPromptState(
|
|
76
76
|
_config: PromptModelConfig,
|
|
@@ -88,7 +88,11 @@ export function initialPromptState(
|
|
|
88
88
|
|
|
89
89
|
/**
|
|
90
90
|
* Advance the dialog by one input event, returning either the next view state
|
|
91
|
-
* to render or the committed {@link
|
|
91
|
+
* to render or the committed {@link UnattributedDecision}.
|
|
92
|
+
*
|
|
93
|
+
* The model states the outcome and not the decider: which human surface this
|
|
94
|
+
* is gets attributed by the dispatcher that chose to render this dialog, so
|
|
95
|
+
* the two cannot disagree about the surface.
|
|
92
96
|
*/
|
|
93
97
|
export function reducePrompt(
|
|
94
98
|
config: PromptModelConfig,
|
|
@@ -1,9 +1,11 @@
|
|
|
1
|
+
import type { DecisionSource } from "#src/authority/decision-source";
|
|
1
2
|
import type { PermissionPromptDecision } from "#src/authority/permission-dialog";
|
|
2
3
|
import type {
|
|
3
4
|
ForwardedAccessFacts,
|
|
4
5
|
ForwardedSessionApproval,
|
|
5
6
|
} from "#src/authority/permission-forwarding";
|
|
6
7
|
import type { PromptPayload } from "#src/presentation/prompt-payload";
|
|
8
|
+
import { renderReviewLogFacts } from "#src/presentation/review-log-renderer";
|
|
7
9
|
import type { ReviewLogger } from "#src/session-logger";
|
|
8
10
|
import type { TerminalAuthorizer } from "./authorizer";
|
|
9
11
|
|
|
@@ -27,13 +29,13 @@ export interface PromptPermissionDetails {
|
|
|
27
29
|
requestId: string;
|
|
28
30
|
source: PermissionReviewSource;
|
|
29
31
|
agentName: string | null;
|
|
30
|
-
message: string;
|
|
31
32
|
/**
|
|
32
33
|
* The complete structured description of this ask (ADR 0011 §2).
|
|
33
34
|
*
|
|
34
35
|
* Required: every ask carries one, and the type is what guarantees it rather
|
|
35
|
-
* than a convention each gate has to remember.
|
|
36
|
-
*
|
|
36
|
+
* than a convention each gate has to remember. Every consumer — the dialog,
|
|
37
|
+
* the wire, the broadcast, the review log, the agent-facing denial text — is
|
|
38
|
+
* a render over it, so no two of them can disagree.
|
|
37
39
|
*/
|
|
38
40
|
payload: PromptPayload;
|
|
39
41
|
toolCallId?: string;
|
|
@@ -130,6 +132,7 @@ export class PermissionPrompter implements PermissionPrompterApi {
|
|
|
130
132
|
? "confirmation_unavailable"
|
|
131
133
|
: decision.state,
|
|
132
134
|
denialReason: decision.denialReason,
|
|
135
|
+
decidedBy: decision.decidedBy,
|
|
133
136
|
},
|
|
134
137
|
);
|
|
135
138
|
|
|
@@ -138,18 +141,24 @@ export class PermissionPrompter implements PermissionPrompterApi {
|
|
|
138
141
|
|
|
139
142
|
// ── Private helpers ──────────────────────────────────────────────────────
|
|
140
143
|
|
|
144
|
+
/**
|
|
145
|
+
* The `waiting` entry carries no `decidedBy` — nothing has decided yet, and
|
|
146
|
+
* a `null` there would read as "decided by nobody" rather than "not yet".
|
|
147
|
+
*/
|
|
141
148
|
private writeReviewEntry(
|
|
142
149
|
event: string,
|
|
143
150
|
details: PromptPermissionDetails & {
|
|
144
151
|
resolution?: string;
|
|
145
152
|
denialReason?: string;
|
|
153
|
+
decidedBy?: DecisionSource;
|
|
146
154
|
},
|
|
147
155
|
): void {
|
|
148
156
|
this.deps.logger.review(event, {
|
|
157
|
+
...(details.decidedBy ? { decidedBy: details.decidedBy } : {}),
|
|
149
158
|
requestId: details.requestId,
|
|
150
159
|
source: details.source,
|
|
151
160
|
agentName: details.agentName,
|
|
152
|
-
|
|
161
|
+
...renderReviewLogFacts(details.payload),
|
|
153
162
|
toolCallId: details.toolCallId ?? null,
|
|
154
163
|
toolName: details.toolName ?? null,
|
|
155
164
|
skillName: details.skillName ?? null,
|
package/src/config-loader.ts
CHANGED
|
@@ -224,6 +224,7 @@ export function mergeUnifiedConfigs(
|
|
|
224
224
|
"forwardingTimeoutMs",
|
|
225
225
|
"promptMaxRows",
|
|
226
226
|
"promptFieldMaxWidth",
|
|
227
|
+
"reviewLogFieldMaxWidth",
|
|
227
228
|
"toolInputPreviewMaxLength",
|
|
228
229
|
"toolTextSummaryMaxLength",
|
|
229
230
|
] as const) {
|
|
@@ -374,6 +375,9 @@ export function loadAndMergeConfigs(
|
|
|
374
375
|
const bashFallbackIssue = detectPermissiveBashFallback(merged.permission);
|
|
375
376
|
if (bashFallbackIssue) allIssues.push(bashFallbackIssue);
|
|
376
377
|
|
|
378
|
+
const deprecatedCapsIssue = detectDeprecatedPreviewCaps(merged);
|
|
379
|
+
if (deprecatedCapsIssue) allIssues.push(deprecatedCapsIssue);
|
|
380
|
+
|
|
377
381
|
return {
|
|
378
382
|
global: globalConfig,
|
|
379
383
|
project: projectConfig,
|
|
@@ -414,6 +418,33 @@ export function detectPermissiveBashFallback(
|
|
|
414
418
|
);
|
|
415
419
|
}
|
|
416
420
|
|
|
421
|
+
/**
|
|
422
|
+
* Detect a config still setting one of the two superseded tool-preview caps.
|
|
423
|
+
*
|
|
424
|
+
* `toolInputPreviewMaxLength` and `toolTextSummaryMaxLength` bounded one
|
|
425
|
+
* preview inside a prompt, never the prompt itself, which is why they never
|
|
426
|
+
* bounded it; `promptMaxRows` and `promptFieldMaxWidth` supersede them
|
|
427
|
+
* (ADR 0011 §5). Both stay valid in the schema so an existing config is not
|
|
428
|
+
* rejected fail-closed — they are simply no longer read.
|
|
429
|
+
*
|
|
430
|
+
* Pure, following `detectPermissiveBashFallback`: it takes the merged config
|
|
431
|
+
* and returns a message; the caller owns pushing it onto the issue list.
|
|
432
|
+
*/
|
|
433
|
+
export function detectDeprecatedPreviewCaps(
|
|
434
|
+
config: UnifiedPermissionConfig,
|
|
435
|
+
): string | undefined {
|
|
436
|
+
const set = (
|
|
437
|
+
["toolInputPreviewMaxLength", "toolTextSummaryMaxLength"] as const
|
|
438
|
+
).filter((key) => config[key] !== undefined);
|
|
439
|
+
if (set.length === 0) return undefined;
|
|
440
|
+
|
|
441
|
+
return (
|
|
442
|
+
`Permission config sets ${set.map((key) => `'${key}'`).join(" and ")}, ` +
|
|
443
|
+
"which is deprecated and ignored. The prompt is bounded by " +
|
|
444
|
+
"'promptMaxRows' and 'promptFieldMaxWidth' instead; remove the setting."
|
|
445
|
+
);
|
|
446
|
+
}
|
|
447
|
+
|
|
417
448
|
/**
|
|
418
449
|
* Load and normalize a unified config file.
|
|
419
450
|
* Returns an empty config with no issues if the file does not exist.
|
package/src/config-schema.ts
CHANGED
|
@@ -208,17 +208,26 @@ export const unifiedConfigSchema = z
|
|
|
208
208
|
"Maximum characters of any one field shown in a permission prompt.\n\nOmit to use the default (400). This is what bounds a single pathological field — a long here-string command, say — that would otherwise fill the prompt through wrapping. A shortened field is marked with an ellipsis, and `Ctrl+O` shows it in full.",
|
|
209
209
|
default: 400,
|
|
210
210
|
}),
|
|
211
|
+
reviewLogFieldMaxWidth: z.number().int().min(1).optional().meta({
|
|
212
|
+
description:
|
|
213
|
+
"Maximum characters of any one value written to the permission review log. Omit to use the default (1000).",
|
|
214
|
+
markdownDescription:
|
|
215
|
+
"Maximum characters of any one value written to the permission review log.\n\nOmit to use the default (1000). Every string the review log writes is narrowed to this width and marked with an ellipsis, so the log's growth is a decision you make rather than a side effect of how long a command happened to be. Raise it to keep longer values \u2014 a bash command exceeding the width is stored shortened.\n\nThis is a length bound, not redaction: it never inspects a value to decide what to hide. Key-name masking is unchanged and applies independently.",
|
|
216
|
+
default: 1000,
|
|
217
|
+
}),
|
|
211
218
|
toolInputPreviewMaxLength: z.number().int().min(1).optional().meta({
|
|
219
|
+
deprecated: true,
|
|
212
220
|
description:
|
|
213
|
-
"
|
|
221
|
+
"Deprecated and ignored. Superseded by promptMaxRows and promptFieldMaxWidth, which bound the whole prompt rather than one preview. Still accepted so an existing config is not rejected; remove it.",
|
|
214
222
|
markdownDescription:
|
|
215
|
-
"
|
|
223
|
+
"**Deprecated and ignored.** Superseded by `promptMaxRows` and `promptFieldMaxWidth`, which bound the whole permission prompt rather than one preview inside it.\n\nStill accepted so an existing config is not rejected fail-closed, but the value no longer takes effect. Remove it.",
|
|
216
224
|
}),
|
|
217
225
|
toolTextSummaryMaxLength: z.number().int().min(1).optional().meta({
|
|
226
|
+
deprecated: true,
|
|
218
227
|
description:
|
|
219
|
-
"
|
|
228
|
+
"Deprecated and ignored. Superseded by promptMaxRows and promptFieldMaxWidth, which bound the whole prompt rather than one summary. Still accepted so an existing config is not rejected; remove it.",
|
|
220
229
|
markdownDescription:
|
|
221
|
-
"
|
|
230
|
+
"**Deprecated and ignored.** Superseded by `promptMaxRows` and `promptFieldMaxWidth`, which bound the whole permission prompt rather than one summary inside it.\n\nStill accepted so an existing config is not rejected fail-closed, but the value no longer takes effect. Remove it.",
|
|
222
231
|
}),
|
|
223
232
|
piInfrastructureReadPaths: z.array(z.string().min(1)).optional().meta({
|
|
224
233
|
description:
|
package/src/extension-config.ts
CHANGED
|
@@ -26,10 +26,8 @@ export interface PermissionSystemExtensionConfig {
|
|
|
26
26
|
promptMaxRows?: number;
|
|
27
27
|
/** Max characters of any one field shown in a permission prompt. Defaults to 400. */
|
|
28
28
|
promptFieldMaxWidth?: number;
|
|
29
|
-
/** Max
|
|
30
|
-
|
|
31
|
-
/** Max length of inline pattern/path summaries (grep/find/ls) in permission prompts. Defaults to 80. */
|
|
32
|
-
toolTextSummaryMaxLength?: number;
|
|
29
|
+
/** Max characters of any one value written to the permission review log. Defaults to 1000. */
|
|
30
|
+
reviewLogFieldMaxWidth?: number;
|
|
33
31
|
/** Non-bash tools that carry shell semantics, keyed by tool name. */
|
|
34
32
|
shellTools?: ShellToolsConfig;
|
|
35
33
|
/** Ordered names of registered live-authority chain links to consult before the terminal authorizer. */
|
|
@@ -86,12 +84,13 @@ export function normalizePermissionSystemConfig(
|
|
|
86
84
|
if (raw.promptFieldMaxWidth !== undefined) {
|
|
87
85
|
result.promptFieldMaxWidth = raw.promptFieldMaxWidth;
|
|
88
86
|
}
|
|
89
|
-
if (raw.
|
|
90
|
-
result.
|
|
91
|
-
}
|
|
92
|
-
if (raw.toolTextSummaryMaxLength !== undefined) {
|
|
93
|
-
result.toolTextSummaryMaxLength = raw.toolTextSummaryMaxLength;
|
|
87
|
+
if (raw.reviewLogFieldMaxWidth !== undefined) {
|
|
88
|
+
result.reviewLogFieldMaxWidth = raw.reviewLogFieldMaxWidth;
|
|
94
89
|
}
|
|
90
|
+
// `toolInputPreviewMaxLength` / `toolTextSummaryMaxLength` are deliberately
|
|
91
|
+
// absent: the schema and the merge still accept them so the deprecation
|
|
92
|
+
// detector can see an operator's setting, but no runtime consumer may read
|
|
93
|
+
// one (ADR 0011 §5, #745).
|
|
95
94
|
if (raw.shellTools !== undefined) {
|
|
96
95
|
result.shellTools = raw.shellTools;
|
|
97
96
|
}
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import type { BashProgram } from "#src/access-intent/bash/program";
|
|
2
2
|
import type { ScopedPermissionResolver } from "#src/permission-resolver";
|
|
3
|
-
import { renderLegacyMessage } from "#src/presentation/legacy-message";
|
|
4
3
|
import { buildBashExternalDirectoryAskPayload } from "#src/presentation/path-ask-payload";
|
|
5
4
|
import { SessionApproval } from "#src/session-approval";
|
|
6
5
|
import { deriveApprovalPattern } from "#src/session-rules";
|
|
@@ -48,6 +47,15 @@ export function describeBashExternalDirectoryGate(
|
|
|
48
47
|
if (uncoveredPaths.length === 0) {
|
|
49
48
|
return {
|
|
50
49
|
action: "allow",
|
|
50
|
+
// A whole-command bypass covers every external path at once, and each
|
|
51
|
+
// may have matched a different session pattern -- so the surface is one
|
|
52
|
+
// value and the pattern is not. The entry's `externalPaths` lists what
|
|
53
|
+
// was covered.
|
|
54
|
+
decidedBy: {
|
|
55
|
+
kind: "session_approval",
|
|
56
|
+
surface: "external_directory",
|
|
57
|
+
pattern: null,
|
|
58
|
+
},
|
|
51
59
|
log: {
|
|
52
60
|
event: "permission_request.session_approved",
|
|
53
61
|
details: {
|
|
@@ -85,26 +93,17 @@ export function describeBashExternalDirectoryGate(
|
|
|
85
93
|
toolName: tcc.toolName,
|
|
86
94
|
matchedPattern: preCheck.matchedPattern,
|
|
87
95
|
});
|
|
88
|
-
const bashExtMessage = renderLegacyMessage(payload);
|
|
89
96
|
|
|
90
97
|
const patterns = uncoveredPaths.map((p) => deriveApprovalPattern(p));
|
|
91
98
|
|
|
92
99
|
return {
|
|
93
100
|
surface: "external_directory",
|
|
94
101
|
input: {},
|
|
95
|
-
|
|
96
|
-
kind: "bash_external_directory",
|
|
97
|
-
command,
|
|
98
|
-
externalPaths: disclosures,
|
|
99
|
-
cwd: tcc.cwd,
|
|
100
|
-
agentName: tcc.agentName ?? undefined,
|
|
101
|
-
},
|
|
102
|
+
payload,
|
|
102
103
|
sessionApproval: SessionApproval.multiple("external_directory", patterns),
|
|
103
104
|
promptDetails: {
|
|
104
105
|
source: "tool_call",
|
|
105
106
|
agentName: tcc.agentName,
|
|
106
|
-
message: bashExtMessage,
|
|
107
|
-
payload,
|
|
108
107
|
toolCallId: tcc.toolCallId,
|
|
109
108
|
toolName: tcc.toolName,
|
|
110
109
|
command,
|
|
@@ -117,7 +116,6 @@ export function describeBashExternalDirectoryGate(
|
|
|
117
116
|
agentName: tcc.agentName,
|
|
118
117
|
command,
|
|
119
118
|
externalPaths: uncoveredPaths,
|
|
120
|
-
message: bashExtMessage,
|
|
121
119
|
},
|
|
122
120
|
decision: {
|
|
123
121
|
surface: "external_directory",
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import type { AccessPath } from "#src/access-intent/access-path";
|
|
2
2
|
import type { BashProgram } from "#src/access-intent/bash/program";
|
|
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";
|
|
@@ -85,6 +84,14 @@ export function describeBashPathGate(
|
|
|
85
84
|
if (allSessionCovered) {
|
|
86
85
|
return {
|
|
87
86
|
action: "allow",
|
|
87
|
+
// Every token was covered, each possibly by a different session pattern
|
|
88
|
+
// -- the surface is one value and the pattern is not. The entry's
|
|
89
|
+
// `tokens` lists what was covered.
|
|
90
|
+
decidedBy: {
|
|
91
|
+
kind: "session_approval",
|
|
92
|
+
surface: "path",
|
|
93
|
+
pattern: null,
|
|
94
|
+
},
|
|
88
95
|
log: {
|
|
89
96
|
event: "permission_request.session_approved",
|
|
90
97
|
details: {
|
|
@@ -120,23 +127,15 @@ export function describeBashPathGate(
|
|
|
120
127
|
agentName: tcc.agentName,
|
|
121
128
|
matchedPattern: worstCheck.matchedPattern,
|
|
122
129
|
});
|
|
123
|
-
const askMessage = renderLegacyMessage(payload);
|
|
124
130
|
|
|
125
131
|
return {
|
|
126
132
|
surface: "path",
|
|
127
133
|
input: { path: worstToken },
|
|
128
|
-
|
|
129
|
-
kind: "bash_path",
|
|
130
|
-
command,
|
|
131
|
-
pathValue: worstToken,
|
|
132
|
-
agentName: tcc.agentName ?? undefined,
|
|
133
|
-
},
|
|
134
|
+
payload,
|
|
134
135
|
sessionApproval: SessionApproval.single("path", pattern),
|
|
135
136
|
promptDetails: {
|
|
136
137
|
source: "tool_call",
|
|
137
138
|
agentName: tcc.agentName,
|
|
138
|
-
message: askMessage,
|
|
139
|
-
payload,
|
|
140
139
|
toolCallId: tcc.toolCallId,
|
|
141
140
|
toolName: tcc.toolName,
|
|
142
141
|
command,
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
import type { DecisionSource } from "#src/authority/decision-source";
|
|
1
2
|
import type { PromptPermissionDetails } from "#src/authority/permission-prompter";
|
|
2
|
-
import type { DenialContext } from "#src/denial-messages";
|
|
3
3
|
import type { PermissionDecisionEvent } from "#src/permission-events";
|
|
4
|
+
import type { PromptPayload } from "#src/presentation/prompt-payload";
|
|
4
5
|
import type { SessionApproval } from "#src/session-approval";
|
|
5
6
|
import type { PermissionCheckResult, PermissionState } from "#src/types";
|
|
6
7
|
|
|
@@ -18,16 +19,27 @@ export interface GateDescriptor {
|
|
|
18
19
|
surface: string;
|
|
19
20
|
/** Input passed to checkPermission. */
|
|
20
21
|
input: unknown;
|
|
21
|
-
/**
|
|
22
|
-
|
|
22
|
+
/**
|
|
23
|
+
* The complete structured description of this ask (ADR 0011 §2).
|
|
24
|
+
*
|
|
25
|
+
* The descriptor's one presentation fact: every render over it — the dialog,
|
|
26
|
+
* the agent-facing denial text, the review log — reads this and nothing
|
|
27
|
+
* else, so a gate states its facts once.
|
|
28
|
+
*/
|
|
29
|
+
payload: PromptPayload;
|
|
23
30
|
/**
|
|
24
31
|
* Session-approval suggestion for the "for this session" option.
|
|
25
32
|
* Wraps either a single pattern or multiple patterns behind a unified
|
|
26
33
|
* interface — the runner never needs to know which case applies.
|
|
27
34
|
*/
|
|
28
35
|
sessionApproval?: SessionApproval;
|
|
29
|
-
/**
|
|
30
|
-
|
|
36
|
+
/**
|
|
37
|
+
* Details passed to the interactive permission prompt.
|
|
38
|
+
*
|
|
39
|
+
* The runner stamps both `requestId` (which it mints) and `payload` (which
|
|
40
|
+
* the descriptor owns), so neither is a gate's to supply twice.
|
|
41
|
+
*/
|
|
42
|
+
promptDetails: Omit<PromptPermissionDetails, "requestId" | "payload">;
|
|
31
43
|
/** Extra context fields written to the review log alongside gate outcomes. */
|
|
32
44
|
logContext: Record<string, unknown>;
|
|
33
45
|
/** Surface and value for the decision event (may differ from the check surface). */
|
|
@@ -68,6 +80,14 @@ export type DecisionEventFacts = Omit<PermissionDecisionEvent, "requestId">;
|
|
|
68
80
|
*/
|
|
69
81
|
export interface GateBypass {
|
|
70
82
|
action: "allow";
|
|
83
|
+
/**
|
|
84
|
+
* What decided this short-circuit.
|
|
85
|
+
*
|
|
86
|
+
* The gate that bypasses *is* the decider, so it states its own provenance
|
|
87
|
+
* and the runner relays it onto the log entry rather than inferring one from
|
|
88
|
+
* the event name (#726). Required, so a bypass added later cannot omit it.
|
|
89
|
+
*/
|
|
90
|
+
decidedBy: DecisionSource;
|
|
71
91
|
/** Optional review log entry to emit. */
|
|
72
92
|
log?: { event: string; details: Record<string, unknown> };
|
|
73
93
|
/** Optional decision event to emit. */
|