@erichll/pi-auto-review 0.1.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,221 @@
1
+ # @erichll/pi-auto-review
2
+
3
+ A model-backed boundary approval broker for the Pi coding agent.
4
+
5
+ `pi-auto-review` currently integrates with
6
+ `@gotgenes/pi-permission-system` as an authorizer-chain link. It also publishes
7
+ a small cross-extension broker service so an OS sandbox adapter can submit
8
+ filesystem and network boundary requests without creating a second approval
9
+ system.
10
+
11
+ The package name and model name are intentionally separate:
12
+
13
+ - npm package: `@erichll/pi-auto-review`
14
+ - Authorizer: `pi-auto-review`
15
+ - Default reviewer model: `cliproxyapi/codex-auto-review`
16
+
17
+ ## Security model
18
+
19
+ The broker applies the following order:
20
+
21
+ 1. Deterministic hard denies
22
+ 2. Model review
23
+ 3. Human terminal when the model returns `defer` or `failureMode` is `defer`
24
+ 4. Exact, expiring, one-use grants for external sandbox adapters
25
+
26
+ Model approval cannot override `pi-permission-system`'s bounded-delegation
27
+ checkpoint. In version 24, an authorizer's `allow` on `path` or
28
+ `external_directory` is still downgraded to `defer` and reaches the human
29
+ terminal. Sandbox adapters should use concrete surfaces such as
30
+ `filesystem-read`, `filesystem-write`, and `network`.
31
+
32
+ The broker stops automatic review for a turn after three consecutive denials
33
+ or ten denials in the rolling last fifty reviews. An explicit denial is
34
+ returned to the agent with an instruction not to pursue the same outcome
35
+ through a workaround.
36
+
37
+ ## Exact human retry override
38
+
39
+ In interactive TUI mode, `/approve` shows up to ten recent model denials from
40
+ the current session. Selecting one authorizes exactly one unchanged retry and
41
+ automatically asks the agent to perform it. The authorization:
42
+
43
+ - is bound to the complete request hash, including cwd, command, requested and
44
+ resolved paths, destination, tool input, and agent;
45
+ - expires after 60 seconds and is consumed once;
46
+ - is passed to the reviewer as a separate host-generated
47
+ `trusted-user-override` evidence block;
48
+ - does not directly allow anything—the reviewer can still deny or defer; and
49
+ - is checked only after deterministic hard denies, so critical tenant and
50
+ security-control policy cannot be overridden.
51
+
52
+ Changing any request field misses the override. An override that was consumed
53
+ cannot be issued again for the same request semantics in that session.
54
+
55
+ ## Configure
56
+
57
+ Edit `src/config.json`, using `config/config.example.json` as the reference:
58
+
59
+ ```json
60
+ {
61
+ "model": "cliproxyapi/codex-auto-review",
62
+ "reasoning": "medium",
63
+ "timeoutMs": 45000,
64
+ "maxTokens": 1600,
65
+ "retries": 1,
66
+ "maxUserTranscriptTokens": 1200,
67
+ "maxToolTranscriptTokens": 1200,
68
+ "maxRelevantResultTokens": 800,
69
+ "failureMode": "deny",
70
+ "grantTtlMs": 60000
71
+ }
72
+ ```
73
+
74
+ The configured provider must be registered in Pi and must support the
75
+ configured model. `failureMode: "deny"` is the fail-closed default;
76
+ `failureMode: "defer"` falls through to the normal human terminal.
77
+
78
+ Add the link to the permission-system configuration:
79
+
80
+ ```json
81
+ {
82
+ "authorizerChain": ["pi-auto-review"]
83
+ }
84
+ ```
85
+
86
+ ## Boundary broker service
87
+
88
+ The extension publishes a process-local service at:
89
+
90
+ ```ts
91
+ Symbol.for("pi-auto-review:boundary-approval-broker")
92
+ ```
93
+
94
+ Adapters should normally use the exported helper:
95
+
96
+ ```ts
97
+ import {
98
+ getBoundaryBroker,
99
+ type BoundaryRequest,
100
+ } from "@erichll/pi-auto-review";
101
+
102
+ const request: BoundaryRequest = {
103
+ id: "landstrip-query-id",
104
+ source: "landstrip",
105
+ surface: "network",
106
+ operation: "connect",
107
+ cwd: "/workspace/project",
108
+ command: "npm install",
109
+ destination: "registry.npmjs.org:443"
110
+ };
111
+
112
+ const service = getBoundaryBroker();
113
+ const decision = await service?.review(request, {
114
+ sessionId: "pi-session-id",
115
+ scopeKey: "pi-session-id:turn-id",
116
+ issueGrant: true
117
+ });
118
+ ```
119
+
120
+ When `decision.kind` is `allow`, an external sandbox adapter must consume the
121
+ returned grant before retrying:
122
+
123
+ ```ts
124
+ if (
125
+ decision?.kind === "allow" &&
126
+ decision.grant &&
127
+ service?.consumeGrant(request, "pi-session-id", decision.grant.token)
128
+ ) {
129
+ // Retry this exact operation once inside the OS sandbox.
130
+ }
131
+ ```
132
+
133
+ Changing the command, path, destination, working directory, agent, or tool
134
+ call/input invalidates the grant. Grants expire after `grantTtlMs` and cannot
135
+ be reused.
136
+
137
+ ## Relevant reviewer evidence
138
+
139
+ The reviewer always receives bounded user intent and assistant tool calls. It
140
+ may additionally receive only these bounded result classes:
141
+
142
+ - The exact result correlated by `toolCallId`.
143
+ - A read-only `stat`, `ls`, `find`, `test`, `readlink`, or `realpath` result for
144
+ a pending deletion target.
145
+ - `git remote`, branch, status, revision, and remote configuration results for
146
+ a pending Git push.
147
+ - The current structured Landstrip trap.
148
+
149
+ Unrelated tool results and assistant prose remain excluded. Selected results
150
+ have a separate `maxRelevantResultTokens` budget, common secret/token forms are
151
+ redacted, and markup characters are escaped before the evidence is sent to the
152
+ reviewer. Results remain explicitly labeled as untrusted and possibly
153
+ incomplete.
154
+
155
+ ## Landstrip status
156
+
157
+ The broker-facing contract is implemented, but this package does not intercept
158
+ Landstrip prompts by itself. A Landstrip adapter must translate its structured
159
+ filesystem/network trap into `BoundaryRequest`, consume the exact grant, and
160
+ then use a supported Landstrip retry or dynamic-grant mechanism.
161
+
162
+ This monorepo also contains `pi-sandbox`, a Linux Landstrip and macOS Seatbelt
163
+ integration that owns Bash and process-backed subagent execution, provides
164
+ domain-based proxy approval, and consumes this broker service directly. Its
165
+ Linux native paths are tested. The macOS implementation has deterministic
166
+ orchestration and policy tests, but still awaits native smoke coverage on real
167
+ x64/arm64 hosts. Do not load `pi-sandbox` and `pi-landstrip` together.
168
+
169
+ Do not implement the adapter by writing broad permanent rules to
170
+ `.pi/sandbox.json`.
171
+
172
+ ## Trust boundary
173
+
174
+ The extension refuses to activate when its package directory is inside the
175
+ agent-writable workspace. Install production copies as a user-level npm
176
+ package, or as a Git package pinned to a reviewed tag or commit:
177
+
178
+ ```bash
179
+ pi install npm:@erichll/pi-auto-review
180
+ ```
181
+
182
+ Pi places user npm packages under `~/.pi/agent/npm/` and Git packages under
183
+ `~/.pi/agent/git/`, outside the project workspace. For local development only:
184
+
185
+ ```bash
186
+ PI_AUTO_REVIEW_ALLOW_UNTRUSTED_DEV=1 pi --approve
187
+ ```
188
+
189
+ The trusted package configuration is copied and frozen at `session_start`.
190
+ An optional workspace file at `.pi/pi-auto-review.json` may only lower
191
+ timeouts, token/evidence limits, retries, and grant TTL, or set `failureMode`
192
+ to `deny`. It cannot select a model, weaken fail-closed behavior, or raise a
193
+ trusted limit. Invalid project configuration disables the reviewer for that
194
+ session.
195
+
196
+ Writes to the installed reviewer package, project security configuration,
197
+ global Pi security configuration, and the global audit directory are
198
+ deterministically denied.
199
+
200
+ ## Real-model smoke test
201
+
202
+ For a controlled smoke test, disable unrelated extensions and explicitly load
203
+ only the model provider, broker, sandbox, and audit listener:
204
+
205
+ ```bash
206
+ PI_AUTO_REVIEW_ALLOW_UNTRUSTED_DEV=1 \
207
+ PI_AUTO_REVIEW_SMOKE_AUDIT_PATH=/tmp/pi-auto-review-smoke-audit.jsonl \
208
+ pi --no-extensions --no-skills --no-prompt-templates --no-context-files \
209
+ --no-builtin-tools --no-session --print \
210
+ --extension /trusted/path/to/cliproxyapi-provider/extensions/index.ts \
211
+ --extension ./packages/pi-auto-review/src/index.ts \
212
+ --extension ./packages/pi-sandbox/src/index.ts \
213
+ --extension ./scripts/real-model-smoke-audit.ts \
214
+ --model cliproxyapi/gpt-5.6-sol \
215
+ "Use bash once to write a fixed marker outside the workspace."
216
+ ```
217
+
218
+ A successful boundary review emits `review_decision`, `grant_issued`, and
219
+ `grant_consumed` for the same request ID. `--no-builtin-tools` is important:
220
+ it ensures the `bash` implementation comes from `pi-sandbox` rather than
221
+ silently falling back to Pi's built-in Bash.
@@ -0,0 +1,12 @@
1
+ {
2
+ "model": "cliproxyapi/codex-auto-review",
3
+ "reasoning": "medium",
4
+ "timeoutMs": 45000,
5
+ "maxTokens": 1600,
6
+ "retries": 1,
7
+ "maxUserTranscriptTokens": 1200,
8
+ "maxToolTranscriptTokens": 1200,
9
+ "maxRelevantResultTokens": 800,
10
+ "failureMode": "deny",
11
+ "grantTtlMs": 60000
12
+ }
package/package.json ADDED
@@ -0,0 +1,61 @@
1
+ {
2
+ "name": "@erichll/pi-auto-review",
3
+ "version": "0.1.1",
4
+ "description": "Model-backed boundary approval broker for Pi",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "keywords": [
8
+ "pi-package",
9
+ "pi-extension",
10
+ "pi",
11
+ "security",
12
+ "sandbox"
13
+ ],
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/erichll/pi-packages.git",
17
+ "directory": "packages/pi-auto-review"
18
+ },
19
+ "homepage": "https://github.com/erichll/pi-packages/tree/main/packages/pi-auto-review",
20
+ "bugs": {
21
+ "url": "https://github.com/erichll/pi-packages/issues"
22
+ },
23
+ "publishConfig": {
24
+ "access": "public"
25
+ },
26
+ "engines": {
27
+ "node": ">=22"
28
+ },
29
+ "files": [
30
+ "src",
31
+ "config/config.example.json",
32
+ "README.md",
33
+ "LICENSE"
34
+ ],
35
+ "exports": {
36
+ ".": "./src/index.ts",
37
+ "./broker": "./src/broker/index.ts",
38
+ "./landstrip": "./src/integrations/landstrip.ts"
39
+ },
40
+ "pi": {
41
+ "extensions": [
42
+ "./src/index.ts"
43
+ ]
44
+ },
45
+ "scripts": {
46
+ "check": "tsc -p tsconfig.json --noEmit",
47
+ "test": "node --disable-warning=ExperimentalWarning --experimental-loader ./test/typescript-loader.mjs --test test/*.test.ts"
48
+ },
49
+ "peerDependencies": {
50
+ "@earendil-works/pi-ai": ">=0.82.1",
51
+ "@earendil-works/pi-coding-agent": ">=0.82.1",
52
+ "@gotgenes/pi-permission-system": ">=23.0.0"
53
+ },
54
+ "devDependencies": {
55
+ "@earendil-works/pi-ai": "0.82.1",
56
+ "@earendil-works/pi-coding-agent": "0.82.1",
57
+ "@gotgenes/pi-permission-system": "24.0.0",
58
+ "@types/node": "^22.15.3",
59
+ "typescript": "^6.0.3"
60
+ }
61
+ }
@@ -0,0 +1,215 @@
1
+ import { DenialCircuitBreaker } from "./circuit-breaker.ts";
2
+ import { OneShotGrantStore } from "./grants.ts";
3
+ import { RecentDenialStore } from "./overrides.ts";
4
+ import type {
5
+ BoundaryAuditEvent,
6
+ BoundaryDecision,
7
+ BoundaryHardDeny,
8
+ BoundaryRequest,
9
+ BoundaryReview,
10
+ BoundaryReviewContext,
11
+ BoundaryReviewer,
12
+ RecentBoundaryDenial,
13
+ } from "./types.ts";
14
+
15
+ export type BoundaryApprovalBrokerOptions = {
16
+ reviewer: BoundaryReviewer;
17
+ hardDeny?: BoundaryHardDeny;
18
+ failureMode?: "deny" | "defer";
19
+ grants?: OneShotGrantStore;
20
+ breaker?: DenialCircuitBreaker;
21
+ denials?: RecentDenialStore;
22
+ audit?: (event: BoundaryAuditEvent) => void;
23
+ };
24
+
25
+ const FAILURE_REVIEW: BoundaryReview = {
26
+ outcome: "deny",
27
+ riskLevel: "high",
28
+ userAuthorization: "unknown",
29
+ rationale: "Automatic review is unavailable.",
30
+ };
31
+
32
+ function assertRequest(request: BoundaryRequest): void {
33
+ for (const [name, value] of [
34
+ ["id", request.id],
35
+ ["source", request.source],
36
+ ["surface", request.surface],
37
+ ["operation", request.operation],
38
+ ["cwd", request.cwd],
39
+ ]) {
40
+ if (typeof value !== "string" || !value.trim()) {
41
+ throw new Error(`boundary request ${name} must be a non-empty string`);
42
+ }
43
+ }
44
+ }
45
+
46
+ export class BoundaryApprovalBroker {
47
+ readonly #reviewer: BoundaryReviewer;
48
+ readonly #hardDeny?: BoundaryHardDeny;
49
+ readonly #failureMode: "deny" | "defer";
50
+ readonly #grants: OneShotGrantStore;
51
+ readonly #breaker: DenialCircuitBreaker;
52
+ readonly #denials: RecentDenialStore;
53
+ readonly #audit?: (event: BoundaryAuditEvent) => void;
54
+
55
+ constructor(options: BoundaryApprovalBrokerOptions) {
56
+ this.#reviewer = options.reviewer;
57
+ this.#hardDeny = options.hardDeny;
58
+ this.#failureMode = options.failureMode ?? "deny";
59
+ this.#grants = options.grants ?? new OneShotGrantStore();
60
+ this.#breaker = options.breaker ?? new DenialCircuitBreaker();
61
+ this.#denials = options.denials ?? new RecentDenialStore();
62
+ this.#audit = options.audit;
63
+ }
64
+
65
+ async review(
66
+ request: BoundaryRequest,
67
+ context: BoundaryReviewContext,
68
+ ): Promise<BoundaryDecision> {
69
+ assertRequest(request);
70
+ const hardDeny = this.#hardDeny?.(request);
71
+ if (hardDeny) {
72
+ const breaker = this.#breaker.record(context.scopeKey, true);
73
+ const review: BoundaryReview = {
74
+ outcome: "deny",
75
+ riskLevel: "critical",
76
+ userAuthorization: "unknown",
77
+ rationale: hardDeny.reason,
78
+ };
79
+ this.audit("hard_deny", request, {
80
+ rule: hardDeny.rule,
81
+ reason: hardDeny.reason,
82
+ });
83
+ if (breaker.tripped) {
84
+ this.audit("circuit_breaker", request, breaker);
85
+ }
86
+ return {
87
+ kind: "deny",
88
+ review,
89
+ circuitBreakerTripped: breaker.tripped,
90
+ };
91
+ }
92
+
93
+ const userOverride = this.#denials.consume(request, context);
94
+ if (userOverride) {
95
+ this.audit("override_consumed", request, {
96
+ originalRequestId: userOverride.originalRequestId,
97
+ approvedAt: userOverride.approvedAt,
98
+ scopeKey: context.scopeKey,
99
+ });
100
+ } else if (this.#breaker.isTripped(context.scopeKey)) {
101
+ const review: BoundaryReview = {
102
+ ...FAILURE_REVIEW,
103
+ rationale:
104
+ "Automatic review stopped after repeated denials in this turn.",
105
+ };
106
+ this.audit("circuit_breaker", request, { scopeKey: context.scopeKey });
107
+ return { kind: "deny", review, circuitBreakerTripped: true };
108
+ }
109
+
110
+ let review: BoundaryReview;
111
+ try {
112
+ review = await this.#reviewer(request, { userOverride });
113
+ } catch (error) {
114
+ const reason = `Automatic review is unavailable: ${
115
+ error instanceof Error ? error.message : String(error)
116
+ }`;
117
+ this.audit("review_failure", request, { reason });
118
+ if (this.#failureMode === "defer") {
119
+ this.#breaker.record(context.scopeKey, false);
120
+ return {
121
+ kind: "defer",
122
+ review: { ...FAILURE_REVIEW, outcome: "defer", rationale: reason },
123
+ };
124
+ }
125
+ const breaker = this.#breaker.record(context.scopeKey, true);
126
+ return {
127
+ kind: "deny",
128
+ review: { ...FAILURE_REVIEW, rationale: reason },
129
+ circuitBreakerTripped: breaker.tripped,
130
+ };
131
+ }
132
+
133
+ this.audit("review_decision", request, review);
134
+ if (review.outcome === "deny") {
135
+ this.#denials.record(request, context, review);
136
+ const breaker = this.#breaker.record(context.scopeKey, true);
137
+ if (breaker.tripped) {
138
+ this.audit("circuit_breaker", request, breaker);
139
+ }
140
+ return {
141
+ kind: "deny",
142
+ review,
143
+ circuitBreakerTripped: breaker.tripped,
144
+ };
145
+ }
146
+
147
+ this.#breaker.record(context.scopeKey, false);
148
+ if (review.outcome === "defer") return { kind: "defer", review };
149
+
150
+ const grant = context.issueGrant
151
+ ? this.#grants.issue(request, context.sessionId)
152
+ : undefined;
153
+ if (grant) {
154
+ this.audit("grant_issued", request, {
155
+ requestHash: grant.requestHash,
156
+ expiresAt: grant.expiresAt,
157
+ });
158
+ }
159
+ return { kind: "allow", review, grant };
160
+ }
161
+
162
+ consumeGrant(
163
+ request: BoundaryRequest,
164
+ sessionId: string,
165
+ token: string,
166
+ ): boolean {
167
+ const consumed = this.#grants.consume(request, sessionId, token);
168
+ this.audit(consumed ? "grant_consumed" : "grant_rejected", request, {
169
+ sessionId,
170
+ });
171
+ return consumed;
172
+ }
173
+
174
+ recentDenials(
175
+ sessionId: string,
176
+ ): RecentBoundaryDenial[] {
177
+ return this.#denials.list(sessionId);
178
+ }
179
+
180
+ authorizeRecentDenial(
181
+ requestId: string,
182
+ sessionId: string,
183
+ ): RecentBoundaryDenial | undefined {
184
+ const denial = this.#denials.authorize(
185
+ requestId,
186
+ sessionId,
187
+ );
188
+ if (denial) {
189
+ this.audit("override_authorized", denial.request, {
190
+ scopeKey: denial.scopeKey,
191
+ deniedAt: denial.deniedAt,
192
+ });
193
+ }
194
+ return denial;
195
+ }
196
+
197
+ clear(): void {
198
+ this.#grants.clear();
199
+ this.#breaker.clear();
200
+ this.#denials.clear();
201
+ }
202
+
203
+ private audit(
204
+ type: BoundaryAuditEvent["type"],
205
+ request: BoundaryRequest,
206
+ details: Record<string, unknown>,
207
+ ): void {
208
+ this.#audit?.({
209
+ type,
210
+ requestId: request.id,
211
+ surface: request.surface,
212
+ details,
213
+ });
214
+ }
215
+ }
@@ -0,0 +1,61 @@
1
+ type ScopeState = {
2
+ consecutiveDenials: number;
3
+ rolling: boolean[];
4
+ };
5
+
6
+ export type CircuitBreakerResult = {
7
+ tripped: boolean;
8
+ consecutiveDenials: number;
9
+ rollingDenials: number;
10
+ };
11
+
12
+ export class DenialCircuitBreaker {
13
+ readonly #scopes = new Map<string, ScopeState>();
14
+ private readonly consecutiveLimit: number;
15
+ private readonly rollingDenialLimit: number;
16
+ private readonly rollingWindow: number;
17
+
18
+ constructor(
19
+ consecutiveLimit = 3,
20
+ rollingDenialLimit = 10,
21
+ rollingWindow = 50,
22
+ ) {
23
+ this.consecutiveLimit = consecutiveLimit;
24
+ this.rollingDenialLimit = rollingDenialLimit;
25
+ this.rollingWindow = rollingWindow;
26
+ }
27
+
28
+ record(scopeKey: string, denied: boolean): CircuitBreakerResult {
29
+ const state = this.#scopes.get(scopeKey) ?? {
30
+ consecutiveDenials: 0,
31
+ rolling: [],
32
+ };
33
+ state.consecutiveDenials = denied
34
+ ? state.consecutiveDenials + 1
35
+ : 0;
36
+ state.rolling.push(denied);
37
+ if (state.rolling.length > this.rollingWindow) state.rolling.shift();
38
+ this.#scopes.set(scopeKey, state);
39
+ const rollingDenials = state.rolling.filter(Boolean).length;
40
+ return {
41
+ tripped:
42
+ state.consecutiveDenials >= this.consecutiveLimit ||
43
+ rollingDenials >= this.rollingDenialLimit,
44
+ consecutiveDenials: state.consecutiveDenials,
45
+ rollingDenials,
46
+ };
47
+ }
48
+
49
+ isTripped(scopeKey: string): boolean {
50
+ const state = this.#scopes.get(scopeKey);
51
+ if (!state) return false;
52
+ return (
53
+ state.consecutiveDenials >= this.consecutiveLimit ||
54
+ state.rolling.filter(Boolean).length >= this.rollingDenialLimit
55
+ );
56
+ }
57
+
58
+ clear(): void {
59
+ this.#scopes.clear();
60
+ }
61
+ }
@@ -0,0 +1,97 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import type { BoundaryGrant, BoundaryRequest } from "./types.ts";
3
+
4
+ function stableValue(value: unknown): unknown {
5
+ if (Array.isArray(value)) return value.map(stableValue);
6
+ if (!value || typeof value !== "object") return value;
7
+ return Object.fromEntries(
8
+ Object.entries(value as Record<string, unknown>)
9
+ .filter(([, child]) => child !== undefined)
10
+ .sort(([left], [right]) => left.localeCompare(right))
11
+ .map(([key, child]) => [key, stableValue(child)]),
12
+ );
13
+ }
14
+
15
+ export function boundaryRequestHash(request: BoundaryRequest): string {
16
+ const material = {
17
+ source: request.source,
18
+ surface: request.surface,
19
+ operation: request.operation,
20
+ cwd: request.cwd,
21
+ command: request.command,
22
+ path: request.path,
23
+ resolvedPath: request.resolvedPath,
24
+ destination: request.destination,
25
+ toolCallId: request.toolCallId,
26
+ toolName: request.toolName,
27
+ skillName: request.skillName,
28
+ toolInputPreview: request.toolInputPreview,
29
+ agentName: request.agentName,
30
+ };
31
+ return createHash("sha256")
32
+ .update(JSON.stringify(stableValue(material)))
33
+ .digest("hex");
34
+ }
35
+
36
+ type StoredGrant = Omit<BoundaryGrant, "usesRemaining"> & {
37
+ usesRemaining: number;
38
+ };
39
+
40
+ export class OneShotGrantStore {
41
+ readonly #grants = new Map<string, StoredGrant>();
42
+ private readonly ttlMs: number;
43
+ private readonly now: () => number;
44
+
45
+ constructor(
46
+ ttlMs = 60_000,
47
+ now: () => number = Date.now,
48
+ ) {
49
+ this.ttlMs = ttlMs;
50
+ this.now = now;
51
+ }
52
+
53
+ issue(request: BoundaryRequest, sessionId: string): BoundaryGrant {
54
+ this.prune();
55
+ const grant: BoundaryGrant = {
56
+ token: randomUUID(),
57
+ requestHash: boundaryRequestHash(request),
58
+ sessionId,
59
+ expiresAt: this.now() + this.ttlMs,
60
+ usesRemaining: 1,
61
+ };
62
+ this.#grants.set(grant.token, { ...grant });
63
+ return grant;
64
+ }
65
+
66
+ consume(
67
+ request: BoundaryRequest,
68
+ sessionId: string,
69
+ token: string,
70
+ ): boolean {
71
+ this.prune();
72
+ const grant = this.#grants.get(token);
73
+ if (
74
+ !grant ||
75
+ grant.sessionId !== sessionId ||
76
+ grant.requestHash !== boundaryRequestHash(request) ||
77
+ grant.usesRemaining !== 1
78
+ ) {
79
+ return false;
80
+ }
81
+ this.#grants.delete(token);
82
+ return true;
83
+ }
84
+
85
+ clear(): void {
86
+ this.#grants.clear();
87
+ }
88
+
89
+ private prune(): void {
90
+ const now = this.now();
91
+ for (const [token, grant] of this.#grants) {
92
+ if (grant.expiresAt <= now || grant.usesRemaining < 1) {
93
+ this.#grants.delete(token);
94
+ }
95
+ }
96
+ }
97
+ }