@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.
@@ -4,12 +4,16 @@
4
4
  * Threat: the Claude Code subprocess runs model-chosen shell commands over
5
5
  * attacker-influenceable input (issue bodies, alerts, fetched pages). Before
6
6
  * this module it inherited the full `process.env` (every CI secret) and ran
7
- * Bash with unrestricted network egress. Two controls live here:
7
+ * Bash with unrestricted network egress. Three controls live here:
8
8
  *
9
9
  * 1. {@link scopeAgentEnv}: the subprocess env is an allowlist, not a copy.
10
10
  * On by default in CI, off locally (full env, as before); see
11
11
  * {@link resolveEnvScope}.
12
- * 2. {@link resolveAgentSandbox}: the SDK `sandbox` option is enabled with a
12
+ * 2. {@link withholdCredentials}: skill credentials (`GITHUB_TOKEN`,
13
+ * `LINEAR_API_KEY`, ...) never reach the agent on any harness, scoped or
14
+ * not, unless the node grants one with `agent_env`. Skill tools run in the
15
+ * sweny process, so the agent does not need them.
16
+ * 3. {@link resolveAgentSandbox}: the SDK `sandbox` option is enabled with a
13
17
  * network allowlist and the agent's own credentials denied to sandboxed
14
18
  * commands. `auto` (default in CI) falls back to unsandboxed with one loud
15
19
  * warning when the host cannot sandbox; `strict` fails closed.
@@ -66,6 +70,37 @@ export declare function resolveCodexAuthEnv(env: Record<string, string>): Record
66
70
  export declare const PI_AUTH_VARS: readonly string[];
67
71
  /** Prefixes for a pi run: locale only. `ANTHROPIC_*` / `CLAUDE_*` beyond the names above never reach pi. */
68
72
  export declare const PI_ENV_PREFIXES: readonly string[];
73
+ /**
74
+ * pi provider id to the {@link PI_AUTH_VARS} that carry its credential and
75
+ * settings (pi docs/providers.md, `envMap` in packages/ai/src/env-api-keys.ts).
76
+ * A provider not listed here maps by convention to `<ID>_API_KEY` when that
77
+ * name is in {@link PI_AUTH_VARS} (`meta` to `META_API_KEY`).
78
+ */
79
+ export declare const PI_PROVIDER_VARS: Readonly<Record<string, readonly string[]>>;
80
+ /** The {@link PI_AUTH_VARS} names for one pi provider id (empty for a custom `models.json` provider). */
81
+ export declare function piProviderVars(provider: string): string[];
82
+ export interface PiProviderResolution {
83
+ /** The provider, when named (explicitly or by the model) or the only one with a credential set. */
84
+ provider?: string;
85
+ /** Whether `provider` came from `pi_provider` (so pi gets `--provider`). */
86
+ explicit: boolean;
87
+ /** The only {@link PI_AUTH_VARS} names that reach pi. */
88
+ vars: string[];
89
+ /** Set when the provider cannot be told apart; the node must not run. */
90
+ error?: string;
91
+ }
92
+ /**
93
+ * Which provider's credential a pi run gets (security review 2026-09-30,
94
+ * finding 2). pi's bash tool inherits pi's env, so pi receives one provider's
95
+ * credential, never every key in the environment.
96
+ *
97
+ * In order: `explicit` (`pi_provider`, `SWENY_PI_PROVIDER`); the `provider/`
98
+ * prefix of the model when it names a provider sweny knows; else the one
99
+ * provider whose secret is set. Several set and none named is an error that
100
+ * asks for `pi_provider`. None set: nothing is passed (a custom provider in
101
+ * `models.json` reads its own variables through env-passthrough).
102
+ */
103
+ export declare function resolvePiProvider(env: Record<string, string | undefined>, model?: string, explicit?: string): PiProviderResolution;
69
104
  /** A stored Codex login (`codex login`): `auth.json` under `CODEX_HOME`, default `~/.codex`. */
70
105
  export declare function hasCodexLogin(env?: Record<string, string | undefined>, exists?: (p: string) => boolean): boolean;
