@sweny-ai/core 0.1.142 → 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.
@@ -3,17 +3,25 @@
3
3
  * public harness types. Every construction site goes through `createHarness`.
4
4
  */
5
5
  import { ClaudeCodeHarness, type ClaudeCodeHarnessOptions } from "./claude-code.js";
6
+ import { CodexHarness, type CodexHarnessOptions } from "./codex.js";
6
7
  export { claudeCompat, asClaude } from "./compat.js";
7
8
  export type { AgentHarness, HarnessCapabilities, HarnessCompleteRequest, HarnessId, HarnessInfo, HarnessRunRequest, HarnessRunResult, NodePolicy, PolicyGateResult, PolicyWrappers, ToolClass, } from "./types.js";
8
- export { policyGate } from "./policy.js";
9
+ export { policyGate, nativeDenyClasses, resolveHarnessPolicy, isToolClass, TOOL_CLASSES } from "./policy.js";
10
+ export type { HarnessPolicyMode } from "./policy.js";
9
11
  export { ask, evaluate, buildAskPrompt, buildEvaluatePrompt } from "./prompts.js";
10
12
  export { ClaudeCodeHarness, CLAUDE_CODE_CAPABILITIES } from "./claude-code.js";
11
13
  export type { ClaudeCodeHarnessOptions } from "./claude-code.js";
14
+ export { CodexHarness, CODEX_CAPABILITIES, MIN_CODEX_VERSION } from "./codex.js";
15
+ export type { CodexHarnessOptions } from "./codex.js";
12
16
  export { startToolBridge } from "./tool-bridge/server.js";
13
17
  export type { ToolBridge, ToolBridgeOptions } from "./tool-bridge/server.js";
18
+ export { SUPPORTED_AGENTS, isSupportedAgent, unsupportedAgentError } from "./agents.js";
19
+ export type { SupportedAgent } from "./agents.js";
14
20
  /**
15
21
  * Build the harness for an agent id. `claude` is the historical `--agent` /
16
22
  * `coding-agent-provider` value and means the same as `claude-code`.
17
- * Unknown ids fail with the same error `sweny` has always given.
23
+ * Unknown ids throw: sweny never runs one agent under another's name.
18
24
  */
19
- export declare function createHarness(id: string, opts?: ClaudeCodeHarnessOptions): ClaudeCodeHarness;
25
+ export declare function createHarness(id: "claude" | "claude-code", opts?: ClaudeCodeHarnessOptions): ClaudeCodeHarness;
26
+ export declare function createHarness(id: "codex", opts?: CodexHarnessOptions): CodexHarness;
27
+ export declare function createHarness(id: string, opts?: ClaudeCodeHarnessOptions & CodexHarnessOptions): ClaudeCodeHarness | CodexHarness;
@@ -3,23 +3,23 @@
3
3
  * public harness types. Every construction site goes through `createHarness`.
4
4
  */
5
5
  import { ClaudeCodeHarness } from "./claude-code.js";
6
+ import { CodexHarness } from "./codex.js";
7
+ import { unsupportedAgentError } from "./agents.js";
6
8
  export { claudeCompat, asClaude } from "./compat.js";
7
- export { policyGate } from "./policy.js";
9
+ export { policyGate, nativeDenyClasses, resolveHarnessPolicy, isToolClass, TOOL_CLASSES } from "./policy.js";
8
10
  export { ask, evaluate, buildAskPrompt, buildEvaluatePrompt } from "./prompts.js";
9
11
  export { ClaudeCodeHarness, CLAUDE_CODE_CAPABILITIES } from "./claude-code.js";
12
+ export { CodexHarness, CODEX_CAPABILITIES, MIN_CODEX_VERSION } from "./codex.js";
10
13
  export { startToolBridge } from "./tool-bridge/server.js";
11
- /**
12
- * Build the harness for an agent id. `claude` is the historical `--agent` /
13
- * `coding-agent-provider` value and means the same as `claude-code`.
14
- * Unknown ids fail with the same error `sweny` has always given.
15
- */
14
+ export { SUPPORTED_AGENTS, isSupportedAgent, unsupportedAgentError } from "./agents.js";
16
15
  export function createHarness(id, opts = {}) {
17
16
  switch (id) {
18
17
  case "claude":
19
18
  case "claude-code":
20
19
  return new ClaudeCodeHarness(opts);
20
+ case "codex":
21
+ return new CodexHarness(opts);
21
22
  default:
22
- throw new Error(`Unsupported coding agent "${id}": the only supported agent is "claude" ` +
23
- `(headless Claude Code). Remove --agent / coding-agent-provider or set it to claude.`);
23
+ throw new Error(unsupportedAgentError(id));
24
24
  }
25
25
  }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Harness-neutral parsing of agent output: tool results and the final JSON
