Skip to content

Commit ae9fc3d

Browse files
mchwangclaude
andauthored
F5: prepare rebases and run head-bound checks (#149)
* Add production pre-merge preparation * Harden pre-merge preparation lifecycle * Close pre-merge review lifecycle gaps * Close pre-merge authorization and cleanup gaps * Guard final pre-merge readiness races * Close pre-merge settlement safety gaps * Bound pre-merge shutdown settlement * Bind merge admission to preparation readiness * Reauthorize merge and bound rewrite scans * Close pre-merge timeout and retry gaps * Bound pre-merge reload and retry settlement * Reject normalized command arguments * Keep legacy command reviews repairable * Seed fresh runner repositories for review * Preserve pre-merge lifecycle history * Close final rebase admission races * Seal final admission validation windows * Keep merge refresh authorization-scoped * Address F5 review findings * Revalidate trust after pre-merge inspection * Keep settled rebase failures current * Bind queue checks after final authorization * Bound final merge and rebase admission * Share preparation validation deadlines * Bind preparation policy and cleanup budget * Keep unsettled rebase failures current * Retain refreshed remote commits * Preserve command-check exit semantics * Retain startup snapshots and cleanup budget * Keep cleanup failures current for recovery * Fail closed on pre-merge lifecycle gaps * Bound multibyte command diagnostics * Close pre-merge recovery races * Reserve rebase cleanup handoff * Arm conflict deadline guard before starting bounded work bounded() now takes a thunk and starts the operation only after its deadline check and timer exist, so an authorization validator never runs after the read consumed the work deadline. The running child's settlement promise is marked handled at launch because bounded() may now refuse before awaiting it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Keep later attribution choices binding across head moves Once a completed prefix exists, every attribution choice for the plan revision again invalidates older execution approvals. The narrower snapshot-lineage rule let a collaborator head move drop a later choice and revive an approval recorded before it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Count every attribution choice against execution approvals Approvals can now survive head moves through equal or rewritten heads, so a choice recorded on a snapshot that a later head replaced must still invalidate older approvals even before any item has run. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Bind pre-execution approvals to the current snapshot Equal-head and rewritten-head approval evidence now applies only once a completed prefix exists. Before execution the start gate does not compare fingerprints, so a rebase that changed attributed content could otherwise start items on approvals the review marks stale. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 5ecf92f commit ae9fc3d

48 files changed

Lines changed: 2836 additions & 154 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎README.md‎

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
Review agent-made Git changes one plan item at a time. The approved plan lists each item's files and acceptance checks; the review engine shows which item produced each change and flags foreign or overlapping work.
44

5-
**Status:** the plan/linking library, SQLite store, and local review screen are implemented. Run `npm run demo` and open its private local URL. Ask runs Claude Code inside the locked-down agent container for read-only answers; choose it in Settings. Codex is refused in every phase for now, including Ask, because it reads code only by running commands ([why](docs/implementation/agent-isolation.md#codex-is-refused-in-every-phase)). Ask needs Docker, plus `CLAUDE_CODE_OAUTH_TOKEN` (from `claude setup-token`). The first question builds the agent image, which can take a few minutes. A configured GitHub review can merge only after the guarded exact-head gate passes. The agent container, vendor-only network and Claude/Codex adapters are implemented ([agent isolation](docs/implementation/agent-isolation.md)); only Ask uses them so far. Automated rebasing, plan command execution, and code-writing agents are not implemented. The paired human review experiment was cancelled before results were recorded and no longer blocks roadmap work; optional future validation is tracked in [#19](https://github.com/codeabovelab/codeboost/issues/19).
5+
**Status:** the plan/linking library, SQLite store, local review screen, opt-in code-writing runner, durable local rebase, and exact-head plan command checks are implemented. Run `npm run demo` and open its private local URL. Ask and the runner use Claude Code inside locked-down containers; Codex is refused because it reads code only by running commands ([why](docs/implementation/agent-isolation.md#codex-is-refused-in-every-phase)). They need Docker, plus `CLAUDE_CODE_OAUTH_TOKEN` (from `claude setup-token`). Startup builds the agent image, which can take a few minutes. A configured GitHub review can merge only after the guarded exact-head gate passes. The agent container, vendor-only network, credential-free command-check profile, and Claude/Codex adapters are described in [agent isolation](docs/implementation/agent-isolation.md). Pushing a rebased head, waiting for its required checks, and handing that exact pair to guarded merge remain follow-up work. The paired human review experiment was cancelled before results were recorded and no longer blocks roadmap work; optional future validation is tracked in [#19](https://github.com/codeabovelab/codeboost/issues/19).
66

77
## Development
88

@@ -14,7 +14,7 @@ npm run typecheck
1414
npm test
1515
```
1616

17-
Tests create disposable local repositories. They do not invoke agents, access GitHub, or execute plan acceptance commands.
17+
Tests create disposable local repositories and exercise command checks in disposable credential-free containers. They do not invoke model providers or access GitHub.
1818

1919
## Guarded GitHub merge
2020

@@ -31,7 +31,7 @@ An existing-store configuration may add a trusted GitHub binding:
3131
}
3232
```
3333

34-
The issue must match the stored plan. `pullRequest` is required unless the configuration has a `runner` block (see below). The authenticated `gh` account must be able to read the pull request, issue timeline, applicable rulesets, and classic branch protection, and to merge the PR. Codeboost unions required checks from both rule sources, requires strict server-enforced current-base checks, rechecks the base and head immediately before merging, and passes the reviewed head to `gh pr merge --match-head-commit`. Missing permissions and ambiguous rule responses block the merge. When the branch uses a merge queue, codeboost adds the exact reviewed head to the queue and treats the merge as done only when GitHub confirms it merged; a queued pull request is not merged. If GitHub removes it from the queue while the plan, snapshot, review and head are unchanged, Merge offers a retry after the status refresh; if the head changed, it goes back to review first. A moved base and any unexecuted `cmd:` acceptance check remain blocked until [#22](https://github.com/codeabovelab/codeboost/issues/22) adds the runner path.
34+
The issue must match the stored plan. `pullRequest` is required unless the configuration has a `runner` block (see below). The authenticated `gh` account must be able to read the pull request, issue timeline, applicable rulesets, and classic branch protection, and to merge the PR. Codeboost unions required checks from both rule sources, requires strict server-enforced current-base checks, rechecks the base and head immediately before merging, and passes the reviewed head to `gh pr merge --match-head-commit`. Missing permissions and ambiguous rule responses block the merge. When the branch uses a merge queue, codeboost adds the exact reviewed head to the queue and treats the merge as done only when GitHub confirms it merged; a queued pull request is not merged. If GitHub removes it from the queue while the plan, snapshot, review and head are unchanged, Merge offers a retry after the status refresh; if the head changed, it goes back to review first. With the opt-in runner, `prepare-merge` handles a moved base and runs every allowed `cmd:` acceptance check on the resulting exact head; merge remains blocked until that evidence is current.
3535

3636
## Runner (opt-in)
3737

@@ -51,7 +51,7 @@ A configuration with a `github` block may also add a `runner` block. The runner
5151
- `diagnosticsCapBytes`: the size retention trims that directory back to. It is a target, not a hard limit: the file just saved is always kept, even when it alone passes the cap. The default is 256 MiB.
5252
- `limits`: task storage limits (`workBytes`, `workInodes`, `metadataBytes`, `metadataInodes`).
5353

54-
The runner needs Docker and `CLAUDE_CODE_OAUTH_TOKEN`. At start, before the server opens, codeboost recovers what an earlier run left and builds the agent image; a recovery it cannot finish safely stops startup with what to do. `POST /api/runner` with `start` or `resume` runs the plan; the review screen has no runner buttons yet. A task paused for an out-of-scope change stays paused until the plan is amended, every changed path is declared, the remaining items validate against the runner's audited tree, and a person sends `approve-continuation` with the current `expectedStateVersion`, `expectedReviewVersion`, and an action ID. Then `resume` starts at the first unfinished item. `GET /api/runner` reports the checkpoint and whether the continuation is approved. When a run has completed every item, codeboost pushes the task head to a `codeboost/…` branch with your `gh` credentials and opens a ready pull request into `baseBranch`; a task that needs a person gets a draft pull request with its problems. A publish that was refused (for example, the branch holds a commit codeboost did not make) is retried with the `publish` action, and `GET /api/runner` shows the last outcome under `publish`. When a task is cancelled, codeboost closes the open pull requests it opened for it (the branch is kept). A pull request a person moved is left open and reported, and `close-pull-requests` retries a close that failed. Merge PR then merges that pull request, so `github.pullRequest` can be left out; if it is set, it must name the task's pull request, or merging is blocked. Demos never publish.
54+
The runner needs Docker and `CLAUDE_CODE_OAUTH_TOKEN`. At start, before the server opens, codeboost recovers what an earlier run left and builds the agent image; a recovery it cannot finish safely stops startup with what to do. `POST /api/runner` with `start` or `resume` runs the plan; the review screen has no runner buttons yet. A task paused for an out-of-scope change stays paused until the plan is amended, every changed path is declared, the remaining items validate against the runner's audited tree, and a person sends `approve-continuation` with the current `expectedStateVersion`, `expectedReviewVersion`, and an action ID. Then `resume` starts at the first unfinished item. `GET /api/runner` reports the checkpoint and whether the continuation is approved. When a run has completed every item, codeboost pushes the task head to a `codeboost/…` branch with your `gh` credentials and opens a ready pull request into `baseBranch`; a task that needs a person gets a draft pull request with its problems. Before merge, `prepare-merge` refreshes the published PR's exact base/head, rebases onto a moved base, refreshes attribution and approvals, and runs allowed `cmd:` checks in a credential-free read-only container. The check result counts only for that exact head and command list. A publish that was refused (for example, the branch holds a commit codeboost did not make) is retried with the `publish` action, and `GET /api/runner` shows the last outcome under `publish`. When a task is cancelled, codeboost closes the open pull requests it opened for it (the branch is kept). A pull request a person moved is left open and reported, and `close-pull-requests` retries a close that failed. Merge PR then merges that pull request, so `github.pullRequest` can be left out; if it is set, it must name the task's pull request, or merging is blocked. Demos never publish.
5555

5656
## Library
5757

@@ -87,8 +87,8 @@ Inputs such as `planText` and the ledger must come from the trusted runner. `run
8787
- The caller selects and trusts the repository and its Git administrative directory. Normal Git discovery, linked-worktree gitfiles, and symlinked gitdirs are supported; object-storage links and alternates inside that selected gitdir are rejected. This adapter is not a filesystem-containment boundary for untrusted repository roots.
8888
- Git administrative metadata and object storage must remain unchanged during a read; these library checks do not isolate a concurrently hostile filesystem.
8989
- Ownership uses line diffs, not semantic inference. Within one replacement block, new lines inherit all affected owners conservatively. Function context comes from Git hunk headers, not an AST.
90-
- The importer requires accurate typed base entries, stable plan identity, a selected issue, and a trusted checkout path-identity function. It rejects path traversal, Git metadata paths, and traversal through a listed file/symlink/submodule. Runtime symlink and write-scope enforcement belong to the future container/runner; plan validation alone is not a sandbox.
91-
- Allowed commands restrict accidents, not hostile programs or changed scripts. Parsing returns argv and never executes it. An unlisted valid command is a warning and must not run until allowed.
92-
- Container isolation, vendor-only network access and credential handling are implemented by the lane D boundary (`agents/`), not by this library. Ask runs in that boundary in the read-only "questions" phase: it sees a clone of the reviewed head, supplied review context, and nothing else from your computer. Safe dependency installation is not implemented.
90+
- The importer requires accurate typed base entries, stable plan identity, a selected issue, and a trusted checkout path-identity function. It rejects path traversal, Git metadata paths, and traversal through a listed file/symlink/submodule. The opt-in runner adds runtime symlink and write-scope enforcement; plan validation alone is not a sandbox.
91+
- Allowed commands restrict accidents, not hostile programs or changed scripts. The plan library only parses them into argv. The opt-in runner executes an exact allowed argv only in its credential-free command-check container; an unlisted command never runs.
92+
- Container isolation, vendor-only network access and credential handling are implemented by the lane D boundary (`agents/`), not by this library. Ask, code-writing runs, conflict resolution, and credential-free command checks use that boundary with phase-specific mounts and tools. Safe dependency installation is not implemented.
9393

9494
See [implementation decisions and evidence](docs/implementation/build-step-1.md) and the [plan format](docs/plan-format.md).

‎agents/adapters/runner.ts‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import type { InvocationHandle } from '../contract.ts';
2+
import { readCapturedFile } from '../container/profile.ts';
3+
import { createPhasePolicy, createRunnerCommand, MAX_COMMAND_SCHEMA_BYTES } from '../policy.ts';
4+
import { launchInvocation } from './supervisor.ts';
5+
import { setUpProfile } from './setup.ts';
6+
import { assertAdapterRequest, capAdapterInvocationBudget, createAdapterInvocationBudget,
7+
type AgentAdapterOptions, type AgentAdapterRequest } from './types.ts';
8+
import { join } from 'node:path';
9+
10+
/** Run exact approved argv arrays in the read-only review container, without a provider credential or external host. */
11+
export function startRunnerCommandInvocation(request: AgentAdapterRequest,
12+
options: AgentAdapterOptions = {}): InvocationHandle {
13+
if (request.invocation.vendor !== 'runner' || request.invocation.phase !== 'review')
14+
throw new Error('Command checks require a runner-owned review invocation.');
15+
const policy = createPhasePolicy(request.invocation);
16+
const remaining = options.invocationBudget
17+
? capAdapterInvocationBudget(options.invocationBudget, options.timeoutMs)
18+
: createAdapterInvocationBudget(request.invocation, options.timeoutMs);
19+
const raw = readCapturedFile(join(request.inputDirectory, 'schema.json'), 'Runner command input', MAX_COMMAND_SCHEMA_BYTES).content;
20+
const commands = new TextDecoder('utf-8', { fatal: true }).decode(raw);
21+
const command = createRunnerCommand(policy, commands);
22+
assertAdapterRequest(request);
23+
return launchInvocation(request.invocation, remaining, (signal, start) => setUpProfile(request, remaining, signal,
24+
network => ({ ...request, policy, network, command }),
25+
profile => start(profile, { ...options, processLifecycle: request.processLifecycle, invocationBudget: remaining,
26+
diagnosticOutput: true })));
27+
}

‎agents/adapters/supervisor.ts‎

Lines changed: 34 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ export interface SupervisorOptions {
4141
readonly secrets?: Readonly<Record<string, string>>;
4242
readonly timeoutMs?: number;
4343
readonly limits?: Partial<CaptureLimits>;
44+
/** Keep bounded, lossy diagnostics without letting command output change the process exit result. */
45+
readonly diagnosticOutput?: boolean;
4446
/** Trusted monotonic budget carried from adapter setup. */
4547
readonly invocationBudget?: () => number;
4648
readonly decode?: (profile: ContainerProfile, rawStdout: Buffer, maximumBytes: number,
@@ -480,6 +482,9 @@ export function startProfileInvocation(profile: ContainerProfile, options: Super
480482
throw new Error('invocationBudget cannot exceed the production ten-minute ceiling.');
481483
if (options.processLifecycle && profile.deferredOutput)
482484
throw new Error('Durably tracked invocations cannot use concurrent deferred-output controls.');
485+
if (options.diagnosticOutput && (invocation.vendor !== 'runner' || invocation.phase !== 'review'
486+
|| options.decode || profile.deferredOutput))
487+
throw new Error('Lossy diagnostic output is reserved for runner command checks.');
483488
} catch (error) {
484489
return rejectProfile(profile, error, true, disposeContainerProfile,
485490
isDeadlineError(error) ? 'timeout' : undefined, options.processLifecycle);
@@ -498,6 +503,7 @@ export function startProfileInvocation(profile: ContainerProfile, options: Super
498503

499504
const stdoutChunks = new ByteCollector(), stderrChunks = new ByteCollector();
500505
let stdoutBytes = 0, stderrBytes = 0, combinedBytes = 0;
506+
let outputTruncated = false;
501507
let stopReason: StopReason | undefined, failureDetail: string | undefined;
502508
let closed = false, terminating = false, settlementComplete = false;
503509
let decodedOutput: DecodedOutput | undefined, decodePromise: Promise<void> | undefined;
@@ -595,7 +601,8 @@ export function startProfileInvocation(profile: ContainerProfile, options: Super
595601
combinedBytes += retained.length;
596602
}
597603
if (chunk.length > available) {
598-
if (final) stopReason ??= 'output-limit';
604+
if (options.diagnosticOutput) outputTruncated = true;
605+
else if (final) stopReason ??= 'output-limit';
599606
else stop('output-limit');
600607
}
601608
};
@@ -777,20 +784,38 @@ export function startProfileInvocation(profile: ContainerProfile, options: Super
777784
});
778785
}
779786
}
780-
// Publish only strictly valid UTF-8: replacement characters would grow the result past the byte ceilings.
781-
// Only output cut at a capture limit may end in an incomplete character, which the streaming decode then drops;
782-
// otherwise the decode flushes, so a trailing lone lead byte fails closed.
787+
// Agent answers publish only strictly valid UTF-8: replacement characters would grow the result past the byte
788+
// ceilings. Runner command output is diagnostic rather than an answer, so it is decoded lossily and bounded again.
789+
// Only strict output cut at a capture limit may end in an incomplete character, which streaming decode drops;
790+
// otherwise strict decode flushes, so a trailing lone lead byte fails closed.
783791
const truncated = stopReason === 'output-limit';
784792
const strictText = (value: Buffer) => {
785793
try { return new TextDecoder('utf-8', { fatal: true }).decode(value, truncated ? { stream: true } : undefined); }
786794
catch { return undefined; }
787795
};
788-
const stdoutText = strictText(finalStdout), stderrText = strictText(finalStderr);
789-
if (stdoutText === undefined || stderrText === undefined) {
790-
stopReason ??= 'capture-failure';
791-
failureDetail ??= 'Captured output is not valid UTF-8.';
796+
const boundedDiagnostic = (value: Buffer, maximum: number) => {
797+
const encoded = Buffer.from(new TextDecoder('utf-8').decode(value));
798+
if (encoded.length <= maximum) return encoded;
799+
return Buffer.from(new TextDecoder('utf-8', { fatal: true }).decode(encoded.subarray(0, maximum), { stream: true }));
800+
};
801+
if (options.diagnosticOutput) {
802+
finalStdout = boundedDiagnostic(finalStdout, Math.min(limits.stdoutBytes, limits.combinedBytes));
803+
finalStderr = boundedDiagnostic(finalStderr,
804+
Math.max(0, Math.min(limits.stderrBytes, limits.combinedBytes - finalStdout.length)));
805+
if (outputTruncated) {
806+
const notice = Buffer.from('[codeboost: command output truncated]\n');
807+
const maximum = Math.max(0, Math.min(limits.stderrBytes, limits.combinedBytes - finalStdout.length));
808+
finalStderr = maximum <= notice.length ? notice.subarray(0, maximum)
809+
: Buffer.concat([boundedDiagnostic(finalStderr, maximum - notice.length), notice]);
810+
}
811+
} else {
812+
const stdoutText = strictText(finalStdout), stderrText = strictText(finalStderr);
813+
if (stdoutText === undefined || stderrText === undefined) {
814+
stopReason ??= 'capture-failure';
815+
failureDetail ??= 'Captured output is not valid UTF-8.';
816+
}
817+
finalStdout = Buffer.from(stdoutText ?? ''); finalStderr = Buffer.from(stderrText ?? '');
792818
}
793-
finalStdout = Buffer.from(stdoutText ?? ''); finalStderr = Buffer.from(stderrText ?? '');
794819
if (stopReason) finalStderr = withDiagnostic(finalStderr, finalStdout.length, stopReason, limits, failureDetail);
795820
const result = Object.freeze({ attemptId: invocation.attemptId, context: invocation.context,
796821
exitCode, signal: finalSignal, ...(stopReason ? { stopReason } : {}),

‎agents/container/Dockerfile‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ RUN npm install --global --allow-scripts=@anthropic-ai/claude-code \
1212
&& install --directory --owner=10001 --group=10001 --mode=0755 /work /work/.git
1313

1414
COPY --chmod=0555 container/probe.sh /usr/local/bin/codeboost-container-probe
15+
COPY --chmod=0555 container/command-check.mjs /usr/local/bin/codeboost-command-check
1516
COPY --chmod=0444 network/proxy.mjs /usr/local/lib/codeboost-egress-proxy.mjs
1617

1718
LABEL org.opencontainers.image.base.name="docker.io/library/node:26.7.0-bookworm@sha256:e929171d35b9df7773a3ec5b068e387fa109441dc90f91e6560af5d39b7e9bf1" \

0 commit comments

Comments
 (0)