71
106
  export interface BuildAgentEnvOpts {
@@ -183,7 +218,9 @@ export declare function noPushGitConfig(dir: string): Array<[string, string]>;
183
218
  /**
184
219
  * The agent env with pushes blocked (#442). Applied to every node's agent
185
220
  * 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
221
+ * scoping is on. Withholds {@link PUSH_TOKEN_VARS} (the ssh agent socket and
222
+ * askpass programs among them) and every {@link isAgentCredential} name, even
223
+ * one a node granted with `agent_env`, appends
187
224
  * {@link noPushGitConfig} after any `GIT_CONFIG_*` entries already present,
188
225
  * sets `GIT_ASKPASS` to a refusing program, turns off git's terminal prompt,
189
226
  * routes git's ssh through a wrapper that refuses `git-receive-pack` (so an
@@ -191,20 +228,97 @@ export declare function noPushGitConfig(dir: string): Array<[string, string]>;
191
228
  * `--no-verify`; ssh fetches still run the operator's own ssh command), and
192
229
  * gives `gh` an empty config dir so a stored `gh auth login` is not used.
193
230
  *
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.
231
+ * Not a sandbox: these are env vars and a git hook, and the agent can unset
232
+ * them. A deliberate agent on an unsandboxed run that finds a credential on
233
+ * disk (an ssh key without a passphrase, a keychain entry, a token persisted
234
+ * in `.git/config` by a checkout) can still push. Run sandboxed (the sandbox
235
+ * hides credential files such as `~/.ssh`) and without push credentials in CI
236
+ * for a guarantee; under a strict harness policy an unsandboxed staged write
237
+ * node is refused (`stagedWrite` in policy.ts). `enabled` false returns `env`
238
+ * unchanged. Pure apart from creating {@link noPushDir} once.
199
239
  */
200
240
  export declare function withPushBlocked(env: Record<string, string>, enabled: boolean | undefined): Record<string, string>;
241
+ /**
242
+ * Credentials the agent process never receives by default, on any harness,
243
+ * whether env scoping is on or off and even under `env-passthrough: "*"`.
244
+ *
245
+ * Skill tools run in the sweny process (in-process, or through the tool
246
+ * bridge), so the agent never needs them. An agent that holds one can use it
247
+ * outside every sweny opinion: a read-only node could still POST to GitHub
248
+ * with `GITHUB_TOKEN` and network access (security review 2026-09-30,
249
+ * finding 1). The list is the source-hosting write tokens plus every config
250
+ * variable of the built-in skills (`skills/*.ts`, checked by a unit test). A
251
+ * run also withholds its custom skills' config names
252
+ * ({@link AgentAccess.withhold}). A node grants one back with `agent_env`.
253
+ */
254
+ export declare const AGENT_CREDENTIAL_VARS: readonly string[];
255
+ /** Credential families withheld by prefix (`SLACK_BOT_TOKEN`, `SLACK_WEBHOOK_URL`, ...). */
256
+ export declare const AGENT_CREDENTIAL_PREFIXES: readonly string[];
257
+ /** Is `name` a credential the agent never gets by default ({@link AGENT_CREDENTIAL_VARS}, plus `extra`)? */
258
+ export declare function isAgentCredential(name: string, extra?: readonly string[]): boolean;
259
+ export interface WithholdCredentialsOpts {
260
+ /** Names the node granted with `agent_env`. They pass even when they are credentials. */
261
+ grant?: readonly string[];
262
+ /** The run's own skill credential names (custom skills included). */
263
+ withhold?: readonly string[];
264
+ /** The harness's own auth vars. Never dropped: the agent cannot start without them. */
265
+ keep?: readonly string[];
266
+ }
267
+ /**
268
+ * Drop every skill credential from an agent env, after scoping (on or off).
269
+ * Only `grant` and `keep` survive. Returns the env, the names dropped, and
270
+ * `held`: the credential names still in the env (granted or kept), which the
271
+ * policy gate weighs against a read-only node. Pure: values never leave `env`.
272
+ */
273
+ export declare function withholdCredentials(env: Record<string, string>, opts?: WithholdCredentialsOpts): {
274
+ env: Record<string, string>;
275
+ withheld: string[];
276
+ held: string[];
277
+ };
278
+ /** The credential names present in a finished agent env (granted, or a harness's own auth var). */
279
+ export declare function heldCredentials(env: Record<string, string>, extra?: readonly string[]): string[];
280
+ /**
281
+ * The last step of every harness's agent env: {@link withholdCredentials}
282
+ * with the node's grants and the harness's own auth vars, plus one warning
283
+ * per process when `env-passthrough` names a credential (passthrough never
284
+ * carries one; `agent_env` on the node that needs it does). Withheld names go
285
+ * to `debug`, never values.
286
+ */
287
+ export declare function finishAgentEnv(env: Record<string, string>, opts?: {
288
+ access?: Pick<AgentAccess, "envVars" | "withhold">;
289
+ keep?: readonly string[];
290
+ passthrough?: readonly string[];
291
+ logger?: Pick<Logger, "warn" | "debug">;
292
+ }): {
293
+ env: Record<string, string>;
294
+ held: string[];
295
+ };
296
+ /**
297
+ * The names a node's `agent_env` may actually grant. A read-only node and a
298
+ * staged or dry run get none: the first is refused at load time (validate),
299
+ * this is the runtime backstop, and the second must not push or write.
300
+ */
301
+ export declare function grantedAgentEnv(agentEnv: readonly string[] | undefined, opts: {
302
+ readOnly: boolean;
303
+ staged: boolean;
304
+ }): {
305
+ grant: string[];
306
+ dropped?: string;
307
+ };
201
308
  /** What one node's agent call may see: env var names and network hosts. */
202
309
  export interface AgentAccess {
310
+ /**
311
+ * Names the node granted with `agent_env`: added to a scoped env, and the
312
+ * only credentials ({@link isAgentCredential}) that reach the agent.
313
+ */
203
314
  envVars: string[];
204
315
  domains: string[];
316
+ /** The run's skill config names (every configured skill), never passed unless granted. */
317
+ withhold?: string[];
205
318
  /**
206
319
  * 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.
320
+ * {@link withPushBlocked} to the agent env. That blocks git push by env and
321
+ * hooks; only a sandbox stops a deliberate agent from undoing it.
208
322
  */
209
323
  noPush?: boolean;
210
324
  }
@@ -213,11 +327,14 @@ export declare const DEFAULT_SANDBOX_DOMAINS: readonly string[];
213
327
  /** Provider hosts added when a node references the built-in skill. */
214
328
  export declare const SKILL_SANDBOX_DOMAINS: Readonly<Record<string, readonly string[]>>;
215
329
  /**
216
- * Resolve a node's access from its `skills:` list: every env var named by a
217
- * referenced skill's config fields, plus that skill's provider hosts.
218
- * Unknown skill ids contribute nothing (the executor validates them).
330
+ * Resolve a node's access: the referenced skills' provider hosts, the names
331
+ * the node granted with `agent_env` (`grant`, see {@link grantedAgentEnv}),
332
+ * and every config variable of every skill in the run (`withhold`), which the
333
+ * harness keeps out of the agent env unless granted. Skill tools run in the
334
+ * sweny process, so the agent never needs a skill's own credential. Unknown
335
+ * skill ids contribute nothing (the executor validates them).
219
336
  */
220
- export declare function resolveAgentAccess(skillIds: readonly string[], skills: Map<string, Skill>): AgentAccess;
337
+ export declare function resolveAgentAccess(skillIds: readonly string[], skills: Map<string, Skill>, grant?: readonly string[]): AgentAccess;
221
338
  /**
222
339
  * `off`: never sandbox (default for local runs, so "it's your repo" keeps
223
340
  * working: private registries, docker, cargo, go). `auto` (default when CI is
package/dist/agent-env.js CHANGED
@@ -4,12 +4,16 @@
4
4
  * Threat: the Claude Code subprocess runs model-chosen shell commands over
5
5
  * attacker-influenceable input (issue bodies, alerts, fetched pages). Before
6
6
  * this module it inherited the full `process.env` (every CI secret) and ran
7
- * Bash with unrestricted network egress. Two controls live here:
7
+ * Bash with unrestricted network egress. Three controls live here:
8
8
  *
9
9
  * 1. {@link scopeAgentEnv}: the subprocess env is an allowlist, not a copy.
10
10
  * On by default in CI, off locally (full env, as before); see
11
11
  * {@link resolveEnvScope}.
12
- * 2. {@link resolveAgentSandbox}: the SDK `sandbox` option is enabled with a
12
+ * 2. {@link withholdCredentials}: skill credentials (`GITHUB_TOKEN`,
13
+ * `LINEAR_API_KEY`, ...) never reach the agent on any harness, scoped or
14
+ * not, unless the node grants one with `agent_env`. Skill tools run in the
15
+ * sweny process, so the agent does not need them.
16
+ * 3. {@link resolveAgentSandbox}: the SDK `sandbox` option is enabled with a
13
17
  * network allowlist and the agent's own credentials denied to sandboxed
14
18
  * commands. `auto` (default in CI) falls back to unsandboxed with one loud
15
19
  * warning when the host cannot sandbox; `strict` fails closed.
@@ -219,6 +223,118 @@ export const PI_AUTH_VARS = [
219
223
  ];
220
224
  /** Prefixes for a pi run: locale only. `ANTHROPIC_*` / `CLAUDE_*` beyond the names above never reach pi. */
221
225
  export const PI_ENV_PREFIXES = ["LC_"];
226
+ /**
227
+ * pi provider id to the {@link PI_AUTH_VARS} that carry its credential and
228
+ * settings (pi docs/providers.md, `envMap` in packages/ai/src/env-api-keys.ts).
229
+ * A provider not listed here maps by convention to `<ID>_API_KEY` when that
230
+ * name is in {@link PI_AUTH_VARS} (`meta` to `META_API_KEY`).
231
+ */
232
+ export const PI_PROVIDER_VARS = {
233
+ anthropic: ["ANTHROPIC_API_KEY", "ANTHROPIC_OAUTH_TOKEN", "ANTHROPIC_AUTH_TOKEN"],
234
+ openai: ["OPENAI_API_KEY"],
235
+ "azure-openai-responses": ["AZURE_OPENAI_API_KEY", "AZURE_OPENAI_BASE_URL", "AZURE_OPENAI_RESOURCE_NAME"],
236
+ google: ["GEMINI_API_KEY"],
237
+ "google-vertex": ["GOOGLE_CLOUD_API_KEY", "GOOGLE_CLOUD_PROJECT", "GCLOUD_PROJECT", "GOOGLE_CLOUD_LOCATION"],
238
+ "amazon-bedrock": [
239
+ "AWS_ACCESS_KEY_ID",
240
+ "AWS_SECRET_ACCESS_KEY",
241
+ "AWS_SESSION_TOKEN",
242
+ "AWS_BEARER_TOKEN_BEDROCK",
243
+ "AWS_REGION",
244
+ "AWS_DEFAULT_REGION",
245
+ ],
246
+ "cloudflare-ai-gateway": ["CLOUDFLARE_API_KEY", "CLOUDFLARE_ACCOUNT_ID", "CLOUDFLARE_GATEWAY_ID"],
247
+ "cloudflare-workers-ai": ["CLOUDFLARE_API_KEY", "CLOUDFLARE_ACCOUNT_ID"],
248
+ "github-copilot": ["COPILOT_GITHUB_TOKEN"],
249
+ "vercel-ai-gateway": ["AI_GATEWAY_API_KEY"],
250
+ "ant-ling": ["ANT_LING_API_KEY"],
251
+ "zai-coding-cn": ["ZAI_CODING_CN_API_KEY"],
252
+ "opencode-go": ["OPENCODE_API_KEY"],
253
+ huggingface: ["HF_TOKEN"],
254
+ "kimi-coding": ["KIMI_API_KEY"],
255
+ "minimax-cn": ["MINIMAX_CN_API_KEY"],
256
+ "qwen-token-plan": ["QWEN_TOKEN_PLAN_API_KEY"],
257
+ "qwen-token-plan-individual": ["QWEN_TOKEN_PLAN_API_KEY"],
258
+ "qwen-token-plan-cn": ["QWEN_TOKEN_PLAN_CN_API_KEY"],
259
+ "xiaomi-token-plan-cn": ["XIAOMI_TOKEN_PLAN_CN_API_KEY"],
260
+ "xiaomi-token-plan-ams": ["XIAOMI_TOKEN_PLAN_AMS_API_KEY"],
261
+ "xiaomi-token-plan-sgp": ["XIAOMI_TOKEN_PLAN_SGP_API_KEY"],
262
+ };
263
+ /** pi settings that are not secrets: their presence alone never selects a provider. */
264
+ const PI_NON_SECRET_VARS = new Set([
265
+ "AZURE_OPENAI_BASE_URL",
266
+ "AZURE_OPENAI_RESOURCE_NAME",
267
+ "CLOUDFLARE_ACCOUNT_ID",
268
+ "CLOUDFLARE_GATEWAY_ID",
269
+ "GOOGLE_CLOUD_PROJECT",
270
+ "GCLOUD_PROJECT",
271
+ "GOOGLE_CLOUD_LOCATION",
272
+ "AWS_REGION",
273
+ "AWS_DEFAULT_REGION",
274
+ ]);
275
+ /** The {@link PI_AUTH_VARS} names for one pi provider id (empty for a custom `models.json` provider). */
276
+ export function piProviderVars(provider) {
277
+ const id = provider.trim().toLowerCase();
278
+ const known = PI_PROVIDER_VARS[id];
279
+ if (known)
280
+ return [...known];
281
+ const conventional = `${id.replace(/[^a-z0-9]+/g, "_").toUpperCase()}_API_KEY`;
282
+ return PI_AUTH_VARS.includes(conventional) ? [conventional] : [];
283
+ }
284
+ /** True when `id` is a pi provider sweny knows a credential for. */
285
+ function isKnownPiProvider(id) {
286
+ return piProviderVars(id).length > 0;
287
+ }
288
+ /**
289
+ * Which provider's credential a pi run gets (security review 2026-09-30,
290
+ * finding 2). pi's bash tool inherits pi's env, so pi receives one provider's
291
+ * credential, never every key in the environment.
292
+ *
293
+ * In order: `explicit` (`pi_provider`, `SWENY_PI_PROVIDER`); the `provider/`
294
+ * prefix of the model when it names a provider sweny knows; else the one
295
+ * provider whose secret is set. Several set and none named is an error that
296
+ * asks for `pi_provider`. None set: nothing is passed (a custom provider in
297
+ * `models.json` reads its own variables through env-passthrough).
298
+ */
299
+ export function resolvePiProvider(env, model, explicit) {
300
+ const named = (explicit ?? env.SWENY_PI_PROVIDER ?? "").trim().toLowerCase();
301
+ const prefix = model && model.includes("/") ? model.split("/")[0].trim().toLowerCase() : "";
302
+ const fromModel = prefix && isKnownPiProvider(prefix) ? prefix : "";
303
+ if (named) {
304
+ if (fromModel && fromModel !== named) {
305
+ return {
306
+ explicit: true,
307
+ vars: [],
308
+ error: `pi_provider "${named}" does not match the model "${model}" (provider "${fromModel}"); set one of them`,
309
+ };
310
+ }
311
+ return { provider: named, explicit: true, vars: piProviderVars(named) };
312
+ }
313
+ if (fromModel)
314
+ return { provider: fromModel, explicit: false, vars: piProviderVars(fromModel) };
315
+ const set = PI_AUTH_VARS.filter((v) => !PI_NON_SECRET_VARS.has(v) && !!env[v]);
316
+ if (set.length === 0)
317
+ return { explicit: false, vars: [] };
318
+ const groups = [
319
+ ...Object.entries(PI_PROVIDER_VARS).map(([id, vars]) => ({ id, vars: [...vars] })),
320
+ ...PI_AUTH_VARS.filter((v) => !Object.values(PI_PROVIDER_VARS).some((vs) => vs.includes(v))).map((v) => ({
321
+ id: v.replace(/_API_KEY$|_TOKEN$/, "").toLowerCase(),
322
+ vars: [v],
323
+ })),
324
+ ];
325
+ const covering = groups.filter((g) => set.every((v) => g.vars.includes(v)));
326
+ if (covering.length > 0) {
327
+ // Several ids can share one key (opencode, opencode-go): the vars are what matter.
328
+ const vars = [...new Set(covering.flatMap((g) => g.vars))];
329
+ return { provider: covering.length === 1 ? covering[0].id : undefined, explicit: false, vars };
330
+ }
331
+ return {
332
+ explicit: false,
333
+ vars: [],
334
+ error: `pi: credentials for several providers are set (${set.join(", ")}) and the model does not name one. ` +
335
+ `Set SWENY_PI_PROVIDER (pi_provider) or a provider/model, so only that provider's credential reaches pi.`,
336
+ };
337
+ }
222
338
  /** A stored Codex login (`codex login`): `auth.json` under `CODEX_HOME`, default `~/.codex`. */
223
339
  export function hasCodexLogin(env = process.env, exists = existsSync) {
224
340
  const home = env.CODEX_HOME || path.join(homedir(), ".codex");
@@ -360,6 +476,7 @@ let withheldReported = false;
360
476
  /** Test seam: forget that the withheld notice was already emitted. */
361
477
  export function resetWithheldReport() {
362
478
  withheldReported = false;
479
+ passthroughCredentialWarned = false;
363
480
  }
364
481
  /**
365
482
  * Report withheld env names, once per process (not per node or per client).
@@ -374,7 +491,10 @@ export function reportWithheldEnv(withheld, logger, env = process.env) {
374
491
  if (withheld.length === 0 || withheldReported)
375
492
  return;
376
493
  withheldReported = true;
377
- const { baseline, other } = classifyWithheld(withheld);
494
+ const { baseline, other: rest } = classifyWithheld(withheld);
495
+ // Skill credentials are withheld by design and env-passthrough cannot add
496
+ // them back, so they are not "fix it with passthrough" material.
497
+ const other = rest.filter((n) => !isAgentCredential(n));
378
498
  logger.info(formatScopeSummary(withheld.length, baseline.length));
379
499
  if (other.length > 0) {
380
500
  const prefix = env.GITHUB_ACTIONS === "true" ? "::warning title=SWEny agent env::" : "";
@@ -512,7 +632,9 @@ export function noPushGitConfig(dir) {
512
632
  /**
513
633
  * The agent env with pushes blocked (#442). Applied to every node's agent
514
634
  * 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
635
+ * scoping is on. Withholds {@link PUSH_TOKEN_VARS} (the ssh agent socket and
636
+ * askpass programs among them) and every {@link isAgentCredential} name, even
637
+ * one a node granted with `agent_env`, appends
516
638
  * {@link noPushGitConfig} after any `GIT_CONFIG_*` entries already present,
517
639
  * sets `GIT_ASKPASS` to a refusing program, turns off git's terminal prompt,
518
640
  * routes git's ssh through a wrapper that refuses `git-receive-pack` (so an
@@ -520,11 +642,14 @@ export function noPushGitConfig(dir) {
520
642
  * `--no-verify`; ssh fetches still run the operator's own ssh command), and
521
643
  * gives `gh` an empty config dir so a stored `gh auth login` is not used.
522
644
  *
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.
645
+ * Not a sandbox: these are env vars and a git hook, and the agent can unset
646
+ * them. A deliberate agent on an unsandboxed run that finds a credential on
647
+ * disk (an ssh key without a passphrase, a keychain entry, a token persisted
648
+ * in `.git/config` by a checkout) can still push. Run sandboxed (the sandbox
649
+ * hides credential files such as `~/.ssh`) and without push credentials in CI
650
+ * for a guarantee; under a strict harness policy an unsandboxed staged write
651
+ * node is refused (`stagedWrite` in policy.ts). `enabled` false returns `env`
652
+ * unchanged. Pure apart from creating {@link noPushDir} once.
528
653
  */
529
654
  export function withPushBlocked(env, enabled) {
530
655
  if (!enabled)
@@ -532,7 +657,8 @@ export function withPushBlocked(env, enabled) {
532
657
  const dir = noPushDir();
533
658
  const out = {};
534
659
  for (const [k, v] of Object.entries(env)) {
535
- if (PUSH_TOKEN_VARS.includes(k))
660
+ // Every skill credential too, even one a node granted with `agent_env`.
661
+ if (PUSH_TOKEN_VARS.includes(k) || isAgentCredential(k))
536
662
  continue;
537
663
  out[k] = v;
538
664
  }
@@ -555,6 +681,132 @@ export function withPushBlocked(env, enabled) {
555
681
  out.GH_PROMPT_DISABLED = "1";
556
682
  return out;
557
683
  }
684
+ // ─── Skill credentials stay in the sweny process ─────────────────
685
+ /**
686
+ * Credentials the agent process never receives by default, on any harness,
687
+ * whether env scoping is on or off and even under `env-passthrough: "*"`.
688
+ *
689
+ * Skill tools run in the sweny process (in-process, or through the tool
690
+ * bridge), so the agent never needs them. An agent that holds one can use it
691
+ * outside every sweny opinion: a read-only node could still POST to GitHub
692
+ * with `GITHUB_TOKEN` and network access (security review 2026-09-30,
693
+ * finding 1). The list is the source-hosting write tokens plus every config
694
+ * variable of the built-in skills (`skills/*.ts`, checked by a unit test). A
695
+ * run also withholds its custom skills' config names
696
+ * ({@link AgentAccess.withhold}). A node grants one back with `agent_env`.
697
+ */
698
+ export const AGENT_CREDENTIAL_VARS = [
699
+ // source hosting: gh, the GitHub/GitLab REST APIs and git's HTTPS auth read these
700
+ "GITHUB_TOKEN",
701
+ "GH_TOKEN",
702
+ "GH_ENTERPRISE_TOKEN",
703
+ "GITHUB_ENTERPRISE_TOKEN",
704
+ "GITHUB_PAT",
705
+ "GITLAB_TOKEN",
706
+ "GL_TOKEN",
707
+ "CI_JOB_TOKEN",
708
+ "BITBUCKET_TOKEN",
709
+ // built-in skills (config fields of skills/*.ts)
710
+ "LINEAR_API_KEY",
711
+ "SENTRY_AUTH_TOKEN",
712
+ "SENTRY_ORG",
713
+ "SENTRY_BASE_URL",
714
+ "DD_API_KEY",
715
+ "DD_APP_KEY",
716
+ "DD_SITE",
717
+ "BETTERSTACK_API_TOKEN",
718
+ "BETTERSTACK_QUERY_ENDPOINT",
719
+ "BETTERSTACK_QUERY_USERNAME",
720
+ "BETTERSTACK_QUERY_PASSWORD",
721
+ "NOTIFICATION_WEBHOOK_URL",
722
+ "NOTIFICATION_WEBHOOK_ALLOWED_HOSTS",
723
+ "DISCORD_WEBHOOK_URL",
724
+ "TEAMS_WEBHOOK_URL",
725
+ "SMTP_URL",
726
+ "SUPABASE_URL",
727
+ "SUPABASE_SERVICE_ROLE_KEY",
728
+ "SUPABASE_ALLOWED_TABLES",
729
+ "SUPABASE_ALLOWED_FUNCTIONS",
730
+ ];
731
+ /** Credential families withheld by prefix (`SLACK_BOT_TOKEN`, `SLACK_WEBHOOK_URL`, ...). */
732
+ export const AGENT_CREDENTIAL_PREFIXES = ["SLACK_"];
733
+ /** Is `name` a credential the agent never gets by default ({@link AGENT_CREDENTIAL_VARS}, plus `extra`)? */
734
+ export function isAgentCredential(name, extra = []) {
735
+ return (AGENT_CREDENTIAL_VARS.includes(name) ||
736
+ AGENT_CREDENTIAL_PREFIXES.some((p) => name.startsWith(p)) ||
737
+ extra.includes(name));
738
+ }
739
+ /**
740
+ * Drop every skill credential from an agent env, after scoping (on or off).
741
+ * Only `grant` and `keep` survive. Returns the env, the names dropped, and
742
+ * `held`: the credential names still in the env (granted or kept), which the
743
+ * policy gate weighs against a read-only node. Pure: values never leave `env`.
744
+ */
745
+ export function withholdCredentials(env, opts = {}) {
746
+ const grant = opts.grant ?? [];
747
+ const keep = opts.keep ?? [];
748
+ const extra = opts.withhold ?? [];
749
+ const out = {};
750
+ const withheld = [];
751
+ const held = [];
752
+ for (const [k, v] of Object.entries(env)) {
753
+ if (!isAgentCredential(k, extra)) {
754
+ out[k] = v;
755
+ }
756
+ else if (grant.includes(k) || keep.includes(k)) {
757
+ out[k] = v;
758
+ held.push(k);
759
+ }
760
+ else {
761
+ withheld.push(k);
762
+ }
763
+ }
764
+ return { env: out, withheld: withheld.sort(), held: held.sort() };
765
+ }
766
+ /** The credential names present in a finished agent env (granted, or a harness's own auth var). */
767
+ export function heldCredentials(env, extra = []) {
768
+ return Object.keys(env)
769
+ .filter((k) => isAgentCredential(k, extra))
770
+ .sort();
771
+ }
772
+ let passthroughCredentialWarned = false;
773
+ /**
774
+ * The last step of every harness's agent env: {@link withholdCredentials}
775
+ * with the node's grants and the harness's own auth vars, plus one warning
776
+ * per process when `env-passthrough` names a credential (passthrough never
777
+ * carries one; `agent_env` on the node that needs it does). Withheld names go
778
+ * to `debug`, never values.
779
+ */
780
+ export function finishAgentEnv(env, opts = {}) {
781
+ const grant = opts.access?.envVars ?? [];
782
+ const keep = opts.keep ?? [];
783
+ const result = withholdCredentials(env, { grant, withhold: opts.access?.withhold, keep });
784
+ const asked = (opts.passthrough ?? []).filter((n) => isAgentCredential(n, opts.access?.withhold) && !grant.includes(n) && !keep.includes(n));
785
+ if (asked.length > 0 && !passthroughCredentialWarned) {
786
+ passthroughCredentialWarned = true;
787
+ opts.logger?.warn(`[sweny] env-passthrough names skill credentials (${asked.sort().join(", ")}); they never reach the agent. ` +
788
+ `Skill tools run in sweny and already have them. If a node's own shell needs one, add it to that node's agent_env.`);
789
+ }
790
+ if (result.withheld.length > 0) {
791
+ opts.logger?.debug(`sweny: skill credentials withheld from the agent: ${result.withheld.join(", ")}`);
792
+ }
793
+ return { env: result.env, held: result.held };
794
+ }
795
+ /**
796
+ * The names a node's `agent_env` may actually grant. A read-only node and a
797
+ * staged or dry run get none: the first is refused at load time (validate),
798
+ * this is the runtime backstop, and the second must not push or write.
799
+ */
800
+ export function grantedAgentEnv(agentEnv, opts) {
801
+ const names = [...new Set(agentEnv ?? [])];
802
+ if (names.length === 0)
803
+ return { grant: [] };
804
+ if (opts.readOnly)
805
+ return { grant: [], dropped: `agent_env withheld on a read-only node: ${names.join(", ")}` };
806
+ if (opts.staged)
807
+ return { grant: [], dropped: `agent_env withheld in a staged or dry run: ${names.join(", ")}` };
808
+ return { grant: names };
809
+ }
558
810
  /** Hosts reachable by sandboxed commands in every node: source hosting + package registries. */
559
811
  export const DEFAULT_SANDBOX_DOMAINS = [
560
812
  "github.com",
@@ -585,23 +837,27 @@ export const SKILL_SANDBOX_DOMAINS = {
585
837
  gitlab: ["gitlab.com"],
586
838
  };
587
839
  /**
588
- * Resolve a node's access from its `skills:` list: every env var named by a
589
- * referenced skill's config fields, plus that skill's provider hosts.
590
- * Unknown skill ids contribute nothing (the executor validates them).
840
+ * Resolve a node's access: the referenced skills' provider hosts, the names
841
+ * the node granted with `agent_env` (`grant`, see {@link grantedAgentEnv}),
842
+ * and every config variable of every skill in the run (`withhold`), which the
843
+ * harness keeps out of the agent env unless granted. Skill tools run in the
844
+ * sweny process, so the agent never needs a skill's own credential. Unknown
845
+ * skill ids contribute nothing (the executor validates them).
591
846
  */
592
- export function resolveAgentAccess(skillIds, skills) {
593
- const envVars = new Set();
847
+ export function resolveAgentAccess(skillIds, skills, grant = []) {
848
+ const withhold = new Set();
594
849
  const domains = new Set();
595
- for (const id of skillIds) {
596
- const skill = skills.get(id);
597
- for (const field of Object.values(skill?.config ?? {})) {
850
+ for (const skill of skills.values()) {
851
+ for (const field of Object.values(skill.config ?? {})) {
598
852
  if (field.env)
599
- envVars.add(field.env);
853
+ withhold.add(field.env);
600
854
  }
855
+ }
856
+ for (const id of skillIds) {
601
857
  for (const d of SKILL_SANDBOX_DOMAINS[id] ?? [])
602
858
  domains.add(d);
603
859
  }
604
- return { envVars: [...envVars], domains: [...domains] };
860
+ return { envVars: [...grant], domains: [...domains], withhold: [...withhold].sort() };
605
861
  }
606
862
  /**
607
863
  * Parse the sandbox mode from the explicit `mode` or `SWENY_SANDBOX`.
@@ -42,6 +42,8 @@ const AGENT_FILE_KEYS = [
42
42
  [["env-scope", "env_scope"], "SWENY_ENV_SCOPE"],
43
43
  [["sandbox"], "SWENY_SANDBOX"],
44
44
  [["sandbox-allowed-domains", "sandbox_allowed_domains"], "SWENY_SANDBOX_ALLOWED_DOMAINS"],
45
+ // The one model provider whose credential a pi run gets (agent-env.ts resolvePiProvider).
46
+ [["pi-provider", "pi_provider"], "SWENY_PI_PROVIDER"],
45
47
  ];
46
48
  /**
47
49
  * Copy agent sandbox / env-passthrough settings from `.sweny.yml` into the
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import type { Workflow } from "../types.js";
3
+ import { type ResumeContext } from "./resume.js";
3
4
  export declare function loadWorkflowFile(filePath: string, knownSkills?: Set<string>): Workflow;
4
5
  export declare function workflowRunAction(file: string | undefined, options: Record<string, unknown> & {
5
6
  json?: boolean;
@@ -13,7 +14,9 @@ export declare function workflowRunAction(file: string | undefined, options: Rec
13
14
  maxCost?: string;
14
15
  yes?: boolean;
15
16
  decider?: string;
16
- }): Promise<void>;
17
+ },
18
+ /** Set by `sweny workflow resume` (#363): the journal, run id and original input. */
19
+ resume?: ResumeContext): Promise<void>;
17
20
  export declare function workflowExportAction(name: string): void;
18
21
  export declare function workflowValidateAction(file: string, options: {
19
22
  json?: boolean;