@sweny-ai/core 0.1.164 → 0.1.166
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent-env.d.ts +130 -13
- package/dist/agent-env.js +276 -20
- package/dist/cli/config-file.js +2 -0
- package/dist/cli/main.d.ts +4 -1
- package/dist/cli/main.js +72 -3
- package/dist/cli/resume.d.ts +56 -0
- package/dist/cli/resume.js +182 -0
- package/dist/executor.d.ts +7 -0
- package/dist/executor.js +87 -11
- package/dist/harness/acp.d.ts +6 -1
- package/dist/harness/acp.js +35 -17
- package/dist/harness/claude-code.d.ts +12 -0
- package/dist/harness/claude-code.js +37 -14
- package/dist/harness/codex.d.ts +5 -1
- package/dist/harness/codex.js +30 -16
- package/dist/harness/pi.d.ts +17 -1
- package/dist/harness/pi.js +54 -16
- package/dist/harness/policy.js +16 -0
- package/dist/harness/types.d.ts +16 -0
- package/dist/journal.d.ts +307 -0
- package/dist/journal.js +885 -0
- package/dist/schema.d.ts +156 -136
- package/dist/schema.js +21 -0
- package/dist/types.d.ts +15 -3
- package/package.json +1 -1
package/dist/agent-env.d.ts
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.
|
|
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
|
|
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}
|
|
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:
|
|
195
|
-
*
|
|
196
|
-
*
|
|
197
|
-
*
|
|
198
|
-
*
|
|
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
|
|
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
|
|
217
|
-
*
|
|
218
|
-
*
|
|
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
|
|
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.
|
|
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
|
|
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}
|
|
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:
|
|
524
|
-
*
|
|
525
|
-
*
|
|
526
|
-
*
|
|
527
|
-
*
|
|
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
|
-
|
|
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
|
|
589
|
-
*
|
|
590
|
-
*
|
|
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
|
|
847
|
+
export function resolveAgentAccess(skillIds, skills, grant = []) {
|
|
848
|
+
const withhold = new Set();
|
|
594
849
|
const domains = new Set();
|
|
595
|
-
for (const
|
|
596
|
-
const
|
|
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
|
-
|
|
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: [...
|
|
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`.
|
package/dist/cli/config-file.js
CHANGED
|
@@ -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
|
package/dist/cli/main.d.ts
CHANGED
|
@@ -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
|
-
}
|
|
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;
|