@sweny-ai/core 0.1.141 → 0.1.143
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.
- package/dist/agent-env.d.ts +24 -0
- package/dist/agent-env.js +40 -7
- package/dist/cli/config-file.d.ts +1 -1
- package/dist/cli/config-file.js +1 -1
- package/dist/cli/config.d.ts +2 -0
- package/dist/cli/config.js +15 -7
- package/dist/cli/main.js +45 -15
- package/dist/cli/output.d.ts +2 -0
- package/dist/cli/output.js +11 -0
- package/dist/cli/run-history.d.ts +2 -0
- package/dist/cli/run-history.js +2 -0
- package/dist/cli/run-output.d.ts +13 -2
- package/dist/cli/run-output.js +23 -2
- package/dist/executor.js +30 -25
- package/dist/harness/abort.d.ts +17 -0
- package/dist/harness/abort.js +53 -0
- package/dist/harness/agents.d.ts +11 -0
- package/dist/harness/agents.js +15 -0
- package/dist/harness/capabilities.d.ts +23 -0
- package/dist/harness/capabilities.js +35 -0
- package/dist/harness/claude-code.d.ts +9 -40
- package/dist/harness/claude-code.js +21 -308
- package/dist/harness/codex.d.ts +175 -0
- package/dist/harness/codex.js +917 -0
- package/dist/harness/index.d.ts +11 -3
- package/dist/harness/index.js +8 -8
- package/dist/harness/parse.d.ts +49 -0
- package/dist/harness/parse.js +253 -0
- package/dist/harness/policy.d.ts +23 -1
- package/dist/harness/policy.js +73 -15
- package/dist/harness/prompts.d.ts +7 -1
- package/dist/harness/prompts.js +16 -0
- package/dist/harness/types.d.ts +13 -2
- package/dist/harness/types.js +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/schema.d.ts +12 -11
- package/dist/schema.js +7 -3
- package/dist/types.d.ts +8 -1
- package/package.json +1 -1
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Timeout + caller-signal wiring shared by every adapter. No agent SDK imports.
|
|
3
|
+
*/
|
|
4
|
+
/**
|
|
5
|
+
* Wire an optional timeout + caller signal onto a single AbortController.
|
|
6
|
+
*
|
|
7
|
+
* Returns undefined when neither a timeout nor a signal is supplied so the
|
|
8
|
+
* default code path (no abortController, no timer) is byte-for-byte unchanged.
|
|
9
|
+
*
|
|
10
|
+
* `reason()` reports whether the abort came from the timeout timer or an
|
|
11
|
+
* external signal, so callers can log a distinct timeout message.
|
|
12
|
+
*/
|
|
13
|
+
export declare function makeAbort(timeoutMs?: number, signal?: AbortSignal): {
|
|
14
|
+
controller: AbortController;
|
|
15
|
+
clear: () => void;
|
|
16
|
+
reason: () => "timeout" | "signal" | undefined;
|
|
17
|
+
} | undefined;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Timeout + caller-signal wiring shared by every adapter. No agent SDK imports.
|
|
3
|
+
*/
|
|
4
|
+
/**
|
|
5
|
+
* Wire an optional timeout + caller signal onto a single AbortController.
|
|
6
|
+
*
|
|
7
|
+
* Returns undefined when neither a timeout nor a signal is supplied so the
|
|
8
|
+
* default code path (no abortController, no timer) is byte-for-byte unchanged.
|
|
9
|
+
*
|
|
10
|
+
* `reason()` reports whether the abort came from the timeout timer or an
|
|
11
|
+
* external signal, so callers can log a distinct timeout message.
|
|
12
|
+
*/
|
|
13
|
+
export function makeAbort(timeoutMs, signal) {
|
|
14
|
+
if (!timeoutMs && !signal)
|
|
15
|
+
return undefined;
|
|
16
|
+
const controller = new AbortController();
|
|
17
|
+
let reason;
|
|
18
|
+
let timer;
|
|
19
|
+
const onSignalAbort = () => {
|
|
20
|
+
if (controller.signal.aborted)
|
|
21
|
+
return;
|
|
22
|
+
reason = "signal";
|
|
23
|
+
controller.abort();
|
|
24
|
+
};
|
|
25
|
+
if (signal) {
|
|
26
|
+
if (signal.aborted) {
|
|
27
|
+
reason = "signal";
|
|
28
|
+
controller.abort();
|
|
29
|
+
}
|
|
30
|
+
else {
|
|
31
|
+
signal.addEventListener("abort", onSignalAbort, { once: true });
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
if (timeoutMs && timeoutMs > 0 && !controller.signal.aborted) {
|
|
35
|
+
timer = setTimeout(() => {
|
|
36
|
+
if (controller.signal.aborted)
|
|
37
|
+
return;
|
|
38
|
+
reason = "timeout";
|
|
39
|
+
controller.abort();
|
|
40
|
+
}, timeoutMs);
|
|
41
|
+
// Don't keep the event loop alive just for the abort timer.
|
|
42
|
+
timer?.unref?.();
|
|
43
|
+
}
|
|
44
|
+
return {
|
|
45
|
+
controller,
|
|
46
|
+
reason: () => reason,
|
|
47
|
+
clear: () => {
|
|
48
|
+
if (timer)
|
|
49
|
+
clearTimeout(timer);
|
|
50
|
+
signal?.removeEventListener("abort", onSignalAbort);
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The agent ids `--agent` / `coding-agent-provider` accept. Each one has an
|
|
3
|
+
* adapter and a green contract suite; anything else is refused, so sweny never
|
|
4
|
+
* runs one agent under another's name. No runtime imports (the CLI config and
|
|
5
|
+
* the harness factory share it).
|
|
6
|
+
*/
|
|
7
|
+
export declare const SUPPORTED_AGENTS: readonly ["claude", "codex"];
|
|
8
|
+
export type SupportedAgent = (typeof SUPPORTED_AGENTS)[number];
|
|
9
|
+
export declare function isSupportedAgent(id: string): id is SupportedAgent;
|
|
10
|
+
/** The error for an agent id with no adapter. */
|
|
11
|
+
export declare function unsupportedAgentError(id: string): string;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The agent ids `--agent` / `coding-agent-provider` accept. Each one has an
|
|
3
|
+
* adapter and a green contract suite; anything else is refused, so sweny never
|
|
4
|
+
* runs one agent under another's name. No runtime imports (the CLI config and
|
|
5
|
+
* the harness factory share it).
|
|
6
|
+
*/
|
|
7
|
+
export const SUPPORTED_AGENTS = ["claude", "codex"];
|
|
8
|
+
export function isSupportedAgent(id) {
|
|
9
|
+
return SUPPORTED_AGENTS.includes(id);
|
|
10
|
+
}
|
|
11
|
+
/** The error for an agent id with no adapter. */
|
|
12
|
+
export function unsupportedAgentError(id) {
|
|
13
|
+
return (`Unsupported coding agent "${id}": supported agents are "claude" (headless Claude Code) ` +
|
|
14
|
+
`and "codex" (Codex CLI). Remove --agent / coding-agent-provider or set it to one of them.`);
|
|
15
|
+
}
|
|
@@ -5,3 +5,26 @@
|
|
|
5
5
|
import type { HarnessCapabilities } from "./types.js";
|
|
6
6
|
/** Claude Code enforces every sweny opinion natively, so `policyGate` never degrades or refuses it. */
|
|
7
7
|
export declare const CLAUDE_CODE_CAPABILITIES: HarnessCapabilities;
|
|
8
|
+
/**
|
|
9
|
+
* Codex (`codex exec --json`), declared from the openai/codex source at
|
|
10
|
+
* rust-v0.159.2 and probed by the contract suite against the scripted fake:
|
|
11
|
+
* - structuredOutput native: `--output-schema <file>` (exec/src/cli.rs).
|
|
12
|
+
* - toolTrace full: `item.*` events for command_execution, file_change,
|
|
13
|
+
* mcp_tool_call and web_search, each with an id and a status
|
|
14
|
+
* (exec/src/exec_events.rs).
|
|
15
|
+
* - builtinDeny shell-only, plus net and subagent: `features.shell_tool=false`
|
|
16
|
+
* removes every shell tool (core/src/tools/spec_plan.rs add_shell_tools),
|
|
17
|
+
* `web_search="disabled"` removes web search, and `features.multi_agent=false`
|
|
18
|
+
* with `agents.enabled=false` removes subagents. apply_patch has no switch,
|
|
19
|
+
* so write and edit cannot be denied as classes.
|
|
20
|
+
* - mcp exclusive native: `--ignore-user-config` drops `$CODEX_HOME/config.toml`,
|
|
21
|
+
* and project `.codex/config.toml` layers stay disabled for a project that
|
|
22
|
+
* is not trusted in that ignored file (config/src/loader/mod.rs).
|
|
23
|
+
* - sandbox fs only: `--sandbox read-only|workspace-write`. Network is one
|
|
24
|
+
* on/off switch (`sandbox_workspace_write.network_access`), not a host list.
|
|
25
|
+
* - readOnly native: `--sandbox read-only` plus the shell, web search and
|
|
26
|
+
* subagent switches above.
|
|
27
|
+
* - turnLimit watchdog: codex exec has no turn limit; sweny counts tool calls.
|
|
28
|
+
* - usage tokens only: `turn.completed.usage`; Codex reports no cost.
|
|
29
|
+
*/
|
|
30
|
+
export declare const CODEX_CAPABILITIES: HarnessCapabilities;
|
|
@@ -15,3 +15,38 @@ export const CLAUDE_CODE_CAPABILITIES = {
|
|
|
15
15
|
cancel: "signal",
|
|
16
16
|
resume: false,
|
|
17
17
|
};
|
|
18
|
+
/**
|
|
19
|
+
* Codex (`codex exec --json`), declared from the openai/codex source at
|
|
20
|
+
* rust-v0.159.2 and probed by the contract suite against the scripted fake:
|
|
21
|
+
* - structuredOutput native: `--output-schema <file>` (exec/src/cli.rs).
|
|
22
|
+
* - toolTrace full: `item.*` events for command_execution, file_change,
|
|
23
|
+
* mcp_tool_call and web_search, each with an id and a status
|
|
24
|
+
* (exec/src/exec_events.rs).
|
|
25
|
+
* - builtinDeny shell-only, plus net and subagent: `features.shell_tool=false`
|
|
26
|
+
* removes every shell tool (core/src/tools/spec_plan.rs add_shell_tools),
|
|
27
|
+
* `web_search="disabled"` removes web search, and `features.multi_agent=false`
|
|
28
|
+
* with `agents.enabled=false` removes subagents. apply_patch has no switch,
|
|
29
|
+
* so write and edit cannot be denied as classes.
|
|
30
|
+
* - mcp exclusive native: `--ignore-user-config` drops `$CODEX_HOME/config.toml`,
|
|
31
|
+
* and project `.codex/config.toml` layers stay disabled for a project that
|
|
32
|
+
* is not trusted in that ignored file (config/src/loader/mod.rs).
|
|
33
|
+
* - sandbox fs only: `--sandbox read-only|workspace-write`. Network is one
|
|
34
|
+
* on/off switch (`sandbox_workspace_write.network_access`), not a host list.
|
|
35
|
+
* - readOnly native: `--sandbox read-only` plus the shell, web search and
|
|
36
|
+
* subagent switches above.
|
|
37
|
+
* - turnLimit watchdog: codex exec has no turn limit; sweny counts tool calls.
|
|
38
|
+
* - usage tokens only: `turn.completed.usage`; Codex reports no cost.
|
|
39
|
+
*/
|
|
40
|
+
export const CODEX_CAPABILITIES = {
|
|
41
|
+
structuredOutput: "native",
|
|
42
|
+
toolTrace: "full",
|
|
43
|
+
builtinDeny: "shell-only",
|
|
44
|
+
denyClasses: ["shell", "net", "subagent"],
|
|
45
|
+
mcp: { inject: true, exclusive: "native" },
|
|
46
|
+
sandbox: { fs: true, network: false },
|
|
47
|
+
readOnly: "native",
|
|
48
|
+
turnLimit: "watchdog",
|
|
49
|
+
usage: { tokens: true, costUsd: false, live: false },
|
|
50
|
+
cancel: "kill",
|
|
51
|
+
resume: false,
|
|
52
|
+
};
|
|
@@ -10,10 +10,13 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import type { Claude, Tool, ToolContext, Logger, McpServerConfig } from "../types.js";
|
|
12
12
|
import { type SandboxMode } from "../agent-env.js";
|
|
13
|
-
import type { AgentHarness, HarnessCapabilities, HarnessCompleteRequest, HarnessInfo, HarnessRunRequest, HarnessRunResult } from "./types.js";
|
|
13
|
+
import type { AgentHarness, HarnessCapabilities, HarnessCompleteRequest, HarnessInfo, HarnessRunRequest, HarnessRunResult, ToolClass } from "./types.js";
|
|
14
14
|
import { CLAUDE_CODE_CAPABILITIES } from "./capabilities.js";
|
|
15
15
|
import { buildEvaluatePrompt } from "./prompts.js";
|
|
16
|
+
import { parseToolResultContent, summarizeToolError } from "./parse.js";
|
|
17
|
+
import { makeAbort } from "./abort.js";
|
|
16
18
|
export { buildEvaluatePrompt, CLAUDE_CODE_CAPABILITIES };
|
|
19
|
+
export { parseToolResultContent, summarizeToolError, makeAbort };
|
|
17
20
|
/**
|
|
18
21
|
* Built-in tools disallowed for the route-evaluation (`evaluate`) and
|
|
19
22
|
* reflection/judge (`ask`) calls.
|
|
@@ -34,6 +37,11 @@ export declare const CLASSIFICATION_DISALLOWED_TOOLS: readonly ["Bash", "Write",
|
|
|
34
37
|
* Read, Grep, Glob and WebSearch stay available for analysis.
|
|
35
38
|
*/
|
|
36
39
|
export declare const READ_ONLY_DISALLOWED_TOOLS: readonly ["Bash", "Write", "Edit", "MultiEdit", "NotebookEdit", "Task", "Agent", "WebFetch"];
|
|
40
|
+
/**
|
|
41
|
+
* Claude Code built-in tool names per portable tool class. A node's
|
|
42
|
+
* `tools.deny` classes compile to these names on `disallowedTools`.
|
|
43
|
+
*/
|
|
44
|
+
export declare const CLAUDE_TOOLS_BY_CLASS: Readonly<Record<ToolClass, readonly string[]>>;
|
|
37
45
|
/** How sweny resolves which credentials reach the Claude Code subprocess. */
|
|
38
46
|
export type SwenyAuthMode = "auto" | "api-key" | "oauth";
|
|
39
47
|
export interface ResolveAuthEnvOpts {
|
|
@@ -120,20 +128,6 @@ export interface ClaudeCodeHarnessOptions {
|
|
|
120
128
|
args: string[];
|
|
121
129
|
};
|
|
122
130
|
}
|
|
123
|
-
/**
|
|
124
|
-
* Wire an optional timeout + caller signal onto a single AbortController.
|
|
125
|
-
*
|
|
126
|
-
* Returns undefined when neither a timeout nor a signal is supplied so the
|
|
127
|
-
* default code path (no abortController, no timer) is byte-for-byte unchanged.
|
|
128
|
-
*
|
|
129
|
-
* `reason()` reports whether the abort came from the timeout timer or an
|
|
130
|
-
* external signal, so callers can log a distinct timeout message.
|
|
131
|
-
*/
|
|
132
|
-
export declare function makeAbort(timeoutMs?: number, signal?: AbortSignal): {
|
|
133
|
-
controller: AbortController;
|
|
134
|
-
clear: () => void;
|
|
135
|
-
reason: () => "timeout" | "signal" | undefined;
|
|
136
|
-
} | undefined;
|
|
137
131
|
export declare class ClaudeCodeHarness implements Claude, AgentHarness {
|
|
138
132
|
readonly id: "claude-code";
|
|
139
133
|
readonly capabilities: HarnessCapabilities;
|
|
@@ -227,28 +221,3 @@ export type ClaudeClient = ClaudeCodeHarness;
|
|
|
227
221
|
* @internal Exported for the tool bridge parity test (#414).
|
|
228
222
|
*/
|
|
229
223
|
export declare function coreToolToSdkTool(coreTool: Tool, defaultCtx: ToolContext): import("@anthropic-ai/claude-agent-sdk").SdkMcpToolDefinition<Record<string, import("zod").ZodType<any, import("zod").ZodTypeDef, any>>>;
|
|
230
|
-
/**
|
|
231
|
-
* Best-effort recovery of typed output from a tool_result's `content`.
|
|
232
|
-
*
|
|
233
|
-
* The MCP protocol sends tool results as string content. Our in-process
|
|
234
|
-
* wrapper JSON-stringifies structured output before returning; external
|
|
235
|
-
* MCP servers generally do the same for JSON payloads. If the string
|
|
236
|
-
* looks like a JSON object or array and parses, return the parsed value
|
|
237
|
-
* so verify's output-path walks work against typed data.
|
|
238
|
-
*
|
|
239
|
-
* Raw strings are preserved verbatim. JSON-primitive strings (e.g. the
|
|
240
|
-
* literal four characters `"42"`) are intentionally NOT parsed - we
|
|
241
|
-
* cannot distinguish a tool that returned the number 42 (wrapper sends
|
|
242
|
-
* `"42"`) from a tool that returned the string "42" (wrapper also sends
|
|
243
|
-
* `"42"`). Preserving the string is safer than guessing.
|
|
244
|
-
*/
|
|
245
|
-
export declare function parseToolResultContent(content: unknown): unknown;
|
|
246
|
-
/**
|
|
247
|
-
* Produce a short, single-line description of a tool-error payload suitable
|
|
248
|
-
* for the CI log stream. The full parsed value stays on the ToolCall for
|
|
249
|
-
* verify and downstream tooling - this is only for inline observability.
|
|
250
|
-
*
|
|
251
|
-
* Collapses newlines, trims whitespace, and caps to 300 chars so a huge
|
|
252
|
-
* API response body doesn't flood the log.
|
|
253
|
-
*/
|
|
254
|
-
export declare function summarizeToolError(parsed: unknown): string;
|
|
@@ -12,13 +12,15 @@ import { query, createSdkMcpServer, tool as sdkTool } from "@anthropic-ai/claude
|
|
|
12
12
|
import { createRequire } from "node:module";
|
|
13
13
|
import { consoleLogger } from "../types.js";
|
|
14
14
|
import { reportWithheldEnv, parseList, resolveEnvScope, scopeAgentEnv, resolveAgentSandbox, } from "../agent-env.js";
|
|
15
|
-
import { fenceUntrustedJson } from "../untrusted.js";
|
|
16
15
|
import { policyGate } from "./policy.js";
|
|
17
16
|
import { CLAUDE_CODE_CAPABILITIES } from "./capabilities.js";
|
|
18
|
-
import { ask as coreAsk, evaluate as coreEvaluate, buildEvaluatePrompt } from "./prompts.js";
|
|
17
|
+
import { ask as coreAsk, evaluate as coreEvaluate, buildEvaluatePrompt, buildNodePrompt } from "./prompts.js";
|
|
19
18
|
import { jsonSchemaToZodShape, toolErrorToMcpResult, toolOutputToMcpResult } from "./tool-bridge/protocol.js";
|
|
20
19
|
import { startToolBridge } from "./tool-bridge/server.js";
|
|
20
|
+
import { parseToolResultContent, summarizeToolError, tryParseJSON } from "./parse.js";
|
|
21
|
+
import { makeAbort } from "./abort.js";
|
|
21
22
|
export { buildEvaluatePrompt, CLAUDE_CODE_CAPABILITIES };
|
|
23
|
+
export { parseToolResultContent, summarizeToolError, makeAbort };
|
|
22
24
|
let cachedSdkVersion;
|
|
23
25
|
/** Version of the Claude Agent SDK this adapter drives; "unknown" when it cannot be read. */
|
|
24
26
|
function claudeSdkVersion() {
|
|
@@ -72,6 +74,17 @@ export const READ_ONLY_DISALLOWED_TOOLS = [
|
|
|
72
74
|
"Agent",
|
|
73
75
|
"WebFetch",
|
|
74
76
|
];
|
|
77
|
+
/**
|
|
78
|
+
* Claude Code built-in tool names per portable tool class. A node's
|
|
79
|
+
* `tools.deny` classes compile to these names on `disallowedTools`.
|
|
80
|
+
*/
|
|
81
|
+
export const CLAUDE_TOOLS_BY_CLASS = {
|
|
82
|
+
shell: ["Bash"],
|
|
83
|
+
write: ["Write", "NotebookEdit"],
|
|
84
|
+
edit: ["Edit", "MultiEdit"],
|
|
85
|
+
net: ["WebFetch", "WebSearch"],
|
|
86
|
+
subagent: ["Task", "Agent"],
|
|
87
|
+
};
|
|
75
88
|
/**
|
|
76
89
|
* Decide which auth credentials survive into the Claude Code subprocess env.
|
|
77
90
|
*
|
|
@@ -131,56 +144,6 @@ export function resolveAuthEnv(env, opts = {}) {
|
|
|
131
144
|
opts.logger?.debug?.(`[sweny] auth mode: ${mode}`);
|
|
132
145
|
return out;
|
|
133
146
|
}
|
|
134
|
-
/**
|
|
135
|
-
* Wire an optional timeout + caller signal onto a single AbortController.
|
|
136
|
-
*
|
|
137
|
-
* Returns undefined when neither a timeout nor a signal is supplied so the
|
|
138
|
-
* default code path (no abortController, no timer) is byte-for-byte unchanged.
|
|
139
|
-
*
|
|
140
|
-
* `reason()` reports whether the abort came from the timeout timer or an
|
|
141
|
-
* external signal, so callers can log a distinct timeout message.
|
|
142
|
-
*/
|
|
143
|
-
export function makeAbort(timeoutMs, signal) {
|
|
144
|
-
if (!timeoutMs && !signal)
|
|
145
|
-
return undefined;
|
|
146
|
-
const controller = new AbortController();
|
|
147
|
-
let reason;
|
|
148
|
-
let timer;
|
|
149
|
-
const onSignalAbort = () => {
|
|
150
|
-
if (controller.signal.aborted)
|
|
151
|
-
return;
|
|
152
|
-
reason = "signal";
|
|
153
|
-
controller.abort();
|
|
154
|
-
};
|
|
155
|
-
if (signal) {
|
|
156
|
-
if (signal.aborted) {
|
|
157
|
-
reason = "signal";
|
|
158
|
-
controller.abort();
|
|
159
|
-
}
|
|
160
|
-
else {
|
|
161
|
-
signal.addEventListener("abort", onSignalAbort, { once: true });
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
if (timeoutMs && timeoutMs > 0 && !controller.signal.aborted) {
|
|
165
|
-
timer = setTimeout(() => {
|
|
166
|
-
if (controller.signal.aborted)
|
|
167
|
-
return;
|
|
168
|
-
reason = "timeout";
|
|
169
|
-
controller.abort();
|
|
170
|
-
}, timeoutMs);
|
|
171
|
-
// Don't keep the event loop alive just for the abort timer.
|
|
172
|
-
timer?.unref?.();
|
|
173
|
-
}
|
|
174
|
-
return {
|
|
175
|
-
controller,
|
|
176
|
-
reason: () => reason,
|
|
177
|
-
clear: () => {
|
|
178
|
-
if (timer)
|
|
179
|
-
clearTimeout(timer);
|
|
180
|
-
signal?.removeEventListener("abort", onSignalAbort);
|
|
181
|
-
},
|
|
182
|
-
};
|
|
183
|
-
}
|
|
184
147
|
/**
|
|
185
148
|
* Best-effort interrupt of an SDK query stream. Called from `finally` so an
|
|
186
149
|
* early return / timeout / throw does not leak the underlying subprocess.
|
|
@@ -297,13 +260,16 @@ export class ClaudeCodeHarness {
|
|
|
297
260
|
async run(req) {
|
|
298
261
|
const policy = req.policy ?? {
|
|
299
262
|
readOnly: !!req.readOnly,
|
|
300
|
-
deny: [],
|
|
263
|
+
deny: req.deny ?? [],
|
|
301
264
|
nativeDeny: req.disallowedTools,
|
|
302
265
|
egress: req.agentAccess?.domains ?? [],
|
|
303
266
|
strict: false,
|
|
304
267
|
};
|
|
305
268
|
const gate = policyGate(this.capabilities, policy);
|
|
306
|
-
|
|
269
|
+
// Portable deny classes (`tools.deny: [write]`) compile to Claude Code tool names.
|
|
270
|
+
const classNames = policy.deny.flatMap((c) => CLAUDE_TOOLS_BY_CLASS[c] ?? []);
|
|
271
|
+
const disallowedTools = classNames.length > 0 ? [...new Set([...(req.disallowedTools ?? []), ...classNames])] : req.disallowedTools;
|
|
272
|
+
const result = await this.runQuery({ ...req, disallowedTools });
|
|
307
273
|
return { ...result, harness: this.info(), degraded: gate.degraded };
|
|
308
274
|
}
|
|
309
275
|
async runQuery(opts) {
|
|
@@ -371,15 +337,7 @@ export class ClaudeCodeHarness {
|
|
|
371
337
|
let bridge;
|
|
372
338
|
// Build prompt. Context (workflow input such as issues/alerts, plus prior
|
|
373
339
|
// node outputs) is fenced as untrusted data (#360).
|
|
374
|
-
const prompt =
|
|
375
|
-
`## Instruction\n\n${instruction}`,
|
|
376
|
-
`## Context\n\n${fenceUntrustedJson(context, "context")}`,
|
|
377
|
-
outputSchema
|
|
378
|
-
? `## Required Output\n\nYou MUST end with a JSON object matching this schema:\n\`\`\`json\n${JSON.stringify(outputSchema, null, 2)}\n\`\`\``
|
|
379
|
-
: "",
|
|
380
|
-
]
|
|
381
|
-
.filter(Boolean)
|
|
382
|
-
.join("\n\n");
|
|
340
|
+
const prompt = buildNodePrompt(instruction, context, outputSchema);
|
|
383
341
|
const env = this.buildEnv(agentAccess?.envVars);
|
|
384
342
|
let response = "";
|
|
385
343
|
// CC-08: when the SDK populates typed structured output (because we passed
|
|
@@ -749,112 +707,6 @@ export function coreToolToSdkTool(coreTool, defaultCtx) {
|
|
|
749
707
|
}
|
|
750
708
|
});
|
|
751
709
|
}
|
|
752
|
-
/**
|
|
753
|
-
* Best-effort recovery of typed output from a tool_result's `content`.
|
|
754
|
-
*
|
|
755
|
-
* The MCP protocol sends tool results as string content. Our in-process
|
|
756
|
-
* wrapper JSON-stringifies structured output before returning; external
|
|
757
|
-
* MCP servers generally do the same for JSON payloads. If the string
|
|
758
|
-
* looks like a JSON object or array and parses, return the parsed value
|
|
759
|
-
* so verify's output-path walks work against typed data.
|
|
760
|
-
*
|
|
761
|
-
* Raw strings are preserved verbatim. JSON-primitive strings (e.g. the
|
|
762
|
-
* literal four characters `"42"`) are intentionally NOT parsed - we
|
|
763
|
-
* cannot distinguish a tool that returned the number 42 (wrapper sends
|
|
764
|
-
* `"42"`) from a tool that returned the string "42" (wrapper also sends
|
|
765
|
-
* `"42"`). Preserving the string is safer than guessing.
|
|
766
|
-
*/
|
|
767
|
-
export function parseToolResultContent(content) {
|
|
768
|
-
// The Anthropic tool_result `content` field can be a block array
|
|
769
|
-
// (e.g. [{type:"text",text:"..."}, ...]) rather than a string. When it is,
|
|
770
|
-
// concatenate the text of every {type:"text"} block (recursing through any
|
|
771
|
-
// nested content), then run the same object/array JSON-parse logic on the
|
|
772
|
-
// joined string. This keeps `call.output` the actual payload instead of the
|
|
773
|
-
// raw wrapper array that eval/route logic would otherwise have to walk.
|
|
774
|
-
if (Array.isArray(content)) {
|
|
775
|
-
const text = collectBlockText(content);
|
|
776
|
-
return parseJsonObjectOrArray(text);
|
|
777
|
-
}
|
|
778
|
-
if (typeof content !== "string")
|
|
779
|
-
return content;
|
|
780
|
-
return parseJsonObjectOrArray(content);
|
|
781
|
-
}
|
|
782
|
-
/**
|
|
783
|
-
* Parse a string only when it is an unambiguous JSON object or array.
|
|
784
|
-
*
|
|
785
|
-
* Strings, numbers, booleans, and null would all corrupt or discard
|
|
786
|
-
* information (we cannot distinguish the literal text `"42"` from the number
|
|
787
|
-
* 42), so non-object/array inputs are preserved verbatim.
|
|
788
|
-
*/
|
|
789
|
-
function parseJsonObjectOrArray(content) {
|
|
790
|
-
const trimmed = content.trim();
|
|
791
|
-
if (trimmed.length === 0)
|
|
792
|
-
return content;
|
|
793
|
-
const first = trimmed[0];
|
|
794
|
-
if (first !== "{" && first !== "[")
|
|
795
|
-
return content;
|
|
796
|
-
try {
|
|
797
|
-
return JSON.parse(trimmed);
|
|
798
|
-
}
|
|
799
|
-
catch {
|
|
800
|
-
return content;
|
|
801
|
-
}
|
|
802
|
-
}
|
|
803
|
-
/**
|
|
804
|
-
* Concatenate the text of an array of content blocks. Handles {type:"text"}
|
|
805
|
-
* blocks and recurses into any nested `content` array so deeply-wrapped tool
|
|
806
|
-
* results still flatten to their underlying text.
|
|
807
|
-
*/
|
|
808
|
-
function collectBlockText(blocks) {
|
|
809
|
-
const parts = [];
|
|
810
|
-
for (const block of blocks) {
|
|
811
|
-
if (typeof block === "string") {
|
|
812
|
-
parts.push(block);
|
|
813
|
-
continue;
|
|
814
|
-
}
|
|
815
|
-
if (!block || typeof block !== "object")
|
|
816
|
-
continue;
|
|
817
|
-
const b = block;
|
|
818
|
-
if (b.type === "text" && typeof b.text === "string") {
|
|
819
|
-
parts.push(b.text);
|
|
820
|
-
}
|
|
821
|
-
else if (Array.isArray(b.content)) {
|
|
822
|
-
parts.push(collectBlockText(b.content));
|
|
823
|
-
}
|
|
824
|
-
else if (typeof b.text === "string") {
|
|
825
|
-
// Some producers omit an explicit type but still carry text.
|
|
826
|
-
parts.push(b.text);
|
|
827
|
-
}
|
|
828
|
-
}
|
|
829
|
-
return parts.join("");
|
|
830
|
-
}
|
|
831
|
-
/**
|
|
832
|
-
* Produce a short, single-line description of a tool-error payload suitable
|
|
833
|
-
* for the CI log stream. The full parsed value stays on the ToolCall for
|
|
834
|
-
* verify and downstream tooling - this is only for inline observability.
|
|
835
|
-
*
|
|
836
|
-
* Collapses newlines, trims whitespace, and caps to 300 chars so a huge
|
|
837
|
-
* API response body doesn't flood the log.
|
|
838
|
-
*/
|
|
839
|
-
export function summarizeToolError(parsed) {
|
|
840
|
-
let raw;
|
|
841
|
-
if (typeof parsed === "string") {
|
|
842
|
-
raw = parsed;
|
|
843
|
-
}
|
|
844
|
-
else if (parsed && typeof parsed === "object") {
|
|
845
|
-
try {
|
|
846
|
-
raw = JSON.stringify(parsed);
|
|
847
|
-
}
|
|
848
|
-
catch {
|
|
849
|
-
raw = String(parsed);
|
|
850
|
-
}
|
|
851
|
-
}
|
|
852
|
-
else {
|
|
853
|
-
raw = String(parsed);
|
|
854
|
-
}
|
|
855
|
-
const collapsed = raw.replace(/\s+/g, " ").trim();
|
|
856
|
-
return collapsed.length > 300 ? collapsed.slice(0, 297) + "..." : collapsed;
|
|
857
|
-
}
|
|
858
710
|
/** Strip MCP server prefix: "mcp__server__tool" → "tool" */
|
|
859
711
|
function stripMcpPrefix(name) {
|
|
860
712
|
const parts = name.split("__");
|
|
@@ -862,142 +714,3 @@ function stripMcpPrefix(name) {
|
|
|
862
714
|
return parts.slice(2).join("__");
|
|
863
715
|
return name;
|
|
864
716
|
}
|
|
865
|
-
// ─── JSON extraction ────────────────────────────────────────────
|
|
866
|
-
/**
|
|
867
|
-
* Extract a JSON object from Claude's text response.
|
|
868
|
-
*
|
|
869
|
-
* Strategy (in order):
|
|
870
|
-
* 1. LAST ```json code block``` - models put their real final answer last, so
|
|
871
|
-
* a prompt-injected fake fenced block placed earlier must not win.
|
|
872
|
-
* 2. Last brace-delimited `{...}` block - handles inline JSON at end of text
|
|
873
|
-
* 3. Full text parse - for responses that are pure JSON
|
|
874
|
-
* 4. Empty object - safe fallback
|
|
875
|
-
*
|
|
876
|
-
* When `outputSchema` is supplied, the parsed object is checked against it.
|
|
877
|
-
* A mismatch is logged (warning) rather than silently accepted, so a
|
|
878
|
-
* non-conforming model answer is at least attributable.
|
|
879
|
-
*/
|
|
880
|
-
function tryParseJSON(text, outputSchema, logger) {
|
|
881
|
-
if (!text)
|
|
882
|
-
return {};
|
|
883
|
-
const finalize = (parsed) => {
|
|
884
|
-
if (outputSchema) {
|
|
885
|
-
const problems = schemaMismatches(parsed, outputSchema);
|
|
886
|
-
if (problems.length > 0) {
|
|
887
|
-
logger?.warn?.(`Claude output did not conform to outputSchema: ${problems.join("; ")}`);
|
|
888
|
-
}
|
|
889
|
-
}
|
|
890
|
-
return parsed;
|
|
891
|
-
};
|
|
892
|
-
// 1. Code block - scan ALL fenced blocks, prefer the LAST that parses.
|
|
893
|
-
const fences = [...text.matchAll(/```(?:json)?\s*([\s\S]*?)```/g)];
|
|
894
|
-
for (let i = fences.length - 1; i >= 0; i--) {
|
|
895
|
-
try {
|
|
896
|
-
const parsed = JSON.parse(fences[i][1].trim());
|
|
897
|
-
if (typeof parsed === "object" && parsed !== null)
|
|
898
|
-
return finalize(parsed);
|
|
899
|
-
}
|
|
900
|
-
catch {
|
|
901
|
-
/* try the next-earlier fence, then fall through to brace scan */
|
|
902
|
-
}
|
|
903
|
-
}
|
|
904
|
-
// 2. Last brace-delimited block (scan backwards for matching braces)
|
|
905
|
-
const lastBrace = text.lastIndexOf("}");
|
|
906
|
-
if (lastBrace !== -1) {
|
|
907
|
-
let depth = 0;
|
|
908
|
-
for (let i = lastBrace; i >= 0; i--) {
|
|
909
|
-
if (text[i] === "}")
|
|
910
|
-
depth++;
|
|
911
|
-
else if (text[i] === "{")
|
|
912
|
-
depth--;
|
|
913
|
-
if (depth === 0) {
|
|
914
|
-
try {
|
|
915
|
-
const parsed = JSON.parse(text.slice(i, lastBrace + 1));
|
|
916
|
-
if (typeof parsed === "object" && parsed !== null)
|
|
917
|
-
return finalize(parsed);
|
|
918
|
-
}
|
|
919
|
-
catch {
|
|
920
|
-
/* try next strategy */
|
|
921
|
-
}
|
|
922
|
-
break;
|
|
923
|
-
}
|
|
924
|
-
}
|
|
925
|
-
}
|
|
926
|
-
// 3. Full text parse
|
|
927
|
-
try {
|
|
928
|
-
const parsed = JSON.parse(text.trim());
|
|
929
|
-
if (typeof parsed === "object" && parsed !== null)
|
|
930
|
-
return finalize(parsed);
|
|
931
|
-
}
|
|
932
|
-
catch {
|
|
933
|
-
/* fall through */
|
|
934
|
-
}
|
|
935
|
-
return {};
|
|
936
|
-
}
|
|
937
|
-
/**
|
|
938
|
-
* Lightweight structural check of a parsed object against a JSON Schema.
|
|
939
|
-
*
|
|
940
|
-
* Deliberately shallow: it verifies `required` keys are present and that the
|
|
941
|
-
* top-level `type` and each declared property `type` match. It does NOT do
|
|
942
|
-
* full JSON Schema validation (no `$ref`, `oneOf`, formats, nested arrays).
|
|
943
|
-
* The contract here is "flag an obviously non-conforming object" so a mismatch
|
|
944
|
-
* is logged rather than silently accepted; it is not a validation gate.
|
|
945
|
-
*
|
|
946
|
-
* Returns a list of human-readable problems; empty means no detected mismatch.
|
|
947
|
-
*/
|
|
948
|
-
function schemaMismatches(value, schema) {
|
|
949
|
-
const problems = [];
|
|
950
|
-
const s = schema;
|
|
951
|
-
if (s.type === "object" || s.properties || s.required) {
|
|
952
|
-
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
953
|
-
return [`expected an object, got ${Array.isArray(value) ? "array" : typeof value}`];
|
|
954
|
-
}
|
|
955
|
-
const obj = value;
|
|
956
|
-
const required = Array.isArray(s.required) ? s.required : [];
|
|
957
|
-
for (const key of required) {
|
|
958
|
-
if (!(key in obj) || obj[key] === undefined) {
|
|
959
|
-
problems.push(`missing required property "${key}"`);
|
|
960
|
-
}
|
|
961
|
-
}
|
|
962
|
-
const props = s.properties ?? {};
|
|
963
|
-
for (const [key, propSchema] of Object.entries(props)) {
|
|
964
|
-
if (!(key in obj) || obj[key] === undefined || obj[key] === null)
|
|
965
|
-
continue;
|
|
966
|
-
const expected = propSchema?.type;
|
|
967
|
-
if (typeof expected === "string" && !jsonTypeMatches(obj[key], expected)) {
|
|
968
|
-
problems.push(`property "${key}" expected ${expected}, got ${jsonTypeOf(obj[key])}`);
|
|
969
|
-
}
|
|
970
|
-
}
|
|
971
|
-
}
|
|
972
|
-
else if (typeof s.type === "string" && !jsonTypeMatches(value, s.type)) {
|
|
973
|
-
problems.push(`expected ${s.type}, got ${jsonTypeOf(value)}`);
|
|
974
|
-
}
|
|
975
|
-
return problems;
|
|
976
|
-
}
|
|
977
|
-
function jsonTypeOf(value) {
|
|
978
|
-
if (value === null)
|
|
979
|
-
return "null";
|
|
980
|
-
if (Array.isArray(value))
|
|
981
|
-
return "array";
|
|
982
|
-
return typeof value;
|
|
983
|
-
}
|
|
984
|
-
function jsonTypeMatches(value, expected) {
|
|
985
|
-
switch (expected) {
|
|
986
|
-
case "object":
|
|
987
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
988
|
-
case "array":
|
|
989
|
-
return Array.isArray(value);
|
|
990
|
-
case "string":
|
|
991
|
-
return typeof value === "string";
|
|
992
|
-
case "number":
|
|
993
|
-
return typeof value === "number";
|
|
994
|
-
case "integer":
|
|
995
|
-
return typeof value === "number" && Number.isInteger(value);
|
|
996
|
-
case "boolean":
|
|
997
|
-
return typeof value === "boolean";
|
|
998
|
-
case "null":
|
|
999
|
-
return value === null;
|
|
1000
|
-
default:
|
|
1001
|
-
return true; // unknown/unsupported type keyword - don't flag
|
|
1002
|
-
}
|
|
1003
|
-
}
|