@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 +21 -0
- package/README.md +221 -0
- package/config/config.example.json +12 -0
- package/package.json +61 -0
- package/src/broker/broker.ts +215 -0
- package/src/broker/circuit-breaker.ts +61 -0
- package/src/broker/grants.ts +97 -0
- package/src/broker/index.ts +29 -0
- package/src/broker/overrides.ts +120 -0
- package/src/broker/service.ts +50 -0
- package/src/broker/types.ts +118 -0
- package/src/config.json +12 -0
- package/src/index.ts +941 -0
- package/src/integrations/landstrip.ts +83 -0
- package/src/policy.ts +713 -0
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
|
+
}
|