Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

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.

**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 or Codex inside the locked-down agent container for read-only answers; choose the provider in Settings. Ask needs Docker, plus `CLAUDE_CODE_OAUTH_TOKEN` (from `claude setup-token`) for Claude or a Codex `auth.json` (`CODEBOOST_CODEX_AUTH_FILE`, default `~/.codex/auth.json`). 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).
**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 cannot answer questions yet, because it reads code only by running commands ([why](docs/implementation/agent-isolation.md#codex-in-read-only-phases)). 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).

## Development

Expand Down
48 changes: 40 additions & 8 deletions agents/adapters/claude.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,59 @@
import { join } from 'node:path';
import type { InvocationHandle } from '../contract.ts';
import { createClaudeCommand, createPhasePolicy } from '../policy.ts';
import { readCapturedFile } from '../container/profile.ts';
import { createClaudeCommand, createPhasePolicy, MAX_COMMAND_SCHEMA_BYTES } from '../policy.ts';
import { launchInvocation } from './supervisor.ts';
import { setUpProfile } from './setup.ts';
import { assertAdapterRequest, createAdapterInvocationBudget, type AgentAdapterOptions, type AgentAdapterRequest } from './types.ts';

export function parseClaudeOutput(raw: Buffer): { text: string; providerFailed: boolean } {
/**
* Read Claude's `--output-format json` envelope. A result whose `subtype` is not `success`, or whose `is_error` is
* true, is a provider failure; it can omit `result`. With `--json-schema` (planning), a successful answer needs
* `subtype: "success"` and is the schema-validated `structured_output` object, returned as JSON text; `result` then
* holds only Claude's prose. Plain-text envelopes without a `subtype` are still accepted.
*/
export function parseClaudeOutput(raw: Buffer, structured = false): { text: string; providerFailed: boolean } {
const envelope = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(raw)) as
{ result?: unknown; is_error?: unknown };
if (typeof envelope.result !== 'string' || typeof envelope.is_error !== 'boolean')
{ subtype?: unknown; result?: unknown; is_error?: unknown; structured_output?: unknown };
if (typeof envelope.is_error !== 'boolean' || (envelope.result !== undefined && typeof envelope.result !== 'string')
|| (envelope.subtype !== undefined && typeof envelope.subtype !== 'string'))
throw new Error('Claude returned a malformed output envelope.');
return Object.freeze({ text: envelope.result, providerFailed: envelope.is_error });
const prose = envelope.result ?? '';
if (envelope.is_error || (envelope.subtype !== undefined && envelope.subtype !== 'success'))
return Object.freeze({ text: prose, providerFailed: true });
Comment thread
mchwang marked this conversation as resolved.
if (!structured) {
if (envelope.result === undefined) throw new Error('Claude returned a malformed output envelope.');
return Object.freeze({ text: prose, providerFailed: false });
}
// Claude always reports a subtype; a schema-constrained answer is accepted only from an explicit success.
if (envelope.subtype !== 'success') throw new Error('Claude returned a malformed output envelope.');
const answer = envelope.structured_output;
if (answer === null || typeof answer !== 'object' || Array.isArray(answer))
throw new Error('Claude returned no structured output for a schema-constrained answer.');
return Object.freeze({ text: JSON.stringify(answer), providerFailed: false });
}

/**
* Read the planning schema for `--json-schema` with the profile's bounded no-follow reader. The profile captures the
* mounted file again and refuses a command whose schema differs.
*/
export function readPlanningSchema(inputDirectory: string): string {
const { content } = readCapturedFile(join(inputDirectory, 'schema.json'), 'Planning schema', MAX_COMMAND_SCHEMA_BYTES);
return new TextDecoder('utf-8', { fatal: true }).decode(content);
}

export function startClaudeInvocation(request: AgentAdapterRequest,
oauthToken: string, options: AgentAdapterOptions = {}): InvocationHandle {
if (!oauthToken || oauthToken.includes('\0')) throw new Error('Claude OAuth token is malformed.');
const policy = createPhasePolicy(request.invocation);
const remaining = createAdapterInvocationBudget(request.invocation, options.timeoutMs);
const structured = policy.phase === 'planning';
// Invalid input throws here, before anything is allocated; only Docker setup runs inside the handle.
const command = createClaudeCommand(policy, request.prompt,
structured ? readPlanningSchema(request.inputDirectory) : undefined);
assertAdapterRequest(request);
return launchInvocation(request.invocation, remaining, (signal, start) => setUpProfile(request, remaining, signal,
network => ({ ...request, policy, network, command: createClaudeCommand(policy, request.prompt),
claudeToken: oauthToken }),
network => ({ ...request, policy, network, command, claudeToken: oauthToken }),
profile => start(profile, { ...options, secrets: { CLAUDE_CODE_OAUTH_TOKEN: oauthToken },
invocationBudget: remaining, decode: (_profile, raw) => parseClaudeOutput(raw) })));
invocationBudget: remaining, decode: (_profile, raw) => parseClaudeOutput(raw, structured) })));
}
7 changes: 4 additions & 3 deletions agents/adapters/codex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,12 @@ export function startCodexInvocation(request: AgentAdapterRequest,
if (!authFile || authFile.includes('\0')) throw new Error('Codex auth path is malformed.');
const policy = createPhasePolicy(request.invocation);
const remaining = createAdapterInvocationBudget(request.invocation, options.timeoutMs);
// Invalid input throws here, before anything is allocated; only Docker setup runs inside the handle.
// Invalid input, including a planning or questions phase, throws here, before anything is allocated; only Docker
// setup runs inside the handle.
const command = createCodexCommand(policy, request.prompt);
assertAdapterRequest(request);
return launchInvocation(request.invocation, remaining, (signal, start) => setUpProfile(request, remaining, signal,
network => ({ ...request, policy, network, command: createCodexCommand(policy, request.prompt),
codexAuthFile: authFile, deferredOutput: true }),
network => ({ ...request, policy, network, command, codexAuthFile: authFile, deferredOutput: true }),
profile => start(profile, { ...options, invocationBudget: remaining,
decode: (current, _raw, maximum, timeoutMs, decodeSignal) =>
readCodexOutput(codexContainer(current), maximum, timeoutMs, decodeSignal) })));
Expand Down
13 changes: 9 additions & 4 deletions agents/container/profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { assertTaskFilesystems, taskFilesystemOwner, type TaskFilesystems } from
import { ownerLabelArgs, type ResourceOwner } from '../labels.ts';
import { assertVendorNetwork, removeVendorNetwork, vendorNetworkResources,
type VendorNetwork } from '../network/network.ts';
import { assertAgentCommand, assertPhasePolicy, type AgentCommand, type PhasePolicy } from '../policy.ts';
import { assertAgentCommand, assertCommandSchema, assertPhasePolicy, type AgentCommand, type PhasePolicy } from '../policy.ts';
export interface ContainerProfile {
readonly name: string;
readonly args: readonly string[];
Expand Down Expand Up @@ -90,16 +90,20 @@ const removeOwnedDirectories = (directories: readonly string[]) => {
if (failures.length) throw new AggregateError(failures, 'Profile snapshot cleanup did not settle.');
};

const readCapturedFile = (path: string, kind: string): { identity: FileIdentity; content: Buffer } => {
/**
* Read a regular, unlinked file through one no-follow descriptor, so the path cannot be swapped between check and
* read. `O_NONBLOCK` makes a FIFO fail the regular-file check instead of blocking the open.
*/
export const readCapturedFile = (path: string, kind: string, maximum = 1024 * 1024):
{ identity: FileIdentity; content: Buffer } => {
let fd: number | undefined;
try {
try { fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW); }
try { fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); }
catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ELOOP') throw new Error(`${kind} must be a direct regular file, not a link.`);
throw error;
}
const before = fstatSync(fd);
const maximum = 1024 * 1024;
if (!before.isFile() || before.nlink !== 1 || before.size > maximum)
throw new Error(`${kind} must be a bounded, unlinked regular file.`);
const bounded = Buffer.allocUnsafe(maximum + 1);
Expand Down Expand Up @@ -244,6 +248,7 @@ export async function createContainerProfile(options: ProfileOptions): Promise<C
assertPhasePolicy(options.policy, invocation);
const command = assertAgentCommand(options.command, options.policy, invocation.vendor);
const sourceInput = captureInput(options.inputDirectory);
assertCommandSchema(options.command, sourceInput.content);
if (invocation.vendor === 'codex' && (!options.codexAuthFile || options.claudeToken))
throw new Error('Codex requires only its auth file.');
if (invocation.vendor === 'claude' && (!options.claudeToken || options.codexAuthFile))
Expand Down
68 changes: 59 additions & 9 deletions agents/policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,29 @@ export interface PhasePolicy {
export interface AgentCommand { readonly argv: readonly string[] }
interface PolicyIdentity { readonly invocation: InvocationInput }
const identities = new WeakMap<PhasePolicy, PolicyIdentity>();
const commands = new WeakMap<AgentCommand, { readonly policy: PhasePolicy; readonly vendor: InvocationInput['vendor'] }>();
const commands = new WeakMap<AgentCommand,
{ readonly policy: PhasePolicy; readonly vendor: InvocationInput['vendor']; readonly schema?: string }>();

const command = (policy: PhasePolicy, argv: readonly string[]): AgentCommand => {
const command = (policy: PhasePolicy, argv: readonly string[], schema?: string): AgentCommand => {
assertPhasePolicy(policy);
const value = Object.freeze({ argv: Object.freeze([...argv]) });
commands.set(value, Object.freeze({ policy, vendor: assertPhasePolicy(policy).vendor }));
commands.set(value, Object.freeze({ policy, vendor: assertPhasePolicy(policy).vendor, schema }));
return value;
};

/** The largest answer schema a command carries in its argv. The v1 plan-edit schema is under 10 KiB. */
export const MAX_COMMAND_SCHEMA_BYTES = 64 * 1024;

/**
* A command that carries an answer schema in its argv must carry the exact schema the profile mounts, so the vendor
* validates against the same schema the runner validates against.
*/
export function assertCommandSchema(value: AgentCommand, mounted: Buffer): void {
const schema = commands.get(value)?.schema;
if (schema !== undefined && !Buffer.from(schema, 'utf8').equals(mounted))
throw new Error('Command answer schema does not match the mounted schema.');
}

export function assertAgentCommand(value: AgentCommand, policy: PhasePolicy,
vendor?: InvocationInput['vendor']): readonly string[] {
const identity = commands.get(value);
Expand Down Expand Up @@ -62,16 +76,50 @@ export function dispatchApprovedCommand<T>(policy: PhasePolicy, argv: readonly s
return execute(Object.freeze([...argv]));
}

export function createClaudeCommand(policy: PhasePolicy, prompt: string): AgentCommand {
/**
* Planning answers must match the request's schema, so the planning command carries it. Claude's `--json-schema` takes
* the schema text, not a path; the profile checks that text against the mounted file. Questions answer in plain text
* (their schema is `{"type":"string"}`), and the other phases return no JSON answer, so they carry no schema.
*/
export function createClaudeCommand(policy: PhasePolicy, prompt: string, schema?: string): AgentCommand {
if (!prompt || prompt.includes('\0')) throw new Error('Claude prompt must be nonempty and contain no NUL.');
if (assertPhasePolicy(policy).vendor !== 'claude') throw new Error('Claude command requires a Claude invocation policy.');
if ((policy.phase === 'planning') !== (schema !== undefined))
throw new Error('Only the Claude planning command carries an answer schema, and it must.');
const schemaArguments = schema === undefined ? [] : ['--json-schema', assertPlanningSchema(schema)];
const writable = policy.worktree === 'read-write';
const allowed = writable ? 'Read,Glob,Grep,Edit,Write' : 'Read,Glob,Grep';
// `--` ends option parsing, so a prompt beginning with `-` stays prompt data.
return command(policy, ['claude', '--print', '--output-format', 'json', '--restricted', '--strict-mcp-config',
'--mcp-config', '{"mcpServers":{}}', '--disable-slash-commands', '--no-chrome', '--permission-prompts', 'none',
'--permission-mode', writable ? 'acceptEdits' : 'plan', '--tools', allowed, '--allowedTools', allowed,
'--disallowedTools', 'Bash,WebFetch,WebSearch,NotebookEdit', '--add-dir', '/run/codeboost-input', '--', prompt]);
return command(policy, ['claude', '--print', '--output-format', 'json', ...schemaArguments, '--restricted',
'--strict-mcp-config', '--mcp-config', '{"mcpServers":{}}', '--disable-slash-commands', '--no-chrome',
'--permission-prompts', 'none', '--permission-mode', writable ? 'acceptEdits' : 'plan', '--tools', allowed,
'--allowedTools', allowed, '--disallowedTools', 'Bash,WebFetch,WebSearch,NotebookEdit', '--add-dir',
'/run/codeboost-input', '--', prompt], schema);
}

/**
* A planning schema must describe a JSON object (Claude returns `structured_output` as an object) and fit one command
* argument. Returns the schema unchanged.
*/
export function assertPlanningSchema(schema: string): string {
if (schema.includes('\0') || Buffer.byteLength(schema, 'utf8') > MAX_COMMAND_SCHEMA_BYTES)
throw new Error('Planning schema must contain no NUL and fit the command schema limit.');
let parsed: unknown;
try { parsed = JSON.parse(schema); } catch { throw new Error('Planning schema must be JSON.'); }
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)
|| (parsed as { type?: unknown }).type !== 'object')
throw new Error('Planning schema must be a JSON object schema with "type": "object".');
return schema;
}

/**
* Codex 0.153.4 reads files only through its shell, and planning and questions run no process. Codex would answer
* without seeing the code or the schema, so these phases are refused rather than weakened (#75; see
* docs/implementation/agent-isolation.md, "Codex in read-only phases").
*/
export function assertCodexPhase(phase: Phase): void {
if (phase === 'planning' || phase === 'questions')
throw new Error(`Codex cannot run the ${phase} phase: it has no file-reading tool that runs no process.`);
}

export function codexBaseArguments(policy: PhasePolicy): readonly string[] {
Expand All @@ -82,9 +130,11 @@ export function codexBaseArguments(policy: PhasePolicy): readonly string[] {

export function createCodexCommand(policy: PhasePolicy, prompt: string): AgentCommand {
if (!prompt || prompt.includes('\0')) throw new Error('Codex prompt must be nonempty and contain no NUL.');
const base = codexBaseArguments(policy);
assertCodexPhase(policy.phase);
const sandbox = policy.worktree === 'read-write' ? 'workspace-write' : 'read-only';
// `--` ends option parsing, so a prompt beginning with `-` stays prompt data.
return command(policy, [...codexBaseArguments(policy), 'exec', '--sandbox', sandbox, '--skip-git-repo-check',
return command(policy, [...base, 'exec', '--sandbox', sandbox, '--skip-git-repo-check',
'--output-last-message', '/run/codeboost-output/final.txt', '--', prompt]);
}

Expand Down
Loading
Loading