3
+ * answer. Shared by every adapter so a node's `data` and `toolCalls` mean the
4
+ * same thing whichever harness ran it. Moved out of claude-code.ts unchanged
5
+ * (the contract suite's structured-output golden cases pin the behavior).
6
+ *
7
+ * No agent SDK imports.
8
+ */
9
+ import type { JSONSchema, Logger } from "../types.js";
10
+ /**
11
+ * Best-effort recovery of typed output from a tool_result's `content`.
12
+ *
13
+ * The MCP protocol sends tool results as string content. Our in-process
14
+ * wrapper JSON-stringifies structured output before returning; external
15
+ * MCP servers generally do the same for JSON payloads. If the string
16
+ * looks like a JSON object or array and parses, return the parsed value
17
+ * so verify's output-path walks work against typed data.
18
+ *
19
+ * Raw strings are preserved verbatim. JSON-primitive strings (e.g. the
20
+ * literal four characters `"42"`) are intentionally NOT parsed - we
21
+ * cannot distinguish a tool that returned the number 42 (wrapper sends
22
+ * `"42"`) from a tool that returned the string "42" (wrapper also sends
23
+ * `"42"`). Preserving the string is safer than guessing.
24
+ */
25
+ export declare function parseToolResultContent(content: unknown): unknown;
26
+ /**
27
+ * Produce a short, single-line description of a tool-error payload suitable
28
+ * for the CI log stream. The full parsed value stays on the ToolCall for
29
+ * verify and downstream tooling - this is only for inline observability.
30
+ *
31
+ * Collapses newlines, trims whitespace, and caps to 300 chars so a huge
32
+ * API response body doesn't flood the log.
33
+ */
34
+ export declare function summarizeToolError(parsed: unknown): string;
35
+ /**
36
+ * Extract a JSON object from an agent's final text response.
37
+ *
38
+ * Strategy (in order):
39
+ * 1. LAST ```json code block``` - models put their real final answer last, so
40
+ * a prompt-injected fake fenced block placed earlier must not win.
41
+ * 2. Last brace-delimited `{...}` block - handles inline JSON at end of text
42
+ * 3. Full text parse - for responses that are pure JSON
43
+ * 4. Empty object - safe fallback
44
+ *
45
+ * When `outputSchema` is supplied, the parsed object is checked against it.
46
+ * A mismatch is logged (warning) rather than silently accepted, so a
47
+ * non-conforming model answer is at least attributable.
48
+ */
49
+ export declare function tryParseJSON(text: string, outputSchema?: JSONSchema, logger?: Pick<Logger, "warn">, label?: string): Record<string, unknown>;
@@ -0,0 +1,253 @@
1
+ /**
2
+ * Harness-neutral parsing of agent output: tool results and the final JSON
3
+ * answer. Shared by every adapter so a node's `data` and `toolCalls` mean the
4
+ * same thing whichever harness ran it. Moved out of claude-code.ts unchanged
5
+ * (the contract suite's structured-output golden cases pin the behavior).
6
+ *
7
+ * No agent SDK imports.
8
+ */
9
+ /**
10
+ * Best-effort recovery of typed output from a tool_result's `content`.
11
+ *
12
+ * The MCP protocol sends tool results as string content. Our in-process
13
+ * wrapper JSON-stringifies structured output before returning; external
14
+ * MCP servers generally do the same for JSON payloads. If the string
15
+ * looks like a JSON object or array and parses, return the parsed value
16
+ * so verify's output-path walks work against typed data.
17
+ *
18
+ * Raw strings are preserved verbatim. JSON-primitive strings (e.g. the
19
+ * literal four characters `"42"`) are intentionally NOT parsed - we
20
+ * cannot distinguish a tool that returned the number 42 (wrapper sends
21
+ * `"42"`) from a tool that returned the string "42" (wrapper also sends
22
+ * `"42"`). Preserving the string is safer than guessing.
23
+ */
24
+ export function parseToolResultContent(content) {
25
+ // The Anthropic tool_result `content` field can be a block array
26
+ // (e.g. [{type:"text",text:"..."}, ...]) rather than a string. When it is,
27
+ // concatenate the text of every {type:"text"} block (recursing through any
28
+ // nested content), then run the same object/array JSON-parse logic on the
29
+ // joined string. This keeps `call.output` the actual payload instead of the
30
+ // raw wrapper array that eval/route logic would otherwise have to walk.
31
+ if (Array.isArray(content)) {
32
+ const text = collectBlockText(content);
33
+ return parseJsonObjectOrArray(text);
34
+ }
35
+ if (typeof content !== "string")
36
+ return content;
37
+ return parseJsonObjectOrArray(content);
38
+ }
39
+ /**
40
+ * Parse a string only when it is an unambiguous JSON object or array.
41
+ *
42
+ * Strings, numbers, booleans, and null would all corrupt or discard
43
+ * information (we cannot distinguish the literal text `"42"` from the number
44
+ * 42), so non-object/array inputs are preserved verbatim.
45
+ */
46
+ function parseJsonObjectOrArray(content) {
47
+ const trimmed = content.trim();
48
+ if (trimmed.length === 0)
49
+ return content;
50
+ const first = trimmed[0];
51
+ if (first !== "{" && first !== "[")
52
+ return content;
53
+ try {
54
+ return JSON.parse(trimmed);
55
+ }
56
+ catch {
57
+ return content;
58
+ }
59
+ }
60
+ /**
61
+ * Concatenate the text of an array of content blocks. Handles {type:"text"}
62
+ * blocks and recurses into any nested `content` array so deeply-wrapped tool
63
+ * results still flatten to their underlying text.
64
+ */
65
+ function collectBlockText(blocks) {
66
+ const parts = [];
67
+ for (const block of blocks) {
68
+ if (typeof block === "string") {
69
+ parts.push(block);
70
+ continue;
71
+ }
72
+ if (!block || typeof block !== "object")
73
+ continue;
74
+ const b = block;
75
+ if (b.type === "text" && typeof b.text === "string") {
76
+ parts.push(b.text);
77
+ }
78
+ else if (Array.isArray(b.content)) {
79
+ parts.push(collectBlockText(b.content));
80
+ }
81
+ else if (typeof b.text === "string") {
82
+ // Some producers omit an explicit type but still carry text.
83
+ parts.push(b.text);
84
+ }
85
+ }
86
+ return parts.join("");
87
+ }
88
+ /**
89
+ * Produce a short, single-line description of a tool-error payload suitable
90
+ * for the CI log stream. The full parsed value stays on the ToolCall for
91
+ * verify and downstream tooling - this is only for inline observability.
92
+ *
93
+ * Collapses newlines, trims whitespace, and caps to 300 chars so a huge
94
+ * API response body doesn't flood the log.
95
+ */
96
+ export function summarizeToolError(parsed) {
97
+ let raw;
98
+ if (typeof parsed === "string") {
99
+ raw = parsed;
100
+ }
101
+ else if (parsed && typeof parsed === "object") {
102
+ try {
103
+ raw = JSON.stringify(parsed);
104
+ }
105
+ catch {
106
+ raw = String(parsed);
107
+ }
108
+ }
109
+ else {
110
+ raw = String(parsed);
111
+ }
112
+ const collapsed = raw.replace(/\s+/g, " ").trim();
113
+ return collapsed.length > 300 ? collapsed.slice(0, 297) + "..." : collapsed;
114
+ }
115
+ // ─── JSON extraction ────────────────────────────────────────────
116
+ /**
117
+ * Extract a JSON object from an agent's final text response.
118
+ *
119
+ * Strategy (in order):
120
+ * 1. LAST ```json code block``` - models put their real final answer last, so
121
+ * a prompt-injected fake fenced block placed earlier must not win.
122
+ * 2. Last brace-delimited `{...}` block - handles inline JSON at end of text
123
+ * 3. Full text parse - for responses that are pure JSON
124
+ * 4. Empty object - safe fallback
125
+ *
126
+ * When `outputSchema` is supplied, the parsed object is checked against it.
127
+ * A mismatch is logged (warning) rather than silently accepted, so a
128
+ * non-conforming model answer is at least attributable.
129
+ */
130
+ export function tryParseJSON(text, outputSchema, logger, label = "Claude") {
131
+ if (!text)
132
+ return {};
133
+ const finalize = (parsed) => {
134
+ if (outputSchema) {
135
+ const problems = schemaMismatches(parsed, outputSchema);
136
+ if (problems.length > 0) {
137
+ logger?.warn?.(`${label} output did not conform to outputSchema: ${problems.join("; ")}`);
138
+ }
139
+ }
140
+ return parsed;
141
+ };
142
+ // 1. Code block - scan ALL fenced blocks, prefer the LAST that parses.
143
+ const fences = [...text.matchAll(/```(?:json)?\s*([\s\S]*?)```/g)];
144
+ for (let i = fences.length - 1; i >= 0; i--) {
145
+ try {
146
+ const parsed = JSON.parse(fences[i][1].trim());
147
+ if (typeof parsed === "object" && parsed !== null)
148
+ return finalize(parsed);
149
+ }
150
+ catch {
151
+ /* try the next-earlier fence, then fall through to brace scan */
152
+ }
153
+ }
154
+ // 2. Last brace-delimited block (scan backwards for matching braces)
155
+ const lastBrace = text.lastIndexOf("}");
156
+ if (lastBrace !== -1) {
157
+ let depth = 0;
158
+ for (let i = lastBrace; i >= 0; i--) {
159
+ if (text[i] === "}")
160
+ depth++;
161
+ else if (text[i] === "{")
162
+ depth--;
163
+ if (depth === 0) {
164
+ try {
165
+ const parsed = JSON.parse(text.slice(i, lastBrace + 1));
166
+ if (typeof parsed === "object" && parsed !== null)
167
+ return finalize(parsed);
168
+ }
169
+ catch {
170
+ /* try next strategy */
171
+ }
172
+ break;
173
+ }
174
+ }
175
+ }
176
+ // 3. Full text parse
177
+ try {
178
+ const parsed = JSON.parse(text.trim());
179
+ if (typeof parsed === "object" && parsed !== null)
180
+ return finalize(parsed);
181
+ }
182
+ catch {
183
+ /* fall through */
184
+ }
185
+ return {};
186
+ }
187
+ /**
188
+ * Lightweight structural check of a parsed object against a JSON Schema.
189
+ *
190
+ * Deliberately shallow: it verifies `required` keys are present and that the
191
+ * top-level `type` and each declared property `type` match. It does NOT do
192
+ * full JSON Schema validation (no `$ref`, `oneOf`, formats, nested arrays).
193
+ * The contract here is "flag an obviously non-conforming object" so a mismatch
194
+ * is logged rather than silently accepted; it is not a validation gate.
195
+ *
196
+ * Returns a list of human-readable problems; empty means no detected mismatch.
197
+ */
198
+ function schemaMismatches(value, schema) {
199
+ const problems = [];
200
+ const s = schema;
201
+ if (s.type === "object" || s.properties || s.required) {
202
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
203
+ return [`expected an object, got ${Array.isArray(value) ? "array" : typeof value}`];
204
+ }
205
+ const obj = value;
206
+ const required = Array.isArray(s.required) ? s.required : [];
207
+ for (const key of required) {
208
+ if (!(key in obj) || obj[key] === undefined) {
209
+ problems.push(`missing required property "${key}"`);
210
+ }
211
+ }
212
+ const props = s.properties ?? {};
213
+ for (const [key, propSchema] of Object.entries(props)) {
214
+ if (!(key in obj) || obj[key] === undefined || obj[key] === null)
215
+ continue;
216
+ const expected = propSchema?.type;
217
+ if (typeof expected === "string" && !jsonTypeMatches(obj[key], expected)) {
218
+ problems.push(`property "${key}" expected ${expected}, got ${jsonTypeOf(obj[key])}`);
219
+ }
220
+ }
221
+ }
222
+ else if (typeof s.type === "string" && !jsonTypeMatches(value, s.type)) {
223
+ problems.push(`expected ${s.type}, got ${jsonTypeOf(value)}`);
224
+ }
225
+ return problems;
226
+ }
227
+ function jsonTypeOf(value) {
228
+ if (value === null)
229
+ return "null";
230
+ if (Array.isArray(value))
231
+ return "array";
232
+ return typeof value;
233
+ }
234
+ function jsonTypeMatches(value, expected) {
235
+ switch (expected) {
236
+ case "object":
237
+ return typeof value === "object" && value !== null && !Array.isArray(value);
238
+ case "array":
239
+ return Array.isArray(value);
240
+ case "string":
241
+ return typeof value === "string";
242
+ case "number":
243
+ return typeof value === "number";
244
+ case "integer":
245
+ return typeof value === "number" && Number.isInteger(value);
246
+ case "boolean":
247
+ return typeof value === "boolean";
248
+ case "null":
249
+ return value === null;
250
+ default:
251
+ return true; // unknown/unsupported type keyword - don't flag
252
+ }
253
+ }
@@ -5,6 +5,28 @@
5
5
  * enforced natively, so the result is always `{ degraded: [] }`. A harness
