@gethmy/harness 1.2.1 → 1.4.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/dist/cli.js +497 -225
- package/dist/index.js +1259 -402
- package/package.json +2 -2
- package/src/ci-failure.ts +465 -0
- package/src/cli.ts +11 -1
- package/src/confine-to-repo.test.ts +324 -1
- package/src/confine-to-repo.ts +274 -22
- package/src/error-classifier.ts +52 -1
- package/src/gate-collectors.ts +11 -3
- package/src/git-pr.ts +461 -8
- package/src/index.ts +2 -0
- package/src/model-tier.test.ts +11 -6
- package/src/model-tier.ts +4 -4
- package/src/oracle-collector.ts +244 -23
- package/src/oracle.ts +856 -108
- package/src/pm.ts +15 -5
- package/src/repair-sandbox.test.ts +116 -0
- package/src/repair-sandbox.ts +303 -0
- package/src/run-sizing.test.ts +264 -66
- package/src/run-sizing.ts +146 -26
- package/src/sdk-agent-runner.ts +22 -1
- package/src/worktree.ts +114 -2
package/src/confine-to-repo.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Confine a
|
|
2
|
+
* Confine a spawn to one directory tree.
|
|
3
3
|
*
|
|
4
4
|
* ## Why a handler and not a permission string
|
|
5
5
|
*
|
|
@@ -7,9 +7,10 @@
|
|
|
7
7
|
* The Agent SDK documents `allowedTools` as a list of tool NAMES ("To restrict
|
|
8
8
|
* which tools are available, use the `tools` option instead"), so a rule-shaped
|
|
9
9
|
* entry there most likely matches no tool at all. That failure is silent and
|
|
10
|
-
* expensive: the tool is denied
|
|
11
|
-
*
|
|
12
|
-
*
|
|
10
|
+
* expensive: the tool is denied and the preflight returns no verdict, so the
|
|
11
|
+
* daemon degrades to the policy fallback forever. `sizeRun` now at least names
|
|
12
|
+
* that as `failed: "malformed"` in the log and on the timeline (#954) — before
|
|
13
|
+
* it did so, nothing said the sizing step had stopped working.
|
|
13
14
|
*
|
|
14
15
|
* `canUseTool` is documented to run before each tool execution and receives the
|
|
15
16
|
* tool's INPUT, so the path can be checked directly. It is an ordinary function,
|
|
@@ -18,6 +19,8 @@
|
|
|
18
19
|
*
|
|
19
20
|
* ## What it is for
|
|
20
21
|
*
|
|
22
|
+
* Two spawns, both reasoning over text someone else wrote.
|
|
23
|
+
*
|
|
21
24
|
* The pickup sizing preflight reads the operator's PRIMARY checkout, not a
|
|
22
25
|
* disposable worktree — the card's worktree does not exist when the model has to
|
|
23
26
|
* be chosen. Its prompt is built from card text anyone in the workspace can
|
|
@@ -26,10 +29,34 @@
|
|
|
26
29
|
* daemon's user can read stays reachable. This inverts that into an allowlist —
|
|
27
30
|
* inside the tree, or denied.
|
|
28
31
|
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
32
|
+
* The red-CI repair spawn (#1015) reasons over check names and job logs fetched
|
|
33
|
+
* from a CI provider, and unlike the preflight it holds `Write` and `Edit`, and
|
|
34
|
+
* what it writes is committed and pushed. That is the spawn this module's write
|
|
35
|
+
* coverage exists for. The escape it closes was found in review of #981 and is
|
|
36
|
+
* worth stating plainly, because it needs no shell: an injected directive tells
|
|
37
|
+
* the spawn to read `~/.claude/.credentials.json` and paste the contents into a
|
|
38
|
+
* source comment, which verifies green, commits, and pushes to origin — where
|
|
39
|
+
* the credential is readable by anyone with access to the repository.
|
|
40
|
+
* Exfiltration inside a grant that has no `Bash`, no `WebFetch` and no `Task`.
|
|
41
|
+
*
|
|
42
|
+
* Prompt-level containment (JSON-encoded card data, an untrusted-evidence frame)
|
|
43
|
+
* makes injection harder; this is what bounds it when that fails.
|
|
44
|
+
*
|
|
45
|
+
* ## Symlinks
|
|
46
|
+
*
|
|
47
|
+
* The tree test resolves symlinks (`realpathSync`) before comparing. A purely
|
|
48
|
+
* lexical check is not a bound on a spawn that can WRITE: `ln -s /etc <tree>/x`
|
|
49
|
+
* is inside the tree by string comparison, and every path under it reads and
|
|
50
|
+
* writes outside. The check therefore costs a `realpath` syscall per tool call,
|
|
51
|
+
* which is the correct trade against an escape that is one `Write` wide.
|
|
52
|
+
*
|
|
53
|
+
* A path that does not exist yet — the ordinary case for `Write` creating a new
|
|
54
|
+
* file — has its longest EXISTING ancestor resolved, and the remainder joined
|
|
55
|
+
* lexically onto that. A component that does not exist cannot be a symlink, so
|
|
56
|
+
* the two halves together are exact rather than approximate.
|
|
31
57
|
*/
|
|
32
|
-
import {
|
|
58
|
+
import { realpathSync } from "node:fs";
|
|
59
|
+
import { dirname, isAbsolute, parse, resolve, sep } from "node:path";
|
|
33
60
|
|
|
34
61
|
/** The result shape the Agent SDK's `canUseTool` must return. */
|
|
35
62
|
export type ConfineDecision =
|
|
@@ -37,31 +64,221 @@ export type ConfineDecision =
|
|
|
37
64
|
| { behavior: "deny"; message: string };
|
|
38
65
|
|
|
39
66
|
/**
|
|
40
|
-
* Where each
|
|
67
|
+
* Where each tool carries the path it wants to touch.
|
|
41
68
|
*
|
|
42
|
-
* A tool absent from
|
|
43
|
-
* guards a spawn whose allow-list is meant to be exactly
|
|
44
|
-
* unrecognised tool means either the allow-list drifted or the model reached
|
|
45
|
-
*
|
|
46
|
-
*
|
|
69
|
+
* A tool absent from the ACTIVE map is denied outright rather than allowed: this
|
|
70
|
+
* guards a spawn whose allow-list is meant to be exactly the named set, so an
|
|
71
|
+
* unrecognised tool means either the allow-list drifted or the model reached for
|
|
72
|
+
* something it should not have. Failing closed is the honest answer to "I do not
|
|
73
|
+
* know what this tool would do".
|
|
47
74
|
*/
|
|
48
|
-
const
|
|
75
|
+
const READ_PATH_ARGS: Record<string, readonly string[]> = {
|
|
49
76
|
Read: ["file_path", "path", "notebook_path"],
|
|
50
77
|
Grep: ["path"],
|
|
51
78
|
Glob: ["path"],
|
|
52
79
|
};
|
|
53
80
|
|
|
54
|
-
/**
|
|
81
|
+
/**
|
|
82
|
+
* The write tools, and the argument each carries its target in.
|
|
83
|
+
*
|
|
84
|
+
* `MultiEdit` and `NotebookEdit` are here beside `Write`/`Edit` because a map
|
|
85
|
+
* that covered only the two obvious ones would deny them by name — which is
|
|
86
|
+
* safe — but a later edit adding either to a spawn's `tools` would then find
|
|
87
|
+
* them silently refused rather than confined. Naming all four keeps the map and
|
|
88
|
+
* the grant able to agree.
|
|
89
|
+
*/
|
|
90
|
+
const WRITE_PATH_ARGS: Record<string, readonly string[]> = {
|
|
91
|
+
Write: ["file_path"],
|
|
92
|
+
Edit: ["file_path"],
|
|
93
|
+
MultiEdit: ["file_path"],
|
|
94
|
+
NotebookEdit: ["notebook_path"],
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
/** The read-only surface: what the sizing preflight is confined to. */
|
|
98
|
+
export const CONFINED_READ_TOOLS = Object.freeze(Object.keys(READ_PATH_ARGS));
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The read-and-write surface: what the red-CI repair spawn is confined to.
|
|
102
|
+
*
|
|
103
|
+
* `MultiEdit` is deliberately ABSENT even though the map above covers it. It is
|
|
104
|
+
* not a tool this SDK defines, and this list becomes the SDK's `tools` — a name
|
|
105
|
+
* nothing defines has no business in a grant. Keeping it in the map means that
|
|
106
|
+
* if a future SDK does define it, it arrives confined rather than as an
|
|
107
|
+
* unrecognised name the map would have to fail closed on.
|
|
108
|
+
*/
|
|
109
|
+
export const CONFINED_WRITE_TOOLS = Object.freeze([
|
|
110
|
+
...Object.keys(READ_PATH_ARGS),
|
|
111
|
+
...Object.keys(WRITE_PATH_ARGS).filter((t) => t !== "MultiEdit"),
|
|
112
|
+
]);
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Arguments that are GLOB PATTERNS rather than paths, and still escape a tree.
|
|
116
|
+
*
|
|
117
|
+
* `Glob`'s `path` is optional; its `pattern` is not. node-glob honours an
|
|
118
|
+
* absolute pattern and a leading `../` regardless of `cwd`, so
|
|
119
|
+
* `Glob({pattern: "/Users/me/.claude/**"})` enumerated the credential directory
|
|
120
|
+
* while every path check passed vacuously — the loop found no path argument and
|
|
121
|
+
* fell through to allow (security review of #1015). A pattern cannot be
|
|
122
|
+
* resolved to one path, so it is not tree-checked; it is required to be
|
|
123
|
+
* relative and traversal-free, which is what confines it.
|
|
124
|
+
*/
|
|
125
|
+
const PATTERN_ARG_BY_TOOL: Record<string, readonly string[]> = {
|
|
126
|
+
// `Glob.path` and `Grep.path` are real paths and are tree-checked above;
|
|
127
|
+
// only the glob-shaped arguments belong here. `Grep.pattern` is a REGEX, not
|
|
128
|
+
// a path, and must not be checked as one.
|
|
129
|
+
Glob: ["pattern"],
|
|
130
|
+
Grep: ["glob"],
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Is this path the tree's git metadata?
|
|
135
|
+
*
|
|
136
|
+
* ## The escape this closes, and the comment that got it wrong
|
|
137
|
+
*
|
|
138
|
+
* An earlier version of this feature argued that `.git` needed no entry,
|
|
139
|
+
* because in a LINKED worktree `.git` is a regular file rather than a
|
|
140
|
+
* directory, so "nothing traverses into it". The first half is true and the
|
|
141
|
+
* conclusion does not follow: nothing traverses THROUGH the file, but the file
|
|
142
|
+
* itself sits inside the tree and is therefore writable, and its whole content
|
|
143
|
+
* is a pointer to where git's configuration lives.
|
|
144
|
+
*
|
|
145
|
+
* Proven end to end during the security review of #1015, with no shell, no
|
|
146
|
+
* network tool and no subagent:
|
|
147
|
+
*
|
|
148
|
+
* 1. `Write` to `<tree>/.git` → `gitdir: ./g` (an in-tree dir the spawn owns)
|
|
149
|
+
* 2. `Write` to `<tree>/g/config` → `[core] fsmonitor = ./payload.sh`
|
|
150
|
+
* 3. the diff gate's OWN `git status` runs `payload.sh` on the daemon host.
|
|
151
|
+
*
|
|
152
|
+
* `core.hooksPath=` does nothing about it — `fsmonitor` is config, not a hook —
|
|
153
|
+
* and `core.sshCommand`, `core.pager` and `alias.*` are equivalent primitives.
|
|
154
|
+
* So the bound cannot be "disarm the mechanisms we thought of"; it is that the
|
|
155
|
+
* spawn never touches git's metadata at all.
|
|
156
|
+
*
|
|
157
|
+
* Matched as a path SEGMENT so `.gitignore` and `src/.gitkeep` are unaffected,
|
|
158
|
+
* and checked against the raw candidate BEFORE symlink resolution, since a
|
|
159
|
+
* symlink named `.git` is the same attack.
|
|
160
|
+
*
|
|
161
|
+
* **Case-INSENSITIVELY**, and that is not cosmetic. The daemon's own platform
|
|
162
|
+
* is macOS, whose filesystem is case-insensitive by default, so `.GIT` and
|
|
163
|
+
* `.git` are the same file on disk. A case-sensitive comparison let a write to
|
|
164
|
+
* `.GIT` through every guard and land on the real gitdir pointer — proven
|
|
165
|
+
* end to end during review of #1015, ending in code execution as the daemon
|
|
166
|
+
* user. Windows/NTFS is the same. Refusing `.GIT` on Linux too costs nothing:
|
|
167
|
+
* nobody needs a file by that name.
|
|
168
|
+
*/
|
|
169
|
+
function isGitMetadata(repoRoot: string, candidate: string): boolean {
|
|
170
|
+
const rel = candidate.startsWith(repoRoot)
|
|
171
|
+
? candidate.slice(repoRoot.length)
|
|
172
|
+
: candidate;
|
|
173
|
+
return rel.split(/[\\/]/).some((segment) => segment.toLowerCase() === ".git");
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Does a glob pattern reach outside whatever directory it is rooted at?
|
|
178
|
+
*
|
|
179
|
+
* ## An allowlist of syntax, because a denylist of spellings does not close
|
|
180
|
+
*
|
|
181
|
+
* The first version of this rejected an absolute pattern and a literal `..`
|
|
182
|
+
* segment. The security review of #1015 walked straight past it: the glob
|
|
183
|
+
* language can SPELL `..` several ways, and `{..,x}/y` and `[.][.]/*` both read
|
|
184
|
+
* as ordinary segments while node-glob expands them into real traversal. A
|
|
185
|
+
* probe using brace expansion returned 43 files from `~/.claude` — the exact
|
|
186
|
+
* credential-directory enumeration the guard claimed to prevent.
|
|
187
|
+
*
|
|
188
|
+
* Enumerating the spellings is the losing game this feature has now lost three
|
|
189
|
+
* times. So the rule inverts: the pattern may use only `*`, `**`, `?`, `/` and
|
|
190
|
+
* literal characters. That set is small enough to argue about completely —
|
|
191
|
+
* none of it can name a parent directory, and `?` cannot produce `..` because
|
|
192
|
+
* glob traversal never yields `.` or `..` as directory entries. Brace
|
|
193
|
+
* expansion, character classes, `~` and extglob are refused as SYNTAX, not as
|
|
194
|
+
* payloads, which is why a new spelling of `..` cannot reopen it.
|
|
195
|
+
*
|
|
196
|
+
* A repair searching its own worktree does not need any of the refused syntax.
|
|
197
|
+
*/
|
|
198
|
+
function patternEscapes(pattern: string): boolean {
|
|
199
|
+
if (isAbsolute(pattern)) return true;
|
|
200
|
+
// Brace expansion, character classes, home expansion, extglob, and a literal
|
|
201
|
+
// parent segment. Anything outside `* ** ? /` and literals is refused.
|
|
202
|
+
if (/[{}[\]~()!+@]/.test(pattern)) return true;
|
|
203
|
+
return pattern.split(/[\\/]/).some((segment) => segment === "..");
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** How much of the tool surface a confined spawn may reach. */
|
|
207
|
+
export type ConfineMode = "read" | "write";
|
|
208
|
+
|
|
209
|
+
function pathArgsFor(mode: ConfineMode): Record<string, readonly string[]> {
|
|
210
|
+
return mode === "write"
|
|
211
|
+
? { ...READ_PATH_ARGS, ...WRITE_PATH_ARGS }
|
|
212
|
+
: READ_PATH_ARGS;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Resolve `p` the way the KERNEL does: one component at a time, following each
|
|
217
|
+
* symlink before interpreting what comes after it.
|
|
218
|
+
*
|
|
219
|
+
* ## Why not `resolve()` first
|
|
220
|
+
*
|
|
221
|
+
* `path.resolve` removes `..` TEXTUALLY. The kernel removes it only after
|
|
222
|
+
* following the symlink in front of it. The two disagree exactly when a
|
|
223
|
+
* symlinked directory is followed by `..`, and the disagreement is an escape:
|
|
224
|
+
*
|
|
225
|
+
* <tree>/node_modules/dep -> <elsewhere>/secrets
|
|
226
|
+
* <tree>/node_modules/dep/../STOLEN.txt
|
|
227
|
+
*
|
|
228
|
+
* `resolve` folds that to `<tree>/node_modules/STOLEN.txt`, which is inside the
|
|
229
|
+
* tree, so a lexical-then-realpath check ALLOWS it — while the open(2) the tool
|
|
230
|
+
* then performs lands on `<elsewhere>/STOLEN.txt`. A package install routinely
|
|
231
|
+
* creates such links (`link:`/`file:` deps, a pnpm store), so the precondition
|
|
232
|
+
* is ordinary rather than exotic. Verified against this module during the
|
|
233
|
+
* security review of #1015.
|
|
234
|
+
*
|
|
235
|
+
* Walking instead means `..` is applied to a path whose symlinks are already
|
|
236
|
+
* resolved, which is what the kernel does and therefore the only thing that
|
|
237
|
+
* agrees with it.
|
|
238
|
+
*
|
|
239
|
+
* A component that does not exist is appended literally: it cannot be a
|
|
240
|
+
* symlink, and a later `..` still pops it correctly.
|
|
241
|
+
*/
|
|
242
|
+
function realPathOrNearest(p: string): string {
|
|
243
|
+
const abs = isAbsolute(p) ? p : resolve(p);
|
|
244
|
+
const { root } = parse(abs);
|
|
245
|
+
let real = root;
|
|
246
|
+
|
|
247
|
+
for (const part of abs.slice(root.length).split(sep)) {
|
|
248
|
+
if (part === "" || part === ".") continue;
|
|
249
|
+
if (part === "..") {
|
|
250
|
+
real = dirname(real);
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
const next = real.endsWith(sep) ? real + part : real + sep + part;
|
|
254
|
+
try {
|
|
255
|
+
// `real` is already fully resolved, so this only resolves `part`.
|
|
256
|
+
real = realpathSync(next);
|
|
257
|
+
} catch {
|
|
258
|
+
real = next;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
return real;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Is `target` inside `root` (or root itself)?
|
|
266
|
+
*
|
|
267
|
+
* Both sides are resolved through symlinks first, so a link planted inside the
|
|
268
|
+
* tree cannot widen it and a root reached by a symlinked path (macOS `/tmp` →
|
|
269
|
+
* `/private/tmp`, a worktree under a symlinked parent) does not deny everything.
|
|
270
|
+
*/
|
|
55
271
|
export function isInsideTree(root: string, target: string): boolean {
|
|
56
|
-
const normalizedRoot =
|
|
57
|
-
const normalizedTarget =
|
|
272
|
+
const normalizedRoot = realPathOrNearest(root);
|
|
273
|
+
const normalizedTarget = realPathOrNearest(target);
|
|
58
274
|
if (normalizedTarget === normalizedRoot) return true;
|
|
59
275
|
// The separator matters: without it `/repo-secrets` reads as inside `/repo`.
|
|
60
276
|
return normalizedTarget.startsWith(normalizedRoot + sep);
|
|
61
277
|
}
|
|
62
278
|
|
|
63
279
|
/**
|
|
64
|
-
* Decide one tool call. Pure, so the policy is
|
|
280
|
+
* Decide one tool call. Pure apart from the `realpath` syscall, so the policy is
|
|
281
|
+
* testable without a spawn.
|
|
65
282
|
*
|
|
66
283
|
* A tool with no path argument is allowed: `Grep` and `Glob` default to the
|
|
67
284
|
* session's `cwd`, which IS the tree being confined to. A relative path is
|
|
@@ -72,8 +289,9 @@ export function decideConfinedTool(
|
|
|
72
289
|
repoRoot: string,
|
|
73
290
|
toolName: string,
|
|
74
291
|
input: Record<string, unknown>,
|
|
292
|
+
mode: ConfineMode = "read",
|
|
75
293
|
): ConfineDecision {
|
|
76
|
-
const pathArgs =
|
|
294
|
+
const pathArgs = pathArgsFor(mode)[toolName];
|
|
77
295
|
if (!pathArgs) {
|
|
78
296
|
return {
|
|
79
297
|
behavior: "deny",
|
|
@@ -81,14 +299,42 @@ export function decideConfinedTool(
|
|
|
81
299
|
};
|
|
82
300
|
}
|
|
83
301
|
|
|
302
|
+
const verb = toolName in WRITE_PATH_ARGS ? "write" : "read";
|
|
303
|
+
|
|
304
|
+
// Patterns first: they are the cheaper check, and an escaping pattern makes
|
|
305
|
+
// the path check below irrelevant anyway.
|
|
306
|
+
for (const key of PATTERN_ARG_BY_TOOL[toolName] ?? []) {
|
|
307
|
+
const value = input[key];
|
|
308
|
+
if (typeof value !== "string" || value.length === 0) continue;
|
|
309
|
+
if (patternEscapes(value)) {
|
|
310
|
+
return {
|
|
311
|
+
behavior: "deny",
|
|
312
|
+
message: `${toolName} patterns must stay inside the repository — no absolute path and no "..". Refused: ${value}`,
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
84
317
|
for (const key of pathArgs) {
|
|
85
318
|
const value = input[key];
|
|
86
319
|
if (typeof value !== "string" || value.length === 0) continue;
|
|
87
|
-
|
|
320
|
+
// CONCATENATED, not `resolve`d. `resolve(repoRoot, value)` folds `..`
|
|
321
|
+
// textually — the very defect `realPathOrNearest` exists to avoid — so a
|
|
322
|
+
// RELATIVE `node_modules/dep/../STOLEN.txt` was collapsed to an in-tree
|
|
323
|
+
// path and allowed while the syscall landed outside. The absolute branch
|
|
324
|
+
// was correct and the relative one was not, which is worse than both being
|
|
325
|
+
// wrong: the guard read as closed. `realPathOrNearest` handles a raw path
|
|
326
|
+
// with `..` in it, so hand it one.
|
|
327
|
+
const candidate = isAbsolute(value) ? value : `${repoRoot}${sep}${value}`;
|
|
328
|
+
if (isGitMetadata(repoRoot, candidate)) {
|
|
329
|
+
return {
|
|
330
|
+
behavior: "deny",
|
|
331
|
+
message: `${toolName} may not touch the repository's git metadata. Refused: ${value}`,
|
|
332
|
+
};
|
|
333
|
+
}
|
|
88
334
|
if (!isInsideTree(repoRoot, candidate)) {
|
|
89
335
|
return {
|
|
90
336
|
behavior: "deny",
|
|
91
|
-
message: `${toolName} may only
|
|
337
|
+
message: `${toolName} may only ${verb} inside the repository. Refused: ${value}`,
|
|
92
338
|
};
|
|
93
339
|
}
|
|
94
340
|
}
|
|
@@ -101,13 +347,19 @@ export function decideConfinedTool(
|
|
|
101
347
|
*
|
|
102
348
|
* Kept separate from {@link decideConfinedTool} so the policy stays synchronous
|
|
103
349
|
* and testable while the SDK gets the async shape it expects.
|
|
350
|
+
*
|
|
351
|
+
* `mode` defaults to `"read"`, so a caller that does not ask for write access
|
|
352
|
+
* does not get it. The sizing preflight relies on that default: `Write` and
|
|
353
|
+
* `Edit` are denied by name there even though this module now knows how to
|
|
354
|
+
* confine them.
|
|
104
355
|
*/
|
|
105
356
|
export function confineToRepo(
|
|
106
357
|
repoRoot: string,
|
|
358
|
+
mode: ConfineMode = "read",
|
|
107
359
|
): (
|
|
108
360
|
toolName: string,
|
|
109
361
|
input: Record<string, unknown>,
|
|
110
362
|
) => Promise<ConfineDecision> {
|
|
111
363
|
return async (toolName, input) =>
|
|
112
|
-
decideConfinedTool(repoRoot, toolName, input);
|
|
364
|
+
decideConfinedTool(repoRoot, toolName, input, mode);
|
|
113
365
|
}
|
package/src/error-classifier.ts
CHANGED
|
@@ -17,7 +17,8 @@ export type ApiErrorKind =
|
|
|
17
17
|
| "rate_limit"
|
|
18
18
|
| "out_of_credits"
|
|
19
19
|
| "usage_limit"
|
|
20
|
-
| "auth"
|
|
20
|
+
| "auth"
|
|
21
|
+
| "spend_limit";
|
|
21
22
|
|
|
22
23
|
export interface RunErrorClass {
|
|
23
24
|
kind: ApiErrorKind | null;
|
|
@@ -37,6 +38,38 @@ const USAGE_LIMIT =
|
|
|
37
38
|
const RATE_LIMIT =
|
|
38
39
|
/\b429\b|\b529\b|rate[ _-]?limit|too many requests|overloaded_error|"type"\s*:\s*"overloaded"/i;
|
|
39
40
|
|
|
41
|
+
/**
|
|
42
|
+
* An ACCOUNT-level spend cap (#1012). Measured in production on 2026-09-01:
|
|
43
|
+
*
|
|
44
|
+
* Claude Code returned an error result: You've hit your org's monthly spend
|
|
45
|
+
* limit · raise it at claude.ai/admin-settings/usage
|
|
46
|
+
*
|
|
47
|
+
* All four patterns above miss it, and each near-miss is instructive:
|
|
48
|
+
* `OUT_OF_CREDITS` wants the word `billing` (the text says `admin-settings/usage`),
|
|
49
|
+
* and `USAGE_LIMIT` has `monthly limit` where the text says `monthly **spend**
|
|
50
|
+
* limit`. So `classifyRunError` returned `kind: null`, the worker took the
|
|
51
|
+
* generic-crash path, and the daemon re-picked the same two cards every ~2
|
|
52
|
+
* minutes for four rounds — $198.99 in a day.
|
|
53
|
+
*
|
|
54
|
+
* Only the UNAMBIGUOUS spend phrasing lives here. The remedy URL is deliberately
|
|
55
|
+
* NOT in this pattern: it is checked last, after every other class, by
|
|
56
|
+
* {@link SPEND_LIMIT_REMEDY} — see `classifyRunError`.
|
|
57
|
+
*/
|
|
58
|
+
const SPEND_LIMIT = /spend(?:ing)? (?:limit|cap)|monthly spend/i;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The remedy URL, as a LAST-RESORT signal only.
|
|
62
|
+
*
|
|
63
|
+
* A message that names this console page needs an admin, so on its own it is
|
|
64
|
+
* good evidence of a spend cap. But a self-clearing usage limit may cite the
|
|
65
|
+
* same page, and misreading one of those as a spend cap would latch the daemon
|
|
66
|
+
* and demand an operator resume that nobody owed. Checking it only after
|
|
67
|
+
* `USAGE_LIMIT` and `RATE_LIMIT` have both declined keeps the explicit,
|
|
68
|
+
* self-clearing classes winning while still catching the measured string, whose
|
|
69
|
+
* whole problem is that no other pattern recognises it at all.
|
|
70
|
+
*/
|
|
71
|
+
const SPEND_LIMIT_REMEDY = /admin-settings\/usage/i;
|
|
72
|
+
|
|
40
73
|
/** Best-effort `Retry-After: <seconds>` extraction. */
|
|
41
74
|
function parseRetryAfterMs(message: string): number | undefined {
|
|
42
75
|
const match = message.match(/retry[- ]?after["':\s]+(\d+)/i);
|
|
@@ -54,10 +87,19 @@ export function classifyRunError(message: string): RunErrorClass {
|
|
|
54
87
|
// pauses wholesale rather than churning. Check before billing because some
|
|
55
88
|
// 401 bodies also mention "billing".
|
|
56
89
|
if (AUTH.test(message)) return { kind: "auth", retryAfterMs };
|
|
90
|
+
// Before billing: a spend cap and a credit shortfall read alike but need
|
|
91
|
+
// opposite handling — a top-up clears one, only an admin raising the cap
|
|
92
|
+
// clears the other. A spend-limit body that also says "billing" must not be
|
|
93
|
+
// classified as something the daemon can wait out.
|
|
94
|
+
if (SPEND_LIMIT.test(message)) return { kind: "spend_limit", retryAfterMs };
|
|
57
95
|
if (OUT_OF_CREDITS.test(message))
|
|
58
96
|
return { kind: "out_of_credits", retryAfterMs };
|
|
59
97
|
if (USAGE_LIMIT.test(message)) return { kind: "usage_limit", retryAfterMs };
|
|
60
98
|
if (RATE_LIMIT.test(message)) return { kind: "rate_limit", retryAfterMs };
|
|
99
|
+
// Last: the remedy URL alone. See SPEND_LIMIT_REMEDY for why it ranks below
|
|
100
|
+
// every self-clearing class instead of beside SPEND_LIMIT.
|
|
101
|
+
if (SPEND_LIMIT_REMEDY.test(message))
|
|
102
|
+
return { kind: "spend_limit", retryAfterMs };
|
|
61
103
|
|
|
62
104
|
return { kind: null };
|
|
63
105
|
}
|
|
@@ -73,6 +115,8 @@ export function describeApiError(kind: ApiErrorKind): string {
|
|
|
73
115
|
return "Anthropic usage limit reached — retrying after reset";
|
|
74
116
|
case "rate_limit":
|
|
75
117
|
return "Anthropic rate limit hit — retrying shortly";
|
|
118
|
+
case "spend_limit":
|
|
119
|
+
return "Account spend limit reached — raise it at claude.ai/admin-settings/usage, then run `harmony-agent resume`";
|
|
76
120
|
}
|
|
77
121
|
}
|
|
78
122
|
|
|
@@ -80,6 +124,11 @@ export function describeApiError(kind: ApiErrorKind): string {
|
|
|
80
124
|
* Default daemon-wide cooldown for an API error class, used when the API gave
|
|
81
125
|
* no explicit `Retry-After`. Billing/usage conditions reset slowly (or need a
|
|
82
126
|
* human), so they back off longer; transient rate limits clear fast.
|
|
127
|
+
*
|
|
128
|
+
* **A cooldown is the wrong shape for `spend_limit`**, and its value below says
|
|
129
|
+
* so by being a fallback rather than the mechanism. Waiting does not raise a
|
|
130
|
+
* monthly spend cap, so `Pool.noteApiError` routes that kind to the durable
|
|
131
|
+
* account halt and never reaches this function with it.
|
|
83
132
|
*/
|
|
84
133
|
export function cooldownMsFor(kind: ApiErrorKind): number {
|
|
85
134
|
switch (kind) {
|
|
@@ -91,5 +140,7 @@ export function cooldownMsFor(kind: ApiErrorKind): number {
|
|
|
91
140
|
return 30 * 60_000; // 30 min — usually needs a human top-up
|
|
92
141
|
case "auth":
|
|
93
142
|
return 30 * 60_000; // fallback; pool pauses wholesale on auth anyway
|
|
143
|
+
case "spend_limit":
|
|
144
|
+
return 30 * 60_000; // fallback; the pool latches durably on this one
|
|
94
145
|
}
|
|
95
146
|
}
|
package/src/gate-collectors.ts
CHANGED
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
* - custom → #690's allowlisted command metric (a number to gate on)
|
|
11
11
|
* - oracle_passed → the held test authored by the stage's `author` role, run
|
|
12
12
|
* against the implementer's worktree (Playbooks P2, Task 12)
|
|
13
|
+
* - oracle_red → the SAME held test, graded red: it must run and fail
|
|
14
|
+
* against the current tree (#921)
|
|
13
15
|
*
|
|
14
16
|
* The flow per gate, run daemon-side after a stage's work completes:
|
|
15
17
|
* collector.collect(ctx) → GateEvidence
|
|
@@ -46,7 +48,7 @@ import {
|
|
|
46
48
|
} from "./command-metric.js";
|
|
47
49
|
import { log } from "./log.js";
|
|
48
50
|
import type { OracleDeps } from "./oracle.js";
|
|
49
|
-
import { OracleCollector } from "./oracle-collector.js";
|
|
51
|
+
import { OracleCollector, OracleRedCollector } from "./oracle-collector.js";
|
|
50
52
|
import type { ReviewResult } from "./review-types.js";
|
|
51
53
|
import { runBuild, runLint } from "./verification.js";
|
|
52
54
|
|
|
@@ -349,8 +351,9 @@ export interface GateCollectorDeps {
|
|
|
349
351
|
*/
|
|
350
352
|
command?: CommandMetricDeps;
|
|
351
353
|
/**
|
|
352
|
-
* The oracle_passed
|
|
353
|
-
* worktree, run, removed. Supply it
|
|
354
|
+
* The held-test gates — `oracle_passed` AND `oracle_red` (#921): the held test
|
|
355
|
+
* fetched from Harmony, placed in the worktree, run, removed. Supply it
|
|
356
|
+
* whenever a worktree exists; one dep object serves both polarities.
|
|
354
357
|
*/
|
|
355
358
|
oracle?: OracleDeps;
|
|
356
359
|
}
|
|
@@ -385,6 +388,11 @@ export function buildGateCollectorRegistry(
|
|
|
385
388
|
}
|
|
386
389
|
if (deps.oracle) {
|
|
387
390
|
registry.oracle_passed = new OracleCollector(deps.oracle);
|
|
391
|
+
// Same deps, opposite polarity (#921) — one held test, graded red here and
|
|
392
|
+
// green above. Registered together because they are never independently
|
|
393
|
+
// available: whatever lets the motor fetch and run a held test lets it do
|
|
394
|
+
// so for either polarity.
|
|
395
|
+
registry.oracle_red = new OracleRedCollector(deps.oracle);
|
|
388
396
|
}
|
|
389
397
|
return registry;
|
|
390
398
|
}
|