@crewhaus/spec 0.1.1 → 0.1.3

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/src/index.ts CHANGED
@@ -2,6 +2,22 @@ import { SpecParseError } from "@crewhaus/errors";
2
2
  import { parse as parseYaml } from "yaml";
3
3
  import { z } from "zod";
4
4
 
5
+ // SECURITY (codegen-injection backstop, #147/#148): spec/role/node/step names
6
+ // flow verbatim into generated source across ~14 emitters — `//` and `/* */`
7
+ // comments, template literals, JSON `package.json` manifests, YAML frontmatter,
8
+ // and on-disk file paths (`skills/<name>/SKILL.md`). A raw newline, `*/`, quote,
9
+ // backtick or `/` lets a crafted name break out of those contexts (RCE on
10
+ // build/run, dependency injection, path traversal). The emitters escape per-site
11
+ // as defense-in-depth, but this is the systemic floor: restrict names to a
12
+ // single-line safe charset so the breakout characters can never enter the IR.
13
+ const safeName = z
14
+ .string()
15
+ .min(1)
16
+ .regex(
17
+ /^[\w .:-]+$/,
18
+ "name may contain only letters, digits, spaces, and '_ . - :' (no newlines, quotes, slashes, or comment/template delimiters)",
19
+ );
20
+
5
21
  /**
6
22
  * v0 spec schema — a discriminated union over `target`.
7
23
  *
@@ -83,7 +99,7 @@ const subAgentDefinitionSchema = z
83
99
  })
84
100
  .strict();
85
101
 
86
- const subAgentsBlock = z.record(z.string().min(1), subAgentDefinitionSchema).optional();
102
+ const subAgentsBlock = z.record(safeName, subAgentDefinitionSchema).optional();
87
103
 
88
104
  /**
89
105
  * Section 14 — per-tool runtime config map. Tool-specific schemas live
@@ -91,8 +107,80 @@ const subAgentsBlock = z.record(z.string().min(1), subAgentDefinitionSchema).opt
91
107
  * `unknown` and forwards it verbatim to the IR. The codegen layer emits
92
108
  * an init call (e.g. `registerFetchConfig({ ... })`) for tools whose
93
109
  * BUILTIN_TOOL_MAP entry declares an `initSymbol`.
110
+ *
111
+ * SECURITY (sandbox-override hardening): the code-execution config is the
112
+ * one exception to "opaque `unknown`". Its blob is compiled verbatim into
113
+ * `registerCodeExecutionConfig(...)` in the generated bundle, and the
114
+ * @crewhaus/sandbox boundary validates images/mounts against THIS same
115
+ * blob's allowlist (`allowedImages`, `mountWhitelist`). If a spec could set
116
+ * those — or `backend` (e.g. force `noop`, which is no isolation at all),
117
+ * `images`, or `mounts` — an untrusted marketplace/template spec would be
118
+ * supplying its own sandbox allowlist, making the controls self-defeating.
119
+ * The sandbox boundary must come only from trusted operator config (the CLI
120
+ * / `CREWHAUS_SANDBOX*` env vars), never from a spec file. So the
121
+ * code-execution config is constrained to a strict allowlist of non-security
122
+ * knobs; any sandbox-override key is rejected at parse time (defense in
123
+ * depth, mirroring `permissions.mode: bypass`).
124
+ *
125
+ * The code-execution config can arrive under any of the keys whose
126
+ * BUILTIN_TOOL_MAP entry maps to `registerCodeExecutionConfig` — the
127
+ * `codeExecution`/`code_execution` aliases AND the per-tool keys
128
+ * `python`/`javascript`/`shell` (target-cli `resolveTools` reads the
129
+ * per-tool key first, then the aliases). All of them must be constrained,
130
+ * or the guard is trivially bypassed by nesting the blob under `python`.
94
131
  */
