@sweny-ai/core 0.1.165 → 0.1.167
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.js +9 -3
- package/dist/cli/try-fixture.json +35 -0
- package/dist/cli/try.d.ts +89 -0
- package/dist/cli/try.js +284 -0
- package/dist/cli/version-check.js +3 -0
- package/dist/executor.js +35 -9
- 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/schema.d.ts +156 -136
- package/dist/schema.js +21 -0
- package/dist/types.d.ts +15 -3
- package/package.json +2 -2
|
@@ -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 {
|