@sweny-ai/core 0.1.143 → 0.1.144

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/schema.js CHANGED
@@ -7,7 +7,7 @@
7
7
  * parsing untrusted input (JSON/YAML import, Studio editor).
8
8
  */
9
9
  import { z } from "zod";
10
- import { EVALUATOR_KINDS, EVAL_POLICIES, MCP_TRANSPORTS, NODE_ON_FAIL, REQUIRES_ON_FAIL, SKILL_CATEGORIES, SKILL_ID_MAX_LENGTH, SKILL_ID_PATTERN, TOOL_ACCESS, WORKFLOW_TYPES, } from "./types.js";
10
+ import { AUTHOR_ASSOCIATIONS, EVALUATOR_KINDS, EVAL_POLICIES, MCP_TRANSPORTS, NODE_ACCESS, NODE_ON_FAIL, REQUIRES_ON_FAIL, SAFE_OUTPUT_APPLIERS, SAFE_OUTPUT_EXPIRES_PATTERN, SAFE_OUTPUT_MAX_CEILING, SAFE_OUTPUT_TYPES, SKILL_CATEGORIES, SKILL_ID_MAX_LENGTH, SKILL_ID_PATTERN, TOOL_ACCESS, TOOL_CLASSES, WORKFLOW_TYPES, } from "./types.js";
11
11
  import { sourceZ } from "./sources.js";
12
12
  import { workflowInputsZ, WORKFLOW_INPUT_TYPES } from "./inputs.js";
13
13
  export { sourceZ };
@@ -244,6 +244,47 @@ export const nodeRetryZ = z
244
244
  instruction: z.union([z.string().min(1), retryInstructionAutoZ, retryInstructionReflectZ]).optional(),
245
245
  })
246
246
  .strict();
247
+ /**
248
+ * Node / workflow permissions (#365): `read` | `write`, or an object with
249
+ * `access`, `deny` (portable tool classes) and `strict`. The object form must
250
+ * declare at least one key.
251
+ */
252
+ export const nodePermissionsZ = z.union([
253
+ z.enum(NODE_ACCESS),
254
+ z
255
+ .object({
256
+ access: z.enum(NODE_ACCESS).optional(),
257
+ deny: z.array(z.enum(TOOL_CLASSES)).min(1).optional(),
258
+ strict: z.boolean().optional(),
259
+ })
260
+ .strict()
261
+ .refine((p) => p.access !== undefined || p.deny !== undefined || p.strict !== undefined, {
262
+ message: "permissions must declare at least one of access, deny, strict",
263
+ }),
264
+ ]);
265
+ /** One typed write intent a node may emit (#365). */
266
+ export const safeOutputDeclarationZ = z
267
+ .object({
268
+ type: z.enum(SAFE_OUTPUT_TYPES),
269
+ via: z.string().min(1).optional(),
270
+ max: z.number().int().min(1).max(SAFE_OUTPUT_MAX_CEILING).optional(),
271
+ target: z.string().min(1).optional(),
272
+ title_prefix: z.string().min(1).max(64).optional(),
273
+ labels: z.array(z.string().min(1)).min(1).optional(),
274
+ expires: z.string().regex(SAFE_OUTPUT_EXPIRES_PATTERN).optional(),
275
+ })
276
+ .strict();
277
+ /** Workflow-level safe-output policy (#365): the ceiling and run-wide limits. */
278
+ export const safeOutputsPolicyZ = z
279
+ .object({
280
+ allow: z.array(z.enum(SAFE_OUTPUT_TYPES)).min(1).optional(),
281
+ max: z.number().int().min(1).max(SAFE_OUTPUT_MAX_CEILING).optional(),
282
+ staged: z.boolean().optional(),
283
+ trusted_actors: z.array(z.string().min(1)).min(1).optional(),
284
+ trusted_associations: z.array(z.enum(AUTHOR_ASSOCIATIONS)).min(1).optional(),
285
+ screen: z.boolean().optional(),
286
+ })
287
+ .strict();
247
288
  export const nodeZ = z
