@sweny-ai/core 0.1.165 → 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.
@@ -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
- /** Env for the agent process: scoped like Claude Code's, with only the auth vars the operator named. */
269
- buildEnv(extraVars = []) {
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
- if (!resolveEnvScope(process.env, this.envScope, this.logger))
272
- return full;
273
- const { env, withheld } = scopeAgentEnv(full, {
274
- extraVars,
275
- passthrough: this.envPassthrough ?? parseList(process.env.SWENY_ENV_PASSTHROUGH),
276
- authVars: this.authVars,
277
- prefixes: ["LC_"],
278
- logger: this.logger,
279
- });
280
- reportWithheldEnv(withheld, this.logger);
281
- return env;
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 policy = { ...this.compilePolicy(req, maxTurns), sandbox: mode };
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: this.buildEnv(), cwd: this.cwd },
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(extraVars = []) {
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
- if (!resolveEnvScope(process.env, this.envScope, this.logger))
255
- return authed;
256
- const { env, withheld } = scopeAgentEnv(authed, {
257
- extraVars,
258
- passthrough: this.envPassthrough ?? parseList(process.env.SWENY_ENV_PASSTHROUGH),
259
- logger: this.logger,
260
- });
261
- reportWithheldEnv(withheld, this.logger);
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: { envVars: req.agentAccess?.envVars ?? [], domains: policy.egress, noPush: req.agentAccess?.noPush },
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: gate.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?.envVars), agentAccess?.noPush);
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
@@ -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
- /** Env for the Codex process: scoped like Claude Code's, with Codex's auth vars instead of Anthropic's. */
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;
@@ -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
- /** Env for the Codex process: scoped like Claude Code's, with Codex's auth vars instead of Anthropic's. */
371
- buildEnv(extraVars = []) {
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
- if (!resolveEnvScope(process.env, this.envScope, this.logger))
375
- return authed;
376
- const { env, withheld } = scopeAgentEnv(authed, {
377
- extraVars,
378
- passthrough: this.envPassthrough ?? parseList(process.env.SWENY_ENV_PASSTHROUGH),
379
- authVars: CODEX_AUTH_VARS,
380
- prefixes: CODEX_ENV_PREFIXES,
381
- logger: this.logger,
382
- });
383
- reportWithheldEnv(withheld, this.logger);
384
- return env;
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 policy = { ...this.compilePolicy(req, maxTurns), sandbox: mode };
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.");
@@ -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
- /** Env for the pi process: scoped like Claude Code's, with pi's provider credentials instead of Anthropic's. */
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;
@@ -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
- /** Env for the pi process: scoped like Claude Code's, with pi's provider credentials instead of Anthropic's. */
391
- buildEnv(extraVars = []) {
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: this.envPassthrough ?? parseList(process.env.SWENY_ENV_PASSTHROUGH),
398
- authVars: PI_AUTH_VARS,
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 { ...env, PI_OFFLINE: "1", PI_SKIP_VERSION_CHECK: "1", PI_TELEMETRY: "0" };
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 policy = { ...compiled.policy, sandbox: mode };
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 = [...this.baseArgs(model, Object.keys(entries).length > 0), ...toolArgs];
814
- const backendHosts = piBackendHosts(process.env, model);
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
- // An empty model (the judge default) means pi's own default model.
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
  },
@@ -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";
@@ -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 {