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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ All notable changes will be documented here. This project follows Semantic Versi
### Changed

- Added strict versioned `.agentskit-review.json` policy with lens coverage, budgets, thresholds, context, and safe CI precedence; incomplete profiles require explicit local opt-in.
- Hardened the shared local CLI worker with cancellation, process-tree cleanup, isolated temporary environments, bounded output, and redacted diagnostics.
- Made reviews fail closed when any reviewable file has no successful primary lens or cannot be ingested; advisory mode now suppresses finding-based failures only, never source/provider/execution failures.
- Added primary-lens execution coverage to review summaries so partial provider degradation is visible.
- Repositioned the CLI and GitHub Action as provider-neutral.
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,7 @@ never accepted in CI.
},
"votes": 3,
"budget": { "maxFiles": 20, "maxCalls": 200, "concurrency": 1 },
"worker": { "timeoutMs": 120000, "maxOutputBytes": 20971520 },
"thresholds": { "minSeverity": "med", "minConfidence": 0.7 },
"context": { "mode": "prompt", "patterns": ["src/**"] }
}
Expand Down
2 changes: 2 additions & 0 deletions docs/OPERATIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ It requires `configVersion: 1` and supports lens policy (`enabled` and
`required` per built-in lens), votes, retries, thresholds, file/byte/call and
concurrency budgets, conventions, and context selection. All built-in lenses
are enabled by default; correctness, security, and tests are required.
The shared local worker also accepts bounded `timeoutMs` and `maxOutputBytes`
settings; absolute ceilings are always enforced.

Flags override file values. The file cannot contain credentials or executable
plugins. Provider, model, transport, trust mode, redaction, permissions, and
Expand Down
4 changes: 4 additions & 0 deletions llms-full.txt
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,7 @@ never accepted in CI.
},
"votes": 3,
"budget": { "maxFiles": 20, "maxCalls": 200, "concurrency": 1 },
"worker": { "timeoutMs": 120000, "maxOutputBytes": 20971520 },
"thresholds": { "minSeverity": "med", "minConfidence": 0.7 },
"context": { "mode": "prompt", "patterns": ["src/**"] }
}
Expand Down Expand Up @@ -432,6 +433,8 @@ It requires `configVersion: 1` and supports lens policy (`enabled` and
`required` per built-in lens), votes, retries, thresholds, file/byte/call and
concurrency budgets, conventions, and context selection. All built-in lenses
are enabled by default; correctness, security, and tests are required.
The shared local worker also accepts bounded `timeoutMs` and `maxOutputBytes`
settings; absolute ceilings are always enforced.

Flags override file values. The file cannot contain credentials or executable
plugins. Provider, model, transport, trust mode, redaction, permissions, and
Expand Down Expand Up @@ -795,6 +798,7 @@ All notable changes will be documented here. This project follows Semantic Versi
### Changed

- Added strict versioned `.agentskit-review.json` policy with lens coverage, budgets, thresholds, context, and safe CI precedence; incomplete profiles require explicit local opt-in.
- Hardened the shared local CLI worker with cancellation, process-tree cleanup, isolated temporary environments, bounded output, and redacted diagnostics.
- Made reviews fail closed when any reviewable file has no successful primary lens or cannot be ingested; advisory mode now suppresses finding-based failures only, never source/provider/execution failures.
- Added primary-lens execution coverage to review summaries so partial provider degradation is visible.
- Repositioned the CLI and GitHub Action as provider-neutral.
Expand Down
2 changes: 1 addition & 1 deletion readme-standard-v1.json
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,7 @@
"docs/OPERATIONS.md",
"test/cli-smoke.test.mjs"
],
"sourceHash": "sha256:1a2be3167c85699211c0b9ba45b870d8d88d0841a5d8d99b2c4bfe211a2b22fc"
"sourceHash": "sha256:23a5ee0d6e4a39e12a8fa1e0b05df4f470b2dd2904968ce96b45894e0263f7e9"
},
"exceptions": []
}
Expand Down
19 changes: 11 additions & 8 deletions src/claude-code-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,19 @@
* args and synthesize the `tool_call` stream chunk the runtime expects.
*/
import type { AdapterFactory, AdapterRequest, StreamChunk, StreamSource } from "@agentskit/core";
import { runLocalCli } from "./local-cli-process.js";
import { runLocalCli, type LocalCliMode } from "./local-cli-process.js";

