Skip to content

Commit 6eef546

Browse files
fix: full budget-checkpoint coverage across review + adversarial (Codex #2) (#9)
The cost-controller only recorded executor token spend; self-review (3 persona calls) and the adversarial gate were invisible to both the budget cap and the reported total — System A could spend ~3x what the checkpoint saw, and the kill switch couldn't fire mid-task. System B (papa) recorded an aggregate only AFTER the thinker fan-out, with no pre-flight check. Changes: - CodexCaller contract gains optional token fields; createCodexCaller parses OpenAI's usage block, createOpenAICompatibleCodexCaller estimates chars/4 when the server omits usage. The adversarial gate's spend was previously untracked AT THE SOURCE (the interface returned only { content }). - SelfReviewResult + AdversarialReviewResult carry tokenUsage. selfReview aggregates the three persona calls; adversarialReview surfaces the codex call's tokens. - loop.ts records every stage against the checkpoint: forceCheck before the self-review fan-out, recordAndCheck after self-review and after adversarial, kill→budget-exceeded on any over-budget result. The reported totalTokenUsage now sums executor + review + adversarial on ALL outcome paths (success and failure). - papa.ts forceChecks before the thinker fan-out so a request already at the ceiling never launches N parallel calls. The codex model id is free-form (not a ModelTier); cost-estimator returns $0 for ids absent from the pricing table (PR #7), so tokens are tracked even though the codex wire-cost line is $0. Adding codex pricing is a follow-on. Also adds docs/design/2026-06-17-criticals-sandbox-and-budget.md — the design for this fix (#2) AND critical #1 (sandbox hardening, Level 1 chosen). #1 ships as a separate follow-on PR. Tests: 380 → 384. New: cloud + local codex token surfacing, loop total-token accounting includes review+adversarial. No skips. Refs CODEX_REVIEW.md (#2).
1 parent 8c0f3c4 commit 6eef546

11 files changed

Lines changed: 348 additions & 16 deletions

File tree

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
# Design — Critical fixes #1 (execution sandbox) + #2 (budget coverage)
2+
3+
Status: **proposed** — awaiting decision on sandbox depth before implementation.
4+
Source: Codex whole-repo review (`CODEX_REVIEW.md`), findings #1 and #2 (both `critical`).
5+
6+
---
7+
8+
## Critical #2 — Full budget-checkpoint coverage
9+
10+
### Problem (grounded in code)
11+
12+
`loop.ts` creates one `CostCheckpoint` per task and records spend **only** for `executeTask()` (`loop.ts:~273`). The two downstream LLM stages are unaccounted:
13+
14+
- **`selfReview()`** (`self-review.ts:73`) runs three persona calls and returns per-persona `tokenUsage`, but the loop never records it against the checkpoint. Spend is invisible to the budget.
15+
- **`adversarialReview()`** (`adversarial-gate.ts:16`) is worse: `CodexCaller.call()` returns `{ content }` **only — no token fields at all** (`wiring.ts` `createCodexCaller`). The adversarial gate's spend is untracked *at the source*, not merely unrecorded.
16+
17+
System B has the same shape: `papa.ts:83` records an aggregate **after** the thinker fan-out completes, with no `forceCheck()` before fanning out — so a budget already near its ceiling still launches N parallel thinker calls.
18+
19+
Net effect: System A can spend ~3× (executor + 3 personas + adversarial) what the budget sees, and the kill switch can't fire mid-task. The cost-controller's headline guarantee is partially fictional.
20+
21+
### Fix
22+
23+
1. **`selfReview(execution, llm, model, checkpoint?)`** — thread the checkpoint in. After each persona call, `checkpoint.recordAndCheck(inTok, outTok, model)`; if it returns `recommendation: 'kill'`, stop the remaining personas and return a partial result flagged `budget-exhausted`. Add a `forceCheck()` *before* the persona fan-out so a task already over budget never starts review.
24+
25+
2. **`CodexCaller` gains a token surface.** Change the contract from `{ content }` to `{ content; inputTokens; outputTokens }`. `createCodexCaller` parses OpenAI's `usage` block; `createOpenAICompatibleCodexCaller` reuses the chars/4 estimate the LLM adapter already has. Then `adversarialReview` returns token usage and the loop records it. (This is the one interface change — small, additive, and the OpenAI-compatible side already has the estimator.)
26+
27+
3. **`papa.ts`**`forceCheck()` before the fan-out; record each thinker call individually rather than one aggregate after. The plumbing already passes a checkpoint in, so this is a record-placement change.
28+
29+
4. **Loop records every stage.** After self-review and after adversarial, `recordAndCheck`; on `kill`, mark the task `budget-exceeded` and break (same path the executor stage already uses).
30+
31+
### Risk / blast radius
32+
33+
Low. `selfReview` and `adversarialReview` gain an optional/required param; the analyzer and canary tests that call them with mocks need the extra arg. The `CodexCaller` contract change touches `wiring.ts` (2 factories), `adversarial-gate.ts`, the loop, and their tests. No algorithm changes. ~1 day including tests. This is **implementation-ready** — no open design questions.
34+
35+
---
36+
37+
## Critical #1 — Execution sandbox hardening
38+
39+
### Problem (grounded in code)
40+
41+
ASIL runs untrusted code with trusted credentials:
42+
43+
- `loop.ts:~217` runs `pnpm install --frozen-lockfile`, then `pnpm -r build`, then (in the executor) `pnpm typecheck` / `pnpm test`, all inside a worktree of the **target** repo.
44+
- `wiring.ts:~298` (`createCommandRunner`) uses `execFile` with **no `env` option → full parent-process environment inheritance**.
45+
46+
So any `postinstall`/`prepare` script, any build step, any test in the target repo runs with `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GITHUB_TOKEN`/gh creds, and the user's entire env in scope — and (until PR #8) with `pnpm install` running lifecycle scripts by default. This is remote-code-execution-with-secret-exfiltration by design for any repo ASIL is pointed at. For a tool whose pitch is "point it at a repo and walk away," that's the headline risk.
47+
48+
### Design — layered, choose a depth for v1
49+
50+
Four levels, increasing isolation and cost. They compose — each builds on the prior.
51+
52+
| Level | What | Stops | Cost to build | New runtime dep |
53+
|---|---|---|---|---|
54+
| **0** (today) | worktree isolation only | nothing env-level |||
55+
| **1 — Process hardening** | `--ignore-scripts` on install; **env allowlist** (pass only PATH, HOME, a scrubbed minimal set — never the API keys) to the CommandRunner for target-repo commands; separate the PR-creation credential (gh token) from the execution environment so it's never in scope during install/build/test | secret exfil via scripts; lifecycle-script RCE on install | ~1 day, pure code | none |
56+
| **2 — Containerized exec** | run install/build/test inside a container (Docker/Podman) with `--network=none` for the install+build+test phases, a read-only mount of the worktree except the work dir, and an empty env save the allowlist | network exfil; most filesystem escape; persistent host effects | ~3–5 days | container runtime |
57+
| **3 — microVM / gVisor** | same as 2 but with a VM/syscall-filtering boundary | kernel-level escapes | weeks | firecracker/gVisor |
58+
59+
### Recommendation
60+
61+
**Ship Level 1 as v1**, document Level 2 as an opt-in (`ASIL_SANDBOX=container`) follow-on, leave Level 3 as a note for adopters with hostile-input threat models.
62+
63+
Rationale: Level 1 is pure code, no infra, and removes the **highest-severity, highest-likelihood** vector — credential exfiltration. `--ignore-scripts` already half-landed culturally (PR #8 made install-failure fatal). An env allowlist on the CommandRunner is a contained change. Level 2 is the right *eventual* default for running against genuinely untrusted repos, but forcing a container runtime as a hard dependency now would hurt adoption for the common case (a team running ASIL on its own repo), and it's a clean opt-in later.
64+
65+
### Level 1 specifics
66+
67+
1. **CommandRunner env allowlist.** `createCommandRunner({ envAllowlist?: string[] })`. When set, `execFile(..., { env: pick(process.env, allowlist) })`. Default allowlist: `PATH`, `HOME`, `LANG`, `TMPDIR`, `npm_config_*` as needed for pnpm. **Never** `*_API_KEY`, `GH_TOKEN`, `GITHUB_TOKEN`. The LLM callers keep their keys because they read them at construction time in the runner *parent* process — the keys never need to be in the *child* (pnpm/git) env.
68+
2. **`--ignore-scripts` on install** by default; `ASIL_ALLOW_INSTALL_SCRIPTS=1` to opt back in for repos that genuinely need them (rare, and the operator is then explicitly accepting the risk).
69+
3. **Credential separation for PR creation.** `gh pr create` needs the GitHub token; install/build/test do not. Scope the gh token to only the `createPR` step's env, never the execution steps'. (The git operations that need auth — push — also only need it at push time, not during build.)
70+
4. **Docs:** a "Running ASIL against untrusted repos" hardening section in the README + `examples/local-llm.md` sibling, stating plainly what Level 1 does and does not protect against, and pointing hostile-input users to Level 2 when it lands.
71+
72+
### Risk / blast radius
73+
74+
Medium. The env-allowlist change is the sensitive part: strip too much and pnpm/tsc/vitest break in the target repo (e.g., a repo that needs a registry token in `.npmrc` via env). Mitigation: the allowlist is configurable, and we ship a generous-but-secret-free default, with a clear error path when a build fails for missing-env reasons. ~1–1.5 days for Level 1 including tests + docs.
75+
76+
---
77+
78+
## Sequencing
79+
80+
1. **#2 budget coverage** first — implementation-ready, no open questions, and it's a correctness/honesty fix for the cost-controller's core promise.
81+
2. **#1 Level 1** second — pending the depth decision below.
82+
83+
Each ships as its own PR against `main` (branch protection requires PRs).
84+
85+
## Open decision (for the user)
86+
87+
**How deep should the v1 sandbox go?** Level 1 (process hardening, ships now, no infra) is the recommendation; Level 2 (containerized) is the eventual default for untrusted input but adds a container-runtime dependency. This doc proceeds with Level 1 unless directed otherwise.

packages/asil-improvement-loop/src/__tests__/adversarial-gate.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ function mkSelfReview(allApproved = true): SelfReviewResult {
2828
allApproved,
2929
aggregatedConcerns: allApproved ? [] : ['something'],
3030
recommendation: allApproved ? 'proceed' : 'revise',
31+
tokenUsage: { inputTokens: 0, outputTokens: 0 },
3132
};
3233
}
3334

packages/asil-improvement-loop/src/__tests__/loop.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,33 @@ describe('runLoop — integration', () => {
122122
expect(git.cleanedUp[0]).toMatch(/asil-auto-/);
123123
});
124124

125+
it('reports total token usage including self-review + adversarial, not just the executor (Codex #2)', async () => {
126+
const queue = new TaskQueue(queuePath);
127+
queue.enqueue(mkTask({ id: 't-tokens' }));
128+
129+
const git = mockGit('https://example.com/pr/2');
130+
const result = await runLoop(cfg(), {
131+
llm: goodLLM(), // executor reply = 200 in / 100 out; personas add more
132+
codex: mockCodex(JSON.stringify({ approved: true, severity: 'pass' })),
133+
git,
134+
tracker,
135+
budgetManager,
136+
runner: goodRunner(),
137+
fileReader: mockFileReader(),
138+
fileFetcher: mockFileFetcher(),
139+
diff: mockDiffApplier(),
140+
readCurrent: fakeReadCurrent,
141+
queue,
142+
});
143+
144+
expect(result.outcomes[0]?.status).toBe('pr-opened');
145+
// The executor alone reported 200 input tokens. The reported total
146+
// must EXCEED that — proving the three persona self-review calls were
147+
// added to the accounted total rather than silently dropped.
148+
expect(result.outcomes[0]?.totalTokenUsage.inputTokens).toBeGreaterThan(200);
149+
expect(result.outcomes[0]?.totalTokenUsage.outputTokens).toBeGreaterThan(100);
150+
});
151+
125152
it('pnpm install failure in the worktree → task aborts as infra-failed, LLM never called, worktree cleaned (Codex #6)', async () => {
126153
const queue = new TaskQueue(queuePath);
127154
queue.enqueue(mkTask({ id: 't-install-fail' }));

packages/asil-improvement-loop/src/__tests__/pr-builder.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ const self: SelfReviewResult = {
4747
allApproved: true,
4848
aggregatedConcerns: ['watch out for injection'],
4949
recommendation: 'proceed',
50+
tokenUsage: { inputTokens: 3, outputTokens: 3 },
5051
};
5152

5253
const adversarial: AdversarialReviewResult = {
@@ -55,6 +56,7 @@ const adversarial: AdversarialReviewResult = {
5556
reasoning: 'looks ok',
5657
issuesFound: [],
5758
severity: 'pass',
59+
tokenUsage: { inputTokens: 0, outputTokens: 0 },
5860
};
5961

6062
describe('pr-builder', () => {

packages/asil-improvement-loop/src/adversarial-gate.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,16 @@ export async function adversarialReview(
2121
): Promise<AdversarialReviewResult> {
2222
const prompt = buildAdversarialPrompt(execution, selfReviewResult);
2323
const response = await codex.call(prompt, codexModel);
24-
return parseAdversarialResponse(execution.taskId, response.content);
24+
const parsed = parseAdversarialResponse(execution.taskId, response.content);
25+
return {
26+
...parsed,
27+
// Surface token spend so the loop can budget-account the gate.
28+
// Mocks (and adapters that don't report usage) yield 0. (#2)
29+
tokenUsage: {
30+
inputTokens: response.inputTokens ?? 0,
31+
outputTokens: response.outputTokens ?? 0,
32+
},
33+
};
2534
}
2635

2736
export function buildAdversarialPrompt(
@@ -78,7 +87,7 @@ export function buildAdversarialPrompt(
7887
export function parseAdversarialResponse(
7988
taskId: string,
8089
content: string,
81-
): AdversarialReviewResult {
90+
): Omit<AdversarialReviewResult, 'tokenUsage'> {
8291
const parsed = tryParseJson(content);
8392
if (!parsed) {
8493
// Fail closed.

packages/asil-improvement-loop/src/loop.ts

Lines changed: 80 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import {
22
BudgetManager,
33
CostCheckpoint,
44
TokenTracker,
5+
type ModelTier,
56
} from 'asil-cost-controller';
67
import { TaskQueue } from './task-queue.js';
78
import { scanCodebase } from './scanner.js';
@@ -324,8 +325,39 @@ export async function runLoop(
324325
break;
325326
}
326327

328+
// Running tally of ALL token spend on this task — executor +
329+
// self-review + adversarial. Reported in the outcome and used to
330+
// keep the budget honest (Codex review #2: review/adversarial
331+
// spend used to be invisible to both the report and the cap).
332+
const taskTokens = {
333+
inputTokens: execution.tokenUsage.inputTokens,
334+
outputTokens: execution.tokenUsage.outputTokens,
335+
};
336+
337+
// forceCheck before the self-review fan-out: a task already at the
338+
// ceiling must not launch three more persona calls.
339+
if (checkpoint.forceCheck().recommendation === 'kill') {
340+
checkpoint.kill('Budget exceeded before self-review');
341+
queue.complete(task.id, 'failed', 'Budget exceeded');
342+
outcomes.push({
343+
taskId: task.id,
344+
status: 'budget-exceeded',
345+
totalTokenUsage: { ...taskTokens },
346+
completedAt: new Date(),
347+
});
348+
budgetExhausted = true;
349+
break;
350+
}
351+
327352
// 5. Self-review.
328353
const review = await selfReview(execution, deps.llm, config.reviewModel);
354+
taskTokens.inputTokens += review.tokenUsage.inputTokens;
355+
taskTokens.outputTokens += review.tokenUsage.outputTokens;
356+
const reviewCheck = checkpoint.recordAndCheck(
357+
review.tokenUsage.inputTokens,
358+
review.tokenUsage.outputTokens,
359+
config.reviewModel,
360+
);
329361

330362
if (review.recommendation === 'reject') {
331363
checkpoint.complete();
@@ -341,19 +373,44 @@ export async function runLoop(
341373
taskId: task.id,
342374
status: 'rejected-self-review',
343375
selfReview: review,
344-
totalTokenUsage: execution.tokenUsage,
376+
totalTokenUsage: { ...taskTokens },
345377
completedAt: new Date(),
346378
});
347379
continue;
348380
}
349381

382+
if (reviewCheck.recommendation === 'kill') {
383+
checkpoint.kill('Budget exceeded after self-review');
384+
queue.complete(task.id, 'failed', 'Budget exceeded');
385+
outcomes.push({
386+
taskId: task.id,
387+
status: 'budget-exceeded',
388+
selfReview: review,
389+
totalTokenUsage: { ...taskTokens },
390+
completedAt: new Date(),
391+
});
392+
budgetExhausted = true;
393+
break;
394+
}
395+
350396
// 6. Adversarial gate.
351397
const adversarial = await adversarialReview(
352398
execution,
353399
review,
354400
deps.codex,
355401
config.codexConfig.model,
356402
);
403+
taskTokens.inputTokens += adversarial.tokenUsage.inputTokens;
404+
taskTokens.outputTokens += adversarial.tokenUsage.outputTokens;
405+
const adversarialCheck = checkpoint.recordAndCheck(
406+
adversarial.tokenUsage.inputTokens,
407+
adversarial.tokenUsage.outputTokens,
408+
// The codex model is a free-form id (e.g. 'gpt-4o'), not a
409+
// ModelTier. The cost estimator returns $0 for ids absent from
410+
// the pricing table, so tokens are still tracked even though the
411+
// wire-cost line is $0. Cast is the localized type bridge.
412+
config.codexConfig.model as ModelTier,
413+
);
357414

358415
if (!adversarial.approved) {
359416
checkpoint.complete();
@@ -368,12 +425,27 @@ export async function runLoop(
368425
status: 'rejected-adversarial',
369426
selfReview: review,
370427
adversarialReview: adversarial,
371-
totalTokenUsage: execution.tokenUsage,
428+
totalTokenUsage: { ...taskTokens },
372429
completedAt: new Date(),
373430
});
374431
continue;
375432
}
376433

434+
if (adversarialCheck.recommendation === 'kill') {
435+
checkpoint.kill('Budget exceeded after adversarial review');
436+
queue.complete(task.id, 'failed', 'Budget exceeded');
437+
outcomes.push({
438+
taskId: task.id,
439+
status: 'budget-exceeded',
440+
selfReview: review,
441+
adversarialReview: adversarial,
442+
totalTokenUsage: { ...taskTokens },
443+
completedAt: new Date(),
444+
});
445+
budgetExhausted = true;
446+
break;
447+
}
448+
377449
// 7. All gates passed — commit + push from the worktree + open PR.
378450
const outcome = await buildAndOpenPR(
379451
task,
@@ -385,6 +457,12 @@ export async function runLoop(
385457
);
386458
checkpoint.complete();
387459

460+
// buildAndOpenPR reports execution-only token usage; replace it
461+
// with the full task tally (executor + self-review + adversarial)
462+
// so the success outcome's total matches the budget-accounted
463+
// spend (Codex review #2).
464+
outcome.totalTokenUsage = { ...taskTokens };
465+
388466
if (outcome.status === 'pr-opened') {
389467
// Only mark completed when the PR actually opened. A silent
390468
// failure in commit/push/create used to be mis-marked completed.

packages/asil-improvement-loop/src/self-review.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,12 +89,23 @@ export async function selfReview(
8989
? 'revise'
9090
: 'reject';
9191

92+
// Aggregate the three persona calls' token spend so the loop can
93+
// record self-review against the budget checkpoint (Codex review #2).
94+
const tokenUsage = reviews.reduce(
95+
(sum, r) => ({
96+
inputTokens: sum.inputTokens + r.tokenUsage.inputTokens,
97+
outputTokens: sum.outputTokens + r.tokenUsage.outputTokens,
98+
}),
99+
{ inputTokens: 0, outputTokens: 0 },
100+
);
101+
92102
return {
93103
taskId: execution.taskId,
94104
reviews,
95105
allApproved,
96106
aggregatedConcerns,
97107
recommendation,
108+
tokenUsage,
98109
};
99110
}
100111

packages/asil-improvement-loop/src/types.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,9 @@ export interface SelfReviewResult {
114114
allApproved: boolean;
115115
aggregatedConcerns: string[];
116116
recommendation: 'proceed' | 'revise' | 'reject';
117+
/** Aggregate token spend across the three persona calls, so the loop
118+
* can account self-review against the budget (Codex review #2). */
119+
tokenUsage: { inputTokens: number; outputTokens: number };
117120
}
118121

119122
export type AdversarialSeverity =
@@ -128,6 +131,10 @@ export interface AdversarialReviewResult {
128131
reasoning: string;
129132
issuesFound: string[];
130133
severity: AdversarialSeverity;
134+
/** Token spend for the adversarial call, so the loop can account it
135+
* against the budget. Zero when the caller reports no usage
136+
* (e.g. a mock). (Codex review #2.) */
137+
tokenUsage: { inputTokens: number; outputTokens: number };
131138
}
132139

133140
export type TaskOutcomeStatus =
@@ -186,9 +193,15 @@ export interface LLMResponse {
186193
outputTokens: number;
187194
}
188195

189-
/** Separate call interface for Codex — different provider, distinct mock surface. */
196+
/** Separate call interface for Codex — different provider, distinct mock surface.
197+
* Token fields are optional so existing mocks returning `{ content }` keep
198+
* working; real adapters populate them so adversarial-gate spend is
199+
* budget-accounted (Codex review #2). */
190200
export interface CodexCaller {
191-
call(prompt: string, model: string): Promise<{ content: string }>;
201+
call(
202+
prompt: string,
203+
model: string,
204+
): Promise<{ content: string; inputTokens?: number; outputTokens?: number }>;
192205
}
193206

194207
// ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)