248
289
  .object({
249
290
  name: z.string().min(1),
@@ -255,6 +296,8 @@ export const nodeZ = z
255
296
  tools: nodeToolsZ.optional(),
256
297
  fail_soft: z.boolean().optional(),
257
298
  on_fail: z.enum(NODE_ON_FAIL).optional(),
299
+ permissions: nodePermissionsZ.optional(),
300
+ outputs: z.array(safeOutputDeclarationZ).min(1).optional(),
258
301
  rules: nodeSourcesZ.optional(),
259
302
  context: nodeSourcesZ.optional(),
260
303
  eval: z.array(evaluatorZ).min(1).optional(),
@@ -307,6 +350,8 @@ export const workflowZ = z.object({
307
350
  model: z.string().min(1).optional(),
308
351
  judge_budget: z.number().int().min(0).optional(),
309
352
  inputs: workflowInputsZ.optional(),
353
+ permissions: nodePermissionsZ.optional(),
354
+ safe_outputs: safeOutputsPolicyZ.optional(),
310
355
  });
311
356
  const LEGACY_VERIFY_MESSAGE = "uses the legacy 'verify:' field. It was renamed to 'eval:' in @sweny-ai/core v0.2.0 with a different shape (named evaluators with kind: value | function | judge). " +
312
357
  "Migration guide: https://spec.sweny.ai/nodes/#eval";
@@ -548,6 +593,48 @@ export function validateWorkflow(workflow, knownSkills) {
548
593
  });
549
594
  }
550
595
  }
596
+ // Permissions and safe outputs (#365). The workflow's `permissions` and
597
+ // `safe_outputs.allow` are a ceiling every node inherits.
598
+ const wfAccess = typeof workflow.permissions === "string" ? workflow.permissions : workflow.permissions?.access;
599
+ const allowedOutputs = workflow.safe_outputs?.allow;
600
+ for (const [nodeId, node] of Object.entries(workflow.nodes)) {
601
+ const nodeAccess = typeof node.permissions === "string" ? node.permissions : node.permissions?.access;
602
+ if (wfAccess === "read" && nodeAccess === "write") {
603
+ errors.push({
604
+ code: "PERMISSION_CEILING",
605
+ message: `Node "${nodeId}" asks for permissions write, above the workflow's permissions read`,
606
+ nodeId,
607
+ });
608
+ }
609
+ const seenTypes = new Set();
610
+ for (const out of node.outputs ?? []) {
611
+ if (seenTypes.has(out.type)) {
612
+ errors.push({
613
+ code: "DUPLICATE_OUTPUT",
614
+ message: `Node "${nodeId}" declares output "${out.type}" more than once; merge them into one entry`,
615
+ nodeId,
616
+ });
617
+ }
618
+ seenTypes.add(out.type);
619
+ if (allowedOutputs && !allowedOutputs.includes(out.type)) {
620
+ errors.push({
621
+ code: "OUTPUT_NOT_ALLOWED",
622
+ message: `Node "${nodeId}" declares output "${out.type}", outside the workflow's safe_outputs.allow [${allowedOutputs.join(", ")}]`,
623
+ nodeId,
624
+ });
625
+ }
626
+ if (out.via && !SAFE_OUTPUT_APPLIERS[out.via]?.includes(out.type)) {
627
+ const supported = Object.entries(SAFE_OUTPUT_APPLIERS)
628
+ .filter(([, types]) => types.includes(out.type))
629
+ .map(([id]) => id);
630
+ errors.push({
631
+ code: "UNSUPPORTED_OUTPUT",
632
+ message: `Node "${nodeId}" output "${out.type}" names via "${out.via}", which cannot apply it (supported: ${supported.join(", ")})`,
633
+ nodeId,
634
+ });
635
+ }
636
+ }
637
+ }
551
638
  // Skill references
552
639
  if (knownSkills) {
553
640
  // Merge workflow inline skills into known set
@@ -705,6 +792,71 @@ export const workflowJsonSchema = {
705
792
  },
706
793
  ],
707
794
  },
