@sweny-ai/core 0.1.209 → 0.1.211
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/cli/main.js +4 -0
- package/dist/cli/packs.d.ts +1 -0
- package/dist/cli/packs.js +8 -3
- package/dist/cli/templates.js +148 -21
- package/dist/cli/workflow-upgrade.d.ts +7 -1
- package/dist/cli/workflow-upgrade.js +28 -2
- package/dist/executor.d.ts +16 -1
- package/dist/executor.js +88 -4
- package/dist/harness/claude-code.d.ts +39 -4
- package/dist/harness/claude-code.js +98 -11
- package/dist/harness/types.d.ts +8 -0
- package/dist/journal.d.ts +2 -1
- package/dist/journal.js +4 -3
- package/dist/loader.js +5 -0
- package/dist/migrations.d.ts +33 -2
- package/dist/migrations.js +106 -4
- package/dist/node-policy.d.ts +27 -4
- package/dist/node-policy.js +59 -4
- package/dist/safe-outputs.d.ts +3 -1
- package/dist/safe-outputs.js +22 -4
- package/dist/schema.d.ts +31 -11
- package/dist/schema.js +30 -7
- package/dist/types.d.ts +15 -4
- package/dist/workflow-builder.d.ts +1 -1
- package/dist/workflow-builder.js +11 -4
- package/dist/workflows/browser.js +3 -3
- package/dist/workflows/implement.yml +7 -0
- package/dist/workflows/seed-content.yml +17 -0
- package/dist/workflows/triage.yml +7 -0
- package/package.json +1 -1
|
@@ -34,11 +34,12 @@ export declare const CLASSIFICATION_DISALLOWED_TOOLS: readonly ["Bash", "Write",
|
|
|
34
34
|
/**
|
|
35
35
|
* Built-in tools disallowed for a node run with `readOnly: true` (dry run,
|
|
36
36
|
* #380): everything that can edit the workspace, shell out (and so reach
|
|
37
|
-
* `gh`, `curl`, `git push`), spawn a subagent that could,
|
|
38
|
-
*
|
|
39
|
-
*
|
|
37
|
+
* `gh`, `curl`, `git push`), spawn a subagent that could, fetch an arbitrary
|
|
38
|
+
* URL (a GET can still trigger a webhook or exfiltrate data) or search the web
|
|
39
|
+
* (a query is an egress channel that can carry repo data out). Read, Grep and
|
|
40
|
+
* Glob stay available for analysis.
|
|
40
41
|
*/
|
|
41
|
-
export declare const READ_ONLY_DISALLOWED_TOOLS: readonly ["Bash", "Write", "Edit", "MultiEdit", "NotebookEdit", "Task", "Agent", "WebFetch"];
|
|
42
|
+
export declare const READ_ONLY_DISALLOWED_TOOLS: readonly ["Bash", "Write", "Edit", "MultiEdit", "NotebookEdit", "Task", "Agent", "WebFetch", "WebSearch"];
|
|
42
43
|
/**
|
|
43
44
|
* Claude Code built-in tool names per portable tool class. A node's
|
|
44
45
|
* `tools.deny` classes compile to these names on `disallowedTools`.
|
|
@@ -71,6 +72,40 @@ export declare function claudeReadDenyRule(absPath: string): string;
|
|
|
71
72
|
export declare function claudeStateDirDenyRules(root?: string): string[];
|
|
72
73
|
/** Native `disallowedTools` for a policy: legacy names, compiled classes, and the read-only set. */
|
|
73
74
|
export declare function compileClaudeCodeDeny(policy: NodePolicy, legacy?: readonly string[]): string[];
|
|
75
|
+
/**
|
|
76
|
+
* The only Claude Code built-in tools a node may get, by policy. Every node
|
|
77
|
+
* reads and searches the checkout, and a read-only node gets nothing else: no
|
|
78
|
+
* web search or fetch, since either can carry repo data out. A write node adds
|
|
79
|
+
* the shell, file-edit and web built-ins (its `tools.deny` classes and
|
|
80
|
+
* `disallowed_tools` names then leave the list). No node gets the subagent
|
|
81
|
+
* tools (Task, Agent) by default: nothing shows a subagent is held to its
|
|
82
|
+
* parent's `tools`, so the default must not depend on it.
|
|
83
|
+
*
|
|
84
|
+
* Passed as the SDK's `tools` option, which "Specif[ies] the base set of
|
|
85
|
+
* available built-in tools" (sdk.d.ts); `allowedTools` only auto-approves
|
|
86
|
+
* ("To restrict which tools are available, use the `tools` option instead").
|
|
87
|
+
* Anything not named here never reaches a node: agent-to-agent messaging
|
|
88
|
+
* (SendMessage, ListAgents), scheduling (Cron*, ScheduleWakeup), remote
|
|
89
|
+
* triggers, notifications, worktrees, design sync, Skill, Workflow and
|
|
90
|
+
* ToolSearch (added back only while a server defers its tools, see
|
|
91
|
+
* {@link runQuery}). Dogfood run 42890468912 listed 23 tools for a read-only
|
|
92
|
+
* node that needed two.
|
|
93
|
+
*/
|
|
94
|
+
export declare const CLAUDE_READ_ONLY_BUILTIN_TOOLS: readonly ["Read", "Grep", "Glob"];
|
|
95
|
+
export declare const CLAUDE_WRITE_BUILTIN_TOOLS: readonly ["Read", "Grep", "Glob", ...string[]];
|
|
96
|
+
/**
|
|
97
|
+
* The built-in allow-list for one node: the policy's list minus every name the
|
|
98
|
+
* compiled deny list removes. Path-scoped rules (`Read(//dir/**)`) narrow a
|
|
99
|
+
* tool, they do not remove it, so they are not matched here.
|
|
100
|
+
*/
|
|
101
|
+
export declare function claudeCodeBuiltinTools(policy: {
|
|
102
|
+
readOnly: boolean;
|
|
103
|
+
}, deny?: readonly string[]): string[];
|
|
104
|
+
/**
|
|
105
|
+
* Env vars the harness itself sets on the agent (not passed through from the
|
|
106
|
+
* parent): `ENABLE_TOOL_SEARCH`, see {@link ClaudeCodeHarness} runQuery.
|
|
107
|
+
*/
|
|
108
|
+
export declare const CLAUDE_CODE_HARNESS_VARS: readonly ["ENABLE_TOOL_SEARCH"];
|
|
74
109
|
/** How sweny resolves which credentials reach the Claude Code subprocess. */
|
|
75
110
|
export type SwenyAuthMode = "auto" | "api-key" | "oauth";
|
|
76
111
|
export interface ResolveAuthEnvOpts {
|
|
@@ -68,9 +68,10 @@ export const CLASSIFICATION_DISALLOWED_TOOLS = [
|
|
|
68
68
|
/**
|
|
69
69
|
* Built-in tools disallowed for a node run with `readOnly: true` (dry run,
|
|
70
70
|
* #380): everything that can edit the workspace, shell out (and so reach
|
|
71
|
-
* `gh`, `curl`, `git push`), spawn a subagent that could,
|
|
72
|
-
*
|
|
73
|
-
*
|
|
71
|
+
* `gh`, `curl`, `git push`), spawn a subagent that could, fetch an arbitrary
|
|
72
|
+
* URL (a GET can still trigger a webhook or exfiltrate data) or search the web
|
|
73
|
+
* (a query is an egress channel that can carry repo data out). Read, Grep and
|
|
74
|
+
* Glob stay available for analysis.
|
|
74
75
|
*/
|
|
75
76
|
export const READ_ONLY_DISALLOWED_TOOLS = [
|
|
76
77
|
"Bash",
|
|
@@ -81,6 +82,7 @@ export const READ_ONLY_DISALLOWED_TOOLS = [
|
|
|
81
82
|
"Task",
|
|
82
83
|
"Agent",
|
|
83
84
|
"WebFetch",
|
|
85
|
+
"WebSearch",
|
|
84
86
|
];
|
|
85
87
|
/**
|
|
86
88
|
* Claude Code built-in tool names per portable tool class. A node's
|
|
@@ -146,6 +148,55 @@ export function compileClaudeCodeDeny(policy, legacy = []) {
|
|
|
146
148
|
]),
|
|
147
149
|
];
|
|
148
150
|
}
|
|
151
|
+
/**
|
|
152
|
+
* The only Claude Code built-in tools a node may get, by policy. Every node
|
|
153
|
+
* reads and searches the checkout, and a read-only node gets nothing else: no
|
|
154
|
+
* web search or fetch, since either can carry repo data out. A write node adds
|
|
155
|
+
* the shell, file-edit and web built-ins (its `tools.deny` classes and
|
|
156
|
+
* `disallowed_tools` names then leave the list). No node gets the subagent
|
|
157
|
+
* tools (Task, Agent) by default: nothing shows a subagent is held to its
|
|
158
|
+
* parent's `tools`, so the default must not depend on it.
|
|
159
|
+
*
|
|
160
|
+
* Passed as the SDK's `tools` option, which "Specif[ies] the base set of
|
|
161
|
+
* available built-in tools" (sdk.d.ts); `allowedTools` only auto-approves
|
|
162
|
+
* ("To restrict which tools are available, use the `tools` option instead").
|
|
163
|
+
* Anything not named here never reaches a node: agent-to-agent messaging
|
|
164
|
+
* (SendMessage, ListAgents), scheduling (Cron*, ScheduleWakeup), remote
|
|
165
|
+
* triggers, notifications, worktrees, design sync, Skill, Workflow and
|
|
166
|
+
* ToolSearch (added back only while a server defers its tools, see
|
|
167
|
+
* {@link runQuery}). Dogfood run 42890468912 listed 23 tools for a read-only
|
|
168
|
+
* node that needed two.
|
|
169
|
+
*/
|
|
170
|
+
export const CLAUDE_READ_ONLY_BUILTIN_TOOLS = ["Read", "Grep", "Glob"];
|
|
171
|
+
export const CLAUDE_WRITE_BUILTIN_TOOLS = [
|
|
172
|
+
"Read",
|
|
173
|
+
"Grep",
|
|
174
|
+
"Glob",
|
|
175
|
+
...CLAUDE_TOOLS_BY_CLASS.shell,
|
|
176
|
+
...CLAUDE_TOOLS_BY_CLASS.write,
|
|
177
|
+
...CLAUDE_TOOLS_BY_CLASS.edit,
|
|
178
|
+
...CLAUDE_TOOLS_BY_CLASS.net,
|
|
179
|
+
];
|
|
180
|
+
/**
|
|
181
|
+
* The built-in allow-list for one node: the policy's list minus every name the
|
|
182
|
+
* compiled deny list removes. Path-scoped rules (`Read(//dir/**)`) narrow a
|
|
183
|
+
* tool, they do not remove it, so they are not matched here.
|
|
184
|
+
*/
|
|
185
|
+
export function claudeCodeBuiltinTools(policy, deny = []) {
|
|
186
|
+
const denied = new Set(deny);
|
|
187
|
+
const base = policy.readOnly ? CLAUDE_READ_ONLY_BUILTIN_TOOLS : CLAUDE_WRITE_BUILTIN_TOOLS;
|
|
188
|
+
return base.filter((t) => !denied.has(t));
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Env vars the harness itself sets on the agent (not passed through from the
|
|
192
|
+
* parent): `ENABLE_TOOL_SEARCH`, see {@link ClaudeCodeHarness} runQuery.
|
|
193
|
+
*/
|
|
194
|
+
export const CLAUDE_CODE_HARNESS_VARS = ["ENABLE_TOOL_SEARCH"];
|
|
195
|
+
/** CI: a truthy `CI` (not "", "0", "false", "no", "off"), or GitHub Actions itself. */
|
|
196
|
+
function runningInCi(env = process.env) {
|
|
197
|
+
const ci = (env.CI ?? "").trim().toLowerCase();
|
|
198
|
+
return env.GITHUB_ACTIONS === "true" || !["", "0", "false", "no", "off"].includes(ci);
|
|
199
|
+
}
|
|
149
200
|
/**
|
|
150
201
|
* Decide which auth credentials survive into the Claude Code subprocess env.
|
|
151
202
|
*
|
|
@@ -562,6 +613,8 @@ export class ClaudeCodeHarness {
|
|
|
562
613
|
// Fail closed: a stream that ends with no terminal `result` message
|
|
563
614
|
// (subprocess died, stream truncated) is not a success.
|
|
564
615
|
let sawResult = false;
|
|
616
|
+
// The tool names the agent was given (init message), for the executor's repair rule.
|
|
617
|
+
let agentTools;
|
|
565
618
|
// Timeout / abort wiring (back-compat: only armed when requested).
|
|
566
619
|
// A single AbortController drives both an optional caller signal and an
|
|
567
620
|
// optional timeout timer. The SDK aborts the subprocess when this fires.
|
|
@@ -596,6 +649,20 @@ export class ClaudeCodeHarness {
|
|
|
596
649
|
else if (sdkTools.length > 0) {
|
|
597
650
|
allMcpServers["sweny-core"] = mcpServer;
|
|
598
651
|
}
|
|
652
|
+
// Deterministic tool loading: when every server loads its tools up front
|
|
653
|
+
// (sweny-core always does; a node's skill servers do unless they opt out),
|
|
654
|
+
// tool search has nothing to defer, so it is off and ToolSearch is not on
|
|
655
|
+
// the list. `ENABLE_TOOL_SEARCH=false` is the bundled CLI's switch (its
|
|
656
|
+
// own docs: "To turn it off ... set `ENABLE_TOOL_SEARCH` to `false`"; CLI
|
|
657
|
+
// 0.3.319 drops ToolSearch from the init tool list with it). A client-level
|
|
658
|
+
// server not marked alwaysLoad keeps tool search, and ToolSearch, so its
|
|
659
|
+
// deferred tools stay reachable.
|
|
660
|
+
const defers = Object.entries(allMcpServers).some(([name, cfg]) => name !== "sweny-core" && cfg?.alwaysLoad !== true);
|
|
661
|
+
if (!defers)
|
|
662
|
+
env.ENABLE_TOOL_SEARCH = "false";
|
|
663
|
+
const builtinTools = opts.nativeNone
|
|
664
|
+
? []
|
|
665
|
+
: [...claudeCodeBuiltinTools({ readOnly: !!readOnly }, disallowedTools), ...(defers ? ["ToolSearch"] : [])];
|
|
599
666
|
stream = query({
|
|
600
667
|
prompt,
|
|
601
668
|
options: {
|
|
@@ -622,10 +689,13 @@ export class ClaudeCodeHarness {
|
|
|
622
689
|
strictMcpConfig: true,
|
|
623
690
|
settingSources: [],
|
|
624
691
|
...(disallowedTools && disallowedTools.length > 0 ? { disallowedTools } : {}),
|
|
625
|
-
//
|
|
626
|
-
//
|
|
627
|
-
//
|
|
628
|
-
|
|
692
|
+
// Least privilege: only the built-ins this node's policy names (see
|
|
693
|
+
// CLAUDE_WRITE_BUILTIN_TOOLS). permissions.native: none passes `tools: []`,
|
|
694
|
+
// which removes every built-in tool whatever its name.
|
|
695
|
+
tools: builtinTools,
|
|
696
|
+
// `settingSources: []` loads no user, project or local settings, CLAUDE.md or skills
|
|
697
|
+
// (already the default above since #497; kept so native: none states its whole contract).
|
|
698
|
+
...(opts.nativeNone ? { settingSources: [] } : {}),
|
|
629
699
|
// CC-08: ask the SDK to produce validated structured output when the
|
|
630
700
|
// node declares an output schema. The SDK then returns the parsed
|
|
631
701
|
// value on `result.structured_output`, which we prefer over the
|
|
@@ -635,11 +705,27 @@ export class ClaudeCodeHarness {
|
|
|
635
705
|
});
|
|
636
706
|
for await (const message of stream) {
|
|
637
707
|
if (message.type === "system" && message.subtype === "init") {
|
|
638
|
-
// What the agent was actually given: the tool
|
|
639
|
-
// loudly, a node tool server that did not
|
|
708
|
+
// What the agent was actually given: the tool names (never inputs)
|
|
709
|
+
// and, loudly, a node tool or tool server that did not make it onto
|
|
710
|
+
// the list. In CI the names go out as one warning-level line, the
|
|
711
|
+
// level the default (non-verbose) run log shows, so a failed node is
|
|
712
|
+
// diagnosable without a verbose rerun; locally they stay at info.
|
|
640
713
|
const init = message;
|
|
641
|
-
if (Array.isArray(init.tools))
|
|
642
|
-
|
|
714
|
+
if (Array.isArray(init.tools)) {
|
|
715
|
+
agentTools = init.tools.filter((t) => typeof t === "string");
|
|
716
|
+
const line = ` agent tools: ${init.tools.join(", ")}`;
|
|
717
|
+
if (runningInCi())
|
|
718
|
+
this.logger.warn(line);
|
|
719
|
+
else
|
|
720
|
+
this.logger.info(line);
|
|
721
|
+
const listed = new Set(init.tools);
|
|
722
|
+
const absent = allMcpServers["sweny-core"]
|
|
723
|
+
? tools.map((t) => t.name).filter((n) => !listed.has(`mcp__sweny-core__${n}`))
|
|
724
|
+
: [];
|
|
725
|
+
if (absent.length > 0) {
|
|
726
|
+
this.logger.warn(` node tools missing from the agent's tool list: ${absent.join(", ")}`);
|
|
727
|
+
}
|
|
728
|
+
}
|
|
643
729
|
const core = Array.isArray(init.mcp_servers)
|
|
644
730
|
? init.mcp_servers.find((s) => s?.name === "sweny-core")
|
|
645
731
|
: undefined;
|
|
@@ -833,6 +919,7 @@ export class ClaudeCodeHarness {
|
|
|
833
919
|
data: { summary: response, ...parsedData },
|
|
834
920
|
toolCalls,
|
|
835
921
|
...(usage ? { usage } : {}),
|
|
922
|
+
...(agentTools ? { agentTools } : {}),
|
|
836
923
|
};
|
|
837
924
|
}
|
|
838
925
|
/**
|
package/dist/harness/types.d.ts
CHANGED
|
@@ -152,6 +152,14 @@ export interface HarnessRunRequest extends ClaudeRunRequest {
|
|
|
152
152
|
export interface HarnessRunResult extends NodeResult {
|
|
153
153
|
harness: HarnessInfo;
|
|
154
154
|
degraded: string[];
|
|
155
|
+
/**
|
|
156
|
+
* Names of every tool the agent was given for this run, as the agent itself
|
|
157
|
+
* listed them (Claude Code: the init message; MCP tools as
|
|
158
|
+
* `mcp__<server>__<tool>`). The executor repairs a skipped required tool
|
|
159
|
+
* only when it is on this list. Absent when the harness does not report it,
|
|
160
|
+
* and then no such repair happens. Never journaled.
|
|
161
|
+
*/
|
|
162
|
+
agentTools?: string[];
|
|
155
163
|
}
|
|
156
164
|
export interface HarnessCompleteRequest {
|
|
157
165
|
prompt: string;
|
package/dist/journal.d.ts
CHANGED
|
@@ -123,7 +123,8 @@ export declare function canonicalHash(value: unknown): string;
|
|
|
123
123
|
/**
|
|
124
124
|
* Hash of the workflow a journal is bound to: the canonical workflow plus its
|
|
125
125
|
* effective `spec_version` (#469), so a file that only changes spec version
|
|
126
|
-
* (and therefore migrations) is a different workflow for resume.
|
|
126
|
+
* (and therefore migrations) is a different workflow for resume. Absent means
|
|
127
|
+
* 1, the version such a workflow runs as (#598), not the current one.
|
|
127
128
|
*/
|
|
128
129
|
export declare function workflowHashOf(workflow: Workflow): string;
|
|
129
130
|
/** Hash of what the nodes were told: every resolved instruction, rule and context Source. */
|
package/dist/journal.js
CHANGED
|
@@ -109,7 +109,7 @@ import os from "node:os";
|
|
|
109
109
|
import path from "node:path";
|
|
110
110
|
import { isRefusal, refusalReason } from "./types.js";
|
|
111
111
|
import { resolveNodePermissions } from "./node-policy.js";
|
|
112
|
-
import {
|
|
112
|
+
import { effectiveSpecVersion } from "./migrations.js";
|
|
113
113
|
import { spendOf } from "./budget.js";
|
|
114
114
|
/**
|
|
115
115
|
* v3: each record's HMAC chains the run id and the previous record's HMAC.
|
|
@@ -152,10 +152,11 @@ export function canonicalHash(value) {
|
|
|
152
152
|
/**
|
|
153
153
|
* Hash of the workflow a journal is bound to: the canonical workflow plus its
|
|
154
154
|
* effective `spec_version` (#469), so a file that only changes spec version
|
|
155
|
-
* (and therefore migrations) is a different workflow for resume.
|
|
155
|
+
* (and therefore migrations) is a different workflow for resume. Absent means
|
|
156
|
+
* 1, the version such a workflow runs as (#598), not the current one.
|
|
156
157
|
*/
|
|
157
158
|
export function workflowHashOf(workflow) {
|
|
158
|
-
return canonicalHash({ ...workflow, spec_version:
|
|
159
|
+
return canonicalHash({ ...workflow, spec_version: String(effectiveSpecVersion(workflow)) });
|
|
159
160
|
}
|
|
160
161
|
/** Hash of what the nodes were told: every resolved instruction, rule and context Source. */
|
|
161
162
|
export function instructionHash(sources) {
|
package/dist/loader.js
CHANGED
|
@@ -14,6 +14,7 @@ import * as path from "node:path";
|
|
|
14
14
|
import { parse as parseYaml } from "yaml";
|
|
15
15
|
import { ZodError } from "zod";
|
|
16
16
|
import { workflowZ, validateWorkflow, preflightLegacyVerify } from "./schema.js";
|
|
17
|
+
import { approveDirectiveWarnings } from "./node-policy.js";
|
|
17
18
|
import { DEFAULT_MIGRATION_CONFIG, migrateWorkflow, migrationWarning, } from "./migrations.js";
|
|
18
19
|
/**
|
|
19
20
|
* Load a workflow YAML or JSON file, parse with the canonical Zod schema,
|
|
@@ -57,6 +58,9 @@ export function validateParsed(raw, options = {}) {
|
|
|
57
58
|
}
|
|
58
59
|
raw = migrated.workflow;
|
|
59
60
|
const warnings = migrated.applied.length > 0 ? [migrationWarning(migrated.from, migrated.to, options.filePath)] : [];
|
|
61
|
+
for (const note of migrated.notes)
|
|
62
|
+
if (note.warning)
|
|
63
|
+
warnings.push(note.warning);
|
|
60
64
|
// Detect legacy `verify:` blocks (renamed to `eval:` in v0.2.0) before the
|
|
61
65
|
// Zod parse. workflowZ is intentionally non-strict at the top level, so a
|
|
62
66
|
// top-level `verify:` would be silently STRIPPED instead of surfacing the
|
|
@@ -93,5 +97,6 @@ export function validateParsed(raw, options = {}) {
|
|
|
93
97
|
errors: structural.map((e) => ({ message: e.message, code: e.code, nodeId: e.nodeId })),
|
|
94
98
|
};
|
|
95
99
|
}
|
|
100
|
+
warnings.push(...approveDirectiveWarnings(parsed));
|
|
96
101
|
return warnings.length > 0 ? { ok: true, workflow: parsed, warnings } : { ok: true, workflow: parsed };
|
|
97
102
|
}
|
package/dist/migrations.d.ts
CHANGED
|
@@ -12,6 +12,11 @@
|
|
|
12
12
|
*
|
|
13
13
|
* Adding a migration: see `__tests__/migrations.test.ts` for the harness
|
|
14
14
|
* (registry validity check plus a chain test to copy).
|
|
15
|
+
*
|
|
16
|
+
* A migration must keep behavior: a file migrated in memory runs exactly as it
|
|
17
|
+
* did. Where a new version changes a default, the migration writes the old
|
|
18
|
+
* default out explicitly, and reports each spot as a {@link MigrationNote}: the
|
|
19
|
+
* loader turns notes into warnings, `sweny workflow upgrade` into comments.
|
|
15
20
|
*/
|
|
16
21
|
/** A raw, not-yet-validated workflow object as parsed from YAML or JSON. */
|
|
17
22
|
export type RawWorkflow = Record<string, unknown>;
|
|
@@ -24,11 +29,35 @@ export interface Migration {
|
|
|
24
29
|
description: string;
|
|
25
30
|
/** Pure: must not mutate its input. Returns the next-version object. */
|
|
26
31
|
migrate: (raw: RawWorkflow) => RawWorkflow;
|
|
32
|
+
/** Optional, pure: what the author should know about this step's changes to `raw` (its input). */
|
|
33
|
+
notes?: (raw: RawWorkflow) => MigrationNote[];
|
|
34
|
+
}
|
|
35
|
+
/** One spot a migration made explicit that the author should review. */
|
|
36
|
+
export interface MigrationNote {
|
|
37
|
+
/** Path of the key the migration set, in the migrated object (e.g. `["nodes", "post", "permissions"]`). */
|
|
38
|
+
path: (string | number)[];
|
|
39
|
+
/** The node it is about, when there is one. */
|
|
40
|
+
nodeId?: string;
|
|
41
|
+
/** One line `sweny workflow upgrade` writes as a comment above `path`. */
|
|
42
|
+
comment: string;
|
|
43
|
+
/** One line the loader emits when it migrates in memory. Absent: nothing to warn about. */
|
|
44
|
+
warning?: string;
|
|
27
45
|
}
|
|
28
46
|
/** The spec version this build of sweny reads natively. */
|
|
29
|
-
export declare const CURRENT_SPEC_VERSION =
|
|
47
|
+
export declare const CURRENT_SPEC_VERSION = 2;
|
|
48
|
+
/**
|
|
49
|
+
* The spec version a workflow object runs as: its `spec_version`, or 1 when
|
|
50
|
+
* absent (a file written before the field, or a workflow built in code that
|
|
51
|
+
* the loader never migrated). Permission defaults and the journal's workflow
|
|
52
|
+
* hash both read this, so resume binds to the version the policy ran under.
|
|
53
|
+
*/
|
|
54
|
+
export declare function effectiveSpecVersion(workflow: {
|
|
55
|
+
spec_version?: string;
|
|
56
|
+
}): number;
|
|
57
|
+
/** The warning for one node that can write only because nothing limits it. */
|
|
58
|
+
export declare function implicitWriteWarning(nodeId: string): string;
|
|
30
59
|
/**
|
|
31
|
-
* Ordered registry, one entry per version step.
|
|
60
|
+
* Ordered registry, one entry per version step.
|
|
32
61
|
* Append only; never edit a shipped migration.
|
|
33
62
|
*/
|
|
34
63
|
export declare const MIGRATIONS: readonly Migration[];
|
|
@@ -53,6 +82,8 @@ export type MigrateResult = {
|
|
|
53
82
|
declared: boolean;
|
|
54
83
|
/** Migrations applied, in order. Empty when the file is already current. */
|
|
55
84
|
applied: Migration[];
|
|
85
|
+
/** Notes from the applied migrations, in order. Empty when there are none. */
|
|
86
|
+
notes: MigrationNote[];
|
|
56
87
|
} | ({
|
|
57
88
|
ok: false;
|
|
58
89
|
} & MigrateError);
|
package/dist/migrations.js
CHANGED
|
@@ -12,14 +12,113 @@
|
|
|
12
12
|
*
|
|
13
13
|
* Adding a migration: see `__tests__/migrations.test.ts` for the harness
|
|
14
14
|
* (registry validity check plus a chain test to copy).
|
|
15
|
+
*
|
|
16
|
+
* A migration must keep behavior: a file migrated in memory runs exactly as it
|
|
17
|
+
* did. Where a new version changes a default, the migration writes the old
|
|
18
|
+
* default out explicitly, and reports each spot as a {@link MigrationNote}: the
|
|
19
|
+
* loader turns notes into warnings, `sweny workflow upgrade` into comments.
|
|
15
20
|
*/
|
|
16
21
|
/** The spec version this build of sweny reads natively. */
|
|
17
|
-
export const CURRENT_SPEC_VERSION =
|
|
22
|
+
export const CURRENT_SPEC_VERSION = 2;
|
|
23
|
+
/**
|
|
24
|
+
* The spec version a workflow object runs as: its `spec_version`, or 1 when
|
|
25
|
+
* absent (a file written before the field, or a workflow built in code that
|
|
26
|
+
* the loader never migrated). Permission defaults and the journal's workflow
|
|
27
|
+
* hash both read this, so resume binds to the version the policy ran under.
|
|
28
|
+
*/
|
|
29
|
+
export function effectiveSpecVersion(workflow) {
|
|
30
|
+
return workflow.spec_version === undefined ? 1 : Number(workflow.spec_version);
|
|
31
|
+
}
|
|
32
|
+
// ─── v1 -> v2: safe by default (#598) ────────────────────────────
|
|
33
|
+
function isRecord(v) {
|
|
34
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
35
|
+
}
|
|
36
|
+
/** The `access` a raw `permissions` value declares, if any. */
|
|
37
|
+
function declaredAccess(p) {
|
|
38
|
+
if (typeof p === "string")
|
|
39
|
+
return p;
|
|
40
|
+
return isRecord(p) ? p.access : undefined;
|
|
41
|
+
}
|
|
42
|
+
function declaresNativeNone(p) {
|
|
43
|
+
return isRecord(p) && p.native === "none";
|
|
44
|
+
}
|
|
45
|
+
function skillList(node) {
|
|
46
|
+
return Array.isArray(node.skills) ? node.skills.filter((s) => typeof s === "string") : [];
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Nodes whose access spec v1 left to its `write` fallback: no `access` of
|
|
50
|
+
* their own, no `outputs`, and no workflow `access`. Spec v2 reads those as
|
|
51
|
+
* `read`, so the migration declares what each one had.
|
|
52
|
+
*
|
|
53
|
+
* `read` only where it is provably the same: a node with no skills that runs
|
|
54
|
+
* with `native: none` (its own or the workflow's) has no tool at all, so
|
|
55
|
+
* nothing for `write` to unlock. Every other such node keeps `write`, the
|
|
56
|
+
* access it had; the migration never grants more than v1 did.
|
|
57
|
+
*/
|
|
58
|
+
function implicitAccessNodes(raw) {
|
|
59
|
+
if (declaredAccess(raw.permissions) !== undefined || !isRecord(raw.nodes))
|
|
60
|
+
return [];
|
|
61
|
+
const wfNone = declaresNativeNone(raw.permissions);
|
|
62
|
+
const found = [];
|
|
63
|
+
for (const [id, node] of Object.entries(raw.nodes)) {
|
|
64
|
+
if (!isRecord(node))
|
|
65
|
+
continue;
|
|
66
|
+
if (declaredAccess(node.permissions) !== undefined)
|
|
67
|
+
continue;
|
|
68
|
+
if (Array.isArray(node.outputs) && node.outputs.length > 0)
|
|
69
|
+
continue;
|
|
70
|
+
const skills = skillList(node);
|
|
71
|
+
const noTools = skills.length === 0 && (wfNone || declaresNativeNone(node.permissions));
|
|
72
|
+
const path = isRecord(node.permissions) ? ["nodes", id, "permissions", "access"] : ["nodes", id, "permissions"];
|
|
73
|
+
found.push({ id, access: noTools ? "read" : "write", path, skills });
|
|
74
|
+
}
|
|
75
|
+
return found;
|
|
76
|
+
}
|
|
77
|
+
/** The warning for one node that can write only because nothing limits it. */
|
|
78
|
+
export function implicitWriteWarning(nodeId) {
|
|
79
|
+
return (`Node "${nodeId}" can write because nothing limits it (spec_version "1" default). ` +
|
|
80
|
+
"Declare permissions or outputs on it, or run `sweny workflow upgrade`.");
|
|
81
|
+
}
|
|
82
|
+
const v1ToV2 = {
|
|
83
|
+
from: 1,
|
|
84
|
+
to: 2,
|
|
85
|
+
description: "safe by default: declare `permissions` on every node that relied on the v1 `write` default",
|
|
86
|
+
migrate: (raw) => {
|
|
87
|
+
const found = implicitAccessNodes(raw);
|
|
88
|
+
if (found.length === 0)
|
|
89
|
+
return { ...raw };
|
|
90
|
+
const nodes = { ...raw.nodes };
|
|
91
|
+
for (const { id, access } of found) {
|
|
92
|
+
const node = nodes[id];
|
|
93
|
+
nodes[id] = {
|
|
94
|
+
...node,
|
|
95
|
+
permissions: isRecord(node.permissions) ? { ...node.permissions, access } : access,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
return { ...raw, nodes };
|
|
99
|
+
},
|
|
100
|
+
notes: (raw) => implicitAccessNodes(raw).map(({ id, access, path, skills }) => access === "read"
|
|
101
|
+
? {
|
|
102
|
+
path,
|
|
103
|
+
nodeId: id,
|
|
104
|
+
comment: "sweny workflow upgrade: read, because this node has no skills and no built-in tools.",
|
|
105
|
+
}
|
|
106
|
+
: {
|
|
107
|
+
path,
|
|
108
|
+
nodeId: id,
|
|
109
|
+
comment: `sweny workflow upgrade: write, kept from spec v1 (nothing limited this node; ` +
|
|
110
|
+
(skills.length > 0
|
|
111
|
+
? `its skills [${skills.join(", ")}] may need their write tools). `
|
|
112
|
+
: "it can use built-in shell and file edit tools). ") +
|
|
113
|
+
"If it only reads, set permissions: read; if it only posts, declare outputs.",
|
|
114
|
+
warning: implicitWriteWarning(id),
|
|
115
|
+
}),
|
|
116
|
+
};
|
|
18
117
|
/**
|
|
19
|
-
* Ordered registry, one entry per version step.
|
|
118
|
+
* Ordered registry, one entry per version step.
|
|
20
119
|
* Append only; never edit a shipped migration.
|
|
21
120
|
*/
|
|
22
|
-
export const MIGRATIONS = [];
|
|
121
|
+
export const MIGRATIONS = [v1ToV2];
|
|
23
122
|
export const DEFAULT_MIGRATION_CONFIG = {
|
|
24
123
|
current: CURRENT_SPEC_VERSION,
|
|
25
124
|
migrations: MIGRATIONS,
|
|
@@ -65,12 +164,15 @@ export function migrateWorkflow(raw, config = DEFAULT_MIGRATION_CONFIG) {
|
|
|
65
164
|
}
|
|
66
165
|
let current = declared ? { ...raw, spec_version: String(from) } : raw;
|
|
67
166
|
const applied = [];
|
|
167
|
+
const notes = [];
|
|
68
168
|
for (let v = from; v < config.current; v++) {
|
|
69
169
|
const step = config.migrations.find((m) => m.from === v);
|
|
70
170
|
if (!step) {
|
|
71
171
|
return { ok: false, code: "MIGRATION_FAILED", message: `No migration registered from spec_version "${v}".` };
|
|
72
172
|
}
|
|
73
173
|
try {
|
|
174
|
+
if (step.notes)
|
|
175
|
+
notes.push(...step.notes(current));
|
|
74
176
|
current = { ...step.migrate(current), spec_version: String(step.to) };
|
|
75
177
|
}
|
|
76
178
|
catch (err) {
|
|
@@ -83,7 +185,7 @@ export function migrateWorkflow(raw, config = DEFAULT_MIGRATION_CONFIG) {
|
|
|
83
185
|
}
|
|
84
186
|
applied.push(step);
|
|
85
187
|
}
|
|
86
|
-
return { ok: true, workflow: current, from, to: config.current, declared, applied };
|
|
188
|
+
return { ok: true, workflow: current, from, to: config.current, declared, applied, notes };
|
|
87
189
|
}
|
|
88
190
|
/** The single warning the loader emits when it migrated in memory. */
|
|
89
191
|
export function migrationWarning(from, to, filePath) {
|
package/dist/node-policy.d.ts
CHANGED
|
@@ -12,15 +12,29 @@ export interface ResolvedPermissions {
|
|
|
12
12
|
strict: boolean;
|
|
13
13
|
native?: NodeNative;
|
|
14
14
|
}
|
|
15
|
+
/**
|
|
16
|
+
* The access a node gets when neither it nor the workflow declares one (#598).
|
|
17
|
+
* Spec v2 and later: `read`, safe by default. Spec v1, or no `spec_version` (a
|
|
18
|
+
* workflow built in code, or one the loader did not migrate): `write`, the v1
|
|
19
|
+
* behavior. The loader migrates v1 files to v2 with that `write` made explicit.
|
|
20
|
+
*/
|
|
21
|
+
export declare function defaultNodeAccess(workflow?: Pick<Workflow, "spec_version">): NodeAccess;
|
|
22
|
+
/**
|
|
23
|
+
* True when the node runs under a declared policy: its own or the workflow's
|
|
24
|
+
* `permissions`, its `outputs`, or the spec v2 `read` default. A node that runs
|
|
25
|
+
* under none keeps each harness's own defaults (spec v1 only).
|
|
26
|
+
*/
|
|
27
|
+
export declare function hasNodePolicy(node: Pick<Node, "permissions" | "outputs">, workflow?: Pick<Workflow, "permissions" | "spec_version">): boolean;
|
|
15
28
|
/**
|
|
16
29
|
* Effective permissions for a node.
|
|
17
30
|
*
|
|
18
31
|
* access: the node's own, else `read` when it declares `outputs`, else the
|
|
19
|
-
* workflow's, else `
|
|
20
|
-
* over a node `write` (the loader rejects
|
|
21
|
-
* runtime backstop). deny: union. strict:
|
|
32
|
+
* workflow's, else {@link defaultNodeAccess} (`read` from spec v2, `write` in
|
|
33
|
+
* v1). A workflow `read` ceiling wins over a node `write` (the loader rejects
|
|
34
|
+
* that combination; this is the runtime backstop). deny: union. strict:
|
|
35
|
+
* either. native: `none` if either says so.
|
|
22
36
|
*/
|
|
23
|
-
export declare function resolveNodePermissions(node: Pick<Node, "permissions" | "outputs">, workflow?: Pick<Workflow, "permissions">): ResolvedPermissions;
|
|
37
|
+
export declare function resolveNodePermissions(node: Pick<Node, "permissions" | "outputs">, workflow?: Pick<Workflow, "permissions" | "spec_version">): ResolvedPermissions;
|
|
24
38
|
/** True when a node asks for more than the workflow ceiling allows. */
|
|
25
39
|
export declare function exceedsPermissionCeiling(node: Pick<Node, "permissions">, workflow: Pick<Workflow, "permissions">): boolean;
|
|
26
40
|
/** The portable policy handed to the harness for one node run. */
|
|
@@ -30,3 +44,12 @@ export declare function buildNodePolicy(opts: {
|
|
|
30
44
|
disallowedTools?: string[];
|
|
31
45
|
egress?: string[];
|
|
32
46
|
}): NodePolicy;
|
|
47
|
+
/**
|
|
48
|
+
* Load warnings for review nodes told to approve (#598). The pr-review
|
|
49
|
+
* template before #598 said "If no issues found, approve with a brief note";
|
|
50
|
+
* files generated from it keep that line through `sweny workflow upgrade`.
|
|
51
|
+
* Precise on purpose: only a node that can write (so it could act on it), in a
|
|
52
|
+
* `pr_review` workflow or whose instruction is about a pull request or review,
|
|
53
|
+
* with an inline instruction line that says "approve", not negated ("never approve").
|
|
54
|
+
*/
|
|
55
|
+
export declare function approveDirectiveWarnings(workflow: Workflow): string[];
|
package/dist/node-policy.js
CHANGED
|
@@ -4,23 +4,45 @@
|
|
|
4
4
|
* Pure and browser-safe. The workflow's `permissions` is both the default and
|
|
5
5
|
* the ceiling for every node; a node may narrow it, never widen it.
|
|
6
6
|
*/
|
|
7
|
+
import { effectiveSpecVersion } from "./migrations.js";
|
|
7
8
|
function spec(p) {
|
|
8
9
|
if (p === undefined)
|
|
9
10
|
return {};
|
|
10
11
|
return typeof p === "string" ? { access: p } : p;
|
|
11
12
|
}
|
|
13
|
+
/**
|
|
14
|
+
* The access a node gets when neither it nor the workflow declares one (#598).
|
|
15
|
+
* Spec v2 and later: `read`, safe by default. Spec v1, or no `spec_version` (a
|
|
16
|
+
* workflow built in code, or one the loader did not migrate): `write`, the v1
|
|
17
|
+
* behavior. The loader migrates v1 files to v2 with that `write` made explicit.
|
|
18
|
+
*/
|
|
19
|
+
export function defaultNodeAccess(workflow = {}) {
|
|
20
|
+
return effectiveSpecVersion(workflow) >= 2 ? "read" : "write";
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* True when the node runs under a declared policy: its own or the workflow's
|
|
24
|
+
* `permissions`, its `outputs`, or the spec v2 `read` default. A node that runs
|
|
25
|
+
* under none keeps each harness's own defaults (spec v1 only).
|
|
26
|
+
*/
|
|
27
|
+
export function hasNodePolicy(node, workflow = {}) {
|
|
28
|
+
return (node.permissions !== undefined ||
|
|
29
|
+
workflow.permissions !== undefined ||
|
|
30
|
+
(node.outputs?.length ?? 0) > 0 ||
|
|
31
|
+
defaultNodeAccess(workflow) === "read");
|
|
32
|
+
}
|
|
12
33
|
/**
|
|
13
34
|
* Effective permissions for a node.
|
|
14
35
|
*
|
|
15
36
|
* access: the node's own, else `read` when it declares `outputs`, else the
|
|
16
|
-
* workflow's, else `
|
|
17
|
-
* over a node `write` (the loader rejects
|
|
18
|
-
* runtime backstop). deny: union. strict:
|
|
37
|
+
* workflow's, else {@link defaultNodeAccess} (`read` from spec v2, `write` in
|
|
38
|
+
* v1). A workflow `read` ceiling wins over a node `write` (the loader rejects
|
|
39
|
+
* that combination; this is the runtime backstop). deny: union. strict:
|
|
40
|
+
* either. native: `none` if either says so.
|
|
19
41
|
*/
|
|
20
42
|
export function resolveNodePermissions(node, workflow = {}) {
|
|
21
43
|
const wf = spec(workflow.permissions);
|
|
22
44
|
const own = spec(node.permissions);
|
|
23
|
-
let access = own.access ?? (node.outputs && node.outputs.length > 0 ? "read" : (wf.access ??
|
|
45
|
+
let access = own.access ?? (node.outputs && node.outputs.length > 0 ? "read" : (wf.access ?? defaultNodeAccess(workflow)));
|
|
24
46
|
if (wf.access === "read")
|
|
25
47
|
access = "read";
|
|
26
48
|
const deny = [...new Set([...(wf.deny ?? []), ...(own.deny ?? [])])];
|
|
@@ -48,3 +70,36 @@ export function buildNodePolicy(opts) {
|
|
|
48
70
|
...(opts.permissions.native === "none" ? { nativeNone: true } : {}),
|
|
49
71
|
};
|
|
50
72
|
}
|
|
73
|
+
const APPROVE = /\bapprove\b/i;
|
|
74
|
+
/** "never approve", "do not approve", "don't ever approve": the negation governs approve itself. */
|
|
75
|
+
const NEGATED = /(?:\b(?:never|not|no|nor)|n't)\s+(?:\w+\s+)?approve\b/i;
|
|
76
|
+
const REVIEW = /\bpull requests?\b|\bPRs?\b|\breview/i;
|
|
77
|
+
/**
|
|
78
|
+
* Load warnings for review nodes told to approve (#598). The pr-review
|
|
79
|
+
* template before #598 said "If no issues found, approve with a brief note";
|
|
80
|
+
* files generated from it keep that line through `sweny workflow upgrade`.
|
|
81
|
+
* Precise on purpose: only a node that can write (so it could act on it), in a
|
|
82
|
+
* `pr_review` workflow or whose instruction is about a pull request or review,
|
|
83
|
+
* with an inline instruction line that says "approve", not negated ("never approve").
|
|
84
|
+
*/
|
|
85
|
+
export function approveDirectiveWarnings(workflow) {
|
|
86
|
+
const warnings = [];
|
|
87
|
+
for (const [id, node] of Object.entries(workflow.nodes)) {
|
|
88
|
+
const text = typeof node.instruction === "string"
|
|
89
|
+
? node.instruction
|
|
90
|
+
: "inline" in node.instruction
|
|
91
|
+
? node.instruction.inline
|
|
92
|
+
: undefined;
|
|
93
|
+
if (text === undefined || resolveNodePermissions(node, workflow).access !== "write")
|
|
94
|
+
continue;
|
|
95
|
+
if (workflow.workflow_type !== "pr_review" && !REVIEW.test(text))
|
|
96
|
+
continue;
|
|
97
|
+
const line = text.split("\n").find((l) => APPROVE.test(l) && !NEGATED.test(l));
|
|
98
|
+
if (line === undefined)
|
|
99
|
+
continue;
|
|
100
|
+
warnings.push(`Node "${id}" can write and its instruction tells the agent to approve ("${line.trim()}"). ` +
|
|
101
|
+
"An AI reviewer should never approve: delete that line, and post the review as a comment output " +
|
|
102
|
+
"(see `sweny new --template pr-review`).");
|
|
103
|
+
}
|
|
104
|
+
return warnings;
|
|
105
|
+
}
|
package/dist/safe-outputs.d.ts
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
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
|
*/
|
|
16
|
-
import type { BranchPusher, Logger, SafeOutputDeclaration, SafeOutputPin, SafeOutputReceipt, SafeOutputsPolicy, SafeOutputType, Skill, Tool, ToolContext } from "./types.js";
|
|
16
|
+
import type { BranchPusher, Logger, SafeOutputDeclaration, SafeOutputPin, SafeOutputTarget, SafeOutputReceipt, SafeOutputsPolicy, SafeOutputType, Skill, Tool, ToolContext } from "./types.js";
|
|
17
17
|
/** Name of the intent-recording tool a node with `outputs` receives. */
|
|
18
18
|
export declare const EMIT_OUTPUT_TOOL = "emit_output";
|
|
19
19
|
/** Longest title the write stage accepts (after the prefix). */
|
|
@@ -60,6 +60,8 @@ export declare function actorTrusted(policy: SafeOutputsPolicy | undefined, acto
|
|
|
60
60
|
* The issue or PR a declaration pins, as a string. `{ input: name }` reads the
|
|
61
61
|
* run input; an absent or empty value is `undefined`, which refuses the write.
|
|
62
62
|
*/
|
|
63
|
+
/** A declared target: the literal, or the run input it names. Empty or missing: undefined. */
|
|
64
|
+
export declare function resolveTarget(target: SafeOutputTarget | undefined, input?: Record<string, unknown>): string | undefined;
|
|
63
65
|
export declare function resolvePin(pin: SafeOutputPin, input?: Record<string, unknown>): string | undefined;
|
|
64
66
|
/** `30m` → 1_800_000. Undefined for anything the schema would reject. */
|
|
65
67
|
export declare function parseDuration(s: string | undefined): number | undefined;
|