95
- const toolConfigBlock = z.record(z.string().min(1), z.unknown()).optional();
132
+ const SANDBOX_OVERRIDE_KEYS = [
133
+ "sandbox",
134
+ "backend",
135
+ "allowedImages",
136
+ "allowed_images",
137
+ "mountWhitelist",
138
+ "mount_whitelist",
139
+ "images",
140
+ "mounts",
141
+ ] as const;
142
+
143
+ const CODE_EXECUTION_CONFIG_KEYS = [
144
+ "codeExecution",
145
+ "code_execution",
146
+ "python",
147
+ "javascript",
148
+ "shell",
149
+ ] as const;
150
+
151
+ const codeExecutionConfigSchema = z
152
+ .object({
153
+ // Non-security knobs only. The sandbox boundary (backend, image
154
+ // allowlist, mount whitelist, per-language images, mounts) is owned by
155
+ // trusted operator config and is intentionally NOT settable from a spec.
156
+ defaultTimeoutMs: z.number().int().positive().optional(),
157
+ default_timeout_ms: z.number().int().positive().optional(),
158
+ warmPoolSize: z.number().int().nonnegative().optional(),
159
+ warm_pool_size: z.number().int().nonnegative().optional(),
160
+ })
161
+ .strict(
162
+ `code-execution config may only set non-security knobs (defaultTimeoutMs, warmPoolSize); sandbox-boundary keys (${SANDBOX_OVERRIDE_KEYS.join(", ")}) are owned by trusted operator config and rejected from specs`,
163
+ );
164
+
165
+ const toolConfigBlock = z
166
+ .record(z.string().min(1), z.unknown())
167
+ .superRefine((cfg, ctx) => {
168
+ for (const key of CODE_EXECUTION_CONFIG_KEYS) {
169
+ const value = cfg[key];
170
+ if (value === undefined) continue;
171
+ const parsed = codeExecutionConfigSchema.safeParse(value);
172
+ if (!parsed.success) {
173
+ for (const issue of parsed.error.issues) {
174
+ ctx.addIssue({
175
+ code: z.ZodIssueCode.custom,
176
+ path: [key, ...issue.path],
177
+ message: issue.message,
178
+ });
179
+ }
180
+ }
181
+ }
182
+ })
183
+ .optional();
96
184
 
