@gotgenes/pi-permission-system 39.1.1 → 40.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 +28 -0
- package/dist/public.d.ts +36 -0
- package/docs/configuration.md +36 -0
- package/docs/cross-extension-api.md +11 -10
- package/docs/subagent-integration.md +1 -0
- package/package.json +1 -1
- package/src/access-intent/bash/bash-path-resolver.ts +85 -16
- package/src/access-intent/bash/command-enumeration.ts +77 -17
- package/src/access-intent/bash/program.ts +34 -8
- package/src/access-intent/bash/token-collection.ts +47 -10
- package/src/authority/forwarded-request-server.ts +6 -1
- package/src/authority/forwarding-io.ts +44 -0
- package/src/authority/permission-forwarding.ts +9 -0
- package/src/handlers/gates/bash-command.ts +28 -3
- package/src/handlers/gates/tool.ts +15 -1
- package/src/logging/command-redaction.ts +1 -0
- package/src/policy/permission-manager.ts +44 -11
- package/src/policy/serving-policy.ts +40 -1
- package/src/presentation/dialog-renderer.ts +3 -0
- package/src/presentation/forwarded-ask-payload.ts +1 -0
- package/src/presentation/path-ask-payload.ts +2 -0
- package/src/presentation/prompt-payload.ts +11 -0
- package/src/presentation/review-log-renderer.ts +1 -0
- package/src/presentation/skill-ask-payload.ts +1 -0
- package/src/presentation/tool-ask-payload.ts +1 -0
- package/src/service/bash-advisory-check.ts +16 -5
- package/src/service/permissions-service.ts +6 -1
- package/src/types.ts +23 -0
- package/src/access-intent/bash/sync-commands.ts +0 -43
|
@@ -39,6 +39,19 @@ export interface PathToken {
|
|
|
39
39
|
readonly token: string;
|
|
40
40
|
readonly effect: TokenEffect;
|
|
41
41
|
readonly role: TokenRole;
|
|
42
|
+
/**
|
|
43
|
+
* The source span of the argument node, set only when {@link token} is that
|
|
44
|
+
* whole node's resolved text. A token read from part of an argument (an
|
|
45
|
+
* option's embedded value, a script file glued to its flag) carries none, so
|
|
46
|
+
* nothing keyed on the span can mistake it for the whole word.
|
|
47
|
+
*/
|
|
48
|
+
readonly span?: SourceSpan;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Where a node sits in the parsed source: `startIndex` to `endIndex`. */
|
|
52
|
+
export interface SourceSpan {
|
|
53
|
+
readonly start: number;
|
|
54
|
+
readonly end: number;
|
|
42
55
|
}
|
|
43
56
|
|
|
44
57
|
// ── Public surface ─────────────────────────────────────────────────────────
|
|
@@ -160,7 +173,7 @@ export function collectRedirectTokens(
|
|
|
160
173
|
i === target && provesTarget(effect, child, token, words)
|
|
161
174
|
? "redirect-destination"
|
|
162
175
|
: "operand";
|
|
163
|
-
tokens.push(
|
|
176
|
+
tokens.push(wholeArgumentToken(child, token, effect, role));
|
|
164
177
|
}
|
|
165
178
|
}
|
|
166
179
|
tokens.push(...collectHostedExecutionTokens(child, words));
|
|
@@ -271,11 +284,9 @@ function collectStatementOperandTokens(
|
|
|
271
284
|
tokens.push(...collectPathCandidateTokens(child, words));
|
|
272
285
|
continue;
|
|
273
286
|
}
|
|
274
|
-
tokens.push(
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
role: "operand",
|
|
278
|
-
});
|
|
287
|
+
tokens.push(
|
|
288
|
+
wholeArgumentToken(child, words.text(child), UNPROVEN_EFFECT, "operand"),
|
|
289
|
+
);
|
|
279
290
|
tokens.push(...collectHostedExecutionTokens(child, words));
|
|
280
291
|
}
|
|
281
292
|
return tokens;
|
|
@@ -833,7 +844,12 @@ function collectPatternCommandTokens(
|
|
|
833
844
|
tokens.push(...collectPathCandidateTokens(child, words));
|
|
834
845
|
continue;
|
|
835
846
|
}
|
|
836
|
-
const discharge = dischargePendingConsumption(
|
|
847
|
+
const discharge = dischargePendingConsumption(
|
|
848
|
+
consumption,
|
|
849
|
+
child,
|
|
850
|
+
text,
|
|
851
|
+
effect,
|
|
852
|
+
);
|
|
837
853
|
if (discharge.token) tokens.push(discharge.token);
|
|
838
854
|
if (discharge.consumed) continue;
|
|
839
855
|
}
|
|
@@ -907,7 +923,7 @@ function collectPatternCommandTokens(
|
|
|
907
923
|
if (!hasExplicitScript && positionalsSeen < patternPositionals) {
|
|
908
924
|
positionalsSeen++; // Skip: this is an inline pattern/script.
|
|
909
925
|
} else {
|
|
910
|
-
tokens.push(
|
|
926
|
+
tokens.push(wholeArgumentToken(child, text, effect, "operand"));
|
|
911
927
|
}
|
|
912
928
|
// A quoted token that did not act as a flag above has its embedded value
|
|
913
929
|
// split here instead.
|
|
@@ -957,6 +973,7 @@ interface ConsumptionDischarge {
|
|
|
957
973
|
/** Apply a pending consumption to the argument text that follows its flag. */
|
|
958
974
|
function dischargePendingConsumption(
|
|
959
975
|
role: PatternFlagRole,
|
|
976
|
+
argument: TSNode,
|
|
960
977
|
text: string,
|
|
961
978
|
effect: TokenEffect,
|
|
962
979
|
): ConsumptionDischarge {
|
|
@@ -964,7 +981,7 @@ function dischargePendingConsumption(
|
|
|
964
981
|
case "script-file":
|
|
965
982
|
return {
|
|
966
983
|
consumed: true,
|
|
967
|
-
token:
|
|
984
|
+
token: wholeArgumentToken(argument, text, effect, "operand"),
|
|
968
985
|
};
|
|
969
986
|
case "script":
|
|
970
987
|
case "value":
|
|
@@ -1015,7 +1032,9 @@ function collectGenericCommandTokens(
|
|
|
1015
1032
|
|
|
1016
1033
|
// Argument nodes: resolve their text and collect.
|
|
1017
1034
|
if (ARG_NODE_TYPES.has(child.type)) {
|
|
1018
|
-
tokens.push(
|
|
1035
|
+
tokens.push(
|
|
1036
|
+
wholeArgumentToken(child, words.text(child), effect, "operand"),
|
|
1037
|
+
);
|
|
1019
1038
|
continue;
|
|
1020
1039
|
}
|
|
1021
1040
|
|
|
@@ -1025,3 +1044,21 @@ function collectGenericCommandTokens(
|
|
|
1025
1044
|
|
|
1026
1045
|
return tokens;
|
|
1027
1046
|
}
|
|
1047
|
+
|
|
1048
|
+
/**
|
|
1049
|
+
* A token that is `node`'s whole resolved text, carrying the node's source span
|
|
1050
|
+
* so a reader of the span knows the token stands for the entire word.
|
|
1051
|
+
*/
|
|
1052
|
+
function wholeArgumentToken(
|
|
1053
|
+
node: TSNode,
|
|
1054
|
+
token: string,
|
|
1055
|
+
effect: TokenEffect,
|
|
1056
|
+
role: TokenRole,
|
|
1057
|
+
): PathToken {
|
|
1058
|
+
return {
|
|
1059
|
+
token,
|
|
1060
|
+
effect,
|
|
1061
|
+
role,
|
|
1062
|
+
span: { start: node.startIndex, end: node.endIndex },
|
|
1063
|
+
};
|
|
1064
|
+
}
|
|
@@ -57,7 +57,9 @@ export interface InboxProcessor {
|
|
|
57
57
|
* used as-is, never re-derived through this session's `PathNormalizer`/cwd.
|
|
58
58
|
*
|
|
59
59
|
* The answer honors the floor the child raised (`intent.floor`): a serving
|
|
60
|
-
* `allow` rule does not answer an ask the child's own gate floored.
|
|
60
|
+
* `allow` rule does not answer an ask the child's own gate floored. A bash
|
|
61
|
+
* chain is judged on every unit the child left asking (`intent.askingUnits`),
|
|
62
|
+
* each with its own floor, since this answer approves the whole tool call.
|
|
61
63
|
*
|
|
62
64
|
* Narrow by design (ISP): the server needs one decision, not the whole
|
|
63
65
|
* resolver. The composition root satisfies it with `ResolverServingPolicy`
|
|
@@ -155,6 +157,9 @@ function toAccessFacts(intent: ForwardedAccessIntent): ForwardedAccessFacts {
|
|
|
155
157
|
matchValues: intent.matchValues,
|
|
156
158
|
boundaryValue: intent.boundaryValue,
|
|
157
159
|
...(intent.floor === undefined ? {} : { floor: intent.floor }),
|
|
160
|
+
...(intent.askingUnits === undefined
|
|
161
|
+
? {}
|
|
162
|
+
: { askingUnits: intent.askingUnits }),
|
|
158
163
|
};
|
|
159
164
|
}
|
|
160
165
|
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
type ApprovalGrant,
|
|
20
20
|
isSessionGrantWidth,
|
|
21
21
|
} from "#src/session/approval-grant";
|
|
22
|
+
import type { AskingBashUnit } from "#src/types";
|
|
22
23
|
import { asDecisionSource } from "./decision-source";
|
|
23
24
|
import { isPermissionDecisionState } from "./permission-dialog";
|
|
24
25
|
import {
|
|
@@ -125,6 +126,7 @@ function asForwardedAccessIntent(
|
|
|
125
126
|
matchValues?: unknown;
|
|
126
127
|
boundaryValue?: unknown;
|
|
127
128
|
floor?: unknown;
|
|
129
|
+
askingUnits?: unknown;
|
|
128
130
|
requesterCwd?: unknown;
|
|
129
131
|
principal?: unknown;
|
|
130
132
|
};
|
|
@@ -139,6 +141,12 @@ function asForwardedAccessIntent(
|
|
|
139
141
|
// A malformed floor drops the whole intent, which escalates: it must
|
|
140
142
|
// never read as "no floor".
|
|
141
143
|
!(candidate.floor === undefined || typeof candidate.floor === "string") ||
|
|
144
|
+
// Likewise a malformed unit list: it must never read as "no units", which
|
|
145
|
+
// would judge the chain's winner alone.
|
|
146
|
+
!(
|
|
147
|
+
candidate.askingUnits === undefined ||
|
|
148
|
+
isWellFormedAskingUnits(candidate.askingUnits)
|
|
149
|
+
) ||
|
|
142
150
|
typeof candidate.requesterCwd !== "string" ||
|
|
143
151
|
typeof candidate.principal !== "object" ||
|
|
144
152
|
candidate.principal === null
|
|
@@ -160,6 +168,9 @@ function asForwardedAccessIntent(
|
|
|
160
168
|
matchValues: [...candidate.matchValues],
|
|
161
169
|
boundaryValue: candidate.boundaryValue,
|
|
162
170
|
...(candidate.floor === undefined ? {} : { floor: candidate.floor }),
|
|
171
|
+
...(isWellFormedAskingUnits(candidate.askingUnits)
|
|
172
|
+
? { askingUnits: copyAskingUnits(candidate.askingUnits) }
|
|
173
|
+
: {}),
|
|
163
174
|
requesterCwd: candidate.requesterCwd,
|
|
164
175
|
principal: {
|
|
165
176
|
sessionId: principal.sessionId,
|
|
@@ -168,6 +179,39 @@ function asForwardedAccessIntent(
|
|
|
168
179
|
};
|
|
169
180
|
}
|
|
170
181
|
|
|
182
|
+
/**
|
|
183
|
+
* True for a non-empty list of `{ command: string, floor?: string }` units.
|
|
184
|
+
*
|
|
185
|
+
* Empty is malformed: a child stamps the list only when it holds a unit, so an
|
|
186
|
+
* empty one says nothing a well-formed request would.
|
|
187
|
+
*/
|
|
188
|
+
function isWellFormedAskingUnits(
|
|
189
|
+
value: unknown,
|
|
190
|
+
): value is readonly { command: string; floor?: string }[] {
|
|
191
|
+
return (
|
|
192
|
+
Array.isArray(value) &&
|
|
193
|
+
value.length > 0 &&
|
|
194
|
+
value.every(
|
|
195
|
+
(unit: unknown) =>
|
|
196
|
+
typeof unit === "object" &&
|
|
197
|
+
unit !== null &&
|
|
198
|
+
typeof (unit as { command?: unknown }).command === "string" &&
|
|
199
|
+
((unit as { floor?: unknown }).floor === undefined ||
|
|
200
|
+
typeof (unit as { floor?: unknown }).floor === "string"),
|
|
201
|
+
)
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Rebuild each unit field by field, so no unread key rides through. */
|
|
206
|
+
function copyAskingUnits(
|
|
207
|
+
units: readonly { command: string; floor?: string }[],
|
|
208
|
+
): AskingBashUnit[] {
|
|
209
|
+
return units.map((unit) => ({
|
|
210
|
+
command: unit.command,
|
|
211
|
+
...(unit.floor === undefined ? {} : { floor: unit.floor }),
|
|
212
|
+
}));
|
|
213
|
+
}
|
|
214
|
+
|
|
171
215
|
export function formatUnknownErrorMessage(error: unknown): string {
|
|
172
216
|
if (error instanceof Error && error.message) {
|
|
173
217
|
return error.message;
|
|
@@ -5,6 +5,7 @@ import type {
|
|
|
5
5
|
ApprovalGrant,
|
|
6
6
|
SessionGrantWidth,
|
|
7
7
|
} from "#src/session/approval-grant";
|
|
8
|
+
import type { AskingBashUnit } from "#src/types";
|
|
8
9
|
import type { DecisionSource } from "./decision-source";
|
|
9
10
|
import type { PermissionDecisionState } from "./permission-dialog";
|
|
10
11
|
import type { SubagentSessionRegistry } from "./subagent-registry";
|
|
@@ -131,6 +132,14 @@ export interface ForwardedAccessFacts {
|
|
|
131
132
|
* than recomputing it. Absent when no floor was raised.
|
|
132
133
|
*/
|
|
133
134
|
floor?: string;
|
|
135
|
+
/**
|
|
136
|
+
* Every unit of a bash chain the child left asking, each with its own floor.
|
|
137
|
+
* `matchValues` names only the chain's winner, and the serving node's answer
|
|
138
|
+
* approves the whole tool call, so it judges each of these instead. Absent
|
|
139
|
+
* for a non-bash ask and for a bash ask raised without a chain (the empty
|
|
140
|
+
* parse), where `matchValues` is the whole command.
|
|
141
|
+
*/
|
|
142
|
+
askingUnits?: AskingBashUnit[];
|
|
134
143
|
}
|
|
135
144
|
|
|
136
145
|
/**
|
|
@@ -115,7 +115,30 @@ export function resolveBashCommandCheck(
|
|
|
115
115
|
const winner =
|
|
116
116
|
pickMostRestrictive(results) ??
|
|
117
117
|
resolveOnBashSurface(command, [], agentName, resolver);
|
|
118
|
-
return withChainFloor(winner, results);
|
|
118
|
+
return withAskingUnits(withChainFloor(winner, results), results);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* List on an asking winner every unit of the chain the gate left asking.
|
|
123
|
+
*
|
|
124
|
+
* The winner names one command, but a forwarded ask's answer approves the
|
|
125
|
+
* whole tool call, so the serving node has to judge every unit the child could
|
|
126
|
+
* not resolve, each with its own floor (#1030). A unit the child's rules
|
|
127
|
+
* allowed stays home, and so does one the session already granted: the user
|
|
128
|
+
* approved exactly that command.
|
|
129
|
+
*/
|
|
130
|
+
function withAskingUnits(
|
|
131
|
+
winner: PermissionCheckResult,
|
|
132
|
+
results: readonly PermissionCheckResult[],
|
|
133
|
+
): PermissionCheckResult {
|
|
134
|
+
if (winner.state !== "ask") return winner;
|
|
135
|
+
const askingUnits = results
|
|
136
|
+
.filter((result) => result.state === "ask" && result.source !== "session")
|
|
137
|
+
.map((result) => ({
|
|
138
|
+
command: result.command ?? "",
|
|
139
|
+
...(result.floor === undefined ? {} : { floor: result.floor }),
|
|
140
|
+
}));
|
|
141
|
+
return askingUnits.length === 0 ? winner : { ...winner, askingUnits };
|
|
119
142
|
}
|
|
120
143
|
|
|
121
144
|
/**
|
|
@@ -205,14 +228,16 @@ function floorUnparsedUnit(
|
|
|
205
228
|
* it.
|
|
206
229
|
*
|
|
207
230
|
* Spreads `resolved`, so a `source: "session"` grant survives to `GateRunner`'s
|
|
208
|
-
* session fast path.
|
|
231
|
+
* session fast path. Drops `matchedSpelling`: it names what the replaced rule
|
|
232
|
+
* matched, and beside the sentinel it would name a match that did not decide.
|
|
209
233
|
*/
|
|
210
234
|
function floorToAsk(
|
|
211
235
|
resolved: PermissionCheckResult,
|
|
212
236
|
sentinel: string,
|
|
213
237
|
): PermissionCheckResult {
|
|
238
|
+
const { matchedSpelling: _replaced, ...unspelled } = resolved;
|
|
214
239
|
return {
|
|
215
|
-
...
|
|
240
|
+
...unspelled,
|
|
216
241
|
state: "ask",
|
|
217
242
|
matchedPattern: sentinel,
|
|
218
243
|
floor: sentinel,
|
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
import { buildToolAskPayload } from "#src/presentation/tool-ask-payload";
|
|
15
15
|
import { SessionApproval } from "#src/session/session-approval";
|
|
16
16
|
import type { ToolPreviewFormatter } from "#src/tool-input/tool-preview-formatter";
|
|
17
|
-
import type { PermissionCheckResult } from "#src/types";
|
|
17
|
+
import type { AskingBashUnit, PermissionCheckResult } from "#src/types";
|
|
18
18
|
import type { GateDescriptor } from "./descriptor";
|
|
19
19
|
import {
|
|
20
20
|
accessFactsFromPath,
|
|
@@ -180,6 +180,7 @@ export function describeToolGate(
|
|
|
180
180
|
: {
|
|
181
181
|
...accessFactsFromValue(gateSurface, decisionValue),
|
|
182
182
|
...floorFact(check),
|
|
183
|
+
...askingUnitsFact(check),
|
|
183
184
|
};
|
|
184
185
|
|
|
185
186
|
return {
|
|
@@ -241,3 +242,16 @@ function floorExemptionFact(
|
|
|
241
242
|
function floorFact(check: PermissionCheckResult): { floor?: string } {
|
|
242
243
|
return check.floor === undefined ? {} : { floor: check.floor };
|
|
243
244
|
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Every unit of a bash chain the child left asking, as a child-fixed fact for
|
|
248
|
+
* the wire, so the serving node judges each command rather than the winner
|
|
249
|
+
* alone (#1030). Absent when the check lists none.
|
|
250
|
+
*/
|
|
251
|
+
function askingUnitsFact(check: PermissionCheckResult): {
|
|
252
|
+
askingUnits?: AskingBashUnit[];
|
|
253
|
+
} {
|
|
254
|
+
return check.askingUnits === undefined
|
|
255
|
+
? {}
|
|
256
|
+
: { askingUnits: [...check.askingUnits] };
|
|
257
|
+
}
|
|
@@ -42,6 +42,7 @@ import { isSensitiveName, REDACTED_PLACEHOLDER } from "./log-redaction";
|
|
|
42
42
|
export const COMMAND_BEARING_LOG_KEYS: ReadonlySet<string> = new Set([
|
|
43
43
|
"command",
|
|
44
44
|
"executedUnit",
|
|
45
|
+
"matchedSpelling",
|
|
45
46
|
]);
|
|
46
47
|
|
|
47
48
|
/** A range of the command to replace, and what to put in its place. */
|
|
@@ -376,7 +376,7 @@ export class PermissionManager implements ScopedPermissionManager {
|
|
|
376
376
|
intent.command,
|
|
377
377
|
intent.spellings,
|
|
378
378
|
);
|
|
379
|
-
|
|
379
|
+
const { result, matchedValue } = evaluateCheck(
|
|
380
380
|
surface,
|
|
381
381
|
values,
|
|
382
382
|
resultExtras,
|
|
@@ -385,6 +385,12 @@ export class PermissionManager implements ScopedPermissionManager {
|
|
|
385
385
|
fullRules,
|
|
386
386
|
this.flavor,
|
|
387
387
|
);
|
|
388
|
+
// `values[0]` is the unit as typed, and the evaluator reports the first
|
|
389
|
+
// value the winning rule matches, so any other value is a spelling the
|
|
390
|
+
// typed text did not match.
|
|
391
|
+
return matchedValue === values[0]
|
|
392
|
+
? result
|
|
393
|
+
: { ...result, matchedSpelling: matchedValue };
|
|
388
394
|
}
|
|
389
395
|
|
|
390
396
|
// kind === "tool"
|
|
@@ -423,6 +429,30 @@ function buildCheckResult(
|
|
|
423
429
|
fullRules: Ruleset,
|
|
424
430
|
flavor: PathFlavor,
|
|
425
431
|
): PermissionCheckResult {
|
|
432
|
+
return evaluateCheck(
|
|
433
|
+
surface,
|
|
434
|
+
values,
|
|
435
|
+
resultExtras,
|
|
436
|
+
normalizedToolName,
|
|
437
|
+
toolName,
|
|
438
|
+
fullRules,
|
|
439
|
+
flavor,
|
|
440
|
+
).result;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* {@link buildCheckResult}, plus the candidate value the decision was reported
|
|
445
|
+
* under — for a caller that knows what that value means on its own surface.
|
|
446
|
+
*/
|
|
447
|
+
function evaluateCheck(
|
|
448
|
+
surface: string,
|
|
449
|
+
values: string[],
|
|
450
|
+
resultExtras: Record<string, unknown>,
|
|
451
|
+
normalizedToolName: string,
|
|
452
|
+
toolName: string,
|
|
453
|
+
fullRules: Ruleset,
|
|
454
|
+
flavor: PathFlavor,
|
|
455
|
+
): { result: PermissionCheckResult; matchedValue: string } {
|
|
426
456
|
const { rule, value } = evaluateAnyValue(surface, values, fullRules, flavor);
|
|
427
457
|
|
|
428
458
|
// For MCP, replace the normalizer's fallback target with the actual
|
|
@@ -433,16 +463,19 @@ function buildCheckResult(
|
|
|
433
463
|
: resultExtras;
|
|
434
464
|
|
|
435
465
|
return {
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
466
|
+
result: {
|
|
467
|
+
toolName,
|
|
468
|
+
state: rule.action,
|
|
469
|
+
reason: rule.reason,
|
|
470
|
+
matchedPattern:
|
|
471
|
+
rule.layer === "config" || rule.layer === "session"
|
|
472
|
+
? rule.pattern
|
|
473
|
+
: undefined,
|
|
474
|
+
source: deriveSource(rule, normalizedToolName),
|
|
475
|
+
origin: rule.origin,
|
|
476
|
+
...extras,
|
|
477
|
+
},
|
|
478
|
+
matchedValue: value,
|
|
446
479
|
};
|
|
447
480
|
}
|
|
448
481
|
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { buildResolvedIntentFromMatchValues } from "#src/access-intent/input-normalizer";
|
|
2
2
|
import type { ServingPolicy } from "#src/authority/forwarded-request-server";
|
|
3
3
|
import type { ForwardedAccessIntent } from "#src/authority/permission-forwarding";
|
|
4
|
-
import type { PermissionCheckResult } from "#src/types";
|
|
4
|
+
import type { AskingBashUnit, PermissionCheckResult } from "#src/types";
|
|
5
5
|
import type { PermissionResolver } from "./permission-resolver";
|
|
6
|
+
import { mostRestrictiveOf } from "./restrictiveness";
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* The serving node's recorded authority over a forwarded request: the
|
|
@@ -10,6 +11,12 @@ import type { PermissionResolver } from "./permission-resolver";
|
|
|
10
11
|
* agent-scoped to the requester (`principal.agentName`, §3), with the floor the
|
|
11
12
|
* child raised honored.
|
|
12
13
|
*
|
|
14
|
+
* A bash chain is judged unit by unit: the child names its winner in
|
|
15
|
+
* `matchValues`, but this answer approves the whole tool call, so every unit the
|
|
16
|
+
* child left asking (`askingUnits`) is resolved with its own floor and the most
|
|
17
|
+
* restrictive answer decides (#1030). A request without units (an older child,
|
|
18
|
+
* or an ask raised without a chain) is judged on its single value.
|
|
19
|
+
*
|
|
13
20
|
* The `matchValues` are used as the child fixed them, never re-derived through
|
|
14
21
|
* this session's `PathNormalizer`/cwd. The resolver's surface-family fold is
|
|
15
22
|
* what keeps a parent's bare `path` deny answering a child's request, which is
|
|
@@ -22,6 +29,38 @@ export class ResolverServingPolicy implements ServingPolicy {
|
|
|
22
29
|
) {}
|
|
23
30
|
|
|
24
31
|
resolve(intent: ForwardedAccessIntent): PermissionCheckResult {
|
|
32
|
+
const units = intent.askingUnits ?? [];
|
|
33
|
+
const first = units.at(0);
|
|
34
|
+
if (first === undefined) return this.resolveValue(intent);
|
|
35
|
+
const judge = (unit: AskingBashUnit): PermissionCheckResult =>
|
|
36
|
+
this.resolveUnit(unit, intent.principal.agentName);
|
|
37
|
+
return mostRestrictiveOf([judge(first), ...units.slice(1).map(judge)]);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Judge one unit the child left asking, with that unit's own floor.
|
|
42
|
+
*
|
|
43
|
+
* Resolved as the same `bash-command` intent the child's gate emitted for
|
|
44
|
+
* it, so a serving rule matches the unit exactly as a local one would.
|
|
45
|
+
*/
|
|
46
|
+
private resolveUnit(
|
|
47
|
+
unit: AskingBashUnit,
|
|
48
|
+
agentName: string,
|
|
49
|
+
): PermissionCheckResult {
|
|
50
|
+
const check = this.resolver.resolve({
|
|
51
|
+
kind: "bash-command",
|
|
52
|
+
surface: "bash",
|
|
53
|
+
command: unit.command,
|
|
54
|
+
spellings: [],
|
|
55
|
+
agentName,
|
|
56
|
+
});
|
|
57
|
+
return honorChildFloor(check, unit.floor, this.isYoloEnabled());
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Judge the intent's single child-fixed value, with the chain-level floor.
|
|
62
|
+
*/
|
|
63
|
+
private resolveValue(intent: ForwardedAccessIntent): PermissionCheckResult {
|
|
25
64
|
const check = this.resolver.resolve(
|
|
26
65
|
buildResolvedIntentFromMatchValues(
|
|
27
66
|
intent.surface,
|
|
@@ -222,6 +222,9 @@ function coreFacts(payload: PromptPayload): Fact[] {
|
|
|
222
222
|
if (request.matchedPattern !== null) {
|
|
223
223
|
facts.push({ label: "rule", text: request.matchedPattern });
|
|
224
224
|
}
|
|
225
|
+
if (request.matchedSpelling !== null) {
|
|
226
|
+
facts.push({ label: "matched as", text: request.matchedSpelling });
|
|
227
|
+
}
|
|
225
228
|
if (request.value !== "" && request.value !== request.toolName) {
|
|
226
229
|
facts.push({ label, text: request.value });
|
|
227
230
|
}
|
|
@@ -82,6 +82,7 @@ export function buildBashExternalDirectoryAskPayload(
|
|
|
82
82
|
value: facts.command,
|
|
83
83
|
matchedPattern: facts.matchedPattern ?? null,
|
|
84
84
|
commandContext: null,
|
|
85
|
+
matchedSpelling: null,
|
|
85
86
|
executedUnit: null,
|
|
86
87
|
},
|
|
87
88
|
evidence: [
|
|
@@ -113,6 +114,7 @@ function pathPayload(
|
|
|
113
114
|
value: facts.pathValue,
|
|
114
115
|
matchedPattern: facts.matchedPattern ?? null,
|
|
115
116
|
commandContext: null,
|
|
117
|
+
matchedSpelling: null,
|
|
116
118
|
executedUnit: null,
|
|
117
119
|
},
|
|
118
120
|
evidence,
|
|
@@ -63,6 +63,12 @@ export interface PromptRequestFacts {
|
|
|
63
63
|
readonly value: string;
|
|
64
64
|
/** The matched rule, including a sentinel such as `<indirection-bash-wrapper>`. */
|
|
65
65
|
readonly matchedPattern: string | null;
|
|
66
|
+
/**
|
|
67
|
+
* For bash, the spelling of the unit the matched rule matched when it did not
|
|
68
|
+
* match {@link value} as typed — the absolute spelling of a relative path
|
|
69
|
+
* argument, say. `null` when the typed text decided.
|
|
70
|
+
*/
|
|
71
|
+
readonly matchedSpelling: string | null;
|
|
66
72
|
/**
|
|
67
73
|
* Where the offending bash unit runs, when it came from a substitution or a
|
|
68
74
|
* subshell. A fact rather than a rendered clause: it is what makes the
|
|
@@ -172,6 +178,9 @@ function asPromptRequestFacts(value: unknown): PromptRequestFacts | undefined {
|
|
|
172
178
|
candidate.commandContext,
|
|
173
179
|
BASH_COMMAND_CONTEXTS,
|
|
174
180
|
);
|
|
181
|
+
// Absent from a request a node built before the fact existed, which states
|
|
182
|
+
// no spelling rather than a malformed one.
|
|
183
|
+
const matchedSpelling = candidate.matchedSpelling ?? null;
|
|
175
184
|
if (
|
|
176
185
|
!requester ||
|
|
177
186
|
commandContext === undefined ||
|
|
@@ -180,6 +189,7 @@ function asPromptRequestFacts(value: unknown): PromptRequestFacts | undefined {
|
|
|
180
189
|
!isNullableString(candidate.toolName) ||
|
|
181
190
|
!isNullableString(candidate.invokedToolName) ||
|
|
182
191
|
!isNullableString(candidate.matchedPattern) ||
|
|
192
|
+
!isNullableString(matchedSpelling) ||
|
|
183
193
|
!isNullableString(candidate.executedUnit)
|
|
184
194
|
) {
|
|
185
195
|
return undefined;
|
|
@@ -192,6 +202,7 @@ function asPromptRequestFacts(value: unknown): PromptRequestFacts | undefined {
|
|
|
192
202
|
invokedToolName: candidate.invokedToolName,
|
|
193
203
|
value: candidate.value,
|
|
194
204
|
matchedPattern: candidate.matchedPattern,
|
|
205
|
+
matchedSpelling,
|
|
195
206
|
commandContext: commandContext.value,
|
|
196
207
|
executedUnit: candidate.executedUnit,
|
|
197
208
|
};
|
|
@@ -25,6 +25,7 @@ export function renderReviewLogFacts(
|
|
|
25
25
|
return {
|
|
26
26
|
surface: request.surface,
|
|
27
27
|
...present("matchedPattern", request.matchedPattern),
|
|
28
|
+
...present("matchedSpelling", request.matchedSpelling),
|
|
28
29
|
...present("executedUnit", request.executedUnit),
|
|
29
30
|
...present("commandContext", request.commandContext),
|
|
30
31
|
...present("invokedToolName", request.invokedToolName),
|
|
@@ -44,6 +44,7 @@ export function buildToolAskPayload(facts: ToolAskFacts): PromptPayload {
|
|
|
44
44
|
invokedToolName: distinctInvokedName(facts),
|
|
45
45
|
value: askValue(check, bash, mcp),
|
|
46
46
|
matchedPattern: check.matchedPattern ?? null,
|
|
47
|
+
matchedSpelling: check.matchedSpelling ?? null,
|
|
47
48
|
commandContext: check.commandContext ?? null,
|
|
48
49
|
executedUnit: check.executedUnit ?? null,
|
|
49
50
|
},
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { BashProgram } from "#src/access-intent/bash/program";
|
|
2
2
|
import { resolveBashCommandCheck } from "#src/handlers/gates/bash-command";
|
|
3
|
+
import type { PathNormalizer } from "#src/path/path-normalizer";
|
|
3
4
|
import type { ScopedPermissionResolver } from "#src/policy/permission-resolver";
|
|
4
5
|
import type { PermissionCheckResult } from "#src/types";
|
|
5
6
|
|
|
@@ -14,7 +15,11 @@ import type { PermissionCheckResult } from "#src/types";
|
|
|
14
15
|
* sentinels — `<unparseable-bash-command>` (#452) and `<unparsed-bash-subtree>`
|
|
15
16
|
* (#840) — at parity with the gate.
|
|
16
17
|
*
|
|
17
|
-
*
|
|
18
|
+
* The command is parsed by `BashProgram.parseSync` over the session's
|
|
19
|
+
* `normalizer`, the same builder the gate's program comes from, so every slice
|
|
20
|
+
* the gate resolves a unit with is resolved here too.
|
|
21
|
+
*
|
|
22
|
+
* In the pre-warm window (`parseSync` returns `null`) it falls back
|
|
18
23
|
* to the pre-#309 whole-string match, so the advisory answer is never *weaker*
|
|
19
24
|
* than before — only strengthened once warm.
|
|
20
25
|
*
|
|
@@ -25,9 +30,10 @@ export function resolveBashAdvisoryCheck(
|
|
|
25
30
|
command: string,
|
|
26
31
|
agentName: string | undefined,
|
|
27
32
|
resolver: ScopedPermissionResolver,
|
|
33
|
+
normalizer: PathNormalizer,
|
|
28
34
|
): PermissionCheckResult {
|
|
29
|
-
const
|
|
30
|
-
if (
|
|
35
|
+
const program = BashProgram.parseSync(command, normalizer);
|
|
36
|
+
if (program === null) {
|
|
31
37
|
return resolver.resolve({
|
|
32
38
|
kind: "tool",
|
|
33
39
|
surface: "bash",
|
|
@@ -35,5 +41,10 @@ export function resolveBashAdvisoryCheck(
|
|
|
35
41
|
agentName,
|
|
36
42
|
});
|
|
37
43
|
}
|
|
38
|
-
return resolveBashCommandCheck(
|
|
44
|
+
return resolveBashCommandCheck(
|
|
45
|
+
command,
|
|
46
|
+
program.commands(),
|
|
47
|
+
agentName,
|
|
48
|
+
resolver,
|
|
49
|
+
);
|
|
39
50
|
}
|
|
@@ -67,7 +67,12 @@ export class LocalPermissionsService implements PermissionsService {
|
|
|
67
67
|
// the enforcement gate enforces (#309). A cold parser falls back to the
|
|
68
68
|
// whole-string match inside resolveBashAdvisoryCheck.
|
|
69
69
|
if (surface === "bash") {
|
|
70
|
-
return resolveBashAdvisoryCheck(
|
|
70
|
+
return resolveBashAdvisoryCheck(
|
|
71
|
+
value ?? "",
|
|
72
|
+
agentName,
|
|
73
|
+
this.resolver,
|
|
74
|
+
this.session.getPathNormalizer(),
|
|
75
|
+
);
|
|
71
76
|
}
|
|
72
77
|
const intent = buildAccessIntentForSurface(
|
|
73
78
|
surface,
|
package/src/types.ts
CHANGED
|
@@ -83,6 +83,13 @@ export interface PermissionCheckResult {
|
|
|
83
83
|
* {@link floorExemption} says the wrapper's floor has no reason to hold.
|
|
84
84
|
*/
|
|
85
85
|
executedUnit?: string;
|
|
86
|
+
/**
|
|
87
|
+
* The spelling of the bash unit the winning rule matched, when the rule did
|
|
88
|
+
* not also match the unit as typed — the absolute spelling of a relative
|
|
89
|
+
* path argument, say (#910). Display-only: absent whenever the typed text
|
|
90
|
+
* decided, and dropped by a floor that replaces {@link matchedPattern}.
|
|
91
|
+
*/
|
|
92
|
+
matchedSpelling?: string;
|
|
86
93
|
/**
|
|
87
94
|
* Set when the winning bash unit is a wrapper the floor no longer covers,
|
|
88
95
|
* naming why (#803, #963). Recorded in the review log so an allow the floor would
|
|
@@ -96,6 +103,22 @@ export interface PermissionCheckResult {
|
|
|
96
103
|
* node to clamp its own `allow` with.
|
|
97
104
|
*/
|
|
98
105
|
floor?: string;
|
|
106
|
+
/**
|
|
107
|
+
* Every unit of the bash chain that resolved to `ask` and was not
|
|
108
|
+
* session-granted, in chain order, each with its own floor. Set only on an
|
|
109
|
+
* asking chain winner: the serving node of a forwarded ask judges each one,
|
|
110
|
+
* since the winner alone would approve commands it never saw.
|
|
111
|
+
*/
|
|
112
|
+
askingUnits?: readonly AskingBashUnit[];
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* One unit of a bash chain the gate left asking, as the serving node of a
|
|
117
|
+
* forwarded ask needs it: the unit as typed, and the floor that raised it.
|
|
118
|
+
*/
|
|
119
|
+
export interface AskingBashUnit {
|
|
120
|
+
command: string;
|
|
121
|
+
floor?: string;
|
|
99
122
|
}
|
|
100
123
|
|
|
101
124
|
export function isPermissionState(value: unknown): value is PermissionState {
|