@gotgenes/pi-permission-system 32.0.0 → 32.0.2
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 +23 -0
- package/README.md +1 -0
- package/docs/configuration.md +4 -0
- package/docs/troubleshooting.md +10 -9
- package/package.json +1 -1
- package/src/authority/forwarding-io.ts +47 -2
- package/src/authority/transient-fs-retry.ts +118 -0
- package/src/handlers/gates/descriptor.ts +75 -0
- package/src/handlers/gates/runner.ts +6 -16
- package/src/handlers/gates/tool-call-gate-pipeline.ts +14 -3
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,29 @@ 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
|
+
## [32.0.2](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v32.0.1...pi-permission-system-v32.0.2) (2026-09-11)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Bug Fixes
|
|
12
|
+
|
|
13
|
+
* **pi-permission-system:** keep a forwarded write when a file lock blocks the atomic rename ([d901116](https://github.com/gotgenes/pi-packages/commit/d9011165203b130675a3affd20a2792f7abd7105)), closes [#914](https://github.com/gotgenes/pi-packages/issues/914)
|
|
14
|
+
* **pi-permission-system:** create a forwarding directory blocked by a transient file lock ([245683c](https://github.com/gotgenes/pi-packages/commit/245683c9153712d8991ceb21ece7884b2fb12f2a)), closes [#914](https://github.com/gotgenes/pi-packages/issues/914)
|
|
15
|
+
|
|
16
|
+
### Documentation
|
|
17
|
+
|
|
18
|
+
* **pi-permission-system:** record the transient filesystem retry ([eb7ab58](https://github.com/gotgenes/pi-packages/commit/eb7ab58cfd79d15de77bafa6afe8813e9dff7b72)), closes [#914](https://github.com/gotgenes/pi-packages/issues/914)
|
|
19
|
+
|
|
20
|
+
## [32.0.1](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v32.0.0...pi-permission-system-v32.0.1) (2026-09-11)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
### Bug Fixes
|
|
24
|
+
|
|
25
|
+
* **pi-permission-system:** deny a forbidden command without prompting first ([b840196](https://github.com/gotgenes/pi-packages/commit/b8401962a9eeb012317771fc204c3fde8aa8a9a2)), closes [#899](https://github.com/gotgenes/pi-packages/issues/899)
|
|
26
|
+
|
|
27
|
+
### Documentation
|
|
28
|
+
|
|
29
|
+
* **pi-permission-system:** document that a deny on any layer needs no prompt ([e526e95](https://github.com/gotgenes/pi-packages/commit/e526e950ab6d957e6b5bc4d6f7740bcce2e35dca)), closes [#899](https://github.com/gotgenes/pi-packages/issues/899)
|
|
30
|
+
|
|
8
31
|
## [32.0.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v31.1.4...pi-permission-system-v32.0.0) (2026-09-11)
|
|
9
32
|
|
|
10
33
|
|
package/README.md
CHANGED
|
@@ -97,6 +97,7 @@ The trailing `*` is greedy and crosses subdirectory boundaries, so it allows eve
|
|
|
97
97
|
|
|
98
98
|
Four layers compose with most-restrictive-wins: `path` (cross-cutting) → `external_directory` (CWD boundary) → per-tool patterns → `bash` command patterns.
|
|
99
99
|
Because `ask` is more restrictive than `allow`, a `path` allow cannot loosen an `external_directory: ask` boundary — allow outside-CWD directories on `external_directory`.
|
|
100
|
+
And because `deny` is more restrictive than `ask`, a `deny` on any layer refuses the call without prompting, naming the rule that decided.
|
|
100
101
|
See [docs/configuration.md](docs/configuration.md) for the full recipe.
|
|
101
102
|
|
|
102
103
|
Both path surfaces also carry a **direction**, so you can permit reading somewhere without permitting writing there: `path_read`, `path_write`, `external_directory_read`, and `external_directory_write`.
|
package/docs/configuration.md
CHANGED
|
@@ -559,6 +559,10 @@ Use `path` to **deny** sensitive files everywhere (`.env`, `~/.ssh/*`); use `ext
|
|
|
559
559
|
Because the layers compose with most-restrictive-wins, a `path` allow cannot loosen an `external_directory: ask` boundary — `ask` is more restrictive than `allow`, so the prompt still fires.
|
|
560
560
|
Adding `"~/.cargo/registry": "allow"` to the `path` surface therefore does **not** stop the outside-CWD prompt; put the rule on `external_directory` instead (see below).
|
|
561
561
|
|
|
562
|
+
The same ordering runs the other way at the top of the scale.
|
|
563
|
+
`deny` is more restrictive than `ask`, so a `deny` on any layer refuses the call **without prompting**, whichever layer carries the rule.
|
|
564
|
+
A `bash: {"find / *": "deny"}` rule therefore suppresses the outside-CWD prompt that `find /` would otherwise raise, and the refusal names the `bash` rule that decided rather than the boundary that asked.
|
|
565
|
+
|
|
562
566
|
Configs without a `path` key behave identically to before — the gate does not fire.
|
|
563
567
|
When no `path` key is present, the universal fallback (`permission["*"]`) applies: `"*": "allow"` keeps the gate transparent, while `"*": "deny"` would deny all file access via every surface including `path`.
|
|
564
568
|
|
package/docs/troubleshooting.md
CHANGED
|
@@ -2,15 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
## Common Issues
|
|
4
4
|
|
|
5
|
-
| Problem
|
|
6
|
-
|
|
|
7
|
-
| Config not applied (everything asks)
|
|
8
|
-
| Per-agent override not applied
|
|
9
|
-
| Tool blocked as unregistered
|
|
10
|
-
| `/skill:<name>` blocked
|
|
11
|
-
| External file path blocked
|
|
12
|
-
| Spurious external-path prompt for `cd <subdir> && grep … ../path`
|
|
13
|
-
| Permission prompt is too verbose
|
|
5
|
+
| Problem | Cause | Solution |
|
|
6
|
+
| ------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
7
|
+
| Config not applied (everything asks) | File not found or parse error | Verify the global config at `~/.pi/agent/extensions/pi-permission-system/config.json` (respects `PI_CODING_AGENT_DIR`); check for trailing commas |
|
|
8
|
+
| Per-agent override not applied | Frontmatter parsing issue | Ensure `---` delimiters at file top; keep YAML simple; restart session |
|
|
9
|
+
| Tool blocked as unregistered | Unknown tool name | Use a registered `mcp` tool for server tools: `{ "tool": "server:tool" }` |
|
|
10
|
+
| `/skill:<name>` blocked | Deny policy or confirmation unavailable | Check merged `skill` policy (global/project/agent layers). `ask` still requires UI or forwarded confirmation. |
|
|
11
|
+
| External file path blocked | `external_directory` is `ask` without UI or `deny` | Allow/ask the permission or keep file tools inside the active working directory. |
|
|
12
|
+
| Spurious external-path prompt for `cd <subdir> && grep … ../path` | Relative path was resolved against cwd instead of the `cd` target | Fixed in current version — paths after a leading `cd <subdir> &&` are resolved against the cd target, matching actual shell behavior. |
|
|
13
|
+
| Permission prompt is too verbose | Generic extension tool input is large | Built-in file tools are summarized automatically; third-party tools are capped to a bounded one-line JSON preview. |
|
|
14
|
+
| Windows: `permission_forwarding.error — EPERM … rename` in the logs, and a subagent's tool call refused | An antivirus scanner or the search indexer held a transient handle on a forwarding file | The write is retried automatically for a short window. If it still fails, enable `debugLog` and look for `permission_forwarding.fs_retried` entries — their `attempts` and `code` say whether retrying is helping — then exclude the forwarding directory from the scanner. |
|
|
14
15
|
|
|
15
16
|
## Diagnostic Logging
|
|
16
17
|
|
package/package.json
CHANGED
|
@@ -29,6 +29,10 @@ import {
|
|
|
29
29
|
type ForwardedSessionApproval,
|
|
30
30
|
type PermissionForwardingLocation,
|
|
31
31
|
} from "./permission-forwarding";
|
|
32
|
+
import {
|
|
33
|
+
retryOnTransientFsError,
|
|
34
|
+
type TransientFsRetryRecord,
|
|
35
|
+
} from "./transient-fs-retry";
|
|
32
36
|
|
|
33
37
|
/** Valid `permissions:ui_prompt` source values, for tolerant request reads. */
|
|
34
38
|
const UI_PROMPT_SOURCES = [
|
|
@@ -217,7 +221,14 @@ export function ensureDirectoryExists(
|
|
|
217
221
|
description: string,
|
|
218
222
|
): boolean {
|
|
219
223
|
try {
|
|
220
|
-
|
|
224
|
+
recordFsRetry(
|
|
225
|
+
logger,
|
|
226
|
+
"mkdir",
|
|
227
|
+
path,
|
|
228
|
+
retryOnTransientFsError(() => {
|
|
229
|
+
mkdirSync(path, { recursive: true, mode: OWNER_ONLY_DIRECTORY_MODE });
|
|
230
|
+
}),
|
|
231
|
+
);
|
|
221
232
|
return true;
|
|
222
233
|
} catch (error) {
|
|
223
234
|
logPermissionForwardingError(
|
|
@@ -399,17 +410,51 @@ export function writeJsonFileAtomic(
|
|
|
399
410
|
try {
|
|
400
411
|
// `rename` preserves the temp file's mode, so setting it here is enough —
|
|
401
412
|
// a response overwriting an existing file also comes through a fresh temp.
|
|
413
|
+
// The temp write is deliberately outside the retry: its failure shape is a
|
|
414
|
+
// write-denied directory, which no number of attempts resolves.
|
|
402
415
|
writeFileSync(tempPath, JSON.stringify(value), {
|
|
403
416
|
encoding: "utf-8",
|
|
404
417
|
mode: OWNER_ONLY_FILE_MODE,
|
|
405
418
|
});
|
|
406
|
-
|
|
419
|
+
recordFsRetry(
|
|
420
|
+
logger,
|
|
421
|
+
"rename",
|
|
422
|
+
filePath,
|
|
423
|
+
retryOnTransientFsError(() => {
|
|
424
|
+
renameSync(tempPath, filePath);
|
|
425
|
+
}),
|
|
426
|
+
);
|
|
407
427
|
} catch (error) {
|
|
408
428
|
safeDeleteFile(logger, tempPath, "temporary permission-forwarding");
|
|
409
429
|
throw error;
|
|
410
430
|
}
|
|
411
431
|
}
|
|
412
432
|
|
|
433
|
+
/**
|
|
434
|
+
* Record a filesystem operation that only succeeded after retrying.
|
|
435
|
+
*
|
|
436
|
+
* Debug-only: a recovered write decided nothing, so it does not belong in the
|
|
437
|
+
* permission-decision record — but `attempts` and `code` are the whole
|
|
438
|
+
* diagnosis when a host keeps losing writes, and `debugLog` is exactly the
|
|
439
|
+
* switch a user reaching for that diagnosis turns on.
|
|
440
|
+
*/
|
|
441
|
+
function recordFsRetry(
|
|
442
|
+
logger: DebugReviewLogger | null,
|
|
443
|
+
operation: "rename" | "mkdir",
|
|
444
|
+
path: string,
|
|
445
|
+
record: TransientFsRetryRecord,
|
|
446
|
+
): void {
|
|
447
|
+
if (record.attempts === 1) {
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
logger?.debug("permission_forwarding.fs_retried", {
|
|
451
|
+
operation,
|
|
452
|
+
path,
|
|
453
|
+
attempts: record.attempts,
|
|
454
|
+
code: record.code,
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
|
|
413
458
|
export function readForwardedPermissionRequest(
|
|
414
459
|
logger: DebugReviewLogger | null,
|
|
415
460
|
filePath: string,
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* transient-fs-retry.ts — Survive a file lock that is about to clear.
|
|
3
|
+
*
|
|
4
|
+
* On Windows a `rename` or `mkdir` is not the POSIX operation of the same
|
|
5
|
+
* name: an antivirus scanner, the search indexer, or any other process holding
|
|
6
|
+
* a transient handle on a path fails the call with `EPERM`/`EBUSY`/`EACCES`.
|
|
7
|
+
* The forwarded-permission writes that ride on those calls are then simply
|
|
8
|
+
* lost — a child's request write becomes a refused tool call, and a serving
|
|
9
|
+
* heartbeat goes unpublished (#914).
|
|
10
|
+
*
|
|
11
|
+
* A few attempts a few milliseconds apart is enough for the common case. The
|
|
12
|
+
* decision lives here rather than at either call site so both inherit one
|
|
13
|
+
* errno set and one budget, and so it can be tested without a filesystem.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** Tuning seams, injected so a unit test asserts the backoff without sleeping. */
|
|
17
|
+
export interface TransientFsRetryOptions {
|
|
18
|
+
delaysMs?: readonly number[];
|
|
19
|
+
sleep?: (ms: number) => void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** How an operation that eventually succeeded got there. */
|
|
23
|
+
export interface TransientFsRetryRecord {
|
|
24
|
+
/** Attempts made, counting the first — `1` when it succeeded outright. */
|
|
25
|
+
attempts: number;
|
|
26
|
+
/** The transient errno that forced the retries, or `null` when there were none. */
|
|
27
|
+
code: string | null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Run `operation`, retrying while a transient filesystem lock rejects it.
|
|
32
|
+
*
|
|
33
|
+
* Returns how it went, so the caller can record a recovery without this module
|
|
34
|
+
* having to know anything about logging. An error outside the transient set is
|
|
35
|
+
* rethrown immediately with no sleep, keeping a permanent `ENOTDIR`/`ENOENT`
|
|
36
|
+
* exactly as fast as it is today; an exhausted budget rethrows the last error,
|
|
37
|
+
* so every caller's existing `catch` keeps working unchanged.
|
|
38
|
+
*/
|
|
39
|
+
export function retryOnTransientFsError(
|
|
40
|
+
operation: () => void,
|
|
41
|
+
options?: TransientFsRetryOptions,
|
|
42
|
+
): TransientFsRetryRecord {
|
|
43
|
+
const delaysMs = options?.delaysMs ?? DEFAULT_RETRY_DELAYS_MS;
|
|
44
|
+
const sleep = options?.sleep ?? sleepBlocking;
|
|
45
|
+
|
|
46
|
+
let attempts = 0;
|
|
47
|
+
let code: string | null = null;
|
|
48
|
+
|
|
49
|
+
for (;;) {
|
|
50
|
+
try {
|
|
51
|
+
operation();
|
|
52
|
+
return { attempts: attempts + 1, code };
|
|
53
|
+
} catch (error) {
|
|
54
|
+
const transientCode = transientErrorCode(error);
|
|
55
|
+
if (transientCode === null || attempts >= delaysMs.length) {
|
|
56
|
+
throw error;
|
|
57
|
+
}
|
|
58
|
+
code = transientCode;
|
|
59
|
+
sleep(delaysMs[attempts] ?? 0);
|
|
60
|
+
attempts += 1;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ── Module-private ─────────────────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Three retries, 60 ms of blocking at worst.
|
|
69
|
+
*
|
|
70
|
+
* Sized against the two windows it has to fit inside: well under one
|
|
71
|
+
* `PERMISSION_FORWARDING_POLL_INTERVAL_MS` tick, so a retrying heartbeat write
|
|
72
|
+
* cannot push the serving node's timer into the next tick, and far under the
|
|
73
|
+
* grace a forwarding child waits out, so a parent retrying its own writes can
|
|
74
|
+
* never be the reason a child gives up on it.
|
|
75
|
+
*/
|
|
76
|
+
const DEFAULT_RETRY_DELAYS_MS: readonly number[] = [10, 20, 30];
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The errno values a transient lock produces on an otherwise-valid operation.
|
|
80
|
+
*
|
|
81
|
+
* This set is the platform gate: `process.platform` is unreadable in `src/`,
|
|
82
|
+
* and a POSIX host that produces one of these on a rename is in the same
|
|
83
|
+
* situation a Windows host is — worth one more attempt, and no worse off for
|
|
84
|
+
* it.
|
|
85
|
+
*/
|
|
86
|
+
const TRANSIENT_FS_ERROR_CODES: ReadonlySet<string> = new Set([
|
|
87
|
+
"EPERM",
|
|
88
|
+
"EBUSY",
|
|
89
|
+
"EACCES",
|
|
90
|
+
]);
|
|
91
|
+
|
|
92
|
+
/** The transient errno `error` carries, or `null` when retrying cannot help. */
|
|
93
|
+
function transientErrorCode(error: unknown): string | null {
|
|
94
|
+
if (typeof error !== "object" || error === null || !("code" in error)) {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
const { code } = error as { code?: unknown };
|
|
98
|
+
return typeof code === "string" && TRANSIENT_FS_ERROR_CODES.has(code)
|
|
99
|
+
? code
|
|
100
|
+
: null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Block the calling thread for `ms`.
|
|
105
|
+
*
|
|
106
|
+
* Synchronous because every caller is: the heartbeat publish satisfies a
|
|
107
|
+
* `void` seam a timer drives. `Atomics.wait` on a never-notified buffer is the
|
|
108
|
+
* standard blocking sleep and is permitted on Node's main thread.
|
|
109
|
+
*
|
|
110
|
+
* The buffer is module-scoped — which persists across same-cwd session
|
|
111
|
+
* switches — and that is safe here precisely because nothing ever writes to
|
|
112
|
+
* it: every wait sees the initial zero and times out.
|
|
113
|
+
*/
|
|
114
|
+
const SLEEP_BUFFER = new Int32Array(new SharedArrayBuffer(4));
|
|
115
|
+
|
|
116
|
+
function sleepBlocking(ms: number): void {
|
|
117
|
+
Atomics.wait(SLEEP_BUFFER, 0, 0, ms);
|
|
118
|
+
}
|
|
@@ -97,6 +97,81 @@ export interface GateBypass {
|
|
|
97
97
|
/** Union of possible gate function return values. */
|
|
98
98
|
export type GateResult = GateDescriptor | GateBypass | null;
|
|
99
99
|
|
|
100
|
+
// ── Resolved-state readers ─────────────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The permission check a descriptor already carries, or `null` when it
|
|
104
|
+
* resolves nothing of its own.
|
|
105
|
+
*
|
|
106
|
+
* Every tool-call gate resolves its own state before the runner sees it —
|
|
107
|
+
* five of the six stamp a full `preCheck`, and the skill-read gate stamps the
|
|
108
|
+
* `preResolved` state it read off the matched skill entry. This is the one
|
|
109
|
+
* place that precedence is expressed, so the runner and the pre-emption
|
|
110
|
+
* predicate cannot answer it differently.
|
|
111
|
+
*
|
|
112
|
+
* A `null` answer is not "allow": it means the caller must resolve the
|
|
113
|
+
* descriptor itself.
|
|
114
|
+
*/
|
|
115
|
+
export function preResolvedCheckOf(
|
|
116
|
+
descriptor: GateDescriptor,
|
|
117
|
+
): PermissionCheckResult | null {
|
|
118
|
+
if (descriptor.preCheck) {
|
|
119
|
+
return descriptor.preCheck;
|
|
120
|
+
}
|
|
121
|
+
if (descriptor.preResolved) {
|
|
122
|
+
return {
|
|
123
|
+
state: descriptor.preResolved.state,
|
|
124
|
+
toolName: descriptor.surface,
|
|
125
|
+
source: "tool",
|
|
126
|
+
origin: "builtin",
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Whether this gate blocks without escalating, whatever the other gates say.
|
|
134
|
+
*
|
|
135
|
+
* A `deny` is absorbing: wherever it sits in the pipeline's order, the call is
|
|
136
|
+
* refused, so no other gate's answer — and no human's — can change the
|
|
137
|
+
* outcome. That is what makes running it first an ordering change rather than
|
|
138
|
+
* a semantic one, and it is why the same treatment is *not* extended to `ask`
|
|
139
|
+
* (#915): two asking gates ask two different questions.
|
|
140
|
+
*
|
|
141
|
+
* Subordinate to {@link GateRunner.runDescriptor}'s own precedence, which
|
|
142
|
+
* tests `source === "session"` before the deny/ask/allow gate is reached — a
|
|
143
|
+
* session-sourced check is allowed there, so it is not pre-emptive here.
|
|
144
|
+
* `SessionRules` records only allows, so that combination is unreachable
|
|
145
|
+
* today; the clause is kept so the predicate is correct on its own terms
|
|
146
|
+
* rather than by way of a distant invariant, and it errs toward today's
|
|
147
|
+
* behavior by declining to pre-empt.
|
|
148
|
+
*
|
|
149
|
+
* Yolo needs no clause: `resolveYoloGrant` matches an `allow` of origin
|
|
150
|
+
* `yolo` and an `ask`, never a `deny`.
|
|
151
|
+
*/
|
|
152
|
+
export function isUnconditionalDeny(gate: GateResult): boolean {
|
|
153
|
+
if (!isGateDescriptor(gate)) {
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
const check = preResolvedCheckOf(gate);
|
|
157
|
+
return check !== null && check.state === "deny" && check.source !== "session";
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* The gates in run order, with any unconditional deny moved to the front.
|
|
162
|
+
*
|
|
163
|
+
* A stable partition, so two denying gates keep their relative order (the
|
|
164
|
+
* earlier one still decides, exactly as before) and the remainder keeps its
|
|
165
|
+
* own. With no deny present the array is returned unchanged.
|
|
166
|
+
*/
|
|
167
|
+
export function orderDenyFirst(gates: GateResult[]): GateResult[] {
|
|
168
|
+
const denying = gates.filter((gate) => isUnconditionalDeny(gate));
|
|
169
|
+
if (denying.length === 0) {
|
|
170
|
+
return gates;
|
|
171
|
+
}
|
|
172
|
+
return [...denying, ...gates.filter((gate) => !isUnconditionalDeny(gate))];
|
|
173
|
+
}
|
|
174
|
+
|
|
100
175
|
// ── Type guard helpers ─────────────────────────────────────────────────────
|
|
101
176
|
|
|
102
177
|
/** Check whether a GateResult is a GateBypass (early allow). */
|
|
@@ -12,13 +12,12 @@ import {
|
|
|
12
12
|
} from "#src/presentation/agent-renderer";
|
|
13
13
|
import { renderReviewLogFacts } from "#src/presentation/review-log-renderer";
|
|
14
14
|
import type { SessionApprovalRecorder } from "#src/session/session-approval-recorder";
|
|
15
|
-
import type { PermissionCheckResult } from "#src/types";
|
|
16
15
|
import type {
|
|
17
16
|
DecisionEventFacts,
|
|
18
17
|
GateDescriptor,
|
|
19
18
|
GateResult,
|
|
20
19
|
} from "./descriptor";
|
|
21
|
-
import { isGateBypass } from "./descriptor";
|
|
20
|
+
import { isGateBypass, preResolvedCheckOf } from "./descriptor";
|
|
22
21
|
import { buildDecisionEvent, resolveYoloGrant } from "./helpers";
|
|
23
22
|
import type { GateOutcome } from "./types";
|
|
24
23
|
|
|
@@ -89,25 +88,16 @@ export class GateRunner {
|
|
|
89
88
|
agentName: string | null,
|
|
90
89
|
requestId: string,
|
|
91
90
|
): Promise<GateOutcome> {
|
|
92
|
-
// 1. Resolve permission state —
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
check = {
|
|
98
|
-
state: descriptor.preResolved.state,
|
|
99
|
-
toolName: descriptor.surface,
|
|
100
|
-
source: "tool",
|
|
101
|
-
origin: "builtin",
|
|
102
|
-
};
|
|
103
|
-
} else {
|
|
104
|
-
check = this.resolver.resolve({
|
|
91
|
+
// 1. Resolve permission state — what the descriptor already carries, or
|
|
92
|
+
// via the resolver when it carries nothing.
|
|
93
|
+
const check =
|
|
94
|
+
preResolvedCheckOf(descriptor) ??
|
|
95
|
+
this.resolver.resolve({
|
|
105
96
|
kind: "tool",
|
|
106
97
|
surface: descriptor.surface,
|
|
107
98
|
input: descriptor.input,
|
|
108
99
|
agentName: agentName ?? undefined,
|
|
109
100
|
});
|
|
110
|
-
}
|
|
111
101
|
|
|
112
102
|
// The fields every review-log write for this gate shares, whatever the
|
|
113
103
|
// resolution — built once so a field added here reaches all of them. The
|
|
@@ -18,7 +18,7 @@ import type { PermissionCheckResult } from "#src/types";
|
|
|
18
18
|
import { resolveBashCommandCheck } from "./bash-command";
|
|
19
19
|
import { describeBashExternalDirectoryGate } from "./bash-external-directory";
|
|
20
20
|
import { describeBashPathGate } from "./bash-path";
|
|
21
|
-
import type
|
|
21
|
+
import { type GateResult, orderDenyFirst } from "./descriptor";
|
|
22
22
|
import { describeExternalDirectoryGate } from "./external-directory";
|
|
23
23
|
import { describePathGate } from "./path";
|
|
24
24
|
import type { GateRunner } from "./runner";
|
|
@@ -62,7 +62,8 @@ export interface ToolCallGateInputs {
|
|
|
62
62
|
* - `ToolPreviewFormatter` construction from `getToolPreviewLimits()`
|
|
63
63
|
* - infrastructure-dir list from `getInfrastructureReadDirs()`
|
|
64
64
|
* - all six gate producers in their prescribed order
|
|
65
|
-
* - the run loop
|
|
65
|
+
* - the run loop, which runs an unconditionally denying gate ahead of the
|
|
66
|
+
* rest and returns the first block outcome, or allow
|
|
66
67
|
*/
|
|
67
68
|
export class ToolCallGatePipeline {
|
|
68
69
|
constructor(
|
|
@@ -141,8 +142,18 @@ export class ToolCallGatePipeline {
|
|
|
141
142
|
},
|
|
142
143
|
];
|
|
143
144
|
|
|
145
|
+
// Produce every gate before running any of them, so an unconditional deny
|
|
146
|
+
// on a later gate is known before an earlier one suspends the call on an
|
|
147
|
+
// `ask` nobody's answer could change (#899). Producing is side-effect-free
|
|
148
|
+
// — all logging and event emission happens inside `runner.run` — and the
|
|
149
|
+
// loop below already produced every gate on any call it did not block.
|
|
150
|
+
const gates: GateResult[] = [];
|
|
144
151
|
for (const produce of gateProducers) {
|
|
145
|
-
|
|
152
|
+
gates.push(await produce());
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
for (const gate of orderDenyFirst(gates)) {
|
|
156
|
+
const outcome = await runner.run(gate, tcc.agentName);
|
|
146
157
|
if (outcome.action === "block") {
|
|
147
158
|
return outcome;
|
|
148
159
|
}
|