@sweny-ai/core 0.1.164 → 0.1.166
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 +130 -13
- package/dist/agent-env.js +276 -20
- package/dist/cli/config-file.js +2 -0
- package/dist/cli/main.d.ts +4 -1
- package/dist/cli/main.js +72 -3
- package/dist/cli/resume.d.ts +56 -0
- package/dist/cli/resume.js +182 -0
- package/dist/executor.d.ts +7 -0
- package/dist/executor.js +87 -11
- package/dist/harness/acp.d.ts +6 -1
- package/dist/harness/acp.js +35 -17
- package/dist/harness/claude-code.d.ts +12 -0
- package/dist/harness/claude-code.js +37 -14
- package/dist/harness/codex.d.ts +5 -1
- package/dist/harness/codex.js +30 -16
- package/dist/harness/pi.d.ts +17 -1
- package/dist/harness/pi.js +54 -16
- package/dist/harness/policy.js +16 -0
- package/dist/harness/types.d.ts +16 -0
- package/dist/journal.d.ts +307 -0
- package/dist/journal.js +885 -0
- package/dist/schema.d.ts +156 -136
- package/dist/schema.js +21 -0
- package/dist/types.d.ts +15 -3
- package/package.json +1 -1
package/dist/harness/acp.js
CHANGED
|
@@ -48,7 +48,7 @@ import { spawn } from "node:child_process";
|
|
|
48
48
|
import * as fs from "node:fs";
|
|
49
49
|
import * as path from "node:path";
|
|
50
50
|
import { consoleLogger } from "../types.js";
|
|
51
|
-
import { parseList, reportWithheldEnv, resolveEnvScope, resolveSandboxMode, scopeAgentEnv, withPushBlocked, } from "../agent-env.js";
|
|
51
|
+
import { finishAgentEnv, heldCredentials, parseList, reportWithheldEnv, resolveEnvScope, resolveSandboxMode, scopeAgentEnv, withPushBlocked, } from "../agent-env.js";
|
|
52
52
|
import { ACP_CAPABILITIES } from "./capabilities.js";
|
|
53
53
|
import { policyGate, resolveHarnessPolicy, TOOL_CLASSES } from "./policy.js";
|
|
54
54
|
import { buildNodePrompt } from "./prompts.js";
|
|
@@ -265,20 +265,28 @@ export class AcpHarness {
|
|
|
265
265
|
}
|
|
266
266
|
return { ok: true, version: this.agentVersion };
|
|
267
267
|
}
|
|
268
|
-
/**
|
|
269
|
-
|
|
268
|
+
/**
|
|
269
|
+
* Env for the agent process: scoped like Claude Code's, with only the auth
|
|
270
|
+
* vars the operator named. Skill credentials are dropped either way
|
|
271
|
+
* ({@link finishAgentEnv}) unless the node granted them with `agent_env`; an
|
|
272
|
+
* operator-named auth var stays, and the gate counts it if it is a write token.
|
|
273
|
+
*/
|
|
274
|
+
buildEnv(access) {
|
|
270
275
|
const full = Object.fromEntries(Object.entries(process.env).filter((e) => e[1] != null));
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
276
|
+
const passthrough = this.envPassthrough ?? parseList(process.env.SWENY_ENV_PASSTHROUGH);
|
|
277
|
+
let env = full;
|
|
278
|
+
if (resolveEnvScope(process.env, this.envScope, this.logger)) {
|
|
279
|
+
const scoped = scopeAgentEnv(full, {
|
|
280
|
+
extraVars: access?.envVars ?? [],
|
|
281
|
+
passthrough,
|
|
282
|
+
authVars: this.authVars,
|
|
283
|
+
prefixes: ["LC_"],
|
|
284
|
+
logger: this.logger,
|
|
285
|
+
});
|
|
286
|
+
reportWithheldEnv(scoped.withheld, this.logger);
|
|
287
|
+
env = scoped.env;
|
|
288
|
+
}
|
|
289
|
+
return finishAgentEnv(env, { access, keep: this.authVars, passthrough, logger: this.logger }).env;
|
|
282
290
|
}
|
|
283
291
|
/** The node policy, with the harness policy mode and the watchdog budget applied. */
|
|
284
292
|
compilePolicy(req, maxTurns) {
|
|
@@ -684,7 +692,15 @@ export class AcpHarness {
|
|
|
684
692
|
async run(req) {
|
|
685
693
|
const maxTurns = req.maxTurns ?? this.maxTurns;
|
|
686
694
|
const mode = req.policy?.sandbox ?? resolveSandboxMode(process.env, this.sandboxMode, this.logger);
|
|
687
|
-
const
|
|
695
|
+
const env = withPushBlocked(this.buildEnv(req.agentAccess), req.agentAccess?.noPush);
|
|
696
|
+
const compiled = this.compilePolicy(req, maxTurns);
|
|
697
|
+
const held = heldCredentials(env, req.agentAccess?.withhold);
|
|
698
|
+
const policy = {
|
|
699
|
+
...compiled,
|
|
700
|
+
sandbox: mode,
|
|
701
|
+
...(held.length > 0 ? { agentCredentials: held } : {}),
|
|
702
|
+
...(req.agentAccess?.noPush && !compiled.readOnly ? { stagedWrite: true } : {}),
|
|
703
|
+
};
|
|
688
704
|
// ACP has no sandbox of its own: any mode but off needs the process wrapper.
|
|
689
705
|
const wrapper = mode !== "off" && this.sandboxWrapper !== null
|
|
690
706
|
? (this.sandboxWrapper ?? (await defaultSandboxWrapper()).wrapper)
|
|
@@ -716,7 +732,6 @@ export class AcpHarness {
|
|
|
716
732
|
const readOnly = policy.readOnly;
|
|
717
733
|
// Permission answers: the node's deny list, plus legacy `disallowed_tools` names that map to a class.
|
|
718
734
|
const deny = new Set([...policy.deny, ...translateDenyNames(policy.nativeDeny).classes]);
|
|
719
|
-
const env = withPushBlocked(this.buildEnv(req.agentAccess?.envVars), req.agentAccess?.noPush);
|
|
720
735
|
let bridge;
|
|
721
736
|
try {
|
|
722
737
|
// Dry run: external MCP servers cannot be classified per tool, so only
|
|
@@ -885,6 +900,8 @@ export class AcpHarness {
|
|
|
885
900
|
return failClosed(pre.reason);
|
|
886
901
|
// A judge call is a dry run with nothing allowed: same gate, same wrapper.
|
|
887
902
|
const mode = resolveSandboxMode(process.env, this.sandboxMode, this.logger);
|
|
903
|
+
const env = this.buildEnv();
|
|
904
|
+
const held = heldCredentials(env);
|
|
888
905
|
const policy = {
|
|
889
906
|
readOnly: true,
|
|
890
907
|
// Nothing to deny at the gate: there is no MCP server and every permission request is rejected below.
|
|
@@ -892,6 +909,7 @@ export class AcpHarness {
|
|
|
892
909
|
egress: [],
|
|
893
910
|
strict: this.policyMode === "strict",
|
|
894
911
|
sandbox: mode,
|
|
912
|
+
...(held.length > 0 ? { agentCredentials: held } : {}),
|
|
895
913
|
};
|
|
896
914
|
const wrapper = mode !== "off" && this.sandboxWrapper !== null
|
|
897
915
|
? (this.sandboxWrapper ?? (await defaultSandboxWrapper()).wrapper)
|
|
@@ -899,7 +917,7 @@ export class AcpHarness {
|
|
|
899
917
|
const prep = await prepareAgentSpawn({
|
|
900
918
|
caps: this.capabilities,
|
|
901
919
|
policy,
|
|
902
|
-
spawn: { command: this.agent.command, args: this.agent.args, env
|
|
920
|
+
spawn: { command: this.agent.command, args: this.agent.args, env, cwd: this.cwd },
|
|
903
921
|
wrapper: wrapper ?? null,
|
|
904
922
|
harnessEgress: this.egress,
|
|
905
923
|
env: process.env,
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
import type { Claude, Tool, ToolContext, Logger, McpServerConfig } from "../types.js";
|
|
12
12
|
import { type SandboxMode } from "../agent-env.js";
|
|
13
13
|
import type { AgentHarness, HarnessCapabilities, HarnessCompleteRequest, HarnessInfo, HarnessRunRequest, HarnessRunResult, NodePolicy, ToolClass } from "./types.js";
|
|
14
|
+
import { type HarnessPolicyMode } from "./policy.js";
|
|
14
15
|
import { CLAUDE_CODE_CAPABILITIES } from "./capabilities.js";
|
|
15
16
|
import { buildEvaluatePrompt } from "./prompts.js";
|
|
16
17
|
import { parseToolResultContent, summarizeToolError } from "./parse.js";
|
|
@@ -132,6 +133,14 @@ export interface ClaudeCodeHarnessOptions {
|
|
|
132
133
|
command: string;
|
|
133
134
|
args: string[];
|
|
134
135
|
};
|
|
136
|
+
/**
|
|
137
|
+
* Harness policy mode. Claude Code honors every node opinion natively, so
|
|
138
|
+
* this only decides one case: a staged or dry-run write node whose agent
|
|
139
|
+
* runs unsandboxed is refused under `strict` (its push block is env and git
|
|
140
|
+
* hooks, which a deliberate agent can undo) and reported under `warn`.
|
|
141
|
+
* Default: `warn`; the CLI passes `resolveHarnessPolicy()` (strict under GitHub Actions).
|
|
142
|
+
*/
|
|
143
|
+
policy?: HarnessPolicyMode;
|
|
135
144
|
}
|
|
136
145
|
export declare class ClaudeCodeHarness implements Claude, AgentHarness {
|
|
137
146
|
readonly id: "claude-code";
|
|
@@ -155,6 +164,7 @@ export declare class ClaudeCodeHarness implements Claude, AgentHarness {
|
|
|
155
164
|
private toolBridge;
|
|
156
165
|
private toolBridgeShim;
|
|
157
166
|
private authProbe;
|
|
167
|
+
private policyMode;
|
|
158
168
|
constructor(opts?: ClaudeCodeHarnessOptions);
|
|
159
169
|
/**
|
|
160
170
|
* Build env for the Claude Code subprocess (#360). Applies auth precedence
|
|
@@ -164,6 +174,8 @@ export declare class ClaudeCodeHarness implements Claude, AgentHarness {
|
|
|
164
174
|
* `extraVars` (the node's declared skill env vars) and the operator
|
|
165
175
|
* passthrough list, and the withheld names (never values) are reported once
|
|
166
176
|
* per process ({@link reportWithheldEnv}). When off, the full env passes through as before.
|
|
177
|
+
* Either way, skill credentials are then dropped ({@link finishAgentEnv}):
|
|
178
|
+
* only the names the node granted with `agent_env` reach the agent.
|
|
167
179
|
*/
|
|
168
180
|
private buildEnv;
|
|
169
181
|
/** Which harness (and SDK version) produced a result. */
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
import { query, createSdkMcpServer, tool as sdkTool } from "@anthropic-ai/claude-agent-sdk";
|
|
12
12
|
import { createRequire } from "node:module";
|
|
13
13
|
import { consoleLogger } from "../types.js";
|
|
14
|
-
import { reportWithheldEnv, parseList, resolveEnvScope, scopeAgentEnv, withPushBlocked, resolveAgentSandbox, } from "../agent-env.js";
|
|
14
|
+
import { AGENT_AUTH_VARS, finishAgentEnv, reportWithheldEnv, parseList, resolveEnvScope, scopeAgentEnv, withPushBlocked, resolveAgentSandbox, } from "../agent-env.js";
|
|
15
15
|
import { policyGate } from "./policy.js";
|
|
16
16
|
import { CLAUDE_CODE_CAPABILITIES } from "./capabilities.js";
|
|
17
17
|
import { ask as coreAsk, evaluate as coreEvaluate, buildEvaluatePrompt, buildNodePrompt } from "./prompts.js";
|
|
@@ -222,6 +222,7 @@ export class ClaudeCodeHarness {
|
|
|
222
222
|
toolBridge;
|
|
223
223
|
toolBridgeShim;
|
|
224
224
|
authProbe;
|
|
225
|
+
policyMode;
|
|
225
226
|
constructor(opts = {}) {
|
|
226
227
|
this.model = opts.model;
|
|
227
228
|
this.maxTurns = opts.maxTurns ?? 20;
|
|
@@ -238,6 +239,7 @@ export class ClaudeCodeHarness {
|
|
|
238
239
|
this.toolBridge = opts.toolBridge ?? process.env.SWENY_TOOL_BRIDGE === "1";
|
|
239
240
|
this.toolBridgeShim = opts.toolBridgeShim;
|
|
240
241
|
this.authProbe = opts.authProbe ?? (() => claudeCodeAuth(process.env));
|
|
242
|
+
this.policyMode = opts.policy ?? "warn";
|
|
241
243
|
}
|
|
242
244
|
/**
|
|
243
245
|
* Build env for the Claude Code subprocess (#360). Applies auth precedence
|
|
@@ -247,19 +249,20 @@ export class ClaudeCodeHarness {
|
|
|
247
249
|
* `extraVars` (the node's declared skill env vars) and the operator
|
|
248
250
|
* passthrough list, and the withheld names (never values) are reported once
|
|
249
251
|
* per process ({@link reportWithheldEnv}). When off, the full env passes through as before.
|
|
252
|
+
* Either way, skill credentials are then dropped ({@link finishAgentEnv}):
|
|
253
|
+
* only the names the node granted with `agent_env` reach the agent.
|
|
250
254
|
*/
|
|
251
|
-
buildEnv(
|
|
255
|
+
buildEnv(access) {
|
|
252
256
|
const full = Object.fromEntries(Object.entries(process.env).filter((e) => e[1] != null));
|
|
253
257
|
const authed = resolveAuthEnv(full, { logger: this.logger });
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
extraVars,
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
return env;
|
|
258
|
+
const passthrough = this.envPassthrough ?? parseList(process.env.SWENY_ENV_PASSTHROUGH);
|
|
259
|
+
let env = authed;
|
|
260
|
+
if (resolveEnvScope(process.env, this.envScope, this.logger)) {
|
|
261
|
+
const scoped = scopeAgentEnv(authed, { extraVars: access?.envVars ?? [], passthrough, logger: this.logger });
|
|
262
|
+
reportWithheldEnv(scoped.withheld, this.logger);
|
|
263
|
+
env = scoped.env;
|
|
264
|
+
}
|
|
265
|
+
return finishAgentEnv(env, { access, keep: AGENT_AUTH_VARS, passthrough, logger: this.logger }).env;
|
|
263
266
|
}
|
|
264
267
|
/** Which harness (and SDK version) produced a result. */
|
|
265
268
|
info() {
|
|
@@ -306,18 +309,27 @@ export class ClaudeCodeHarness {
|
|
|
306
309
|
}
|
|
307
310
|
const readOnly = !!req.readOnly || policy.readOnly;
|
|
308
311
|
const disallowedTools = compileClaudeCodeDeny(policy, req.disallowedTools ?? []);
|
|
312
|
+
const degraded = [...gate.degraded];
|
|
309
313
|
const result = await this.runQuery({
|
|
310
314
|
...req,
|
|
311
315
|
readOnly,
|
|
312
316
|
disallowedTools: disallowedTools.length > 0 ? disallowedTools : undefined,
|
|
313
317
|
strictMcp: readOnly || policy.strict || policy.exclusiveMcp === true,
|
|
314
318
|
sandboxMode: policy.sandbox,
|
|
315
|
-
agentAccess: {
|
|
319
|
+
agentAccess: {
|
|
320
|
+
envVars: req.agentAccess?.envVars ?? [],
|
|
321
|
+
domains: policy.egress,
|
|
322
|
+
withhold: req.agentAccess?.withhold,
|
|
323
|
+
noPush: req.agentAccess?.noPush,
|
|
324
|
+
},
|
|
325
|
+
stagedWrite: !!req.agentAccess?.noPush && !readOnly,
|
|
326
|
+
strict: policy.strict || this.policyMode === "strict",
|
|
327
|
+
degraded,
|
|
316
328
|
});
|
|
317
329
|
return {
|
|
318
330
|
...result,
|
|
319
331
|
harness: this.info(),
|
|
320
|
-
degraded
|
|
332
|
+
degraded,
|
|
321
333
|
...(this.lastPolicyFacts ? { policy: this.lastPolicyFacts } : {}),
|
|
322
334
|
};
|
|
323
335
|
}
|
|
@@ -356,6 +368,17 @@ export class ClaudeCodeHarness {
|
|
|
356
368
|
const prefix = process.env.GITHUB_ACTIONS === "true" ? "::warning title=SWEny agent sandbox::" : "";
|
|
357
369
|
this.logger.warn(`${prefix}${sandbox.warning}`);
|
|
358
370
|
}
|
|
371
|
+
// #442 + security review 2026-09-30 finding 3: with no sandbox, a staged
|
|
372
|
+
// write node's push block is only env and git hooks the agent can undo.
|
|
373
|
+
if (opts.stagedWrite && !sandbox.settings) {
|
|
374
|
+
const gap = "no push (staged run): blocked by env and git hooks only; with no sandbox a deliberate agent can undo them and push";
|
|
375
|
+
if (opts.strict) {
|
|
376
|
+
const error = `strict policy: ${gap}`;
|
|
377
|
+
this.logger.error(error);
|
|
378
|
+
return { status: "failed", data: { error, refused: true }, toolCalls: [] };
|
|
379
|
+
}
|
|
380
|
+
opts.degraded?.push(gap);
|
|
381
|
+
}
|
|
359
382
|
// Tool-call accounting (Fix #1).
|
|
360
383
|
//
|
|
361
384
|
// Stream-driven recording, correlated by tool_use_id:
|
|
@@ -397,7 +420,7 @@ export class ClaudeCodeHarness {
|
|
|
397
420
|
// Build prompt. Context (workflow input such as issues/alerts, plus prior
|
|
398
421
|
// node outputs) is fenced as untrusted data (#360).
|
|
399
422
|
const prompt = buildNodePrompt(instruction, context, outputSchema);
|
|
400
|
-
const env = withPushBlocked(this.buildEnv(agentAccess
|
|
423
|
+
const env = withPushBlocked(this.buildEnv(agentAccess), agentAccess?.noPush);
|
|
401
424
|
let response = "";
|
|
402
425
|
// CC-08: when the SDK populates typed structured output (because we passed
|
|
403
426
|
// an `outputFormat`), prefer it over re-parsing JSON out of the free-text
|
package/dist/harness/codex.d.ts
CHANGED
|
@@ -161,7 +161,11 @@ export declare class CodexHarness implements AgentHarness {
|
|
|
161
161
|
}>;
|
|
162
162
|
/** `codex --version`, checked against {@link MIN_CODEX_VERSION}. Cached per instance. */
|
|
163
163
|
private checkCli;
|
|
164
|
-
/**
|
|
164
|
+
/**
|
|
165
|
+
* Env for the Codex process: scoped like Claude Code's, with Codex's auth
|
|
166
|
+
* vars instead of Anthropic's. Skill credentials are dropped either way
|
|
167
|
+
* ({@link finishAgentEnv}) unless the node granted them with `agent_env`.
|
|
168
|
+
*/
|
|
165
169
|
private buildEnv;
|
|
166
170
|
/** The node policy after `disallowed_tools` names are translated into Codex classes. */
|
|
167
171
|
private compilePolicy;
|
package/dist/harness/codex.js
CHANGED
|
@@ -28,7 +28,7 @@ import * as fs from "node:fs";
|
|
|
28
28
|
import * as os from "node:os";
|
|
29
29
|
import * as path from "node:path";
|
|
30
30
|
import { consoleLogger } from "../types.js";
|
|
31
|
-
import { CODEX_AUTH_VARS, CODEX_ENV_PREFIXES, checkSandboxSupport, parseList, reportWithheldEnv, resolveCodexAuthEnv, resolveEnvScope, resolveSandboxMode, scopeAgentEnv, withPushBlocked, } from "../agent-env.js";
|
|
31
|
+
import { CODEX_AUTH_VARS, CODEX_ENV_PREFIXES, checkSandboxSupport, finishAgentEnv, heldCredentials, parseList, reportWithheldEnv, resolveCodexAuthEnv, resolveEnvScope, resolveSandboxMode, scopeAgentEnv, withPushBlocked, } from "../agent-env.js";
|
|
32
32
|
import { CODEX_CAPABILITIES } from "./capabilities.js";
|
|
33
33
|
import { policyGate, resolveHarnessPolicy } from "./policy.js";
|
|
34
34
|
import { buildNodePrompt } from "./prompts.js";
|
|
@@ -367,21 +367,28 @@ export class CodexHarness {
|
|
|
367
367
|
});
|
|
368
368
|
return this.preflightResult;
|
|
369
369
|
}
|
|
370
|
-
/**
|
|
371
|
-
|
|
370
|
+
/**
|
|
371
|
+
* Env for the Codex process: scoped like Claude Code's, with Codex's auth
|
|
372
|
+
* vars instead of Anthropic's. Skill credentials are dropped either way
|
|
373
|
+
* ({@link finishAgentEnv}) unless the node granted them with `agent_env`.
|
|
374
|
+
*/
|
|
375
|
+
buildEnv(access) {
|
|
372
376
|
const full = Object.fromEntries(Object.entries(process.env).filter((e) => e[1] != null));
|
|
373
377
|
const authed = resolveCodexAuthEnv(full);
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
378
|
+
const passthrough = this.envPassthrough ?? parseList(process.env.SWENY_ENV_PASSTHROUGH);
|
|
379
|
+
let env = authed;
|
|
380
|
+
if (resolveEnvScope(process.env, this.envScope, this.logger)) {
|
|
381
|
+
const scoped = scopeAgentEnv(authed, {
|
|
382
|
+
extraVars: access?.envVars ?? [],
|
|
383
|
+
passthrough,
|
|
384
|
+
authVars: CODEX_AUTH_VARS,
|
|
385
|
+
prefixes: CODEX_ENV_PREFIXES,
|
|
386
|
+
logger: this.logger,
|
|
387
|
+
});
|
|
388
|
+
reportWithheldEnv(scoped.withheld, this.logger);
|
|
389
|
+
env = scoped.env;
|
|
390
|
+
}
|
|
391
|
+
return finishAgentEnv(env, { access, keep: CODEX_AUTH_VARS, passthrough, logger: this.logger }).env;
|
|
385
392
|
}
|
|
386
393
|
/** The node policy after `disallowed_tools` names are translated into Codex classes. */
|
|
387
394
|
compilePolicy(req, maxTurns) {
|
|
@@ -729,7 +736,15 @@ export class CodexHarness {
|
|
|
729
736
|
async run(req) {
|
|
730
737
|
const maxTurns = req.maxTurns ?? this.maxTurns;
|
|
731
738
|
const mode = req.policy?.sandbox ?? resolveSandboxMode(process.env, this.sandboxMode, this.logger);
|
|
732
|
-
const
|
|
739
|
+
const env = withPushBlocked(this.buildEnv(req.agentAccess), req.agentAccess?.noPush);
|
|
740
|
+
const compiled = this.compilePolicy(req, maxTurns);
|
|
741
|
+
const held = heldCredentials(env, req.agentAccess?.withhold);
|
|
742
|
+
const policy = {
|
|
743
|
+
...compiled,
|
|
744
|
+
sandbox: mode,
|
|
745
|
+
...(held.length > 0 ? { agentCredentials: held } : {}),
|
|
746
|
+
...(req.agentAccess?.noPush && !compiled.readOnly ? { stagedWrite: true } : {}),
|
|
747
|
+
};
|
|
733
748
|
// Codex's network is one switch, not a host list, so any sandbox mode but
|
|
734
749
|
// off needs the process wrapper for full containment.
|
|
735
750
|
const needsWrapper = mode !== "off" && !(this.capabilities.sandbox.fs && this.capabilities.sandbox.network);
|
|
@@ -788,7 +803,6 @@ export class CodexHarness {
|
|
|
788
803
|
sandbox = s.mode;
|
|
789
804
|
}
|
|
790
805
|
}
|
|
791
|
-
const env = withPushBlocked(this.buildEnv(req.agentAccess?.envVars), req.agentAccess?.noPush);
|
|
792
806
|
if (wrapper && !env.CODEX_API_KEY && !env.CODEX_ACCESS_TOKEN && !this.loginWarned) {
|
|
793
807
|
this.loginWarned = true;
|
|
794
808
|
this.logger.warn("Codex runs inside the sandbox wrapper, where a stored `codex login` is not readable; set CODEX_API_KEY or OPENAI_API_KEY.");
|
package/dist/harness/pi.d.ts
CHANGED
|
@@ -52,6 +52,13 @@ export declare function piBackendHosts(env?: Record<string, string | undefined>,
|
|
|
52
52
|
export interface PiHarnessOptions {
|
|
53
53
|
/** Model passed to `--model`. Free text (`provider/id`, `id:thinking`); sweny has no model opinion. */
|
|
54
54
|
model?: string;
|
|
55
|
+
/**
|
|
56
|
+
* The model provider (`pi_provider`, passed as `--provider`). Only this
|
|
57
|
+
* provider's credential reaches pi. Default: `SWENY_PI_PROVIDER`, else the
|
|
58
|
+
* model's `provider/` prefix, else the one provider whose key is set.
|
|
59
|
+
* Required when several providers' keys are set and the model names none.
|
|
60
|
+
*/
|
|
61
|
+
provider?: string;
|
|
55
62
|
/** Tool-call budget the watchdog enforces when a node sets no `max_turns` (default: 20). */
|
|
56
63
|
maxTurns?: number;
|
|
57
64
|
/** Working directory (default: process.cwd()). */
|
|
@@ -145,6 +152,7 @@ export declare class PiHarness implements AgentHarness {
|
|
|
145
152
|
readonly defaultJudgeModel = "";
|
|
146
153
|
readonly logger: Logger;
|
|
147
154
|
private model;
|
|
155
|
+
private provider;
|
|
148
156
|
private maxTurns;
|
|
149
157
|
private cwd;
|
|
150
158
|
private defaultContext;
|
|
@@ -174,7 +182,15 @@ export declare class PiHarness implements AgentHarness {
|
|
|
174
182
|
ok: false;
|
|
175
183
|
reason: string;
|
|
176
184
|
}>;
|
|
177
|
-
/**
|
|
185
|
+
/** Which provider's credential this call gets ({@link resolvePiProvider}). */
|
|
186
|
+
private resolveProvider;
|
|
187
|
+
/**
|
|
188
|
+
* Env for the pi process: scoped like Claude Code's, with ONE provider's
|
|
189
|
+
* credentials instead of Anthropic's (`providerVars`, from
|
|
190
|
+
* {@link resolvePiProvider}). Every other provider key is dropped, scoped or
|
|
191
|
+
* not, since pi's bash tool inherits this env. Skill credentials are dropped
|
|
192
|
+
* too ({@link finishAgentEnv}) unless the node granted them with `agent_env`.
|
|
193
|
+
*/
|
|
178
194
|
private buildEnv;
|
|
179
195
|
/** The node policy after `disallowed_tools` names are translated into pi classes. */
|
|
180
196
|
private compilePolicy;
|
package/dist/harness/pi.js
CHANGED
|
@@ -34,7 +34,7 @@ import * as fs from "node:fs";
|
|
|
34
34
|
import * as os from "node:os";
|
|
35
35
|
import * as path from "node:path";
|
|
36
36
|
import { consoleLogger } from "../types.js";
|
|
37
|
-
import { PI_AUTH_VARS, PI_ENV_PREFIXES, parseList, reportWithheldEnv, resolveEnvScope, resolveSandboxMode, scopeAgentEnv, withPushBlocked, } from "../agent-env.js";
|
|
37
|
+
import { PI_AUTH_VARS, PI_ENV_PREFIXES, finishAgentEnv, heldCredentials, parseList, reportWithheldEnv, resolveEnvScope, resolvePiProvider, resolveSandboxMode, scopeAgentEnv, withPushBlocked, } from "../agent-env.js";
|
|
38
38
|
import { PI_CAPABILITIES } from "./capabilities.js";
|
|
39
39
|
import { policyGate, resolveHarnessPolicy } from "./policy.js";
|
|
40
40
|
import { buildNodePrompt } from "./prompts.js";
|
|
@@ -314,6 +314,7 @@ export class PiHarness {
|
|
|
314
314
|
defaultJudgeModel = "";
|
|
315
315
|
logger;
|
|
316
316
|
model;
|
|
317
|
+
provider;
|
|
317
318
|
maxTurns;
|
|
318
319
|
cwd;
|
|
319
320
|
defaultContext;
|
|
@@ -335,6 +336,7 @@ export class PiHarness {
|
|
|
335
336
|
version = "unknown";
|
|
336
337
|
constructor(opts = {}) {
|
|
337
338
|
this.model = opts.model;
|
|
339
|
+
this.provider = opts.provider;
|
|
338
340
|
this.maxTurns = opts.maxTurns ?? 20;
|
|
339
341
|
this.cwd = opts.cwd ?? process.cwd();
|
|
340
342
|
this.logger = opts.logger ?? consoleLogger;
|
|
@@ -387,23 +389,38 @@ export class PiHarness {
|
|
|
387
389
|
});
|
|
388
390
|
return this.preflightResult;
|
|
389
391
|
}
|
|
390
|
-
/**
|
|
391
|
-
|
|
392
|
+
/** Which provider's credential this call gets ({@link resolvePiProvider}). */
|
|
393
|
+
resolveProvider(model) {
|
|
394
|
+
return resolvePiProvider(process.env, model, this.provider);
|
|
395
|
+
}
|
|
396
|
+
/**
|
|
397
|
+
* Env for the pi process: scoped like Claude Code's, with ONE provider's
|
|
398
|
+
* credentials instead of Anthropic's (`providerVars`, from
|
|
399
|
+
* {@link resolvePiProvider}). Every other provider key is dropped, scoped or
|
|
400
|
+
* not, since pi's bash tool inherits this env. Skill credentials are dropped
|
|
401
|
+
* too ({@link finishAgentEnv}) unless the node granted them with `agent_env`.
|
|
402
|
+
*/
|
|
403
|
+
buildEnv(access, providerVars) {
|
|
392
404
|
const full = Object.fromEntries(Object.entries(process.env).filter((e) => e[1] != null));
|
|
405
|
+
const passthrough = this.envPassthrough ?? parseList(process.env.SWENY_ENV_PASSTHROUGH);
|
|
393
406
|
let env = full;
|
|
394
407
|
if (resolveEnvScope(process.env, this.envScope, this.logger)) {
|
|
395
408
|
const scoped = scopeAgentEnv(full, {
|
|
396
|
-
extraVars,
|
|
397
|
-
passthrough
|
|
398
|
-
authVars:
|
|
409
|
+
extraVars: access?.envVars ?? [],
|
|
410
|
+
passthrough,
|
|
411
|
+
authVars: providerVars,
|
|
399
412
|
prefixes: PI_ENV_PREFIXES,
|
|
400
413
|
logger: this.logger,
|
|
401
414
|
});
|
|
402
415
|
reportWithheldEnv(scoped.withheld, this.logger);
|
|
403
416
|
env = scoped.env;
|
|
404
417
|
}
|
|
418
|
+
// Other providers' keys never reach pi, even when scoping is off, passed through or granted.
|
|
419
|
+
const otherProviders = PI_AUTH_VARS.filter((v) => !providerVars.includes(v));
|
|
420
|
+
env = Object.fromEntries(Object.entries(env).filter(([k]) => !otherProviders.includes(k)));
|
|
421
|
+
const finished = finishAgentEnv(env, { access, keep: providerVars, passthrough, logger: this.logger }).env;
|
|
405
422
|
// pi must not phone home or look for updates; the agent dir is set per spawn.
|
|
406
|
-
return { ...
|
|
423
|
+
return { ...finished, PI_OFFLINE: "1", PI_SKIP_VERSION_CHECK: "1", PI_TELEMETRY: "0" };
|
|
407
424
|
}
|
|
408
425
|
/** The node policy after `disallowed_tools` names are translated into pi classes. */
|
|
409
426
|
compilePolicy(req, maxTurns) {
|
|
@@ -429,7 +446,7 @@ export class PiHarness {
|
|
|
429
446
|
};
|
|
430
447
|
}
|
|
431
448
|
/** argv shared by run() and complete(); the prompt travels as an RPC command, never in argv. */
|
|
432
|
-
baseArgs(model, withMcp) {
|
|
449
|
+
baseArgs(model, withMcp, provider) {
|
|
433
450
|
const args = [
|
|
434
451
|
...this.piCommand.args,
|
|
435
452
|
"--mode",
|
|
@@ -448,6 +465,8 @@ export class PiHarness {
|
|
|
448
465
|
];
|
|
449
466
|
if (withMcp)
|
|
450
467
|
args.push("-e", "builtin:mcp");
|
|
468
|
+
if (provider)
|
|
469
|
+
args.push("--provider", provider);
|
|
451
470
|
if (model)
|
|
452
471
|
args.push("--model", model);
|
|
453
472
|
return args;
|
|
@@ -715,7 +734,16 @@ export class PiHarness {
|
|
|
715
734
|
const maxTurns = req.maxTurns ?? this.maxTurns;
|
|
716
735
|
const mode = req.policy?.sandbox ?? resolveSandboxMode(process.env, this.sandboxMode, this.logger);
|
|
717
736
|
const compiled = this.compilePolicy(req, maxTurns);
|
|
718
|
-
const
|
|
737
|
+
const model = req.model ?? this.model;
|
|
738
|
+
const provider = this.resolveProvider(model);
|
|
739
|
+
const env = withPushBlocked(this.buildEnv(req.agentAccess, provider.vars), req.agentAccess?.noPush);
|
|
740
|
+
const held = heldCredentials(env, req.agentAccess?.withhold);
|
|
741
|
+
const policy = {
|
|
742
|
+
...compiled.policy,
|
|
743
|
+
sandbox: mode,
|
|
744
|
+
...(held.length > 0 ? { agentCredentials: held } : {}),
|
|
745
|
+
...(req.agentAccess?.noPush && !compiled.policy.readOnly ? { stagedWrite: true } : {}),
|
|
746
|
+
};
|
|
719
747
|
const needsWrapper = mode !== "off";
|
|
720
748
|
const wrapper = needsWrapper && this.sandboxWrapper !== null
|
|
721
749
|
? (this.sandboxWrapper ?? (await defaultSandboxWrapper()).wrapper)
|
|
@@ -736,6 +764,10 @@ export class PiHarness {
|
|
|
736
764
|
};
|
|
737
765
|
if (gate.refuse)
|
|
738
766
|
return refused(gate.refuse);
|
|
767
|
+
if (provider.error) {
|
|
768
|
+
this.logger.error(provider.error);
|
|
769
|
+
return tag({ status: "failed", data: { error: provider.error }, toolCalls: [] });
|
|
770
|
+
}
|
|
739
771
|
const pre = await this.preflight();
|
|
740
772
|
if (!pre.ok) {
|
|
741
773
|
this.logger.error(pre.reason);
|
|
@@ -746,8 +778,6 @@ export class PiHarness {
|
|
|
746
778
|
if (readOnly)
|
|
747
779
|
for (const c of ["shell", "write", "edit"])
|
|
748
780
|
deny.add(c);
|
|
749
|
-
const env = withPushBlocked(this.buildEnv(req.agentAccess?.envVars), req.agentAccess?.noPush);
|
|
750
|
-
const model = req.model ?? this.model;
|
|
751
781
|
let bridge;
|
|
752
782
|
let agentDir;
|
|
753
783
|
let prepCleanup;
|
|
@@ -810,8 +840,12 @@ export class PiHarness {
|
|
|
810
840
|
else if (excluded.size > 0) {
|
|
811
841
|
toolArgs.push("--exclude-tools", [...excluded].join(","));
|
|
812
842
|
}
|
|
813
|
-
const args = [
|
|
814
|
-
|
|
843
|
+
const args = [
|
|
844
|
+
...this.baseArgs(model, Object.keys(entries).length > 0, provider.explicit ? provider.provider : undefined),
|
|
845
|
+
...toolArgs,
|
|
846
|
+
];
|
|
847
|
+
// Only the selected provider's host: its key is the only one pi holds.
|
|
848
|
+
const backendHosts = piBackendHosts(env, model);
|
|
815
849
|
// The bridge's loopback endpoint (wrapped runs only): the one extra host the shim needs.
|
|
816
850
|
const harnessEgress = [...backendHosts, ...(bridge?.egress ?? [])];
|
|
817
851
|
if (mode !== "off" && backendHosts.length === 0 && !this.egressWarned) {
|
|
@@ -928,16 +962,20 @@ export class PiHarness {
|
|
|
928
962
|
const pre = await this.preflight();
|
|
929
963
|
if (!pre.ok)
|
|
930
964
|
return failClosed(pre.reason);
|
|
965
|
+
// An empty model (the judge default) means pi's own default model.
|
|
966
|
+
const model = req.model || this.model;
|
|
967
|
+
const provider = this.resolveProvider(model);
|
|
968
|
+
if (provider.error)
|
|
969
|
+
return failClosed(provider.error);
|
|
931
970
|
let agentDir;
|
|
932
971
|
try {
|
|
933
972
|
agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "sweny-pi-"));
|
|
934
973
|
this.writeAgentDir(agentDir);
|
|
935
|
-
const env = { ...this.buildEnv(), PI_CODING_AGENT_DIR: agentDir };
|
|
974
|
+
const env = { ...this.buildEnv(undefined, provider.vars), PI_CODING_AGENT_DIR: agentDir };
|
|
936
975
|
const out = await this.exec({
|
|
937
976
|
spawn: {
|
|
938
977
|
command: this.piCommand.command,
|
|
939
|
-
|
|
940
|
-
args: [...this.baseArgs(req.model || this.model, false), "--no-tools"],
|
|
978
|
+
args: [...this.baseArgs(model, false, provider.explicit ? provider.provider : undefined), "--no-tools"],
|
|
941
979
|
env,
|
|
942
980
|
cwd: this.cwd,
|
|
943
981
|
},
|
package/dist/harness/policy.js
CHANGED
|
@@ -48,6 +48,14 @@ export function policyGate(caps, policy, wrappers = {}) {
|
|
|
48
48
|
if (policy.readOnly && caps.readOnly === "none" && !wrappers.readOnlyMount) {
|
|
49
49
|
unenforced.push("read-only: harness cannot enforce it and no read-only mount is active");
|
|
50
50
|
}
|
|
51
|
+
// A read-only mount stops file writes, not API writes: an agent that holds a
|
|
52
|
+
// write credential and can reach the network can still change remote state
|
|
53
|
+
// without asking (security review 2026-09-30, finding 1).
|
|
54
|
+
const held = policy.agentCredentials ?? [];
|
|
55
|
+
if (policy.readOnly && caps.readOnly === "none" && held.length > 0) {
|
|
56
|
+
unenforced.push(`read-only: the agent process holds write credentials (${held.join(", ")}) and network access; ` +
|
|
57
|
+
`a read-only filesystem mount does not stop API writes`);
|
|
58
|
+
}
|
|
51
59
|
if (policy.deny.length > 0) {
|
|
52
60
|
const native = nativeDenyClasses(caps);
|
|
53
61
|
const unmappable = policy.deny.filter((c) => !native.includes(c));
|
|
@@ -84,6 +92,14 @@ export function policyGate(caps, policy, wrappers = {}) {
|
|
|
84
92
|
unenforced.push("egress allowlist: harness has no network sandbox and no egress wrapper is active");
|
|
85
93
|
}
|
|
86
94
|
const sandboxMode = policy.sandbox ?? "off";
|
|
95
|
+
// #442 + finding 3: a staged write node's push block is env and git hooks.
|
|
96
|
+
// Only fs and network containment stops a deliberate agent from undoing it.
|
|
97
|
+
if (policy.stagedWrite) {
|
|
98
|
+
const contained = sandboxMode !== "off" && ((caps.sandbox.fs && caps.sandbox.network) || wrappers.sandbox === true);
|
|
99
|
+
if (!contained) {
|
|
100
|
+
unenforced.push("no push (staged run): blocked by env and git hooks only; with no sandbox a deliberate agent can undo them and push");
|
|
101
|
+
}
|
|
102
|
+
}
|
|
87
103
|
let sandboxGap;
|
|
88
104
|
if (sandboxMode !== "off" && !(caps.sandbox.fs && caps.sandbox.network) && !wrappers.sandbox) {
|
|
89
105
|
sandboxGap = "sandbox: harness has no native fs and network sandbox and no process sandbox wrapper is available";
|
package/dist/harness/types.d.ts
CHANGED
|
@@ -81,6 +81,22 @@ export interface NodePolicy {
|
|
|
81
81
|
* Unset means `off`: no requirement.
|
|
82
82
|
*/
|
|
83
83
|
sandbox?: "off" | "auto" | "strict";
|
|
84
|
+
/**
|
|
85
|
+
* Credential names the agent process holds (a node's `agent_env` grant, or
|
|
86
|
+
* a harness auth var that is also a write token, such as an ACP agent run
|
|
87
|
+
* on `GITHUB_TOKEN`). A read-only node on a harness with no native read-only
|
|
88
|
+
* mode cannot be held read-only by a filesystem mount alone while the agent
|
|
89
|
+
* holds one of these and can reach the network: reported, refused under
|
|
90
|
+
* `strict`. Set by adapters from the finished env.
|
|
91
|
+
*/
|
|
92
|
+
agentCredentials?: string[];
|
|
93
|
+
/**
|
|
94
|
+
* A write node in a staged or dry run (#442). Its push block is env and git
|
|
95
|
+
* hooks the agent can undo, so without fs and network containment (native,
|
|
96
|
+
* or the process wrapper) it is reported, and refused under `strict`. Set by
|
|
97
|
+
* adapters, which know whether the run is sandboxed.
|
|
98
|
+
*/
|
|
99
|
+
stagedWrite?: boolean;
|
|
84
100
|
}
|
|
85
101
|
/** Process-level wrappers the host provides (OS sandbox, egress proxy, read-only mount). */
|
|
86
102
|
export interface PolicyWrappers {
|