6
6
  * without a native sandbox is covered by a process wrapper when the host has
7
7
  * one (`wrappers`; see `prepareAgentSpawn` in sandbox-wrapper.ts).
8
+ *
9
+ * Two kinds of `degraded` entry:
10
+ * - unenforced: nobody enforces the opinion (the harness cannot, and no host
11
+ * wrapper is active). Strict mode refuses the node on these.
12
+ * - wrapped: sweny keeps the opinion itself, but not natively (for example a
13
+ * watchdog in place of a native turn limit). Reported, never refused.
14
+ */
15
+ import type { HarnessCapabilities, NodePolicy, PolicyGateResult, PolicyWrappers, ToolClass } from "./types.js";
16
+ /** `strict` refuses a node whose opinions the harness cannot honor; `warn` runs it and reports `degraded`. */
17
+ export type HarnessPolicyMode = "strict" | "warn";
18
+ /**
19
+ * Resolve the harness policy mode: the explicit value (`--harness-policy`),
20
+ * else `SWENY_HARNESS_POLICY`, else `strict` under GitHub Actions and `warn`
21
+ * everywhere else. An unknown value warns and uses that default.
8
22
  */
9
- import type { HarnessCapabilities, NodePolicy, PolicyGateResult, PolicyWrappers } from "./types.js";
23
+ export declare function resolveHarnessPolicy(env: Record<string, string | undefined>, explicit?: string, logger?: {
24
+ warn(msg: string): void;
25
+ }): HarnessPolicyMode;
26
+ /** Every portable tool class, in a stable order. */
27
+ export declare const TOOL_CLASSES: readonly ToolClass[];
28
+ /** Is `name` a portable tool class (`tools.deny: [write]`)? */
29
+ export declare function isToolClass(name: string): name is ToolClass;
30
+ /** The tool classes a harness can deny natively. */
31
+ export declare function nativeDenyClasses(caps: HarnessCapabilities): ToolClass[];
10
32
  export declare function policyGate(caps: HarnessCapabilities, policy: NodePolicy, wrappers?: PolicyWrappers): PolicyGateResult;