795
+ Permissions: {
796
+ description: "What a node's agent may do. 'read' runs it read-only: only access: read skill tools, no external skill MCP servers, no shell, file-write, edit, fetch or subagent built-ins. On the workflow it is the default and the ceiling for every node.",
797
+ oneOf: [
798
+ { type: "string", enum: [...NODE_ACCESS] },
799
+ {
800
+ type: "object",
801
+ additionalProperties: false,
802
+ minProperties: 1,
803
+ properties: {
804
+ access: { type: "string", enum: [...NODE_ACCESS] },
805
+ deny: {
806
+ type: "array",
807
+ items: { type: "string", enum: [...TOOL_CLASSES] },
808
+ minItems: 1,
809
+ description: "Built-in tool classes the agent must not have, compiled per harness.",
810
+ },
811
+ strict: {
812
+ type: "boolean",
813
+ description: "Exclusive MCP (only the servers sweny injects), and refuse the node on a harness that cannot enforce the policy.",
814
+ },
815
+ },
816
+ },
817
+ ],
818
+ },
819
+ SafeOutput: {
820
+ type: "object",
821
+ required: ["type"],
822
+ additionalProperties: false,
823
+ properties: {
824
+ type: { type: "string", enum: [...SAFE_OUTPUT_TYPES] },
825
+ via: {
826
+ type: "string",
827
+ minLength: 1,
828
+ description: "Skill that applies the write. Default: the first node skill that supports the type.",
829
+ },
830
+ max: {
831
+ type: "integer",
832
+ minimum: 1,
833
+ maximum: SAFE_OUTPUT_MAX_CEILING,
834
+ description: "Max writes of this type from this node per run. Default 1.",
835
+ },
836
+ target: {
837
+ type: "string",
838
+ minLength: 1,
839
+ description: "Pinned target (GitHub owner/repo, Linear team id). GitHub default: GITHUB_REPOSITORY.",
840
+ },
841
+ title_prefix: {
842
+ type: "string",
843
+ minLength: 1,
844
+ maxLength: 64,
845
+ description: "Prepended to issue and PR titles that do not already start with it.",
846
+ },
847
+ labels: {
848
+ type: "array",
849
+ items: { type: "string", minLength: 1 },
850
+ minItems: 1,
851
+ description: "issue / pr: labels always added. label: the only labels the agent may add.",
852
+ },
853
+ expires: {
854
+ type: "string",
855
+ pattern: SAFE_OUTPUT_EXPIRES_PATTERN.source,
856
+ description: "Drop an intent older than this when the write stage runs (e.g. 30m, 2h, 7d).",
857
+ },
858
+ },
859
+ },
708
860
  },