97
185
  /**
98
186
  * Section 17 — optional override for the model used by
@@ -122,6 +210,67 @@ const compactionBlock = z
122
210
  .strict()
123
211
  .optional();
124
212
 
213
+ /**
214
+ * Pillar 3 (FR-004) — per-target security fabric block. Today it carries
215
+ * the intent-gate's judge selection; the optimizable path
216
+ * `["security", "justification"]` is already registered in
217
+ * `spec-patch`'s `OPTIMIZABLE_PATHS`, so this block MUST be named
218
+ * `security` with a `justification` sub-field to honour it.
219
+ *
220
+ * `justification.judge` selects which `JustificationJudge` the cli run
221
+ * path wires: `"rule-based"` (the deterministic default for tests/offline
222
+ * runs) or `"claude"` (the model-backed `@crewhaus/justification-judge-claude`,
223
+ * the documented production recommendation). `model` is the judge model
224
+ * id for the claude judge; the consumer defaults it to a haiku-class
225
+ * model when omitted.
226
+ *
227
+ * NOTE: `egressPolicy` is reserved — `OPTIMIZABLE_PATHS` also lists
228
+ * `["security", "egressPolicy"]`, owned by the egress-fabric FRs
229
+ * (FR-002/006). Do NOT add `egressPolicy` here; that would clobber their
230
+ * sub-field. FR-004 added `justification`; FR-006 added `egressMatcher`
231
+ * (the substring/semantic selector) alongside it — both are independent
232
+ * optional sub-fields of this same block.
233
+ */
234
+ const securityBlock = z
235
+ .object({
236
+ justification: z
237
+ .object({
238
+ judge: z.enum(["rule-based", "claude"]).default("rule-based"),
239
+ model: z.string().min(1).optional(),
240
+ })
241
+ .strict()
242
+ .optional(),
243
+ /**
244
+ * Pillar 3 sink-side fabric (FR-006) — select the egress-matching
245
+ * strategy. `"substring"` (the default when omitted) is the
246
+ * behavior-preserving `SubstringEgressMatcher` with `MIN_MATCH_LENGTH`.
247
+ * `"semantic"` selects the optional embedding-backed
248
+ * `@crewhaus/egress-matcher-semantic`, which scores outbound payloads
249
+ * against tagged data-lineage by cosine similarity. Switching the
250
+ * matcher changes *how* lineage matches are detected; the per-origin/
251
+ * per-sink policy and the three audit outcomes are unaffected.
252
+ *
253
+ * This field is lowered to `IrSecurity.egressMatcher` (FR-006) and
254
+ * honoured by the `crewhaus run` path, which resolves the selector and
255
+ * threads the matcher into `runChatLoop({ egressMatcher })` — exactly
256
+ * how `security.justification.judge` selects the intent-gate judge on
257
+ * the same path. `"semantic"` constructs the optional
258
+ * `@crewhaus/egress-matcher-semantic` (with an injected embedder; see
259
+ * `--egress-embedder`). The runtime SEAM
260
+ * (`RunChatLoopOptions.egressMatcher`) underlies both.
261
+ *
262
+ * The *generated cli bundle* also honours this field: `@crewhaus/target-cli`
263
+ * emits the matcher construction (the semantic one with an injected
264
+ * `@crewhaus/embedder` embedder) into the bundle's
265
+ * `runChatLoop({ egressMatcher })`, so a compiled standalone artifact uses
266
+ * `semantic` WITHOUT the `crewhaus run` path. The substring default emits
267
+ * nothing, keeping the bundle free of any embedding dependency.
268
+ */
269
+ egressMatcher: z.enum(["substring", "semantic"]).optional(),
270
+ })
271
+ .strict()
272
+ .optional();
273
+
125
274
  /**
126
275
  * Section 55 (Track A) — named failure taxonomy. Cross-cutting block
127
276
  * available on every target shape. Each entry names a failure class and
@@ -202,6 +351,16 @@ const transactionPolicySchema = z
202
351
  .object({
203
352
  defaultWriteApproval: z.enum(["required", "policy", "none"]).default("required"),
204
353
  maxValueUsd: z.number().positive().optional(),
354
+ // Oracle-free native-token spend ceiling (wei). This is the ONLY value cap
355
+ // wallet-engine can actually enforce — maxValueUsd hard-throws without a
356
+ // price oracle. Decimal or 0x-hex string (parsed via BigInt downstream).
357
+ maxValueWei: z
358
+ .string()
359
+ .regex(
360
+ /^(0x[0-9a-fA-F]+|[0-9]+)$/,
361
+ "maxValueWei must be a wei amount as a decimal or 0x-hex string",
362
+ )
363
+ .optional(),
205
364
  allowedContracts: z.array(z.string().min(1)).default([]),
206
365
  simulationRequired: z.boolean().default(true),
207
366
  })
@@ -282,7 +441,7 @@ const channelGatewayBlock = z
282
441
 
283
442
  const cliSchema = z
284
443
  .object({
285
- name: z.string().min(1),
444
+ name: safeName,
286
445
  target: z.literal("cli"),
287
446
  agent: z
288
447
  .object({
@@ -296,6 +455,7 @@ const cliSchema = z
296
455
  mcp_servers: mcpServersBlock,
297
456
  permissions: permissionsBlock,
298
457
  compaction: compactionBlock,
458
+ security: securityBlock,
299
459
  failure_taxonomy: failureTaxonomyBlock,
300
460
  cli: cliOptionsBlock,
301
461
  chains: chainsBlock,
@@ -307,7 +467,7 @@ const cliSchema = z
307
467
 
308
468
  const workflowStepSchema = z
309
469
  .object({
310
- name: z.string().min(1),
470
+ name: safeName,
311
471
  instructions: z.string().min(1),
312
472
  model: z.string().min(1).optional(),
313
473
  tools: z.array(z.string().min(1)).optional(),
@@ -317,7 +477,7 @@ const workflowStepSchema = z
317
477
 
318
478
  const workflowSchema = z
319
479
  .object({
320
- name: z.string().min(1),
480
+ name: safeName,
321
481
  target: z.literal("workflow"),
322
482
  model: z.string().min(1),
323
483
  steps: z.array(workflowStepSchema).min(1),
@@ -414,7 +574,7 @@ const channelAgentSchema = z
414
574
 
415
575
  const channelSchema = z
416
576
  .object({
417
- name: z.string().min(1),
577
+ name: safeName,
418
578
  target: z.literal("channel"),
419
579
  agent: channelAgentSchema,
420
580
  channels: channelsBlock,
@@ -464,11 +624,11 @@ const graphEdgeSchema = z
464
624
 
465
625
  const graphSchema = z
466
626
  .object({
467
- name: z.string().min(1),
627
+ name: safeName,
468
628
  target: z.literal("graph"),
469
629
  model: z.string().min(1),
470
630
  entry: z.string().min(1),
471
- nodes: z.record(z.string().min(1), graphNodeSchema),
631
+ nodes: z.record(safeName, graphNodeSchema),
472
632
  edges: z.array(graphEdgeSchema).default([]),
473
633
  permissions: permissionsBlock,
474
634
  compaction: compactionBlock,
@@ -505,7 +665,7 @@ const managedAgentSchema = z
505
665
 
506
666
  const managedSchema = z
507
667
  .object({
508
- name: z.string().min(1),
668
+ name: safeName,
509
669
  target: z.literal("managed"),
510
670
  agent: managedAgentSchema,
511
671
  tenants: z.array(managedTenantSchema).min(1),
@@ -515,6 +675,17 @@ const managedSchema = z
515
675
  })
516
676
  .strict();
517
677
 
678
+ // Vector-store backend ids accepted in specs. Mirrors `VectorBackendId`
679
+ // from @crewhaus/vector-store (and `IrVectorBackend`) — the canonical set
680
+ // of implemented backends — kept inline so the spec stays dependency-light.
681
+ // Keep in sync when a backend is added or removed.
682
+ const VECTOR_BACKENDS = ["in-memory", "lance", "qdrant", "pinecone", "weaviate"] as const;
683
+
684
+ // The HTTP backends construct only with a `url` + `collection` (the
685
+ // vector-store factory throws otherwise); parseSpec requires both so a
686
+ // spec that selects one without them fails at compile, not at runtime.
687
+ const HTTP_VECTOR_BACKENDS = new Set(["qdrant", "pinecone", "weaviate"]);
688
+
518
689
  // Pipeline / RAG target (Section 21). Carries the embedder + vector-store
519
690
  // config, an indexing pipeline, and a chat agent that uses Retrieve.
520
691
  const pipelineDocumentSchema = z
@@ -527,7 +698,7 @@ const pipelineDocumentSchema = z
527
698
 
528
699
  const pipelineSchema = z
529
700
  .object({
530
- name: z.string().min(1),
701
+ name: safeName,
531
702
  target: z.literal("pipeline"),
532
703
  agent: z
533
704
  .object({
@@ -538,8 +709,16 @@ const pipelineSchema = z
538
709
  retrieve: z
539
710
  .object({
540
711
  embedderModel: z.string().min(1),
541
- vectorBackend: z.enum(["in-memory"]).default("in-memory"),
712
+ vectorBackend: z.enum(VECTOR_BACKENDS).default("in-memory"),
542
713
  defaultK: z.number().int().positive().max(50).default(5),
714
+ // Remote (qdrant/pinecone/weaviate) + file (lance) backend config.
715
+ // `url` is the service base URL (or, for lance, the on-disk index
716
+ // path); `apiKey` accepts a `$ENV_REF` so the secret resolves from
717
+ // `process.env` in the bundle rather than being baked into it. The
718
+ // HTTP backends require `url` + `collection` (enforced in parseSpec).
719
+ url: z.string().min(1).optional(),
720
+ collection: z.string().min(1).optional(),
721
+ apiKey: z.string().min(1).optional(),
543
722
  })
544
723
  .strict(),
545
724
  indexing: z
@@ -588,12 +767,12 @@ const crewRoutingSchema = z
588
767
 
589
768
  const crewSchema = z
590
769
  .object({
591
- name: z.string().min(1),
770
+ name: safeName,
592
771
  target: z.literal("crew"),
593
772
  /** Crew-wide model fallback used by any role that omits `role.model`. */
