@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.
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Confine a read-only spawn to one directory tree.
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, the preflight returns no verdict, `sizeRun`
11
- * answers `null`, and the daemon degrades to the policy fallback forever with
12
- * nothing in the logs to say the sizing step stopped working.
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
- * Prompt-level containment (JSON-encoded card data) makes injection harder; this
30
- * is what bounds it when that fails.
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 { isAbsolute, resolve, sep } from "node:path";
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 read-only tool carries the path it wants to touch.
67
+ * Where each tool carries the path it wants to touch.
41
68
  *
42
- * A tool absent from this map is denied outright rather than allowed: this
43
- * guards a spawn whose allow-list is meant to be exactly these three, so an
44
- * unrecognised tool means either the allow-list drifted or the model reached
45
- * for something it should not have. Failing closed is the honest answer to
46
- * "I do not know what this tool would do".
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 PATH_ARG_BY_TOOL: Record<string, readonly string[]> = {
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
- /** Is `target` inside `root` (or root itself)? */
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 = resolve(root);
57
- const normalizedTarget = resolve(target);
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 testable without a spawn.
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 = PATH_ARG_BY_TOOL[toolName];
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
- const candidate = isAbsolute(value) ? value : resolve(repoRoot, value);
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 read inside the repository. Refused: ${value}`,
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
  }
@@ -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
  }
@@ -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 gate: the held test fetched from Harmony, placed in the
353
- * worktree, run, removed. Supply it whenever a worktree exists.
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
  }