@gotgenes/pi-permission-system 27.1.2 → 28.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 +37 -0
- package/dist/public.d.ts +4 -0
- package/docs/cross-extension-api.md +4 -0
- package/package.json +2 -1
- package/src/access-intent/bash/command-enumeration.ts +126 -6
- package/src/access-intent/bash/nested-execution.ts +22 -0
- package/src/access-intent/bash/token-collection.ts +29 -14
- package/src/authority/decision-resolution.ts +69 -0
- package/src/authority/decision-source.ts +29 -0
- package/src/authority/forwarded-request-server.ts +12 -33
- package/src/authority/permission-dialog.ts +5 -10
- package/src/handlers/gates/helpers.ts +0 -27
- package/src/handlers/gates/runner.ts +32 -32
- package/src/permission-events.ts +4 -0
- package/src/permission-gate.ts +41 -14
- package/src/presentation/agent-renderer.ts +73 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,43 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [28.0.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v27.1.3...pi-permission-system-v28.0.0) (2026-08-30)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### ⚠ BREAKING CHANGES
|
|
12
|
+
|
|
13
|
+
* **pi-permission-system:** `PermissionDecisionResolution` gains `authorizer_allowed` and `authorizer_denied`, and existing decisions change which resolution they report. An ask decided by a registered `authorizerChain` link now reports `authorizer_allowed` / `authorizer_denied` instead of `user_approved` / `user_denied`, and a subagent's ask answered by its parent reports what decided inside that parent — `policy_allow` / `policy_deny` for a rule, `authorizer_*` for a link — instead of attributing it to the user. Consumers switching exhaustively over `resolution` must handle the two new values; a consumer counting `user_denied` as human interactions now gets the accurate count rather than one inflated by machine decisions.
|
|
14
|
+
|
|
15
|
+
### Features
|
|
16
|
+
|
|
17
|
+
* **pi-permission-system:** broadcast the decider that actually resolved a permission ask ([1b92386](https://github.com/gotgenes/pi-packages/commit/1b923863a00284c7bd6ca9bef5307ad83db0677d)), closes [#772](https://github.com/gotgenes/pi-packages/issues/772)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
### Bug Fixes
|
|
21
|
+
|
|
22
|
+
* **pi-permission-system:** tell the agent which authorizer refused its call ([412189b](https://github.com/gotgenes/pi-packages/commit/412189b4e32db3ca419d030c1b2195532e8ebbd4)), closes [#772](https://github.com/gotgenes/pi-packages/issues/772)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
### Documentation
|
|
26
|
+
|
|
27
|
+
* **pi-permission-system:** record decision attribution and mark Phase 14 Step 5 complete ([b31f5d5](https://github.com/gotgenes/pi-packages/commit/b31f5d56d61727ab8bd146c2dfe83ce85aa7eef5)), closes [#772](https://github.com/gotgenes/pi-packages/issues/772)
|
|
28
|
+
|
|
29
|
+
## [27.1.3](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v27.1.2...pi-permission-system-v27.1.3) (2026-08-29)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
### Bug Fixes
|
|
33
|
+
|
|
34
|
+
* **pi-permission-system:** gate commands hosted by declarations, test commands, and assignments ([dfde822](https://github.com/gotgenes/pi-packages/commit/dfde8227d3bf11de5258dcbe376dd0b8b75045e0)), closes [#742](https://github.com/gotgenes/pi-packages/issues/742)
|
|
35
|
+
* **pi-permission-system:** gate commands inside a for loop's body and word list ([6e96464](https://github.com/gotgenes/pi-packages/commit/6e9646486db849afdf80819150a203d293b3847c)), closes [#742](https://github.com/gotgenes/pi-packages/issues/742)
|
|
36
|
+
* **pi-permission-system:** gate commands inside control-flow bodies and function definitions ([3a2f232](https://github.com/gotgenes/pi-packages/commit/3a2f232d9c12588ea1feacf41cade0e56e66a44b)), closes [#742](https://github.com/gotgenes/pi-packages/issues/742)
|
|
37
|
+
* **pi-permission-system:** project a command-name substitution's path operands ([9807d44](https://github.com/gotgenes/pi-packages/commit/9807d444b6d81526b01bc542cd4a829a1b2640c3)), closes [#742](https://github.com/gotgenes/pi-packages/issues/742)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
### Documentation
|
|
41
|
+
|
|
42
|
+
* **pi-permission-system:** bound the path-slice claim on behavior, not diff footprint ([91ef5c9](https://github.com/gotgenes/pi-packages/commit/91ef5c9f2d0578fb64fe317bcddd3ec7d9418d4c)), closes [#742](https://github.com/gotgenes/pi-packages/issues/742)
|
|
43
|
+
* **pi-permission-system:** commit the instrument behind Step 4's measurement ([e634c2c](https://github.com/gotgenes/pi-packages/commit/e634c2cf068340cc0deae86648330e44c2741f06)), closes [#742](https://github.com/gotgenes/pi-packages/issues/742)
|
|
44
|
+
|
|
8
45
|
## [27.1.2](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v27.1.1...pi-permission-system-v27.1.2) (2026-08-29)
|
|
9
46
|
|
|
10
47
|
|
package/dist/public.d.ts
CHANGED
|
@@ -274,6 +274,10 @@ interface PermissionUiPromptEvent {
|
|
|
274
274
|
}
|
|
275
275
|
/** How a permission decision was reached. */
|
|
276
276
|
type PermissionDecisionResolution = "policy_allow" | "policy_deny" | "session_approved" | "infrastructure_auto_allowed" | "user_approved" | "user_approved_for_session" | "user_denied" | "auto_approved" | "confirmation_unavailable"
|
|
277
|
+
/** A registered `authorizerChain` link granted the ask; no human was asked. */
|
|
278
|
+
| "authorizer_allowed"
|
|
279
|
+
/** A registered `authorizerChain` link refused the ask; no human was asked. */
|
|
280
|
+
| "authorizer_denied"
|
|
277
281
|
/** The gate threw, or an escalation failed, and the request was blocked. */
|
|
278
282
|
| "gate_error";
|
|
279
283
|
/** Payload emitted on `permissions:decision`. */
|
|
@@ -420,6 +420,8 @@ A session serving another session's forwarded request emits one too, on its own
|
|
|
420
420
|
That is what makes a forwarded prompt clearable: the ask is gated in the requesting session — a different process for an out-of-process subagent — so without it the serving session broadcasts a `permissions:ui_prompt` whose outcome never appears.
|
|
421
421
|
A forwarded request the serving session's own policy allows or denies is answered without a prompt and broadcasts nothing, matching the UI-prompt channel.
|
|
422
422
|
A served decision carries a non-null `forwarding` context; the requesting session still emits its own decision when the answer comes back.
|
|
423
|
+
That requesting-side decision is attributed to whatever decided **inside** the responding session: a rule there reports `policy_allow` / `policy_deny`, a chain link reports `authorizer_allowed` / `authorizer_denied`, and a human there reports `user_approved` / `user_denied`.
|
|
424
|
+
The `resolution` names what decided, never where.
|
|
423
425
|
|
|
424
426
|
The `requestId` is the same id the request's review-log entries carry, and the same one `permissions:ui_prompt` carried if the request reached a prompt — so a prompt and its outcome are joinable, as are two concurrent prompts for the same command.
|
|
425
427
|
A request that reaches a prompt is answered by exactly one terminal event on that prompt's own bus, including when the dialog itself fails.
|
|
@@ -460,6 +462,8 @@ pi.events.on("permissions:decision", (raw) => {
|
|
|
460
462
|
| `user_approved` | User approved once via dialog |
|
|
461
463
|
| `user_approved_for_session` | User approved for the rest of the session |
|
|
462
464
|
| `user_denied` | User denied via dialog |
|
|
465
|
+
| `authorizer_allowed` | A registered `authorizerChain` link granted the ask — no human asked |
|
|
466
|
+
| `authorizer_denied` | A registered `authorizerChain` link refused the ask — no human asked |
|
|
463
467
|
| `auto_approved` | Yolo mode — approved automatically without dialog |
|
|
464
468
|
| `confirmation_unavailable` | State was `ask` but no UI was available — blocked |
|
|
465
469
|
| `gate_error` | The gate threw, or an escalation failed — blocked, fail-closed |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gotgenes/pi-permission-system",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "28.0.0",
|
|
4
4
|
"description": "Permission enforcement extension for the Pi coding agent.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -90,6 +90,7 @@
|
|
|
90
90
|
"test:watch": "vitest",
|
|
91
91
|
"verify:public-types": "bash scripts/verify-public-types.sh",
|
|
92
92
|
"measure:core-coverage": "node scripts/measure-core-coverage.mjs",
|
|
93
|
+
"measure:statement-descent": "node scripts/measure-statement-descent.mjs",
|
|
93
94
|
"measure:wrapper-transparency": "node scripts/measure-wrapper-transparency.mjs",
|
|
94
95
|
"lint:md": "rumdl check *.md docs/**/*.md",
|
|
95
96
|
"lint": "biome check . && eslint . && pnpm run lint:md"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
EXECUTION_HOST_TYPES,
|
|
3
|
-
|
|
3
|
+
forEachExecutionIn,
|
|
4
4
|
} from "#src/access-intent/bash/nested-execution";
|
|
5
5
|
import type { TSNode } from "#src/access-intent/bash/parser";
|
|
6
6
|
import { redirectMayWriteFile } from "#src/access-intent/bash/redirect-analysis";
|
|
@@ -79,7 +79,7 @@ interface UnitScope {
|
|
|
79
79
|
/** A top-level command in the current shell, writing no file. */
|
|
80
80
|
const TOP_LEVEL_SCOPE: UnitScope = { writesViaRedirect: false };
|
|
81
81
|
|
|
82
|
-
// ──
|
|
82
|
+
// ── Node-type vocabulary ─────────────────────────────────────────────────────
|
|
83
83
|
|
|
84
84
|
/**
|
|
85
85
|
* Container node types descended into with the enclosing scope unchanged.
|
|
@@ -89,6 +89,39 @@ const TOP_LEVEL_SCOPE: UnitScope = { writesViaRedirect: false };
|
|
|
89
89
|
*/
|
|
90
90
|
const COMMAND_ENUM_DESCEND = new Set(["program", "list", "pipeline"]);
|
|
91
91
|
|
|
92
|
+
/**
|
|
93
|
+
* Compound statements: emitted whole, then descended for their statements.
|
|
94
|
+
*
|
|
95
|
+
* The whole emit is what keeps the #306 never-weaker invariant — the commands
|
|
96
|
+
* found inside are additional units, never a replacement.
|
|
97
|
+
*
|
|
98
|
+
* `select` parses as `for_statement` and `until` as `while_statement`, so each
|
|
99
|
+
* pair is one entry.
|
|
100
|
+
*/
|
|
101
|
+
const COMPOUND_STATEMENT_TYPES = new Set([
|
|
102
|
+
"if_statement",
|
|
103
|
+
"while_statement",
|
|
104
|
+
"for_statement",
|
|
105
|
+
"c_style_for_statement",
|
|
106
|
+
"case_statement",
|
|
107
|
+
"function_definition",
|
|
108
|
+
"compound_statement",
|
|
109
|
+
"negated_command",
|
|
110
|
+
]);
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Syntactic groupings inside a compound statement: descended, never emitted.
|
|
114
|
+
*
|
|
115
|
+
* None of these is something anybody runs — a `do_group` is the loop body's
|
|
116
|
+
* punctuation — so emitting one would produce a `do rm $f; done` unit.
|
|
117
|
+
*/
|
|
118
|
+
const STATEMENT_GROUP_TYPES = new Set([
|
|
119
|
+
"do_group",
|
|
120
|
+
"case_item",
|
|
121
|
+
"elif_clause",
|
|
122
|
+
"else_clause",
|
|
123
|
+
]);
|
|
124
|
+
|
|
92
125
|
/**
|
|
93
126
|
* Named node types abandoned during command enumeration: they are neither
|
|
94
127
|
* commands nor able to host one, so nothing in their subtree ever runs.
|
|
@@ -104,6 +137,33 @@ const COMMAND_ENUM_DESCEND = new Set(["program", "list", "pipeline"]);
|
|
|
104
137
|
*/
|
|
105
138
|
const COMMAND_ENUM_SKIP = new Set(["comment", "heredoc_end"]);
|
|
106
139
|
|
|
140
|
+
/**
|
|
141
|
+
* Every node type the enumerator recognizes as a statement.
|
|
142
|
+
*
|
|
143
|
+
* This is the enumerator's third question, beside "is this a command?" and
|
|
144
|
+
* "can this host one?": "is this a *statement*, so that descending an enclosing
|
|
145
|
+
* compound reaches it?" A compound statement's named children are a mix —
|
|
146
|
+
* `for_statement` carries its loop variable and word list, `case_statement` its
|
|
147
|
+
* subject, `function_definition` its name — and descending all of them emits
|
|
148
|
+
* operand words as bash command units, naming `a` as the offending *command* in
|
|
149
|
+
* a prompt. Membership is what {@link descendStatementChildren} filters on.
|
|
150
|
+
*/
|
|
151
|
+
const STATEMENT_TYPES = new Set([
|
|
152
|
+
"command",
|
|
153
|
+
"redirected_statement",
|
|
154
|
+
"subshell",
|
|
155
|
+
"declaration_command",
|
|
156
|
+
"variable_assignment",
|
|
157
|
+
"test_command",
|
|
158
|
+
"unset_command",
|
|
159
|
+
"ERROR",
|
|
160
|
+
...COMMAND_ENUM_DESCEND,
|
|
161
|
+
...COMPOUND_STATEMENT_TYPES,
|
|
162
|
+
...STATEMENT_GROUP_TYPES,
|
|
163
|
+
]);
|
|
164
|
+
|
|
165
|
+
// ── Command enumeration ──────────────────────────────────────────────
|
|
166
|
+
|
|
107
167
|
/**
|
|
108
168
|
* Enumerate the command units of a bash program, in source order.
|
|
109
169
|
*
|
|
@@ -114,10 +174,14 @@ const COMMAND_ENUM_SKIP = new Set(["comment", "heredoc_end"]);
|
|
|
114
174
|
* subshells (`( … )`) — emitting each inner command as its own unit *in
|
|
115
175
|
* addition to* the enclosing command, since those inner commands really execute
|
|
116
176
|
* (#306).
|
|
117
|
-
*
|
|
118
|
-
*
|
|
177
|
+
* A compound statement (control flow, a function definition, a `{ … }` brace
|
|
178
|
+
* group) is emitted whole and then descended for the statements it contains,
|
|
179
|
+
* while its operand words — a loop variable, a word list, a `case` subject, a
|
|
180
|
+
* function's own name — are not commands and are left unemitted. An `ERROR`
|
|
181
|
+
* node is the one exception: its recovered structure is invented rather than
|
|
182
|
+
* observed, so the unparsed blob is emitted whole and never descended (#742).
|
|
119
183
|
*
|
|
120
|
-
* The enclosing command/
|
|
184
|
+
* The enclosing command/statement is always still emitted whole, so adding the
|
|
121
185
|
* nested units can only ever produce a more-restrictive decision, never weaker.
|
|
122
186
|
*
|
|
123
187
|
* Each emitted command unit has any leading `variable_assignment` prefix
|
|
@@ -172,9 +236,33 @@ function collectCommandsInto(
|
|
|
172
236
|
return;
|
|
173
237
|
}
|
|
174
238
|
|
|
239
|
+
if (COMPOUND_STATEMENT_TYPES.has(node.type)) {
|
|
240
|
+
out.push(makeUnit(node.text, scope)); // never-weaker whole emit
|
|
241
|
+
descendStatementChildren(node, scope, out);
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (STATEMENT_GROUP_TYPES.has(node.type)) {
|
|
246
|
+
descendStatementChildren(node, scope, out);
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (node.type === "ERROR") {
|
|
251
|
+
// Tree-sitter's error recovery *invents* structure, so the node types
|
|
252
|
+
// inside an ERROR subtree are not evidence that anything runs: descending
|
|
253
|
+
// one turns backtick-quoted prose in an unterminated heredoc into command
|
|
254
|
+
// units. Emit the unparsed blob whole and stop (#742).
|
|
255
|
+
out.push(makeUnit(node.text, scope));
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
|
|
175
259
|
// Any other named statement (compound_statement `{ … }`, if/while/for/case,
|
|
176
260
|
// function_definition): emit whole, do not descend — deferred (#306).
|
|
261
|
+
// A declaration, assignment, test, or `unset` still hosts executions that
|
|
262
|
+
// really run (`local x=$(rm y)`, `[[ $(rm x) ]]`), so those are enumerated
|
|
263
|
+
// in addition to the statement (#742).
|
|
177
264
|
out.push(makeUnit(node.text, scope));
|
|
265
|
+
collectHostedCommands(node, out);
|
|
178
266
|
}
|
|
179
267
|
|
|
180
268
|
/** The wrapper facts a `command` node's words establish about its unit. */
|
|
@@ -291,6 +379,35 @@ function descendCommandChildren(
|
|
|
291
379
|
}
|
|
292
380
|
}
|
|
293
381
|
|
|
382
|
+
/**
|
|
383
|
+
* Descend a compound statement's children, enumerating only the ones that are
|
|
384
|
+
* themselves statements.
|
|
385
|
+
*
|
|
386
|
+
* The filter is the whole difference from {@link descendCommandChildren}, whose
|
|
387
|
+
* container types (`program` / `list` / `pipeline` / `redirected_statement` /
|
|
388
|
+
* `subshell`) have nothing but statement children. Here the children are a mix,
|
|
389
|
+
* and a non-statement one is an operand word rather than something that runs.
|
|
390
|
+
*
|
|
391
|
+
* A non-statement child is not abandoned, though: `for f in $(rm x)` hosts a
|
|
392
|
+
* real execution in its word list, which is what the second branch reaches.
|
|
393
|
+
*
|
|
394
|
+
* The scope is relayed unchanged — a compound statement's body runs in the
|
|
395
|
+
* current shell, so a write established by an enclosing `redirected_statement`
|
|
396
|
+
* covers every unit beneath it (#803).
|
|
397
|
+
*/
|
|
398
|
+
function descendStatementChildren(
|
|
399
|
+
node: TSNode,
|
|
400
|
+
scope: UnitScope,
|
|
401
|
+
out: BashCommand[],
|
|
402
|
+
): void {
|
|
403
|
+
for (let i = 0; i < node.childCount; i++) {
|
|
404
|
+
const child = node.child(i);
|
|
405
|
+
if (!child?.isNamed) continue;
|
|
406
|
+
if (STATEMENT_TYPES.has(child.type)) collectCommandsInto(child, scope, out);
|
|
407
|
+
else collectHostedCommands(child, out);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
294
411
|
/**
|
|
295
412
|
* Enumerate the commands of every nested execution context in a subtree, each
|
|
296
413
|
* tagged with the context it was found in.
|
|
@@ -298,9 +415,12 @@ function descendCommandChildren(
|
|
|
298
415
|
* The traversal itself lives in `nested-execution.ts` so the bash path surface
|
|
299
416
|
* shares one definition of what counts as a nested execution (#741); this
|
|
300
417
|
* function supplies the command-surface interpretation of each one found.
|
|
418
|
+
*
|
|
419
|
+
* `node` may be a context outright or merely host one, so the traversal is the
|
|
420
|
+
* root-inclusive `forEachExecutionIn`.
|
|
301
421
|
*/
|
|
302
422
|
function collectHostedCommands(node: TSNode, out: BashCommand[]): void {
|
|
303
|
-
|
|
423
|
+
forEachExecutionIn(node, (contextNode, context) => {
|
|
304
424
|
// A nested execution starts fresh: an enclosing statement's redirect is
|
|
305
425
|
// that statement's, not the substitution's, exactly as #807 attributes a
|
|
306
426
|
// nested command's path tokens to its own command.
|
|
@@ -47,6 +47,24 @@ export const EXECUTION_HOST_TYPES: ReadonlySet<string> = new Set([
|
|
|
47
47
|
"heredoc_body",
|
|
48
48
|
]);
|
|
49
49
|
|
|
50
|
+
/**
|
|
51
|
+
* Visit every execution context `node` *is or contains*, in source order.
|
|
52
|
+
*
|
|
53
|
+
* The root-inclusive question, and the one nearly every consumer asks: a node
|
|
54
|
+
* handed in can be a substitution outright (`> $(cmd)`) or merely host one
|
|
55
|
+
* (`> ${DIR}/$(cmd)`), and both really execute. {@link forEachNestedExecution}
|
|
56
|
+
* answers the strictly-within question instead, which is what a visitor needs
|
|
57
|
+
* once it has already decided to treat a context's interior itself.
|
|
58
|
+
*/
|
|
59
|
+
export function forEachExecutionIn(
|
|
60
|
+
node: TSNode,
|
|
61
|
+
visit: (contextNode: TSNode, context: BashCommandContext) => void,
|
|
62
|
+
): void {
|
|
63
|
+
const context = NESTED_EXECUTION_CONTEXTS.get(node.type);
|
|
64
|
+
if (context) visit(node, context);
|
|
65
|
+
else forEachNestedExecution(node, visit);
|
|
66
|
+
}
|
|
67
|
+
|
|
50
68
|
/**
|
|
51
69
|
* Visit every nested execution context in `node`'s subtree, in source order.
|
|
52
70
|
*
|
|
@@ -58,6 +76,10 @@ export const EXECUTION_HOST_TYPES: ReadonlySet<string> = new Set([
|
|
|
58
76
|
* A substitution can nest under `command_name` (when the whole command is
|
|
59
77
|
* `$(…)`), under an argument, inside a redirect destination, or inside an
|
|
60
78
|
* interpolating heredoc body, so the entire subtree is searched.
|
|
79
|
+
*
|
|
80
|
+
* `node` itself is never visited, however it is typed — use
|
|
81
|
+
* {@link forEachExecutionIn} when it may *be* a context rather than merely
|
|
82
|
+
* contain one.
|
|
61
83
|
*/
|
|
62
84
|
export function forEachNestedExecution(
|
|
63
85
|
node: TSNode,
|
|
@@ -2,8 +2,7 @@ import { basename } from "node:path";
|
|
|
2
2
|
import { proveCommandEffect } from "#src/access-intent/bash/command-effects";
|
|
3
3
|
import {
|
|
4
4
|
EXECUTION_HOST_TYPES,
|
|
5
|
-
|
|
6
|
-
NESTED_EXECUTION_CONTEXTS,
|
|
5
|
+
forEachExecutionIn,
|
|
7
6
|
} from "#src/access-intent/bash/nested-execution";
|
|
8
7
|
import {
|
|
9
8
|
ARG_NODE_TYPES,
|
|
@@ -129,15 +128,12 @@ export function collectRedirectTokens(node: TSNode): PathToken[] {
|
|
|
129
128
|
* its prose stays out of the path surface entirely.
|
|
130
129
|
*
|
|
131
130
|
* `node` may be a context outright (`> $(cmd)`) or merely contain one
|
|
132
|
-
* (`> ${DIR}/$(cmd)`)
|
|
133
|
-
*
|
|
131
|
+
* (`> ${DIR}/$(cmd)`), so the traversal is the root-inclusive
|
|
132
|
+
* `forEachExecutionIn`.
|
|
134
133
|
*/
|
|
135
134
|
function collectHostedExecutionTokens(node: TSNode): PathToken[] {
|
|
136
|
-
if (NESTED_EXECUTION_CONTEXTS.has(node.type)) {
|
|
137
|
-
return collectPathCandidateTokens(node);
|
|
138
|
-
}
|
|
139
135
|
const tokens: PathToken[] = [];
|
|
140
|
-
|
|
136
|
+
forEachExecutionIn(node, (contextNode) => {
|
|
141
137
|
tokens.push(...collectPathCandidateTokens(contextNode));
|
|
142
138
|
});
|
|
143
139
|
return tokens;
|
|
@@ -201,6 +197,23 @@ function commandArgumentWords(node: TSNode): string[] {
|
|
|
201
197
|
return words;
|
|
202
198
|
}
|
|
203
199
|
|
|
200
|
+
/**
|
|
201
|
+
* The children of a `command` node that supply no operand text of their own:
|
|
202
|
+
* its head word, and any env-var prefix assignment.
|
|
203
|
+
*
|
|
204
|
+
* Both are skipped as operands — the head word is not an argument, and a prefix
|
|
205
|
+
* assignment's value is assigned rather than accessed — but either can *host* a
|
|
206
|
+
* substitution that really runs (`$(cat /etc/shadow)`,
|
|
207
|
+
* `FOO=$(cat /etc/shadow) echo hi`), whose own operands are candidates like any
|
|
208
|
+
* other position (ADR 0009's positional invariance). The two walkers below are
|
|
209
|
+
* different state machines and so each carry their own skip, which is why the
|
|
210
|
+
* question is named here once rather than spelled twice (#742).
|
|
211
|
+
*/
|
|
212
|
+
const COMMAND_PREFIX_TYPES: ReadonlySet<string> = new Set([
|
|
213
|
+
"command_name",
|
|
214
|
+
"variable_assignment",
|
|
215
|
+
]);
|
|
216
|
+
|
|
204
217
|
/**
|
|
205
218
|
* A long or short option carrying its value inline: one or two leading dashes,
|
|
206
219
|
* a name containing no `=` or whitespace, then `=` and a non-empty value.
|
|
@@ -527,9 +540,11 @@ function collectPatternCommandTokens(
|
|
|
527
540
|
const child = node.child(i);
|
|
528
541
|
if (!child) continue;
|
|
529
542
|
|
|
530
|
-
|
|
531
|
-
|
|
543
|
+
if (COMMAND_PREFIX_TYPES.has(child.type)) {
|
|
544
|
+
// Supplies no operand of its own, but may host one that really runs.
|
|
545
|
+
tokens.push(...collectHostedExecutionTokens(child));
|
|
532
546
|
continue;
|
|
547
|
+
}
|
|
533
548
|
|
|
534
549
|
const isArgNode = ARG_NODE_TYPES.has(child.type);
|
|
535
550
|
const text = resolveNodeText(child);
|
|
@@ -682,12 +697,12 @@ function collectGenericCommandTokens(
|
|
|
682
697
|
const child = node.child(i);
|
|
683
698
|
if (!child) continue;
|
|
684
699
|
|
|
685
|
-
if (child.type
|
|
686
|
-
|
|
700
|
+
if (COMMAND_PREFIX_TYPES.has(child.type)) {
|
|
701
|
+
// Supplies no operand of its own, but may host one that really runs.
|
|
702
|
+
if (child.type === "command_name") seenCommandName = true;
|
|
703
|
+
tokens.push(...collectHostedExecutionTokens(child));
|
|
687
704
|
continue;
|
|
688
705
|
}
|
|
689
|
-
// Skip variable_assignment nodes (FOO=/bar)
|
|
690
|
-
if (child.type === "variable_assignment") continue;
|
|
691
706
|
|
|
692
707
|
// If there was no explicit command_name node, the first word-like
|
|
693
708
|
// child is the command name itself — skip it.
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type DecisionSource,
|
|
3
|
+
effectiveDecider,
|
|
4
|
+
} from "#src/authority/decision-source";
|
|
5
|
+
import type { PermissionDecisionResolution } from "#src/permission-events";
|
|
6
|
+
|
|
7
|
+
/** What became of the request, as the gate that ran it observed. */
|
|
8
|
+
export interface DecisionOutcome {
|
|
9
|
+
/** Whether the request was ultimately allowed. */
|
|
10
|
+
approved: boolean;
|
|
11
|
+
/** Whether the approval was scoped to the rest of the session. */
|
|
12
|
+
forSession: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Name how a decision was reached, from the decider stamped on it.
|
|
17
|
+
*
|
|
18
|
+
* The one place a {@link DecisionSource} becomes a
|
|
19
|
+
* {@link PermissionDecisionResolution}, shared by the local gate runner and the
|
|
20
|
+
* serving node so the two records of one request cannot disagree. Every arm
|
|
21
|
+
* reads the stamp the deciding site wrote (#726) rather than re-deriving the
|
|
22
|
+
* decider from the outcome — the derivation that reported an `authorizerChain`
|
|
23
|
+
* link's verdict, and a serving session's own policy, as the operator's answer
|
|
24
|
+
* (#772).
|
|
25
|
+
*
|
|
26
|
+
* `outcome` supplies what the decider does not record: whether the request was
|
|
27
|
+
* allowed, and whether the human scoped their grant to the session. A
|
|
28
|
+
* `{ kind: "user" }` record names the surface answered on, never the scope.
|
|
29
|
+
*
|
|
30
|
+
* The switch is exhaustive with no `default`, so a new `DecisionSource` variant
|
|
31
|
+
* is a compile error here rather than a silent `user_approved`.
|
|
32
|
+
*/
|
|
33
|
+
export function resolutionFor(
|
|
34
|
+
decidedBy: DecisionSource,
|
|
35
|
+
outcome: DecisionOutcome,
|
|
36
|
+
): PermissionDecisionResolution {
|
|
37
|
+
const decider = effectiveDecider(decidedBy);
|
|
38
|
+
switch (decider.kind) {
|
|
39
|
+
case "rule":
|
|
40
|
+
return outcome.approved ? "policy_allow" : "policy_deny";
|
|
41
|
+
case "session_approval":
|
|
42
|
+
return "session_approved";
|
|
43
|
+
case "infrastructure_read":
|
|
44
|
+
return "infrastructure_auto_allowed";
|
|
45
|
+
case "yolo":
|
|
46
|
+
return "auto_approved";
|
|
47
|
+
case "authorizer":
|
|
48
|
+
return outcome.approved ? "authorizer_allowed" : "authorizer_denied";
|
|
49
|
+
case "unavailable":
|
|
50
|
+
return "confirmation_unavailable";
|
|
51
|
+
case "gate_error":
|
|
52
|
+
return "gate_error";
|
|
53
|
+
case "user":
|
|
54
|
+
// A `forwarded` frame reaches here only when the responder named no
|
|
55
|
+
// decider — an older parent — so the hop is the only fact available and
|
|
56
|
+
// today's attribution is the fail-soft answer.
|
|
57
|
+
case "forwarded":
|
|
58
|
+
return userResolution(outcome);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function userResolution(
|
|
63
|
+
outcome: DecisionOutcome,
|
|
64
|
+
): PermissionDecisionResolution {
|
|
65
|
+
if (!outcome.approved) {
|
|
66
|
+
return "user_denied";
|
|
67
|
+
}
|
|
68
|
+
return outcome.forSession ? "user_approved_for_session" : "user_approved";
|
|
69
|
+
}
|
|
@@ -88,6 +88,35 @@ export function asDecisionSource(value: unknown): DecisionSource | undefined {
|
|
|
88
88
|
return narrowSource(value, MAX_DECISION_SOURCE_DEPTH);
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
+
/**
|
|
92
|
+
* The decider a `forwarded` hop is standing in for: the innermost
|
|
93
|
+
* non-`forwarded` source, or the hop itself when the responder named none.
|
|
94
|
+
*
|
|
95
|
+
* `forwarded` answers *where* a decision was made; every other variant answers
|
|
96
|
+
* *what* made it. A reader asking the second question — how to name the
|
|
97
|
+
* resolution, which refusal to render — wants the inner record, or it reports
|
|
98
|
+
* the parent's policy as the operator's own answer.
|
|
99
|
+
*
|
|
100
|
+
* `responderSessionId` is deliberately dropped: the session that answered is a
|
|
101
|
+
* separate fact, carried on the record itself and (for a served decision) on
|
|
102
|
+
* the bus event's `forwarding` context.
|
|
103
|
+
*
|
|
104
|
+
* Bounded by {@link MAX_DECISION_SOURCE_DEPTH} like its sibling guard. A value
|
|
105
|
+
* read off disk is already bounded there, and a locally-built one nests once
|
|
106
|
+
* per hop, so the bound is insurance rather than a working limit; reaching it
|
|
107
|
+
* yields the deepest frame seen, which reads as "decided elsewhere".
|
|
108
|
+
*/
|
|
109
|
+
export function effectiveDecider(source: DecisionSource): DecisionSource {
|
|
110
|
+
let decider = source;
|
|
111
|
+
for (let hop = 0; hop < MAX_DECISION_SOURCE_DEPTH; hop++) {
|
|
112
|
+
if (decider.kind !== "forwarded" || decider.decision === null) {
|
|
113
|
+
return decider;
|
|
114
|
+
}
|
|
115
|
+
decider = decider.decision;
|
|
116
|
+
}
|
|
117
|
+
return decider;
|
|
118
|
+
}
|
|
119
|
+
|
|
91
120
|
function narrowSource(
|
|
92
121
|
value: unknown,
|
|
93
122
|
depthBudget: number,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { join } from "node:path";
|
|
2
|
+
import { resolutionFor } from "#src/authority/decision-resolution";
|
|
2
3
|
import type { DecisionSource } from "#src/authority/decision-source";
|
|
3
4
|
import {
|
|
4
5
|
type ForwarderContext,
|
|
@@ -15,10 +16,7 @@ import {
|
|
|
15
16
|
} from "#src/authority/permission-forwarding";
|
|
16
17
|
import type { SubagentSessionRegistry } from "#src/authority/subagent-registry";
|
|
17
18
|
import type { DecisionBroadcaster } from "#src/decision-reporter";
|
|
18
|
-
import type {
|
|
19
|
-
PermissionDecisionEvent,
|
|
20
|
-
PermissionDecisionResolution,
|
|
21
|
-
} from "#src/permission-events";
|
|
19
|
+
import type { PermissionDecisionEvent } from "#src/permission-events";
|
|
22
20
|
import { buildForwardedAskPayload } from "#src/presentation/forwarded-ask-payload";
|
|
23
21
|
import { SessionApproval } from "#src/session-approval";
|
|
24
22
|
import type { SessionApprovalRecorder } from "#src/session-approval-recorder";
|
|
@@ -183,41 +181,22 @@ function buildServedDecisionEvent(
|
|
|
183
181
|
value: details.value ?? facts.value,
|
|
184
182
|
agentName: details.agentName,
|
|
185
183
|
result: decision.approved ? "allow" : "deny",
|
|
186
|
-
resolution:
|
|
184
|
+
resolution: resolutionFor(decision.decidedBy, {
|
|
185
|
+
approved: decision.approved,
|
|
186
|
+
// The grant scope is reported as the human chose it. `applyGrantScope`
|
|
187
|
+
// rewrites a whole-serving-session grant to a plain approval on the
|
|
188
|
+
// wire, but that translation is about what the *child* records, not
|
|
189
|
+
// about what was allowed here.
|
|
190
|
+
forSession:
|
|
191
|
+
decision.state === "approved_for_session" ||
|
|
192
|
+
decision.state === "approved_for_serving_session",
|
|
193
|
+
}),
|
|
187
194
|
origin: null,
|
|
188
195
|
matchedPattern: null,
|
|
189
196
|
forwarding: details.forwarding ?? null,
|
|
190
197
|
};
|
|
191
198
|
}
|
|
192
199
|
|
|
193
|
-
/**
|
|
194
|
-
* Name how a served ask resolved, reading the decision's own stamp rather than
|
|
195
|
-
* re-deriving it from the outcome: the site that decided already recorded what
|
|
196
|
-
* it was (#726).
|
|
197
|
-
*
|
|
198
|
-
* The grant scope is reported as the human chose it. {@link applyGrantScope}
|
|
199
|
-
* rewrites a whole-serving-session grant to a plain approval on the wire, but
|
|
200
|
-
* that translation is about what the *child* records, not about what was
|
|
201
|
-
* allowed here.
|
|
202
|
-
*/
|
|
203
|
-
function servedResolution(
|
|
204
|
-
decision: PermissionPromptDecision,
|
|
205
|
-
): PermissionDecisionResolution {
|
|
206
|
-
if (decision.decidedBy.kind === "gate_error") {
|
|
207
|
-
return "gate_error";
|
|
208
|
-
}
|
|
209
|
-
if (decision.confirmationUnavailable) {
|
|
210
|
-
return "confirmation_unavailable";
|
|
211
|
-
}
|
|
212
|
-
if (!decision.approved) {
|
|
213
|
-
return "user_denied";
|
|
214
|
-
}
|
|
215
|
-
return decision.state === "approved_for_session" ||
|
|
216
|
-
decision.state === "approved_for_serving_session"
|
|
217
|
-
? "user_approved_for_session"
|
|
218
|
-
: "user_approved";
|
|
219
|
-
}
|
|
220
|
-
|
|
221
200
|
// ── ForwardedRequestServer ────────────────────────────────────────────────
|
|
222
201
|
|
|
223
202
|
/**
|
|
@@ -11,21 +11,16 @@ export type PermissionPromptDecision = {
|
|
|
11
11
|
approved: boolean;
|
|
12
12
|
state: PermissionDecisionState;
|
|
13
13
|
denialReason?: string;
|
|
14
|
-
/**
|
|
15
|
-
* True when the decision was made automatically by yolo mode rather than
|
|
16
|
-
* by an interactive user prompt. Used by handlers to emit "auto_approved"
|
|
17
|
-
* rather than "user_approved" in the permissions:decision broadcast.
|
|
18
|
-
*/
|
|
19
|
-
autoApproved?: true;
|
|
20
14
|
/**
|
|
21
15
|
* True when no human ever ruled on this ask: either no live authority was
|
|
22
16
|
* reachable at all (`DenyingAuthorizer`, a no-UI non-subagent session) or the
|
|
23
17
|
* forwarding path gave up before reaching one (`ParentAuthorizer` — target
|
|
24
18
|
* unresolvable, request undeliverable, target not serving, or no answer
|
|
25
|
-
* within the timeout). Consumed by
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
19
|
+
* within the timeout). Consumed by the gate (block reason) and
|
|
20
|
+
* `PermissionPrompter` (review-entry resolution) to report
|
|
21
|
+
* "confirmation_unavailable" rather than a plain user denial — a user who
|
|
22
|
+
* was never asked denied nothing (#719). The decision-event resolution
|
|
23
|
+
* reads the `unavailable` decider below instead (#772).
|
|
29
24
|
*/
|
|
30
25
|
confirmationUnavailable?: true;
|
|
31
26
|
/**
|
|
@@ -86,33 +86,6 @@ export function buildDecisionEvent(
|
|
|
86
86
|
};
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
-
/**
|
|
90
|
-
* Map the gate outcome back to a PermissionDecisionResolution.
|
|
91
|
-
*
|
|
92
|
-
* @param state - The permission state passed to the gate.
|
|
93
|
-
* @param action - The gate's resulting action ("allow" | "block").
|
|
94
|
-
* @param hasSession - True when the gate result carries a sessionApproval
|
|
95
|
-
* (indicates the user chose "for this session").
|
|
96
|
-
* @param confirmationUnavailable - True when the denial came from the
|
|
97
|
-
* DenyingAuthorizer (no live authority was reachable).
|
|
98
|
-
*/
|
|
99
|
-
export function deriveResolution(
|
|
100
|
-
state: "allow" | "deny" | "ask",
|
|
101
|
-
action: "allow" | "block",
|
|
102
|
-
hasSession: boolean,
|
|
103
|
-
confirmationUnavailable: boolean,
|
|
104
|
-
autoApproved = false,
|
|
105
|
-
): PermissionDecisionResolution {
|
|
106
|
-
if (state === "allow") return autoApproved ? "auto_approved" : "policy_allow";
|
|
107
|
-
if (state === "deny") return "policy_deny";
|
|
108
|
-
// state === "ask"
|
|
109
|
-
if (action === "allow") {
|
|
110
|
-
if (autoApproved) return "auto_approved";
|
|
111
|
-
return hasSession ? "user_approved_for_session" : "user_approved";
|
|
112
|
-
}
|
|
113
|
-
return confirmationUnavailable ? "confirmation_unavailable" : "user_denied";
|
|
114
|
-
}
|
|
115
|
-
|
|
116
89
|
/**
|
|
117
90
|
* The standing yolo grant covering a gate's resolved check, or `null` when
|
|
118
91
|
* yolo does not answer it.
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import type { AskEscalator } from "#src/authority/authorizer-selection";
|
|
2
|
+
import { resolutionFor } from "#src/authority/decision-resolution";
|
|
3
|
+
import type { DecisionSource } from "#src/authority/decision-source";
|
|
2
4
|
import type { PermissionPromptDecision } from "#src/authority/permission-dialog";
|
|
3
5
|
import type { DecisionReporter } from "#src/decision-reporter";
|
|
4
6
|
import { applyPermissionGate } from "#src/permission-gate";
|
|
@@ -6,8 +8,7 @@ import { createPermissionRequestId } from "#src/permission-request-id";
|
|
|
6
8
|
import type { ScopedPermissionResolver } from "#src/permission-resolver";
|
|
7
9
|
import {
|
|
8
10
|
renderPolicyDenial,
|
|
9
|
-
|
|
10
|
-
renderUserDenial,
|
|
11
|
+
renderRefusal,
|
|
11
12
|
} from "#src/presentation/agent-renderer";
|
|
12
13
|
import { renderReviewLogFacts } from "#src/presentation/review-log-renderer";
|
|
13
14
|
import type { SessionApprovalRecorder } from "#src/session-approval-recorder";
|
|
@@ -18,11 +19,7 @@ import type {
|
|
|
18
19
|
GateResult,
|
|
19
20
|
} from "./descriptor";
|
|
20
21
|
import { isGateBypass } from "./descriptor";
|
|
21
|
-
import {
|
|
22
|
-
buildDecisionEvent,
|
|
23
|
-
deriveResolution,
|
|
24
|
-
resolveYoloGrant,
|
|
25
|
-
} from "./helpers";
|
|
22
|
+
import { buildDecisionEvent, resolveYoloGrant } from "./helpers";
|
|
26
23
|
import type { GateOutcome } from "./types";
|
|
27
24
|
|
|
28
25
|
// ── GateRunner class ───────────────────────────────────────────────────────
|
|
@@ -160,12 +157,17 @@ export class GateRunner {
|
|
|
160
157
|
// single auto_approved review entry + decision event so log parity holds.
|
|
161
158
|
const yoloGrant = resolveYoloGrant(check, this.isYoloEnabled());
|
|
162
159
|
if (yoloGrant) {
|
|
160
|
+
// The pattern that raised the ask, sentinel included: "yolo allowed it"
|
|
161
|
+
// alone does not say why it was asked in the first place. One record for
|
|
162
|
+
// both the review entry and the broadcast, so they cannot disagree.
|
|
163
|
+
const decidedByYolo: DecisionSource = {
|
|
164
|
+
kind: "yolo",
|
|
165
|
+
pattern: check.matchedPattern ?? null,
|
|
166
|
+
};
|
|
163
167
|
this.reporter.writeReviewLog("permission_request.auto_approved", {
|
|
164
168
|
...logContext,
|
|
165
169
|
resolution: "auto_approved",
|
|
166
|
-
|
|
167
|
-
// it" alone does not say why it was asked in the first place.
|
|
168
|
-
decidedBy: { kind: "yolo", pattern: check.matchedPattern ?? null },
|
|
170
|
+
decidedBy: decidedByYolo,
|
|
169
171
|
});
|
|
170
172
|
this.emitDecision(
|
|
171
173
|
requestId,
|
|
@@ -174,7 +176,7 @@ export class GateRunner {
|
|
|
174
176
|
yoloGrant,
|
|
175
177
|
agentName,
|
|
176
178
|
"allow",
|
|
177
|
-
|
|
179
|
+
resolutionFor(decidedByYolo, { approved: true, forSession: false }),
|
|
178
180
|
),
|
|
179
181
|
);
|
|
180
182
|
return { action: "allow" };
|
|
@@ -189,14 +191,22 @@ export class GateRunner {
|
|
|
189
191
|
const { payload } = descriptor;
|
|
190
192
|
const messages = {
|
|
191
193
|
denyReason: renderPolicyDenial(payload, check.reason ?? null),
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
194
|
+
refusedReason: (decision: PermissionPromptDecision) =>
|
|
195
|
+
renderRefusal(
|
|
196
|
+
payload,
|
|
197
|
+
decision.decidedBy,
|
|
198
|
+
decision.denialReason ?? null,
|
|
199
|
+
),
|
|
196
200
|
};
|
|
197
201
|
|
|
198
|
-
|
|
199
|
-
|
|
202
|
+
// The rule that resolved this gate, and the decider for every arm that
|
|
203
|
+
// never escalates: `allow` and `deny` are recorded authority answering.
|
|
204
|
+
const decidedByRule: DecisionSource = {
|
|
205
|
+
kind: "rule",
|
|
206
|
+
surface: descriptor.surface,
|
|
207
|
+
pattern: check.matchedPattern ?? null,
|
|
208
|
+
origin: check.origin,
|
|
209
|
+
};
|
|
200
210
|
const gateResult = await applyPermissionGate({
|
|
201
211
|
state: check.state,
|
|
202
212
|
sessionApproval: descriptor.sessionApproval?.toGateApproval(),
|
|
@@ -209,19 +219,12 @@ export class GateRunner {
|
|
|
209
219
|
? { sessionApproval: descriptor.sessionApproval.toForwardedData() }
|
|
210
220
|
: {}),
|
|
211
221
|
});
|
|
212
|
-
autoApproved = decision.autoApproved === true;
|
|
213
|
-
confirmationUnavailable = decision.confirmationUnavailable === true;
|
|
214
222
|
return decision;
|
|
215
223
|
},
|
|
216
224
|
writeLog: (event, details) =>
|
|
217
225
|
this.reporter.writeReviewLog(event, details),
|
|
218
226
|
logContext,
|
|
219
|
-
decidedByRule
|
|
220
|
-
kind: "rule",
|
|
221
|
-
surface: descriptor.surface,
|
|
222
|
-
pattern: check.matchedPattern ?? null,
|
|
223
|
-
origin: check.origin,
|
|
224
|
-
},
|
|
227
|
+
decidedByRule,
|
|
225
228
|
messages,
|
|
226
229
|
});
|
|
227
230
|
|
|
@@ -237,13 +240,10 @@ export class GateRunner {
|
|
|
237
240
|
check,
|
|
238
241
|
agentName,
|
|
239
242
|
gateResult.action === "allow" ? "allow" : "deny",
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
confirmationUnavailable,
|
|
245
|
-
autoApproved,
|
|
246
|
-
),
|
|
243
|
+
resolutionFor(gateResult.decidedBy, {
|
|
244
|
+
approved: gateResult.action === "allow",
|
|
245
|
+
forSession: hasSessionApproval,
|
|
246
|
+
}),
|
|
247
247
|
),
|
|
248
248
|
);
|
|
249
249
|
|
package/src/permission-events.ts
CHANGED
|
@@ -139,6 +139,10 @@ export type PermissionDecisionResolution =
|
|
|
139
139
|
| "user_denied"
|
|
140
140
|
| "auto_approved"
|
|
141
141
|
| "confirmation_unavailable"
|
|
142
|
+
/** A registered `authorizerChain` link granted the ask; no human was asked. */
|
|
143
|
+
| "authorizer_allowed"
|
|
144
|
+
/** A registered `authorizerChain` link refused the ask; no human was asked. */
|
|
145
|
+
| "authorizer_denied"
|
|
142
146
|
/** The gate threw, or an escalation failed, and the request was blocked. */
|
|
143
147
|
| "gate_error";
|
|
144
148
|
|
package/src/permission-gate.ts
CHANGED
|
@@ -1,10 +1,21 @@
|
|
|
1
1
|
import type { DecisionSource } from "#src/authority/decision-source";
|
|
2
2
|
import type { PermissionPromptDecision } from "#src/authority/permission-dialog";
|
|
3
3
|
|
|
4
|
-
/**
|
|
4
|
+
/**
|
|
5
|
+
* Result of applying the permission gate.
|
|
6
|
+
*
|
|
7
|
+
* Both arms name what decided. The gate is the one place that knows whether
|
|
8
|
+
* recorded authority answered or an escalation did, so it reports the decider
|
|
9
|
+
* rather than leaving the caller to reconstruct it from a captured decision
|
|
10
|
+
* (#772).
|
|
11
|
+
*/
|
|
5
12
|
export type PermissionGateResult =
|
|
6
|
-
| {
|
|
7
|
-
|
|
13
|
+
| {
|
|
14
|
+
action: "allow";
|
|
15
|
+
decidedBy: DecisionSource;
|
|
16
|
+
sessionApproval?: { surface: string; pattern: string };
|
|
17
|
+
}
|
|
18
|
+
| { action: "block"; decidedBy: DecisionSource; reason: string };
|
|
8
19
|
|
|
9
20
|
/** Everything the gate needs — no direct dependency on ExtensionContext. */
|
|
10
21
|
export interface PermissionGateParams {
|
|
@@ -32,7 +43,8 @@ export interface PermissionGateParams {
|
|
|
32
43
|
logContext: Record<string, unknown>;
|
|
33
44
|
|
|
34
45
|
/**
|
|
35
|
-
* The rule that resolved this gate
|
|
46
|
+
* The rule that resolved this gate — the decider for both arms that never
|
|
47
|
+
* escalate, and the deny arm's review entry.
|
|
36
48
|
*
|
|
37
49
|
* A sibling of `logContext` rather than a member of it: the context holds
|
|
38
50
|
* what every resolution of this gate shares, and the decider is by
|
|
@@ -42,9 +54,16 @@ export interface PermissionGateParams {
|
|
|
42
54
|
|
|
43
55
|
/** Message strings/factories for each outcome. */
|
|
44
56
|
messages: {
|
|
57
|
+
/** What the agent is told when recorded authority denied the request. */
|
|
45
58
|
denyReason: string;
|
|
46
|
-
|
|
47
|
-
|
|
59
|
+
/**
|
|
60
|
+
* What the agent is told when an escalation refused it.
|
|
61
|
+
*
|
|
62
|
+
* One factory rather than one per outcome: which sentence a refusal earns
|
|
63
|
+
* follows from the decision's own decider, and that dispatch belongs with
|
|
64
|
+
* the renderers rather than here (#772).
|
|
65
|
+
*/
|
|
66
|
+
refusedReason: (decision: PermissionPromptDecision) => string;
|
|
48
67
|
};
|
|
49
68
|
}
|
|
50
69
|
|
|
@@ -64,26 +83,34 @@ export async function applyPermissionGate(
|
|
|
64
83
|
resolution: "policy_denied",
|
|
65
84
|
decidedBy: params.decidedByRule,
|
|
66
85
|
});
|
|
67
|
-
return {
|
|
86
|
+
return {
|
|
87
|
+
action: "block",
|
|
88
|
+
decidedBy: params.decidedByRule,
|
|
89
|
+
reason: messages.denyReason,
|
|
90
|
+
};
|
|
68
91
|
}
|
|
69
92
|
|
|
70
93
|
if (state === "ask") {
|
|
71
94
|
const decision = await promptForApproval();
|
|
95
|
+
const decidedBy = decision.decidedBy;
|
|
72
96
|
if (!decision.approved) {
|
|
73
97
|
// The gate writes no review entry for an ask denial — the prompter
|
|
74
|
-
// brackets it (waiting/denied).
|
|
75
|
-
// absent-authority denial (confirmationUnavailable) from a user denial.
|
|
98
|
+
// brackets it (waiting/denied).
|
|
76
99
|
return {
|
|
77
100
|
action: "block",
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
: messages.userDeniedReason(decision),
|
|
101
|
+
decidedBy,
|
|
102
|
+
reason: messages.refusedReason(decision),
|
|
81
103
|
};
|
|
82
104
|
}
|
|
83
105
|
if (decision.state === "approved_for_session" && params.sessionApproval) {
|
|
84
|
-
return {
|
|
106
|
+
return {
|
|
107
|
+
action: "allow",
|
|
108
|
+
decidedBy,
|
|
109
|
+
sessionApproval: params.sessionApproval,
|
|
110
|
+
};
|
|
85
111
|
}
|
|
112
|
+
return { action: "allow", decidedBy };
|
|
86
113
|
}
|
|
87
114
|
|
|
88
|
-
return { action: "allow" };
|
|
115
|
+
return { action: "allow", decidedBy: params.decidedByRule };
|
|
89
116
|
}
|
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type DecisionSource,
|
|
3
|
+
effectiveDecider,
|
|
4
|
+
} from "#src/authority/decision-source";
|
|
1
5
|
import { EXTENSION_ID } from "#src/extension-config";
|
|
2
6
|
import { DEFAULT_RENDER_BUDGET } from "#src/presentation/dialog-renderer";
|
|
3
7
|
import {
|
|
@@ -41,6 +45,55 @@ export interface AgentRenderBudget {
|
|
|
41
45
|
readonly fieldMaxWidth: number;
|
|
42
46
|
}
|
|
43
47
|
|
|
48
|
+
/**
|
|
49
|
+
* The agent-facing render a refused ask earns, chosen by what refused it.
|
|
50
|
+
*
|
|
51
|
+
* The single dispatch point for the refusal renderers below, so which sentence
|
|
52
|
+
* the agent gets is decided once from the decider stamped on the decision
|
|
53
|
+
* (#726) rather than re-derived from a marker at each caller. Being told a
|
|
54
|
+
* policy extension refused the call, rather than the operator, is what lets an
|
|
55
|
+
* agent read a link's corrective reason as policy instead of as the user's
|
|
56
|
+
* instruction (ADR 0011 §7).
|
|
57
|
+
*
|
|
58
|
+
* A `forwarded` decision is dispatched on the decider inside the responding
|
|
59
|
+
* session: the hop says *where*, and the agent is told *what*.
|
|
60
|
+
*
|
|
61
|
+
* Exhaustive with no `default`, so a new {@link DecisionSource} variant is a
|
|
62
|
+
* compile error here rather than a silent user attribution.
|
|
63
|
+
*/
|
|
64
|
+
export function renderRefusal(
|
|
65
|
+
payload: PromptPayload,
|
|
66
|
+
decidedBy: DecisionSource,
|
|
67
|
+
denialReason: string | null,
|
|
68
|
+
budget: AgentRenderBudget = DEFAULT_RENDER_BUDGET,
|
|
69
|
+
): string {
|
|
70
|
+
const decider = effectiveDecider(decidedBy);
|
|
71
|
+
switch (decider.kind) {
|
|
72
|
+
case "authorizer":
|
|
73
|
+
return renderAuthorizerDenial(
|
|
74
|
+
payload,
|
|
75
|
+
decider.name,
|
|
76
|
+
denialReason,
|
|
77
|
+
budget,
|
|
78
|
+
);
|
|
79
|
+
case "unavailable":
|
|
80
|
+
return renderUnavailableDenial(payload, denialReason, budget);
|
|
81
|
+
// A `rule` here is a rule in the *serving* session, whose pattern and
|
|
82
|
+
// origin are not on this payload — `matchedPattern` is the pattern that
|
|
83
|
+
// raised this session's own ask, so naming a rule would name the wrong
|
|
84
|
+
// one. That render, and the `gate_error` one beside it, are #844. The
|
|
85
|
+
// remaining kinds never refuse: they only ever allow.
|
|
86
|
+
case "user":
|
|
87
|
+
case "rule":
|
|
88
|
+
case "gate_error":
|
|
89
|
+
case "session_approval":
|
|
90
|
+
case "infrastructure_read":
|
|
91
|
+
case "yolo":
|
|
92
|
+
case "forwarded":
|
|
93
|
+
return renderUserDenial(payload, denialReason, budget);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
44
97
|
/** The agent-facing render of a policy deny. */
|
|
45
98
|
export function renderPolicyDenial(
|
|
46
99
|
payload: PromptPayload,
|
|
@@ -65,6 +118,26 @@ export function renderUserDenial(
|
|
|
65
118
|
);
|
|
66
119
|
}
|
|
67
120
|
|
|
121
|
+
/**
|
|
122
|
+
* The agent-facing render of a registered `authorizerChain` link's refusal.
|
|
123
|
+
*
|
|
124
|
+
* Names the link, because "a policy extension the operator configured refused
|
|
125
|
+
* this" and "the operator refused this" are different facts and only one of
|
|
126
|
+
* them was true (#772). The name is operator configuration rather than agent
|
|
127
|
+
* input, so it is not capped — the same treatment the matched rule gets.
|
|
128
|
+
*/
|
|
129
|
+
export function renderAuthorizerDenial(
|
|
130
|
+
payload: PromptPayload,
|
|
131
|
+
linkName: string,
|
|
132
|
+
denialReason: string | null,
|
|
133
|
+
budget: AgentRenderBudget = DEFAULT_RENDER_BUDGET,
|
|
134
|
+
): string {
|
|
135
|
+
return tagged(
|
|
136
|
+
`The '${linkName}' authorizer denied this ${identification(payload, budget, "call")}${boundaryClause(payload)}${provenanceClause(payload)}.`,
|
|
137
|
+
denialReason,
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
68
141
|
/** The agent-facing render when no live authority could answer the ask. */
|
|
69
142
|
export function renderUnavailableDenial(
|
|
70
143
|
payload: PromptPayload,
|