@sweny-ai/core 0.1.210 → 0.1.212

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.
@@ -11,11 +11,15 @@
11
11
  * keys a migration renamed (they are added at the end of their map), and
12
12
  * formatting of arrays whose length a migration changed (rewritten whole).
13
13
  * JSON files carry no comments and are rewritten with 2-space indentation.
14
+ *
15
+ * Migration notes (#598): each spot a migration made explicit for the author
16
+ * to review gets a comment above its key (YAML), and is returned in `notes`
17
+ * so the CLI can print it (YAML and JSON).
14
18
  */
15
19
  import * as fs from "node:fs";
16
20
  import * as path from "node:path";
17
- import { isMap, parseDocument } from "yaml";
18
- import { DEFAULT_MIGRATION_CONFIG, migrateWorkflow } from "../migrations.js";
21
+ import { isMap, isScalar, parseDocument } from "yaml";
22
+ import { DEFAULT_MIGRATION_CONFIG, migrateWorkflow, } from "../migrations.js";
19
23
  import { validateParsed } from "../loader.js";
20
24
  function isPlainObject(v) {
21
25
  return typeof v === "object" && v !== null && !Array.isArray(v);
@@ -54,6 +58,25 @@ function applyDiff(doc, at, old, next) {
54
58
  else
55
59
  doc.setIn(at, next);
56
60
  }
61
+ /** Put `comment` on its own line above the key at `at`. No-op when the key is not in a block map. */
62
+ function commentAbove(doc, at, comment) {
63
+ if (at.length === 0)
64
+ return;
65
+ const parent = at.length === 1 ? doc.contents : doc.getIn(at.slice(0, -1), true);
66
+ if (!isMap(parent))
67
+ return;
68
+ // A flow map (`{ deny: [shell] }`) has no room for a comment line: put it above the map's own key.
69
+ if (parent.flow)
70
+ return commentAbove(doc, at.slice(0, -1), comment);
71
+ const last = at[at.length - 1];
72
+ const pair = parent.items.find((p) => (isScalar(p.key) ? p.key.value : p.key) === last);
73
+ if (!pair)
74
+ return;
75
+ if (!isScalar(pair.key))
76
+ pair.key = doc.createNode(pair.key);
77
+ if (isScalar(pair.key))
78
+ pair.key.commentBefore = ` ${comment}`;
79
+ }
57
80
  function setSpecVersion(doc, version) {
58
81
  const root = doc.contents;
59
82
  if (!isMap(root))
@@ -121,6 +144,8 @@ export function upgradeWorkflowFile(filePath, options = {}) {
121
144
  let out;
122
145
  if (doc) {
123
146
  applyDiff(doc, [], withoutSpecVersion(raw), withoutSpecVersion(migrated.workflow));
147
+ for (const note of migrated.notes)
148
+ commentAbove(doc, note.path, note.comment);
124
149
  setSpecVersion(doc, versionText);
125
150
  out = doc.toString();
126
151
  }
@@ -134,6 +159,7 @@ export function upgradeWorkflowFile(filePath, options = {}) {
134
159
  from: migrated.from,
135
160
  to: migrated.to,
136
161
  steps: migrated.applied.map((m) => m.description),
162
+ notes: migrated.notes,
137
163
  content: out,
138
164
  };
139
165
  }
package/dist/executor.js CHANGED
@@ -34,7 +34,7 @@ import { budgetGate, isToolClass, policyGate, resolveHarnessPolicy } from "./har
34
34
  import { BudgetGuard, BudgetRefusal, describeOverrun, minLimits, spendOf, toLimits } from "./budget.js";
35
35
  import { createRunDecider, wantsDecider } from "./decider.js";
36
36
  import { redact, runSecretValues } from "./journal.js";
37
- import { buildNodePolicy, resolveNodePermissions } from "./node-policy.js";
37
+ import { buildNodePolicy, hasNodePolicy, resolveNodePermissions } from "./node-policy.js";
38
38
  import { applySafeOutputs, createEmitOutputTool, createWriteStageState, resolveActor, safeOutputsInstruction, unresolvedOutputs, SAFE_OUTPUT_SCREEN_INSTRUCTION, } from "./safe-outputs.js";
39
39
  import { JournalWriteError } from "./journal.js";
40
40
  /**
@@ -463,7 +463,8 @@ async function executeRun(workflow, input, options, results, trace, at) {
463
463
  // #365: `permissions: read` (explicit, inherited, or implied by `outputs`)
464
464
  // gets the same tool gate as a dry run.
465
465
  const permissions = resolveNodePermissions(node, workflow);
466
- const declaresPolicy = node.permissions !== undefined || workflow.permissions !== undefined || (node.outputs?.length ?? 0) > 0;
466
+ // #598: a spec v2 node with nothing declared is `read`, a policy like any other.
467
+ const declaresPolicy = hasNodePolicy(node, workflow);
467
468
  const readOnlyNode = dryRun || permissions.access === "read";
468
469
  // A staged run (--stage, safe_outputs.staged) writes nothing either: its
469
470
  // write tools are withheld too, and the dispatcher below refuses any that
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 { CURRENT_SPEC_VERSION } from "./migrations.js";
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: workflow.spec_version ?? String(CURRENT_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
  }
@@ -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 = 1;
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. Empty while v1 is current.
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);
@@ -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 = 1;
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. Empty while v1 is current.
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) {
@@ -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 `write` (today's behavior). A workflow `read` ceiling wins
20
- * over a node `write` (the loader rejects that combination; this is the
21
- * runtime backstop). deny: union. strict: either. native: `none` if either says so.
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[];
@@ -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 `write` (today's behavior). A workflow `read` ceiling wins
17
- * over a node `write` (the loader rejects that combination; this is the
18
- * runtime backstop). deny: union. strict: either. native: `none` if either says so.
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 ?? "write"));
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
+ }
@@ -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;
@@ -113,8 +113,9 @@ export function createEmitOutputTool(declarations, buffer, now = Date.now) {
113
113
  export function safeOutputsInstruction(declarations, input) {
114
114
  const lines = declarations.map((d) => {
115
115
  const parts = [`at most ${d.max ?? 1}`];
116
- if (d.target)
117
- parts.push(`target ${d.target}`);
116
+ const target = resolveTarget(d.target, input);
117
+ if (target)
118
+ parts.push(`target ${target}`);
118
119
  if (d.number !== undefined) {
119
120
  const pin = resolvePin(d.number, input);
120
121
  parts.push(pin ? `only issue or PR ${pin} (the number may be omitted)` : "pinned issue not set for this run");
@@ -179,6 +180,14 @@ export function actorTrusted(policy, actor) {
179
180
  * The issue or PR a declaration pins, as a string. `{ input: name }` reads the
180
181
  * run input; an absent or empty value is `undefined`, which refuses the write.
181
182
  */
183
+ /** A declared target: the literal, or the run input it names. Empty or missing: undefined. */
184
+ export function resolveTarget(target, input) {
185
+ const raw = typeof target === "object" ? input?.[target.input] : target;
186
+ if (typeof raw !== "string")
187
+ return undefined;
188
+ const v = raw.trim();
189
+ return v.length > 0 ? v : undefined;
190
+ }
182
191
  export function resolvePin(pin, input) {
183
192
  const raw = typeof pin === "object" ? input?.[pin.input] : pin;
184
193
  if (typeof raw === "number" && Number.isInteger(raw) && raw > 0)
@@ -375,12 +384,21 @@ export async function applySafeOutputs(o) {
375
384
  refuse("no skill can apply this type");
376
385
  continue;
377
386
  }
378
- // The run's repo from the operator's env, never one a workspace .env introduced.
379
- const pinned = decl.target ?? (via === "github" ? trustedEnvValue(o.env, "GITHUB_REPOSITORY") || undefined : undefined);
387
+ // The declared target (a literal, or the run input it names), else the run's
388
+ // repo from the operator's env, never one a workspace .env introduced.
389
+ const pinned = resolveTarget(decl.target, o.input) ??
390
+ (via === "github" ? trustedEnvValue(o.env, "GITHUB_REPOSITORY") || undefined : undefined);
380
391
  if (intent.target && pinned && intent.target.toLowerCase() !== pinned.toLowerCase()) {
381
392
  refuse("target outside the declared target", { via });
382
393
  continue;
383
394
  }
395
+ // #598: an output pinned to one issue or PR is pinned to one repository too, and that
396
+ // repository comes only from the declaration, a run input, or GITHUB_REPOSITORY. Never
397
+ // from the agent: PR #N of any repo the token can write to is not the pinned PR.
398
+ if (via === "github" && decl.number !== undefined && !pinned) {
399
+ refuse("pinned output needs a trusted repository", { via });
400
+ continue;
401
+ }
384
402
  const target = intent.target ?? pinned;
385
403
  if (applier.needsTarget && !target) {
386
404
  refuse("no target", { via });
package/dist/schema.d.ts CHANGED
@@ -297,7 +297,9 @@ export declare const safeOutputDeclarationZ: z.ZodObject<{
297
297
  }>;
298
298
  via: z.ZodOptional<z.ZodString>;
299
299
  max: z.ZodOptional<z.ZodNumber>;
300
- target: z.ZodOptional<z.ZodString>;
300
+ target: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodObject<{
301
+ input: z.ZodString;
302
+ }, z.core.$strict>]>>;
301
303
  title_prefix: z.ZodOptional<z.ZodString>;
302
304
  labels: z.ZodOptional<z.ZodArray<z.ZodString>>;
303
305
  expires: z.ZodOptional<z.ZodString>;
@@ -397,7 +399,9 @@ export declare const nodeZ: z.ZodObject<{
397
399
  }>;
398
400
  via: z.ZodOptional<z.ZodString>;
399
401
  max: z.ZodOptional<z.ZodNumber>;
400
- target: z.ZodOptional<z.ZodString>;
402
+ target: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodObject<{
403
+ input: z.ZodString;
404
+ }, z.core.$strict>]>>;
401
405
  title_prefix: z.ZodOptional<z.ZodString>;
402
406
  labels: z.ZodOptional<z.ZodArray<z.ZodString>>;
403
407
  expires: z.ZodOptional<z.ZodString>;
@@ -603,7 +607,9 @@ export declare const workflowZ: z.ZodObject<{
603
607
  }>;
604
608
  via: z.ZodOptional<z.ZodString>;
605
609
  max: z.ZodOptional<z.ZodNumber>;
606
- target: z.ZodOptional<z.ZodString>;
610
+ target: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodObject<{
611
+ input: z.ZodString;
612
+ }, z.core.$strict>]>>;
607
613
  title_prefix: z.ZodOptional<z.ZodString>;
608
614
  labels: z.ZodOptional<z.ZodArray<z.ZodString>>;
609
615
  expires: z.ZodOptional<z.ZodString>;
@@ -871,7 +877,9 @@ export declare function parseWorkflow(raw: unknown): {
871
877
  type: "label" | "comment" | "issue" | "pr" | "issue_state";
872
878
  via?: string | undefined;
873
879
  max?: number | undefined;
874
- target?: string | undefined;
880
+ target?: string | {
881
+ input: string;
882
+ } | undefined;
875
883
  title_prefix?: string | undefined;
876
884
  labels?: string[] | undefined;
877
885
  expires?: string | undefined;
@@ -1360,9 +1368,21 @@ export declare const workflowJsonSchema: {
1360
1368
  readonly description: "Max writes of this type from this node per run. Default 1.";
1361
1369
  };
1362
1370
  readonly target: {
1363
- readonly type: "string";
1364
- readonly minLength: 1;
1365
- readonly description: "Pinned target (GitHub owner/repo, Linear team id). GitHub default: GITHUB_REPOSITORY.";
1371
+ readonly description: "Pinned target (GitHub owner/repo, Linear team id), or { input: <name> } to take it from a run input. GitHub default: GITHUB_REPOSITORY. An output that pins number never takes its GitHub repository from the agent.";
1372
+ readonly oneOf: readonly [{
1373
+ readonly type: "string";
1374
+ readonly minLength: 1;
1375
+ }, {
1376
+ readonly type: "object";
1377
+ readonly required: readonly ["input"];
1378
+ readonly additionalProperties: false;
1379
+ readonly properties: {
1380
+ readonly input: {
1381
+ readonly type: "string";
1382
+ readonly minLength: 1;
1383
+ };
1384
+ };
1385
+ }];
1366
1386
  };
1367
1387
  readonly title_prefix: {
1368
1388
  readonly type: "string";
@@ -1427,7 +1447,7 @@ export declare const workflowJsonSchema: {
1427
1447
  readonly spec_version: {
1428
1448
  readonly type: "string";
1429
1449
  readonly pattern: "^[1-9][0-9]*$";
1430
- readonly description: "Workflow spec version as a positive integer string. Absent means 1. Older versions are migrated in memory on load; newer than the CLI supports is refused. Run `sweny workflow upgrade <file>` to rewrite a file at the current version.";
1450
+ readonly description: "Workflow spec version as a positive integer string. Absent means 1. Version 2 is safe by default: a node that declares no permissions and no outputs, in a workflow that declares no permissions, is read-only. Version 1 files are migrated in memory on load, keeping write on such nodes with one warning each; newer than the CLI supports is refused. Run `sweny workflow upgrade <file>` to rewrite a file at the current version.";
1431
1451
  };
1432
1452
  readonly entry: {
1433
1453
  readonly type: "string";
@@ -1465,7 +1485,7 @@ export declare const workflowJsonSchema: {
1465
1485
  };
1466
1486
  readonly permissions: {
1467
1487
  readonly $ref: "#/$defs/Permissions";
1468
- readonly description: "Default and ceiling for every node's permissions. Absent: write.";
1488
+ readonly description: "Default and ceiling for every node's permissions. Absent: read (spec_version 2), write (spec_version 1).";
1469
1489
  };
1470
1490
  readonly safe_outputs: {
1471
1491
  readonly type: "object";
@@ -1759,7 +1779,7 @@ export declare const workflowJsonSchema: {
1759
1779
  };
1760
1780
  readonly permissions: {
1761
1781
  readonly $ref: "#/$defs/Permissions";
1762
- readonly description: "What this node's agent may do. Absent: read when the node declares outputs, else the workflow's permissions, else write.";
1782
+ readonly description: "What this node's agent may do. Absent: read when the node declares outputs, else the workflow's permissions, else read (spec_version 2) or write (spec_version 1).";
1763
1783
  };
1764
1784
  readonly outputs: {
1765
1785
  readonly type: "array";