594
773
  model: z.string().min(1),
595
774
  entry: z.string().min(1),
596
- roles: z.record(z.string().min(1), crewRoleSchema),
775
+ roles: z.record(safeName, crewRoleSchema),
597
776
  routing: crewRoutingSchema.optional(),
598
777
  mcp_servers: mcpServersBlock,
599
778
  permissions: permissionsBlock,
@@ -618,13 +797,13 @@ const researchRetrieveSchema = z
618
797
  .object({
619
798
  allowedOrigins: z.array(z.string().min(1)).default([]),
620
799
  allowedFileRoots: z.array(z.string().min(1)).default([]),
621
- vectorBackend: z.enum(["in-memory"]).optional(),
800
+ vectorBackend: z.enum(VECTOR_BACKENDS).optional(),
622
801
  })
623
802
  .strict();
624
803
 
625
804
  const researchSchema = z
626
805
  .object({
627
- name: z.string().min(1),
806
+ name: safeName,
628
807
  target: z.literal("research"),
629
808
  agent: z
630
809
  .object({
@@ -665,7 +844,7 @@ const batchQueueSchema = z
665
844
 
666
845
  const batchSchema = z
667
846
  .object({
668
- name: z.string().min(1),
847
+ name: safeName,
669
848
  target: z.literal("batch"),
670
849
  agent: z
671
850
  .object({
@@ -708,7 +887,7 @@ const voiceTelephonySchema = z
708
887
 
709
888
  const voiceSchema = z
710
889
  .object({
711
- name: z.string().min(1),
890
+ name: safeName,
712
891
  target: z.literal("voice"),
713
892
  agent: z
714
893
  .object({
@@ -744,7 +923,7 @@ const browserDriverSchema = z
744
923
 
745
924
  const browserSchema = z
746
925
  .object({
747
- name: z.string().min(1),
926
+ name: safeName,
748
927
  target: z.literal("browser"),
749
928
  agent: z
750
929
  .object({
@@ -774,7 +953,7 @@ const browserSchema = z
774
953
  */
775
954
  const evalSchema = z
776
955
  .object({
777
- name: z.string().min(1),
956
+ name: safeName,
778
957
  target: z.literal("eval"),
779
958
  agent: z
780
959
  .object({
@@ -785,7 +964,7 @@ const evalSchema = z
785
964
  .strict(),
786
965
  dataset: z
787
966
  .object({
788
- name: z.string().min(1),
967
+ name: safeName,
789
968
  version: z.string().min(1),
790
969
  split: z.enum(["train", "dev", "test"]).default("dev"),
791
970
  })
@@ -794,7 +973,7 @@ const evalSchema = z
794
973
  .array(
795
974
  z
796
975
  .object({
797
- name: z.string().min(1),
976
+ name: safeName,
798
977
  opts: z.record(z.unknown()).optional(),
799
978
  })
800
979
  .strict(),
@@ -842,7 +1021,7 @@ const onchainTriggerSchema = z.discriminatedUnion("kind", [
842
1021
 
843
1022
  const onchainSchema = z
844
1023
  .object({
845
- name: z.string().min(1),
1024
+ name: safeName,
846
1025
  target: z.literal("onchain"),
847
1026
  agent: z
848
1027
  .object({
@@ -877,7 +1056,7 @@ const onchainSchema = z
877
1056
  */
878
1057
  const onchainGameSchema = z
879
1058
  .object({
880
- name: z.string().min(1),
1059
+ name: safeName,
881
1060
  target: z.literal("onchain-game"),
882
1061
  agent: z
883
1062
  .object({
@@ -965,6 +1144,7 @@ export type SpecEval = z.infer<typeof evalSchema>;
965
1144
  export type SpecMcpServerConfig = z.infer<typeof mcpServerConfigSchema>;
966
1145
  export type SpecSubAgentDefinition = z.infer<typeof subAgentDefinitionSchema>;
967
1146
  export type SpecCompactionBlock = z.infer<typeof compactionBlock>;
1147
+ export type SpecSecurityBlock = z.infer<typeof securityBlock>;
968
1148
  export type SpecFailureTaxonomyEntry = z.infer<typeof failureTaxonomyEntrySchema>;
969
1149
  export type SpecFailureTaxonomy = z.infer<typeof failureTaxonomyBlock>;
970
1150
 
@@ -1031,5 +1211,23 @@ export function parseSpec(yamlText: string): Spec {
1031
1211
  }
1032
1212
  }
1033
1213
  }
1214
+ // Section 21 — pipeline HTTP-backend invariants. qdrant/pinecone/weaviate
1215
+ // throw at construction without a url + collection, so selecting one
1216
+ // without both would emit an unrunnable bundle. Reject at parse time with
1217
+ // a message naming the missing field (kept here, not as a `.refine()`, so
1218
+ // the discriminated-union member stays a plain ZodObject).
1219
+ if (data.target === "pipeline" && HTTP_VECTOR_BACKENDS.has(data.retrieve.vectorBackend)) {
1220
+ const { vectorBackend, url, collection } = data.retrieve;
1221
+ if (!url) {
1222
+ throw new SpecParseError(
1223
+ `pipeline retrieve.vectorBackend "${vectorBackend}" requires retrieve.url (the remote service base URL)`,
1224
+ );
1225
+ }
1226
+ if (!collection) {
1227
+ throw new SpecParseError(
1228
+ `pipeline retrieve.vectorBackend "${vectorBackend}" requires retrieve.collection`,
1229
+ );
1230
+ }
1231
+ }
1034
1232
  return data;
1035
1233
  }