709
861
  properties: {
710
862
  id: { type: "string", minLength: 1 },
@@ -739,6 +891,46 @@ export const workflowJsonSchema = {
739
891
  minLength: 1,
740
892
  description: "Default execution model for every node. Overridable per-node. Free-text passthrough (no registry); resolved as node.model ?? workflow.model ?? executor default.",
741
893
  },
894
+ permissions: {
895
+ $ref: "#/$defs/Permissions",
896
+ description: "Default and ceiling for every node's permissions. Absent: write.",
897
+ },
898
+ safe_outputs: {
899
+ type: "object",
900
+ description: "Workflow-level safe-output policy: the ceiling on output types and run-wide limits.",
901
+ additionalProperties: false,
902
+ properties: {
903
+ allow: {
904
+ type: "array",
905
+ items: { type: "string", enum: [...SAFE_OUTPUT_TYPES] },
906
+ minItems: 1,
907
+ description: "Output types any node may declare. Absent: all types.",
908
+ },
909
+ max: {
910
+ type: "integer",
911
+ minimum: 1,
912
+ maximum: SAFE_OUTPUT_MAX_CEILING,
913
+ description: "Total writes per run across all nodes.",
914
+ },
915
+ staged: { type: "boolean", description: "Preview every write and apply none." },
916
+ trusted_actors: {
917
+ type: "array",
918
+ items: { type: "string", minLength: 1 },
919
+ minItems: 1,
920
+ description: "GitHub logins whose runs may write.",
921
+ },
922
+ trusted_associations: {
923
+ type: "array",
924
+ items: { type: "string", enum: [...AUTHOR_ASSOCIATIONS] },
925
+ minItems: 1,
926
+ description: "Author associations (from the GitHub event payload) whose runs may write.",
927
+ },
928
+ screen: {
929
+ type: "boolean",
930
+ description: "One model call that may veto the writes after every deterministic check. It can never authorize one.",
931
+ },
932
+ },
933
+ },
742
934
  judge_budget: {
743
935
  type: "integer",
744
936
  minimum: 0,
@@ -890,6 +1082,16 @@ export const workflowJsonSchema = {
890
1082
  enum: [...NODE_ON_FAIL],
891
1083
  description: "What to do when this node finishes 'failed' (agent-level failure, or an eval failure that exhausted retries and was not softened by fail_soft). 'halt' (default) stops the workflow with the failure surfaced so a broken node never advances down a conditional edge; 'continue' preserves the legacy fall-through where routing proceeds from the failed node. Distinct from requires.on_fail (the pre-condition gate).",
892
1084
  },
1085
+ permissions: {
1086
+ $ref: "#/$defs/Permissions",
1087
+ description: "What this node's agent may do. Absent: read when the node declares outputs, else the workflow's permissions, else write.",
1088
+ },
1089
+ outputs: {
1090
+ type: "array",
1091
+ items: { $ref: "#/$defs/SafeOutput" },
1092
+ minItems: 1,
1093
+ description: "Typed write intents this node may emit with the emit_output tool. sweny applies them after the node, within the declared caps.",
1094
+ },
893
1095
  rules: {
894
1096
  $ref: "#/$defs/NodeSources",
895
1097
  description: "Per-node rules. Additive by default; set { only: true } to block cascade.",
@@ -229,6 +229,24 @@ export const github = {
229
229
  return reused ? { ...pr, reused: true } : pr;
230
230
  },
231
231
  },
232
+ {
233
+ name: "github_add_labels",
234
+ access: "write",
235
+ description: "Add labels to a GitHub issue or pull request",
236
+ input_schema: {
237
+ type: "object",
238
+ properties: {
239
+ repo: { type: "string", description: "owner/repo" },
240
+ issue_number: { type: "number", description: "Issue or PR number" },
241
+ labels: { type: "array", items: { type: "string" }, description: "Labels to add" },
242
+ },
243
+ required: ["repo", "issue_number", "labels"],
244
+ },
245
+ handler: async (input, ctx) => gh(`/repos/${input.repo}/issues/${input.issue_number}/labels`, ctx, {
246
+ method: "POST",
247
+ body: JSON.stringify({ labels: input.labels }),
248
+ }),
249
+ },
232
250
  {
233
251
  name: "github_list_recent_commits",
234
252
  access: "read",
package/dist/types.d.ts CHANGED
@@ -362,6 +362,93 @@ export interface Node {
362
362
  * Distinct from `requires.on_fail`, which is the pre-condition gate.
363
363
  */
364
364
  on_fail?: NodeOnFail;
365
+ /**
366
+ * What this node's agent may do (#365). `read` runs the node read-only:
367
+ * only `access: "read"` skill tools, no external skill MCP servers, no
368
+ * shell / file-write / edit / fetch / subagent built-ins. Absent: `read`
369
+ * when the node declares `outputs`, else the workflow's `permissions`,
370
+ * else `write` (today's behavior).
371
+ */
372
+ permissions?: NodePermissions;
373
+ /**
374
+ * Typed write intents this node may emit (#365). The agent records them with
375
+ * the `emit_output` tool; sweny applies them after the node, within the
376
+ * declared caps. See {@link SafeOutputDeclaration}.
377
+ */
378
+ outputs?: SafeOutputDeclaration[];
379
+ }
380
+ /** Node access level. `read` = no write-capable tool reaches the agent. */
381
+ export declare const NODE_ACCESS: readonly ["read", "write"];
382
+ export type NodeAccess = (typeof NODE_ACCESS)[number];
383
+ /** Portable built-in tool classes, compiled per harness (Claude Code: native tool names). */
384
+ export declare const TOOL_CLASSES: readonly ["shell", "write", "edit", "net", "subagent"];
385
+ /** Object form of {@link NodePermissions}. */
386
+ export interface NodePermissionsSpec {
387
+ access?: NodeAccess;
388
+ /** Built-in tool classes the agent must not have. */
389
+ deny?: (typeof TOOL_CLASSES)[number][];
390
+ /** Exclusive MCP, and refuse the node on a harness that cannot enforce the policy. */
391
+ strict?: boolean;
392
+ }
393
+ /** `read` / `write` shorthand, or the object form. */
394
+ export type NodePermissions = NodeAccess | NodePermissionsSpec;
395
+ /** Write operations a node may request through `emit_output`. */
396
+ export declare const SAFE_OUTPUT_TYPES: readonly ["comment", "issue", "pr", "label"];
397
+ export type SafeOutputType = (typeof SAFE_OUTPUT_TYPES)[number];
398
+ /**
399
+ * Skills that can apply each output type. The write stage calls the skill's
400
+ * own tool handler; a skill not listed here cannot apply safe outputs.
401
+ */
402
+ export declare const SAFE_OUTPUT_APPLIERS: Readonly<Record<string, readonly SafeOutputType[]>>;
403
+ /** GitHub author associations accepted by `safe_outputs.trusted_associations`. */
404
+ export declare const AUTHOR_ASSOCIATIONS: readonly ["OWNER", "MEMBER", "COLLABORATOR", "CONTRIBUTOR", "FIRST_TIME_CONTRIBUTOR", "FIRST_TIMER", "NONE"];
405
+ export type AuthorAssociation = (typeof AUTHOR_ASSOCIATIONS)[number];
406
+ /** Upper bound on a single output's `max` and on `safe_outputs.max`. */
407
+ export declare const SAFE_OUTPUT_MAX_CEILING = 100;
408
+ /** `expires` grammar: a positive integer and a unit (s, m, h, d). */
409
+ export declare const SAFE_OUTPUT_EXPIRES_PATTERN: RegExp;
410
+ /** One declared output on a node. */
411
+ export interface SafeOutputDeclaration {
412
+ type: SafeOutputType;
413
+ /** Skill that applies it. Default: the first node skill that supports the type. */
414
+ via?: string;
415
+ /** Max writes of this type from this node per run. Default 1. */
416
+ max?: number;
417
+ /** Pinned target (GitHub `owner/repo`, Linear team id). GitHub default: `GITHUB_REPOSITORY`. */
418
+ target?: string;
419
+ /** Prepended to issue / PR titles that do not already start with it. */
420
+ title_prefix?: string;
421
+ /** issue / pr: labels always added. label: the only labels the agent may add. */
422
+ labels?: string[];
423
+ /** Drop an intent older than this when the write stage runs (e.g. `30m`, `2h`, `7d`). */
424
+ expires?: string;
425
+ }
426
+ /** Workflow-level safe-output policy: the ceiling and run-wide limits. */
427
+ export interface SafeOutputsPolicy {
428
+ /** Output types any node may declare. Absent: all types. */
429
+ allow?: SafeOutputType[];
430
+ /** Total writes per run across all nodes. */
431
+ max?: number;
432
+ /** Preview every write and apply none. */
433
+ staged?: boolean;
434
+ /** GitHub logins whose runs may write. */
435
+ trusted_actors?: string[];
436
+ /** Author associations whose runs may write (from the GitHub event payload). */
437
+ trusted_associations?: AuthorAssociation[];
438
+ /** One model call that may veto the writes. It can never authorize one. */
439
+ screen?: boolean;
440
+ }
441
+ /** What the write stage did with one intent. Metadata only: never a title or body. */
442
+ export interface SafeOutputReceipt {
443
+ type: string;
444
+ /** Skill that applied (or would apply) it. */
445
+ via?: string;
446
+ status: "applied" | "staged" | "skipped" | "refused" | "vetoed" | "failed";
447
+ /** Why it was skipped, refused, vetoed or failed. Fixed wording, never model text. */
448
+ reason?: string;
449
+ target?: string;
450
+ /** Issue / PR / comment number or id the write produced. */
451
+ ref?: string | number;
365
452
  }
366
453
  /**
367
454
  * Per-node skill-tool filter. See {@link Node.tools}.
@@ -424,6 +511,10 @@ export interface Workflow {
424
511
  * See `src/inputs.ts` for the field shape and validation rules.
425
512
  */
426
513
  inputs?: import("./inputs.js").WorkflowInputs;
514
+ /** Default and ceiling for every node's `permissions` (#365). Absent: `write`. */
515
+ permissions?: NodePermissions;
516
+ /** Workflow-level safe-output policy (#365). */
517
+ safe_outputs?: SafeOutputsPolicy;
427
518
  }
428
519
  /**
429
520
  * Inline skill definition in a workflow's `skills` block.
@@ -490,6 +581,8 @@ export interface NodeResult {
490
581
  };
491
582
  /** Opinions this run could not honor natively. Always empty for Claude Code. */
492
583
  degraded?: string[];
584
+ /** Safe outputs (#365): what the write stage did with each intent. Absent when the node declares none. */
585
+ outputs?: SafeOutputReceipt[];
493
586
  }
494
587
  export interface ToolCall {
495
588
  tool: string;
@@ -652,6 +745,12 @@ export interface Claude {
652
745
  * (external MCP servers, shell, file-edit built-ins).
653
746
  */
654
747
  readOnly?: boolean;
748
+ /**
749
+ * The node's portable policy (#365), compiled per adapter. Legacy `Claude`
750
+ * implementations may ignore it; `readOnly` and `disallowedTools` above
751
+ * carry the same read-only and native-deny intent.
752
+ */
753
+ policy?: import("./harness/types.js").NodePolicy;
655
754
  }): Promise<NodeResult>;
656
755
  /**
657
756
  * Evaluate a routing condition — pick one of N choices.
package/dist/types.js CHANGED
@@ -67,6 +67,35 @@ export const REQUIRES_ON_FAIL = ["fail", "skip"];
67
67
  export const NODE_ON_FAIL = ["halt", "continue"];
68
68
  /** MCP server transport type. Inferred from command/url when omitted. */
69
69
  export const MCP_TRANSPORTS = ["stdio", "http"];
70
+ // ─── Permissions and safe outputs (#365) ─────────────────────────
71
+ /** Node access level. `read` = no write-capable tool reaches the agent. */
72
+ export const NODE_ACCESS = ["read", "write"];
73
+ /** Portable built-in tool classes, compiled per harness (Claude Code: native tool names). */
74
+ export const TOOL_CLASSES = ["shell", "write", "edit", "net", "subagent"];
75
+ /** Write operations a node may request through `emit_output`. */
76
+ export const SAFE_OUTPUT_TYPES = ["comment", "issue", "pr", "label"];
77
+ /**
78
+ * Skills that can apply each output type. The write stage calls the skill's
79
+ * own tool handler; a skill not listed here cannot apply safe outputs.
80
+ */
81
+ export const SAFE_OUTPUT_APPLIERS = {
82
+ github: ["comment", "issue", "pr", "label"],
83
+ linear: ["comment", "issue"],
84
+ };
85
+ /** GitHub author associations accepted by `safe_outputs.trusted_associations`. */
86
+ export const AUTHOR_ASSOCIATIONS = [
87
+ "OWNER",
88
+ "MEMBER",
89
+ "COLLABORATOR",
90
+ "CONTRIBUTOR",
91
+ "FIRST_TIME_CONTRIBUTOR",
92
+ "FIRST_TIMER",
93
+ "NONE",
94
+ ];
95
+ /** Upper bound on a single output's `max` and on `safe_outputs.max`. */
96
+ export const SAFE_OUTPUT_MAX_CEILING = 100;
97
+ /** `expires` grammar: a positive integer and a unit (s, m, h, d). */
98
+ export const SAFE_OUTPUT_EXPIRES_PATTERN = /^[1-9][0-9]*(s|m|h|d)$/;
70
99
  /**
71
100
  * Workflow type discriminator. Cloud uses this to render runs in a
72
101
  * type-specific way. Adding a value requires a corresponding renderer.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sweny-ai/core",
3
- "version": "0.1.143",
3
+ "version": "0.1.144",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "sweny": "./dist/cli/main.js"