@@ -5,37 +5,95 @@
5
5
  * enforced natively, so the result is always `{ degraded: [] }`. A harness
6
6
  * without a native sandbox is covered by a process wrapper when the host has
7
7
  * one (`wrappers`; see `prepareAgentSpawn` in sandbox-wrapper.ts).
8
+ *
9
+ * Two kinds of `degraded` entry:
10
+ * - unenforced: nobody enforces the opinion (the harness cannot, and no host
11
+ * wrapper is active). Strict mode refuses the node on these.
12
+ * - wrapped: sweny keeps the opinion itself, but not natively (for example a
13
+ * watchdog in place of a native turn limit). Reported, never refused.
14
+ */
15
+ /**
16
+ * Resolve the harness policy mode: the explicit value (`--harness-policy`),
17
+ * else `SWENY_HARNESS_POLICY`, else `strict` under GitHub Actions and `warn`
18
+ * everywhere else. An unknown value warns and uses that default.
8
19
  */
20
+ export function resolveHarnessPolicy(env, explicit, logger) {
21
+ const fallback = env.GITHUB_ACTIONS === "true" ? "strict" : "warn";
22
+ const raw = (explicit ?? env.SWENY_HARNESS_POLICY ?? "").trim().toLowerCase();
23
+ if (raw === "strict" || raw === "warn")
24
+ return raw;
25
+ if (raw !== "")
26
+ logger?.warn(`harness policy "${raw}" is not one of strict|warn; using ${fallback}`);
27
+ return fallback;
28
+ }
29
+ /** Every portable tool class, in a stable order. */
30
+ export const TOOL_CLASSES = ["shell", "write", "edit", "net", "subagent"];
31
+ /** Is `name` a portable tool class (`tools.deny: [write]`)? */
32
+ export function isToolClass(name) {
33
+ return TOOL_CLASSES.includes(name);
34
+ }
35
+ /** The tool classes a harness can deny natively. */
36
+ export function nativeDenyClasses(caps) {
37
+ const base = caps.builtinDeny === "by-name" || caps.builtinDeny === "by-class"
38
+ ? TOOL_CLASSES
39
+ : caps.builtinDeny === "shell-only"
40
+ ? ["shell"]
41
+ : [];
42
+ const set = new Set([...base, ...(caps.denyClasses ?? [])]);
43
+ return TOOL_CLASSES.filter((c) => set.has(c));
44
+ }
9
45
  export function policyGate(caps, policy, wrappers = {}) {
10
- const degraded = [];
46
+ const unenforced = [];
47
+ const wrapped = [];
11
48
  if (policy.readOnly && caps.readOnly === "none" && !wrappers.readOnlyMount) {
12
- degraded.push("read-only: harness cannot enforce it and no read-only mount is active");
49
+ unenforced.push("read-only: harness cannot enforce it and no read-only mount is active");
13
50
  }
14
51
  if (policy.deny.length > 0) {
15
- if (caps.builtinDeny === "none") {
16
- degraded.push(`deny [${policy.deny.join(", ")}]: harness cannot deny built-in tools`);
17
- }
18
- else if (caps.builtinDeny === "shell-only") {
19
- const unmappable = policy.deny.filter((c) => c !== "shell");
20
- if (unmappable.length > 0) {
21
- degraded.push(`deny [${unmappable.join(", ")}]: harness can only deny the shell tool`);
52
+ const native = nativeDenyClasses(caps);
53
+ const unmappable = policy.deny.filter((c) => !native.includes(c));
54
+ if (unmappable.length > 0) {
55
+ const list = unmappable.join(", ");
56
+ if (native.length === 0) {
57
+ unenforced.push(`deny [${list}]: harness cannot deny built-in tools`);
58
+ }
59
+ else if (native.length === 1 && native[0] === "shell") {
60
+ unenforced.push(`deny [${list}]: harness can only deny the shell tool`);
61
+ }
62
+ else {
63
+ unenforced.push(`deny [${list}]: harness can only deny [${native.join(", ")}]`);
22
64
  }
23
65
  }
24
66
  }
25
- if ((policy.nativeDeny?.length ?? 0) > 0 && caps.builtinDeny === "none") {
26
- degraded.push("disallowed_tools: harness cannot deny built-in tools");
67
+ if ((policy.nativeDeny?.length ?? 0) > 0) {
68
+ if (caps.builtinDeny === "none") {
69
+ unenforced.push("disallowed_tools: harness cannot deny built-in tools");
70
+ }
71
+ else if (caps.builtinDeny !== "by-name") {
72
+ // Only a by-name harness can take tool names verbatim. Adapters translate
73
+ // the names they know into classes first; what is left is unenforced.
74
+ unenforced.push(`disallowed_tools [${policy.nativeDeny.join(", ")}]: harness has no tools by these names`);
75
+ }
27
76
  }
28
77
  if (policy.egress.length > 0 && !caps.sandbox.network && !wrappers.egress) {
29
- degraded.push("egress allowlist: harness has no network sandbox and no egress wrapper is active");
78
+ unenforced.push("egress allowlist: harness has no network sandbox and no egress wrapper is active");
30
79
  }
31
80
  const sandboxMode = policy.sandbox ?? "off";
32
81
  let sandboxGap;
33
82
  if (sandboxMode !== "off" && !(caps.sandbox.fs && caps.sandbox.network) && !wrappers.sandbox) {
34
83
  sandboxGap = "sandbox: harness has no native fs and network sandbox and no process sandbox wrapper is available";
35
- degraded.push(sandboxGap);
84
+ unenforced.push(sandboxGap);
85
+ }
86
+ if (policy.maxTurns !== undefined && caps.turnLimit !== "native") {
87
+ if (caps.turnLimit === "watchdog") {
88
+ wrapped.push(`max_turns: no native turn limit; sweny stops the run after ${policy.maxTurns} tool calls`);
89
+ }
90
+ else {
91
+ unenforced.push("max_turns: harness has no turn limit and no watchdog");
92
+ }
36
93
  }
37
- if (degraded.length > 0 && policy.strict) {
38
- return { degraded, refuse: `strict policy: ${degraded.join("; ")}` };
94
+ const degraded = [...unenforced, ...wrapped];
95
+ if (unenforced.length > 0 && policy.strict) {
96
+ return { degraded, refuse: `strict policy: ${unenforced.join("; ")}` };
39
97
  }
40
98
  if (sandboxGap && sandboxMode === "strict") {
41
99
  return {
@@ -4,12 +4,18 @@
4
4
  * `ClaudeClient.ask` / `ClaudeClient.evaluate` built before the harness seam
5
5
  * (#330); prompts.test.ts pins them against the old builders.
6
6
  */
7
- import type { Claude, Logger } from "../types.js";
7
+ import type { Claude, JSONSchema, Logger } from "../types.js";
8
8
  import type { AgentHarness } from "./types.js";
9
9
  /** @internal Registers the legacy object behind a compat harness. */
10
10
  export declare function registerLegacyClaude(harness: AgentHarness, claude: Claude): void;
11
11
  /** @internal The legacy `Claude` behind a compat harness, if any. */
12
12
  export declare function legacyClaudeOf(harness: AgentHarness): Claude | undefined;
13
+ /**
14
+ * The prompt for a node run: the instruction, the context fenced as untrusted
15
+ * data (#360), and the output schema when the node declares one. Every
16
+ * adapter sends this string, so fencing holds on every harness.
17
+ */
18
+ export declare function buildNodePrompt(instruction: string, context: Record<string, unknown>, outputSchema?: JSONSchema): string;
13
19
  /** The `ask` prompt: the instruction, then the fenced context when there is any. */
14
20
  export declare function buildAskPrompt(instruction: string, context: Record<string, unknown>): string;
15
21
  /**
@@ -16,6 +16,22 @@ export function registerLegacyClaude(harness, claude) {
16
16
  export function legacyClaudeOf(harness) {
17
17
  return LEGACY.get(harness);
18
18
  }
19
+ /**
20
+ * The prompt for a node run: the instruction, the context fenced as untrusted
21
+ * data (#360), and the output schema when the node declares one. Every
22
+ * adapter sends this string, so fencing holds on every harness.
23
+ */
24
+ export function buildNodePrompt(instruction, context, outputSchema) {
25
+ return [
26
+ `## Instruction\n\n${instruction}`,
27
+ `## Context\n\n${fenceUntrustedJson(context, "context")}`,
28
+ outputSchema
29
+ ? `## Required Output\n\nYou MUST end with a JSON object matching this schema:\n\`\`\`json\n${JSON.stringify(outputSchema, null, 2)}\n\`\`\``
30
+ : "",
31
+ ]
32
+ .filter(Boolean)
33
+ .join("\n\n");
34
+ }
19
35
  /** The `ask` prompt: the instruction, then the fenced context when there is any. */
20
36
  export function buildAskPrompt(instruction, context) {
21
37
  return [instruction, Object.keys(context).length > 0 ? `\nContext:\n${fenceUntrustedJson(context, "context")}` : ""]
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * AgentHarness: the seam between the executor and whatever agent runs a node.
3
3
  *
4
- * Design: research/2026-09-30-sota/harness-design.md, section 1. Claude Code
5
- * (`ClaudeCodeHarness`) is the only adapter today. `evaluate` and `ask` are
4
+ * Design: research/2026-09-30-sota/harness-design.md, section 1. Adapters:
5
+ * Claude Code (`ClaudeCodeHarness`) and Codex (`CodexHarness`). `evaluate` and `ask` are
6
6
  * not adapter methods: core builds the prompt and calls `complete()` (see
7
7
  * prompts.ts), so every adapter gets routing and judging for free.
8
8
  *
@@ -18,6 +18,12 @@ export interface HarnessCapabilities {
18
18
  /** full = built-in tool calls are visible with status; skill-only = only skill tools are traced. */
19
19
  toolTrace: "full" | "skill-only";
20
20
  builtinDeny: "by-name" | "by-class" | "shell-only" | "none";
21
+ /**
22
+ * Tool classes the harness can deny natively on top of what `builtinDeny`
23
+ * implies (Codex: shell-only by name, but web search and subagents are
24
+ * config switches). See `nativeDenyClasses()` in policy.ts.
25
+ */
26
+ denyClasses?: ToolClass[];
21
27
  mcp: {
22
28
  inject: boolean;
23
29
  exclusive: "native" | "home-isolation" | "none";
@@ -49,6 +55,11 @@ export interface NodePolicy {
49
55
  egress: string[];
50
56
  /** Refuse instead of degrade when an opinion cannot be enforced. */
51
57
  strict: boolean;
58
+ /**
59
+ * Turn budget for the run. Set by adapters whose `turnLimit` is not native,
60
+ * so the gate reports how the budget is kept (watchdog) or that it is not.
61
+ */
62
+ maxTurns?: number;
52
63
  /**
53
64
  * Process sandbox mode (`SWENY_SANDBOX`, #360). `auto` and `strict` need fs
54
65
  * and network containment: native, or a process wrapper (sandbox-wrapper.ts).
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * AgentHarness: the seam between the executor and whatever agent runs a node.
3
3
  *
4
- * Design: research/2026-09-30-sota/harness-design.md, section 1. Claude Code
5
- * (`ClaudeCodeHarness`) is the only adapter today. `evaluate` and `ask` are
4
+ * Design: research/2026-09-30-sota/harness-design.md, section 1. Adapters:
5
+ * Claude Code (`ClaudeCodeHarness`) and Codex (`CodexHarness`). `evaluate` and `ask` are
6
6
  * not adapter methods: core builds the prompt and calls `complete()` (see
7
7
  * prompts.ts), so every adapter gets routing and judging for free.
8
8
  *
package/dist/index.d.ts CHANGED
@@ -27,8 +27,8 @@ export { EVALUATOR_KINDS, EVAL_POLICIES, REQUIRES_ON_FAIL, NODE_ON_FAIL, MCP_TRA
27
27
  export { consoleLogger } from "./types.js";
28
28
  export { execute, RouteEvaluationError } from "./executor.js";
29
29
  export type { ExecuteOptions } from "./executor.js";
30
- export { createHarness, claudeCompat, asClaude, policyGate, ClaudeCodeHarness } from "./harness/index.js";
31
- export type { AgentHarness, HarnessCapabilities, HarnessId, HarnessInfo, HarnessRunRequest, HarnessRunResult, NodePolicy, PolicyGateResult, PolicyWrappers, ToolClass, ClaudeCodeHarnessOptions, } from "./harness/index.js";
30
+ export { createHarness, claudeCompat, asClaude, policyGate, ClaudeCodeHarness, CodexHarness, SUPPORTED_AGENTS, } from "./harness/index.js";
31
+ export type { AgentHarness, HarnessCapabilities, HarnessId, HarnessInfo, HarnessRunRequest, HarnessRunResult, NodePolicy, PolicyGateResult, PolicyWrappers, ToolClass, ClaudeCodeHarnessOptions, CodexHarnessOptions, } from "./harness/index.js";
32
32
  export { ClaudeClient, resolveAuthEnv } from "./harness/claude-code.js";
33
33
  export type { ClaudeClientOptions, SwenyAuthMode, ResolveAuthEnvOpts } from "./harness/claude-code.js";
34
34
  export { resolveExecutionModel } from "./model.js";
package/dist/index.js CHANGED
@@ -27,8 +27,8 @@ export { EVALUATOR_KINDS, EVAL_POLICIES, REQUIRES_ON_FAIL, NODE_ON_FAIL, MCP_TRA
27
27
  export { consoleLogger } from "./types.js";
28
28
  // Executor
29
29
  export { execute, RouteEvaluationError } from "./executor.js";
30
- // Agent harness (the seam) and its Claude Code adapter
31
- export { createHarness, claudeCompat, asClaude, policyGate, ClaudeCodeHarness } from "./harness/index.js";
30
+ // Agent harness (the seam) and its adapters: Claude Code and Codex
31
+ export { createHarness, claudeCompat, asClaude, policyGate, ClaudeCodeHarness, CodexHarness, SUPPORTED_AGENTS, } from "./harness/index.js";
32
32
  // Claude client (back-compat: ClaudeClient is ClaudeCodeHarness, @deprecated)
33
33
  export { ClaudeClient, resolveAuthEnv } from "./harness/claude-code.js";
34
34
  // Execution model resolution