/**
* Run `claude` and capture stdout. Crucially we CLOSE the child's stdin: in a
* non-TTY env (CI / self-hosted runner) `claude -p` otherwise blocks waiting for
* stdin ("no stdin data received in 3s …") and fails. Locally stdin is a TTY so it
* never showed. stderr is attached to the error for diagnosis.
*/
async function runClaude(args: string[]): Promise<string> {
async function runClaude(args: string[], signal?: AbortSignal, mode?: LocalCliMode, worker?: { timeoutMs?: number; maxOutputBytes?: number }): Promise<string> {
// Run from HOME (a trusted dir): the runner's checkout dir is untrusted and can
// make claude exit without output (folder-trust). The file under review is in the
// prompt, not read from cwd, so cwd is irrelevant to the result.
const { stdout } = await runLocalCli("claude", args, { cwd: process.env.HOME });
const { stdout } = await runLocalCli("claude", args, { signal, mode, ...worker });
return stdout;
}

Expand All @@ -36,10 +36,12 @@ function extractJson(text: string): string {
return text.slice(start, end + 1);
}

export function claudeCode(opts: { model?: string } = {}): AdapterFactory {
export function claudeCode(opts: { model?: string; mode?: LocalCliMode; worker?: { timeoutMs?: number; maxOutputBytes?: number } } = {}): AdapterFactory {
return {
capabilities: { streaming: false, tools: true, structuredOutput: true },
createSource: (request: AdapterRequest): StreamSource => ({
createSource: (request: AdapterRequest): StreamSource => {
const controller = new AbortController();
return {
stream: async function* (): AsyncIterableIterator<StreamChunk> {
try {
const system = request.messages.find((m) => m.role === "system")?.content ?? "";
Expand All @@ -57,7 +59,7 @@ export function claudeCode(opts: { model?: string } = {}): AdapterFactory {

const args = ["-p", prompt];
if (opts.model) args.push("--model", opts.model);
const out = (await runClaude(args)).trim();
const out = (await runClaude(args, controller.signal, opts.mode, opts.worker)).trim();

if (tools.length === 1) {
yield { type: "tool_call", toolCall: { id: `tc-${Date.now()}`, name: tools[0]!.name, args: extractJson(out) } };
Expand All @@ -73,7 +75,8 @@ export function claudeCode(opts: { model?: string } = {}): AdapterFactory {
yield { type: "error", content: `claude -p failed${detail ? `: ${detail.slice(0, 400)}` : ` (no output): ${(e.message ?? "").split("\n")[0]}`}` };
}
},
abort: () => {},
}),
abort: () => controller.abort(),
};
},
};
}
5 changes: 3 additions & 2 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,8 +184,9 @@ function buildAdapter(reviewConfig: ResolvedReviewConfig): AdapterFactory {
const provider = requestedProvider && resolveProviderId(requestedProvider)
if (!provider) throw new Error(requestedProvider ? `unknown --provider "${requestedProvider}" (run --list-providers for common options)` : 'choose a provider with --provider <name> (run --list-providers for common options)')
const model = reviewConfig.model ?? (has('api') ? 'claude-opus-4-8' : undefined)
if (provider === 'claude-cli') return claudeCode({ model })
if (provider === 'codex-cli') return codexCli({ model })
const mode = flag('mode') === 'trusted-local' ? 'trusted-local' : 'isolated'
if (provider === 'claude-cli') return claudeCode({ model, mode, worker: reviewConfig.worker })
if (provider === 'codex-cli') return codexCli({ model, mode, worker: reviewConfig.worker })
if (provider === 'ollama') {
if (!model) throw new Error('--model is required for provider "ollama"')
return ollamaReview({ model, ...(flag('base-url') ? { baseUrl: flag('base-url') } : {}) })
Expand Down
21 changes: 11 additions & 10 deletions src/codex-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { AdapterFactory, AdapterRequest, StreamChunk, StreamSource } from "@agentskit/core";
import { runLocalCli } from "./local-cli-process.js";
import { runLocalCli, type LocalCliMode } from "./local-cli-process.js";

/** Pull the JSON object out of a reply: first `{` to last `}` over the whole output. */
function extractJson(text: string): string {
Expand All @@ -22,34 +22,34 @@ function extractJson(text: string): string {
}

/** Run `codex exec` and return its final message (captured via -o). */
async function runCodex(prompt: string, model?: string): Promise<string> {
async function runCodex(prompt: string, model?: string, signal?: AbortSignal, mode?: LocalCliMode, worker?: { timeoutMs?: number; maxOutputBytes?: number }): Promise<string> {
const dir = mkdtempSync(join(tmpdir(), "cr-codex-"));
const outFile = join(dir, "out.txt");
const args = [
"exec",
"--skip-git-repo-check",
"-s",
"read-only",
"-C",
process.env.HOME ?? process.cwd(),
"-o",
outFile,
];
if (model) args.push("-m", model);
args.push(prompt);

try {
await runLocalCli("codex", args);
await runLocalCli("codex", args, { signal, mode, ...worker });
return readFileSync(outFile, "utf8");
} finally {
rmSync(dir, { recursive: true, force: true });
}
}

export function codexCli(opts: { model?: string } = {}): AdapterFactory {
export function codexCli(opts: { model?: string; mode?: LocalCliMode; worker?: { timeoutMs?: number; maxOutputBytes?: number } } = {}): AdapterFactory {
return {
capabilities: { streaming: false, tools: true, structuredOutput: true },
createSource: (request: AdapterRequest): StreamSource => ({
createSource: (request: AdapterRequest): StreamSource => {
const controller = new AbortController();
return {
stream: async function* (): AsyncIterableIterator<StreamChunk> {
try {
const system = request.messages.find((m) => m.role === "system")?.content ?? "";
Expand All @@ -65,7 +65,7 @@ export function codexCli(opts: { model?: string } = {}): AdapterFactory {
prompt += `\n\nReturn ONLY a JSON object that is the argument to the "${t.name}" tool, matching this JSON Schema exactly. No prose, no code fences:\n${JSON.stringify(t.schema)}`;
}

const out = (await runCodex(prompt, opts.model)).trim();
const out = (await runCodex(prompt, opts.model, controller.signal, opts.mode, opts.worker)).trim();

if (tools.length === 1) {
yield { type: "tool_call", toolCall: { id: `tc-${Date.now()}`, name: tools[0]!.name, args: extractJson(out) } };
Expand All @@ -79,7 +79,8 @@ export function codexCli(opts: { model?: string } = {}): AdapterFactory {
yield { type: "error", content: `codex exec failed${detail ? `: ${detail.slice(0, 400)}` : ` (no output): ${(e.message ?? "").split("\n")[0]}`}` };
}
},
abort: () => {},
}),
abort: () => controller.abort(),
};
},
};
}
Loading