@sweny-ai/core 0.1.151 → 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.
- package/dist/agent-env.d.ts +53 -0
- package/dist/agent-env.js +128 -2
- package/dist/cli/builtin-workflows.js +2 -1
- package/dist/executor.js +6 -1
- package/dist/harness/acp.js +2 -2
- package/dist/harness/claude-code.js +3 -3
- package/dist/harness/codex.js +2 -2
- package/dist/harness/pi.js +2 -2
- package/dist/harness/types.d.ts +7 -0
- package/dist/safe-outputs.d.ts +3 -1
- package/dist/safe-outputs.js +48 -10
- package/dist/schema.d.ts +49 -28
- package/dist/schema.js +26 -4
- package/dist/skills/github.js +24 -0
- package/dist/skills/linear.js +44 -0
- package/dist/types.d.ts +11 -5
- package/dist/types.js +5 -3
- package/dist/workflows/browser.js +1 -1
- package/dist/workflows/triage.yml +18 -7
- package/package.json +1 -1
package/dist/agent-env.d.ts
CHANGED
|
@@ -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",
|
|
@@ -33,6 +33,7 @@ const LEGACY_WRITE_TOOLS = {
|
|
|
33
33
|
comment: ["github_add_comment", "linear_add_comment", "linear_update_issue"],
|
|
34
34
|
pr: ["github_create_pr"],
|
|
35
35
|
label: ["github_add_labels"],
|
|
36
|
+
issue_state: ["linear_update_issue", "linear_set_issue_state", "github_set_issue_state"],
|
|
36
37
|
};
|
|
37
38
|
const writeToolsBySkill = new Map(builtinSkills.map((s) => [s.id, s.tools.filter((t) => t.access !== "read").map((t) => t.name)]));
|
|
38
39
|
function skillWriteTools(node) {
|
|
@@ -59,7 +60,7 @@ export function readGaps(p) {
|
|
|
59
60
|
}
|
|
60
61
|
/** The skill that applies this output type for these providers, or undefined when none can. */
|
|
61
62
|
export function outputSkill(type, p) {
|
|
62
|
-
if (type === "issue" || type === "comment")
|
|
63
|
+
if (type === "issue" || type === "comment" || type === "issue_state")
|
|
63
64
|
return TRACKER_SKILL[p.issueTracker];
|
|
64
65
|
return p.sourceControl === "github" ? "github" : undefined;
|
|
65
66
|
}
|
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
|
-
|
|
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
|
package/dist/harness/acp.js
CHANGED
|
@@ -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
|
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, } 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.");
|
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, } 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;
|
package/dist/harness/types.d.ts
CHANGED
|
@@ -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/dist/safe-outputs.d.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* the write, so this holds on every harness.
|
|
10
10
|
*
|
|
11
11
|
* Order of checks per intent (all deterministic): declared type, workflow
|
|
12
|
-
* ceiling, actor trust, expiry, skill, target pin, issue pin, shape and size,
|
|
12
|
+
* ceiling, actor trust, expiry, skill, target pin, issue pin, state, shape and size,
|
|
13
13
|
* labels, title prefix, dedupe, node cap, run cap. An optional model screen runs last
|
|
14
14
|
* over the intents that passed and can only veto them.
|
|
15
15
|
*/
|
|
@@ -31,6 +31,8 @@ export interface SafeOutputIntent {
|
|
|
31
31
|
labels?: string[];
|
|
32
32
|
head?: string;
|
|
33
33
|
base?: string;
|
|
34
|
+
/** issue_state: `reopen` or `close`. */
|
|
35
|
+
state?: string;
|
|
34
36
|
dedupe_key?: string;
|
|
35
37
|
/** ms since epoch when the intent was recorded. */
|
|
36
38
|
recordedAt: number;
|
package/dist/safe-outputs.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* the write, so this holds on every harness.
|
|
10
10
|
*
|
|
11
11
|
* Order of checks per intent (all deterministic): declared type, workflow
|
|
12
|
-
* ceiling, actor trust, expiry, skill, target pin, issue pin, shape and size,
|
|
12
|
+
* ceiling, actor trust, expiry, skill, target pin, issue pin, state, shape and size,
|
|
13
13
|
* labels, title prefix, dedupe, node cap, run cap. An optional model screen runs last
|
|
14
14
|
* over the intents that passed and can only veto them.
|
|
15
15
|
*/
|
|
@@ -49,7 +49,7 @@ export function createEmitOutputTool(declarations, buffer, now = Date.now) {
|
|
|
49
49
|
return {
|
|
50
50
|
name: EMIT_OUTPUT_TOOL,
|
|
51
51
|
access: "read",
|
|
52
|
-
description: "Request a write (comment, issue, pr or
|
|
52
|
+
description: "Request a write (comment, issue, pr, label or issue_state). This does NOT write anything now: sweny checks the request " +
|
|
53
53
|
"against this step's declared outputs and limits, and applies it after the step finishes. Call once per write. " +
|
|
54
54
|
`Allowed types here: ${types.join(", ")}.`,
|
|
55
55
|
input_schema: {
|
|
@@ -64,8 +64,9 @@ export function createEmitOutputTool(declarations, buffer, now = Date.now) {
|
|
|
64
64
|
},
|
|
65
65
|
number: {
|
|
66
66
|
type: "string",
|
|
67
|
-
description: "Issue or PR number (GitHub) or issue id (Linear), for comment and
|
|
67
|
+
description: "Issue or PR number (GitHub) or issue id (Linear), for comment, label and issue_state",
|
|
68
68
|
},
|
|
69
|
+
state: { type: "string", enum: ["reopen", "close"], description: "issue_state only: reopen or close" },
|
|
69
70
|
labels: { type: "array", items: { type: "string" }, description: "Labels to add" },
|
|
70
71
|
head: { type: "string", description: "pr only: branch with the changes" },
|
|
71
72
|
base: { type: "string", description: "pr only: target branch (default main)" },
|
|
@@ -88,7 +89,7 @@ export function createEmitOutputTool(declarations, buffer, now = Date.now) {
|
|
|
88
89
|
return { recorded: false, error: `limit reached: at most ${max} ${type} write(s) from this step` };
|
|
89
90
|
}
|
|
90
91
|
const intent = { type, recordedAt: now() };
|
|
91
|
-
const fields = ["title", "body", "target", "number", "head", "base", "dedupe_key"];
|
|
92
|
+
const fields = ["title", "body", "target", "number", "head", "base", "state", "dedupe_key"];
|
|
92
93
|
for (const f of fields) {
|
|
93
94
|
const v = str(input?.[f]);
|
|
94
95
|
if (v !== undefined)
|
|
@@ -117,6 +118,8 @@ export function safeOutputsInstruction(declarations, input) {
|
|
|
117
118
|
const pin = resolvePin(d.number, input);
|
|
118
119
|
parts.push(pin ? `only issue or PR ${pin} (the number may be omitted)` : "pinned issue not set for this run");
|
|
119
120
|
}
|
|
121
|
+
if (d.type === "issue_state")
|
|
122
|
+
parts.push(d.state ? `only to ${d.state}` : "reopen or close");
|
|
120
123
|
if (d.title_prefix)
|
|
121
124
|
parts.push(`title prefix "${d.title_prefix}"`);
|
|
122
125
|
if (d.type === "label" && d.labels?.length)
|
|
@@ -247,6 +250,11 @@ const APPLIERS = {
|
|
|
247
250
|
needsTarget: true,
|
|
248
251
|
build: (i) => ({ repo: i.target, issue_number: Number(i.number), labels: i.labels }),
|
|
249
252
|
},
|
|
253
|
+
issue_state: {
|
|
254
|
+
tool: "github_set_issue_state",
|
|
255
|
+
needsTarget: true,
|
|
256
|
+
build: (i) => ({ repo: i.target, issue_number: Number(i.number), state: i.state }),
|
|
257
|
+
},
|
|
250
258
|
},
|
|
251
259
|
linear: {
|
|
252
260
|
comment: {
|
|
@@ -264,6 +272,11 @@ const APPLIERS = {
|
|
|
264
272
|
...(i.labels ? { labelIds: i.labels } : {}),
|
|
265
273
|
}),
|
|
266
274
|
},
|
|
275
|
+
issue_state: {
|
|
276
|
+
tool: "linear_set_issue_state",
|
|
277
|
+
needsTarget: false,
|
|
278
|
+
build: (i) => ({ issueId: i.number, state: i.state }),
|
|
279
|
+
},
|
|
267
280
|
},
|
|
268
281
|
};
|
|
269
282
|
function dedupeKey(w, explicit) {
|
|
@@ -279,6 +292,7 @@ function dedupeKey(w, explicit) {
|
|
|
279
292
|
[...(w.labels ?? [])].sort().join(","),
|
|
280
293
|
w.head ?? "",
|
|
281
294
|
w.base ?? "",
|
|
295
|
+
...(w.state ? [w.state] : []),
|
|
282
296
|
];
|
|
283
297
|
return createHash("sha256").update(JSON.stringify(parts)).digest("hex");
|
|
284
298
|
}
|
|
@@ -377,9 +391,11 @@ export async function applySafeOutputs(o) {
|
|
|
377
391
|
refuse(type === "comment" && !body.trim() ? "missing body" : "body too long", at);
|
|
378
392
|
continue;
|
|
379
393
|
}
|
|
380
|
-
// Issue pin: a pinned comment or
|
|
394
|
+
// Issue pin: a pinned comment, label or state change may only land on that issue or PR.
|
|
395
|
+
// issue_state never guesses an issue: it needs the pin or a number on the intent.
|
|
396
|
+
const onIssue = type === "comment" || type === "label" || type === "issue_state";
|
|
381
397
|
let issueRef = intent.number?.trim().replace(/^#/, "") || undefined;
|
|
382
|
-
if (
|
|
398
|
+
if (onIssue && decl.number !== undefined) {
|
|
383
399
|
const pin = resolvePin(decl.number, o.input);
|
|
384
400
|
if (!pin) {
|
|
385
401
|
refuse("pinned issue is not set for this run", at);
|
|
@@ -391,10 +407,29 @@ export async function applySafeOutputs(o) {
|
|
|
391
407
|
}
|
|
392
408
|
issueRef = pin;
|
|
393
409
|
}
|
|
394
|
-
if (
|
|
410
|
+
if (onIssue && !issueRef) {
|
|
395
411
|
refuse("missing number", at);
|
|
396
412
|
continue;
|
|
397
413
|
}
|
|
414
|
+
// State change: reopen or close, inside the declared direction.
|
|
415
|
+
let state;
|
|
416
|
+
if (type === "issue_state") {
|
|
417
|
+
const asked = intent.state?.trim().toLowerCase() || decl.state;
|
|
418
|
+
if (asked !== "reopen" && asked !== "close") {
|
|
419
|
+
refuse("state must be reopen or close", at);
|
|
420
|
+
continue;
|
|
421
|
+
}
|
|
422
|
+
if (decl.state && asked !== decl.state) {
|
|
423
|
+
refuse("state outside the declared state", at);
|
|
424
|
+
continue;
|
|
425
|
+
}
|
|
426
|
+
// Closing needs the declaration to pin the issue (a workflow that skipped validation cannot bypass it).
|
|
427
|
+
if (asked === "close" && decl.number === undefined) {
|
|
428
|
+
refuse("close needs a pinned issue", at);
|
|
429
|
+
continue;
|
|
430
|
+
}
|
|
431
|
+
state = asked;
|
|
432
|
+
}
|
|
398
433
|
if (via === "github" && issueRef !== undefined && !/^[1-9][0-9]*$/.test(issueRef)) {
|
|
399
434
|
refuse("number must be an issue or PR number", at);
|
|
400
435
|
continue;
|
|
@@ -432,11 +467,11 @@ export async function applySafeOutputs(o) {
|
|
|
432
467
|
refuse("title too long", at);
|
|
433
468
|
continue;
|
|
434
469
|
}
|
|
435
|
-
// A Linear comment's issue ID does not encode or enforce its declared team.
|
|
470
|
+
// A Linear comment's (or state change's) issue ID does not encode or enforce its declared team.
|
|
436
471
|
// Resolve it through the configured read tool and carry its immutable ID to
|
|
437
472
|
// the write. Missing lookup support or unverifiable membership fails closed.
|
|
438
473
|
let number = issueRef;
|
|
439
|
-
if (via === "linear" && type === "comment" && target) {
|
|
474
|
+
if (via === "linear" && (type === "comment" || type === "issue_state") && target) {
|
|
440
475
|
const lookup = o.skills.get(via)?.tools.find((t) => t.name === "linear_get_issue" && t.access === "read");
|
|
441
476
|
let issue;
|
|
442
477
|
try {
|
|
@@ -464,7 +499,8 @@ export async function applySafeOutputs(o) {
|
|
|
464
499
|
...(target ? { target } : {}),
|
|
465
500
|
...(number !== undefined ? { number } : {}),
|
|
466
501
|
...(title ? { title } : {}),
|
|
467
|
-
...(type
|
|
502
|
+
...(type === "comment" || type === "issue" || type === "pr" ? { body } : {}),
|
|
503
|
+
...(state ? { state } : {}),
|
|
468
504
|
...(labels ? { labels } : {}),
|
|
469
505
|
...(type === "pr" ? { head: intent.head, ...(intent.base ? { base: intent.base } : {}) } : {}),
|
|
470
506
|
};
|
|
@@ -546,6 +582,8 @@ export async function applySafeOutputs(o) {
|
|
|
546
582
|
function printPreview(w, logger) {
|
|
547
583
|
const where = `${w.target ?? ""}${w.number ? `#${w.number}` : ""}`;
|
|
548
584
|
logger.info(` [staged] ${w.type} via ${w.via}${where ? ` -> ${where}` : ""}${w.title ? `: ${w.title}` : ""}`);
|
|
585
|
+
if (w.state)
|
|
586
|
+
logger.info(` state: ${w.state}`);
|
|
549
587
|
if (w.labels?.length)
|
|
550
588
|
logger.info(` labels: ${w.labels.join(", ")}`);
|
|
551
589
|
if (w.type === "pr")
|