@sweny-ai/core 0.1.152 → 0.1.153

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.
@@ -150,10 +150,63 @@ export declare function scopeAgentEnv(source: Record<string, string | undefined>
150
150
  * values are always dropped. Pure: never mutates `source`.
151
151
  */
152
152
  export declare function buildAgentEnv(source: Record<string, string | undefined>, opts?: BuildAgentEnvOpts): Record<string, string>;
153
+ /**
154
+ * Write tokens withheld from the agent when pushes are blocked. `gh`, the
155
+ * GitHub/GitLab REST APIs and git's HTTPS auth all read these; skill tools
156
+ * run in the sweny process, not the agent, so reads through skills still work.
157
+ * The harness's own model credentials are never in this list.
158
+ */
159
+ export declare const PUSH_TOKEN_VARS: readonly string[];
160
+ /** The remote name every push is pointed at under {@link withPushBlocked}. It never exists. */
161
+ export declare const NO_PUSH_REMOTE = "sweny-no-push";
162
+ /**
163
+ * A sweny-owned directory holding a `pre-push` hook and a `GIT_ASKPASS`
164
+ * program that both refuse, an ssh wrapper that refuses pushes, and an empty
165
+ * `gh` config dir. Created once per process under the OS temp dir.
166
+ */
167
+ export declare function noPushDir(): string;
168
+ /**
169
+ * Git config entries (command scope, via `GIT_CONFIG_COUNT`) that make every
170
+ * push fail while leaving fetch, commit and diff alone:
171
+ *
172
+ * - `credential.helper` set to empty resets the helper list, so no stored
173
+ * credential (keychain, `gh auth git-credential`, store) reaches git;
174
+ * - `remote.pushDefault` names a remote that does not exist and
175
+ * `push.default=nothing` refuses a bare `git push`;
176
+ * - `url.<unusable>.pushInsteadOf` rewrites every https/ssh/git/file/absolute
177
+ * path push URL to a scheme git has no helper for;
178
+ * - `core.hooksPath` points at a sweny `pre-push` hook that exits 1, which
179
+ * also covers a remote with an explicit `pushurl` (git ignores
180
+ * pushInsteadOf there). Repo hooks do not run in these nodes.
181
+ */
182
+ export declare function noPushGitConfig(dir: string): Array<[string, string]>;
183
+ /**
184
+ * The agent env with pushes blocked (#442). Applied to every node's agent
185
+ * env when a run is staged or a dry run, on every harness, whether or not env
186
+ * scoping is on. Withholds {@link PUSH_TOKEN_VARS}, appends
187
+ * {@link noPushGitConfig} after any `GIT_CONFIG_*` entries already present,
188
+ * sets `GIT_ASKPASS` to a refusing program, turns off git's terminal prompt,
189
+ * routes git's ssh through a wrapper that refuses `git-receive-pack` (so an
190
+ * ssh push fails even to a remote with an explicit `pushurl` under
191
+ * `--no-verify`; ssh fetches still run the operator's own ssh command), and
192
+ * gives `gh` an empty config dir so a stored `gh auth login` is not used.
193
+ *
194
+ * Not a sandbox: a process that rewrites its own env or git config and
195
+ * finds a credential on disk (an ssh key without a passphrase, a keychain
196
+ * entry) is outside what env can stop. The sandbox wrapper hides credential
197
+ * files such as `~/.ssh`. `enabled` false returns `env` unchanged. Pure apart from
198
+ * creating {@link noPushDir} once.
199
+ */
200
+ export declare function withPushBlocked(env: Record<string, string>, enabled: boolean | undefined): Record<string, string>;
153
201
  /** What one node's agent call may see: env var names and network hosts. */
154
202
  export interface AgentAccess {
155
203
  envVars: string[];
156
204
  domains: string[];
205
+ /**
206
+ * The run is staged or a dry run (#442): the harness applies
207
+ * {@link withPushBlocked} to the agent env, so no push can leave the node.
208
+ */
209
+ noPush?: boolean;
157
210
  }
158
211
  /** Hosts reachable by sandboxed commands in every node: source hosting + package registries. */
159
212
  export declare const DEFAULT_SANDBOX_DOMAINS: readonly string[];
package/dist/agent-env.js CHANGED
@@ -22,8 +22,8 @@
22
22
  * SWENY_SANDBOX_ALLOWED_DOMAINS / sandbox-allowed-domains extra hosts for sandboxed commands
23
23
  */
24
24
  import { execFileSync } from "node:child_process";
25
- import { existsSync } from "node:fs";
26
- import { homedir } from "node:os";
25
+ import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
26
+ import { homedir, tmpdir } from "node:os";
27
27
  import * as path from "node:path";
28
28
  // ─── Scoped env ──────────────────────────────────────────────────
29
29
  /**
@@ -429,6 +429,132 @@ export function buildAgentEnv(source, opts = {}) {
429
429
  }
430
430
  return out;
431
431
  }
432
+ // ─── No push under --stage / --dry-run (#442) ────────────────────
433
+ /**
434
+ * Write tokens withheld from the agent when pushes are blocked. `gh`, the
435
+ * GitHub/GitLab REST APIs and git's HTTPS auth all read these; skill tools
436
+ * run in the sweny process, not the agent, so reads through skills still work.
437
+ * The harness's own model credentials are never in this list.
438
+ */
439
+ export const PUSH_TOKEN_VARS = [
440
+ "GITHUB_TOKEN",
441
+ "GH_TOKEN",
442
+ "GH_ENTERPRISE_TOKEN",
443
+ "GITHUB_ENTERPRISE_TOKEN",
444
+ "GITHUB_PAT",
445
+ "GITLAB_TOKEN",
446
+ "GL_TOKEN",
447
+ "CI_JOB_TOKEN",
448
+ "BITBUCKET_TOKEN",
449
+ // git transport credentials: the ssh agent socket and any askpass program
450
+ "SSH_AUTH_SOCK",
451
+ "SSH_ASKPASS",
452
+ "GIT_ASKPASS",
453
+ ];
454
+ /** The remote name every push is pointed at under {@link withPushBlocked}. It never exists. */
455
+ export const NO_PUSH_REMOTE = "sweny-no-push";
456
+ /** URL prefixes rewritten to an unusable scheme for pushes only (fetches are untouched). */
457
+ const PUSH_URL_PREFIXES = ["https://", "http://", "ssh://", "git://", "git@", "file://", "/"];
458
+ const NO_PUSH_MESSAGE = "sweny: git push is blocked under --stage and --dry-run (#442)";
459
+ let noPushDirCache;
460
+ /** Single-quote for sh: git runs `GIT_SSH_COMMAND` through the shell. */
461
+ function shQuote(s) {
462
+ return `'${s.replace(/'/g, `'\\''`)}'`;
463
+ }
464
+ /**
465
+ * A sweny-owned directory holding a `pre-push` hook and a `GIT_ASKPASS`
466
+ * program that both refuse, an ssh wrapper that refuses pushes, and an empty
467
+ * `gh` config dir. Created once per process under the OS temp dir.
468
+ */
469
+ export function noPushDir() {
470
+ if (noPushDirCache && existsSync(path.join(noPushDirCache, "hooks", "pre-push")))
471
+ return noPushDirCache;
472
+ const dir = mkdtempSync(path.join(tmpdir(), "sweny-no-push-"));
473
+ mkdirSync(path.join(dir, "hooks"));
474
+ mkdirSync(path.join(dir, "gh"));
475
+ const refuse = `#!/bin/sh\necho "${NO_PUSH_MESSAGE}" >&2\nexit 1\n`;
476
+ writeFileSync(path.join(dir, "hooks", "pre-push"), refuse, { mode: 0o755 });
477
+ writeFileSync(path.join(dir, "askpass"), refuse, { mode: 0o755 });
478
+ // git runs `<ssh> host "git-receive-pack '<path>'"` to push and
479
+ // git-upload-pack to fetch: refuse the first, hand everything else to the
480
+ // operator's own ssh command (or plain ssh).
481
+ const ssh = `#!/bin/sh\n` +
482
+ `for a in "$@"; do case "$a" in *receive-pack*) echo "${NO_PUSH_MESSAGE}" >&2; exit 1;; esac; done\n` +
483
+ `if [ -n "$SWENY_NO_PUSH_SSH" ]; then exec sh -c "$SWENY_NO_PUSH_SSH \\"\\$@\\"" ssh "$@"; fi\n` +
484
+ `exec ssh "$@"\n`;
485
+ writeFileSync(path.join(dir, "ssh"), ssh, { mode: 0o755 });
486
+ noPushDirCache = dir;
487
+ return dir;
488
+ }
489
+ /**
490
+ * Git config entries (command scope, via `GIT_CONFIG_COUNT`) that make every
491
+ * push fail while leaving fetch, commit and diff alone:
492
+ *
493
+ * - `credential.helper` set to empty resets the helper list, so no stored
494
+ * credential (keychain, `gh auth git-credential`, store) reaches git;
495
+ * - `remote.pushDefault` names a remote that does not exist and
496
+ * `push.default=nothing` refuses a bare `git push`;
497
+ * - `url.<unusable>.pushInsteadOf` rewrites every https/ssh/git/file/absolute
498
+ * path push URL to a scheme git has no helper for;
499
+ * - `core.hooksPath` points at a sweny `pre-push` hook that exits 1, which
500
+ * also covers a remote with an explicit `pushurl` (git ignores
501
+ * pushInsteadOf there). Repo hooks do not run in these nodes.
502
+ */
503
+ export function noPushGitConfig(dir) {
504
+ return [
505
+ ["credential.helper", ""],
506
+ ["remote.pushDefault", NO_PUSH_REMOTE],
507
+ ["push.default", "nothing"],
508
+ ["core.hooksPath", path.join(dir, "hooks")],
509
+ ...PUSH_URL_PREFIXES.map((p) => [`url.${NO_PUSH_REMOTE}://blocked/.pushInsteadOf`, p]),
510
+ ];
511
+ }
512
+ /**
513
+ * The agent env with pushes blocked (#442). Applied to every node's agent
514
+ * env when a run is staged or a dry run, on every harness, whether or not env
515
+ * scoping is on. Withholds {@link PUSH_TOKEN_VARS}, appends
516
+ * {@link noPushGitConfig} after any `GIT_CONFIG_*` entries already present,
517
+ * sets `GIT_ASKPASS` to a refusing program, turns off git's terminal prompt,
518
+ * routes git's ssh through a wrapper that refuses `git-receive-pack` (so an
519
+ * ssh push fails even to a remote with an explicit `pushurl` under
520
+ * `--no-verify`; ssh fetches still run the operator's own ssh command), and
521
+ * gives `gh` an empty config dir so a stored `gh auth login` is not used.
522
+ *
523
+ * Not a sandbox: a process that rewrites its own env or git config and
524
+ * finds a credential on disk (an ssh key without a passphrase, a keychain
525
+ * entry) is outside what env can stop. The sandbox wrapper hides credential
526
+ * files such as `~/.ssh`. `enabled` false returns `env` unchanged. Pure apart from
527
+ * creating {@link noPushDir} once.
528
+ */
529
+ export function withPushBlocked(env, enabled) {
530
+ if (!enabled)
531
+ return env;
532
+ const dir = noPushDir();
533
+ const out = {};
534
+ for (const [k, v] of Object.entries(env)) {
535
+ if (PUSH_TOKEN_VARS.includes(k))
536
+ continue;
537
+ out[k] = v;
538
+ }
539
+ const base = Number.parseInt(out.GIT_CONFIG_COUNT ?? "0", 10);
540
+ let n = Number.isFinite(base) && base > 0 ? base : 0;
541
+ for (const [key, value] of noPushGitConfig(dir)) {
542
+ out[`GIT_CONFIG_KEY_${n}`] = key;
543
+ out[`GIT_CONFIG_VALUE_${n}`] = value;
544
+ n++;
545
+ }
546
+ out.GIT_CONFIG_COUNT = String(n);
547
+ const ownSsh = env.GIT_SSH_COMMAND ?? (env.GIT_SSH ? shQuote(env.GIT_SSH) : undefined);
548
+ if (ownSsh)
549
+ out.SWENY_NO_PUSH_SSH = ownSsh;
550
+ out.GIT_SSH_COMMAND = shQuote(path.join(dir, "ssh"));
551
+ out.GIT_SSH_VARIANT = "ssh";
552
+ out.GIT_ASKPASS = path.join(dir, "askpass");
553
+ out.GIT_TERMINAL_PROMPT = "0";
554
+ out.GH_CONFIG_DIR = path.join(dir, "gh");
555
+ out.GH_PROMPT_DISABLED = "1";
556
+ return out;
557
+ }
432
558
  /** Hosts reachable by sandboxed commands in every node: source hosting + package registries. */
433
559
  export const DEFAULT_SANDBOX_DOMAINS = [
434
560
  "github.com",
package/dist/executor.js CHANGED
@@ -281,7 +281,12 @@ export async function execute(workflow, input, options) {
281
281
  // compiles them or reports them in `degraded`.
282
282
  const denyClasses = (node.tools?.deny ?? []).filter(isToolClass);
283
283
  let degradedLogged = false;
284
- const agentAccess = resolveAgentAccess(node.skills, skills);
284
+ // #442: a staged or dry run cannot push. The harness blocks git push and
285
+ // withholds write tokens in this node's agent env (see withPushBlocked).
286
+ const agentAccess = {
287
+ ...resolveAgentAccess(node.skills, skills),
288
+ ...(stageOutputs ? { noPush: true } : {}),
289
+ };
285
290
  // #365: a node or workflow that declares `permissions` or `outputs` gets
286
291
  // one portable policy, compiled by each adapter. The gate also runs here so
287
292
  // a strict refusal holds on every harness, before any spend. Nodes that
@@ -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, } from "../agent-env.js";
51
+ import { 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";
@@ -714,7 +714,7 @@ export class AcpHarness {
714
714
  const readOnly = policy.readOnly;
715
715
  // Permission answers: the node's deny list, plus legacy `disallowed_tools` names that map to a class.
716
716
  const deny = new Set([...policy.deny, ...translateDenyNames(policy.nativeDeny).classes]);
717
- const env = this.buildEnv(req.agentAccess?.envVars);
717
+ const env = withPushBlocked(this.buildEnv(req.agentAccess?.envVars), req.agentAccess?.noPush);
718
718
  let bridge;
719
719
  try {
720
720
  // Dry run: external MCP servers cannot be classified per tool, so only
@@ -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, resolveAgentSandbox, } from "../agent-env.js";
14
+ import { 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";
@@ -310,7 +310,7 @@ export class ClaudeCodeHarness {
310
310
  disallowedTools: disallowedTools.length > 0 ? disallowedTools : undefined,
311
311
  strictMcp: readOnly || policy.strict || policy.exclusiveMcp === true,
312
312
  sandboxMode: policy.sandbox,
313
- agentAccess: { envVars: req.agentAccess?.envVars ?? [], domains: policy.egress },
313
+ agentAccess: { envVars: req.agentAccess?.envVars ?? [], domains: policy.egress, noPush: req.agentAccess?.noPush },
314
314
  });
315
315
  return { ...result, harness: this.info(), degraded: gate.degraded };
316
316
  }
@@ -380,7 +380,7 @@ export class ClaudeCodeHarness {
380
380
  // Build prompt. Context (workflow input such as issues/alerts, plus prior
381
381
  // node outputs) is fenced as untrusted data (#360).
382
382
  const prompt = buildNodePrompt(instruction, context, outputSchema);
383
- const env = this.buildEnv(agentAccess?.envVars);
383
+ const env = withPushBlocked(this.buildEnv(agentAccess?.envVars), agentAccess?.noPush);
384
384
  let response = "";
385
385
  // CC-08: when the SDK populates typed structured output (because we passed
386
386
  // an `outputFormat`), prefer it over re-parsing JSON out of the free-text
@@ -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, } from "../agent-env.js";
31
+ import { CODEX_AUTH_VARS, CODEX_ENV_PREFIXES, checkSandboxSupport, 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";
@@ -763,7 +763,7 @@ export class CodexHarness {
763
763
  sandbox = s.mode;
764
764
  }
765
765
  }
766
- const env = this.buildEnv(req.agentAccess?.envVars);
766
+ const env = withPushBlocked(this.buildEnv(req.agentAccess?.envVars), req.agentAccess?.noPush);
767
767
  if (wrapper && !env.CODEX_API_KEY && !env.CODEX_ACCESS_TOKEN && !this.loginWarned) {
768
768
  this.loginWarned = true;
769
769
  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.");
@@ -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, } from "../agent-env.js";
37
+ import { PI_AUTH_VARS, PI_ENV_PREFIXES, parseList, reportWithheldEnv, resolveEnvScope, 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";
@@ -746,7 +746,7 @@ export class PiHarness {
746
746
  if (readOnly)
747
747
  for (const c of ["shell", "write", "edit"])
748
748
  deny.add(c);
749
- const env = this.buildEnv(req.agentAccess?.envVars);
749
+ const env = withPushBlocked(this.buildEnv(req.agentAccess?.envVars), req.agentAccess?.noPush);
750
750
  const model = req.model ?? this.model;
751
751
  let bridge;
752
752
  let agentDir;
@@ -8,6 +8,7 @@
8
8
  *
9
9
  * Browser-safe: types only, no runtime imports.
10
10
  */
11
+ import type { AgentAccess } from "../agent-env.js";
11
12
  import type { Claude, Logger, NodeResult } from "../types.js";
12
13
  export type HarnessId = "claude-code" | "mock" | "codex" | "pi" | "hermes" | "gemini" | `acp:${string}`;
13
14
  /** Portable tool vocabulary for node policy. */
@@ -100,6 +101,12 @@ export interface HarnessRunRequest extends ClaudeRunRequest {
100
101
  * `readOnly` / `disallowedTools` / `agentAccess` fields.
101
102
  */
102
103
  policy?: NodePolicy;
104
+ /**
105
+ * {@link ClaudeRunRequest.agentAccess} plus `noPush` (#442): the executor
106
+ * sets it for a staged or dry run, and every adapter applies
107
+ * `withPushBlocked` to the agent env.
108
+ */
109
+ agentAccess?: AgentAccess;
103
110
  }
104
111
  export interface HarnessRunResult extends NodeResult {
105
112
  harness: HarnessInfo;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sweny-ai/core",
3
- "version": "0.1.152",
3
+ "version": "0.1.153",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "sweny": "./dist/cli/main.js"