@crewhaus/spec 0.1.0 → 0.1.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crewhaus/spec",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "type": "module",
5
5
  "description": "User-facing spec schema (Zod) + YAML parser",
6
6
  "main": "src/index.ts",
@@ -12,15 +12,15 @@
12
12
  "test": "bun test src"
13
13
  },
14
14
  "dependencies": {
15
- "@crewhaus/errors": "0.0.0",
15
+ "@crewhaus/errors": "0.1.2",
16
16
  "yaml": "^2.6.0",
17
17
  "zod": "^3.23.8"
18
18
  },
19
19
  "license": "Apache-2.0",
20
20
  "author": {
21
21
  "name": "Max Meier",
22
- "email": "max@studiomax.io",
23
- "url": "https://studiomax.io"
22
+ "email": "max@crewhaus.ai",
23
+ "url": "https://crewhaus.ai"
24
24
  },
25
25
  "repository": {
26
26
  "type": "git",
@@ -32,12 +32,7 @@
32
32
  "url": "https://github.com/crewhaus/factory/issues"
33
33
  },
34
34
  "publishConfig": {
35
- "access": "restricted"
35
+ "access": "public"
36
36
  },
37
- "files": [
38
- "src",
39
- "README.md",
40
- "LICENSE",
41
- "NOTICE"
42
- ]
37
+ "files": ["src", "README.md", "LICENSE", "NOTICE"]
43
38
  }
package/src/index.test.ts CHANGED
@@ -17,6 +17,28 @@ agent:
17
17
  expect(spec.agent.instructions).toBe("be helpful");
18
18
  });
19
19
 
20
+ // Codegen-injection backstop (#147/#148): names flow verbatim into generated
21
+ // comments, file paths, JSON manifests and frontmatter — reject the breakout
22
+ // characters at parse time so no emitter can be tricked downstream.
23
+ describe("name safe-charset backstop", () => {
24
+ const cli = (name: string) =>
25
+ `\ntarget: cli\nagent:\n model: m\n instructions: be helpful\nname: ${name}\n`;
26
+ test.each([
27
+ ["a newline (block/line-comment escape)", '"line one\\nglobalThis.x=1"'],
28
+ ["a block-comment terminator */", '"safe */ code /* x"'],
29
+ ["a slash (path traversal in plugin emitters)", '"../../etc/evil"'],
30
+ ["a double-quote (JSON manifest break-out)", '"a\\", \\"dependencies\\": {}"'],
31
+ ["a backtick (template-literal escape)", '"a`+code+`b"'],
32
+ ])("rejects a name containing %s", (_label, name) => {
33
+ expect(() => parseSpec(cli(name))).toThrow(SpecParseError);
34
+ });
35
+
36
+ test("accepts ordinary names (letters, digits, space, . _ - :)", () => {
37
+ const spec = parseSpec(cli('"My Agent v1.2 - prod:eu"'));
38
+ expect(spec.name).toBe("My Agent v1.2 - prod:eu");
39
+ });
40
+ });
41
+
20
42
  test("preserves multi-line block-scalar instructions", () => {
21
43
  const spec = parseSpec(`
22
44
  name: hello
@@ -138,6 +160,67 @@ tools:
138
160
  });
139
161
  });
140
162
 
163
+ // SECURITY: the code-execution config blob is compiled verbatim into
164
+ // `registerCodeExecutionConfig(...)` and the sandbox boundary validates
165
+ // images/mounts against THIS same blob — so a spec must NOT be able to
166
+ // supply its own sandbox allowlist/backend/mounts. Those keys are owned by
167
+ // trusted operator config (CLI / CREWHAUS_SANDBOX* env), never a spec file.
168
+ // The blob can arrive under codeExecution/code_execution OR the per-tool
169
+ // keys python/javascript/shell (target-cli reads the per-tool key first),
170
+ // so every one of those must be rejected.
171
+ describe("tool_config code-execution sandbox-override hardening", () => {
172
+ const codeExecKeys = [
173
+ "codeExecution",
174
+ "code_execution",
175
+ "python",
176
+ "javascript",
177
+ "shell",
178
+ ] as const;
179
+ const overrideKeys = [
180
+ ["backend", "backend: noop"],
181
+ ["allowedImages", "allowedImages:\n - evil/image:latest"],
182
+ ["allowed_images", "allowed_images:\n - evil/image:latest"],
183
+ ["mountWhitelist", 'mountWhitelist:\n - "/"'],
184
+ ["mount_whitelist", 'mount_whitelist:\n - "/"'],
185
+ ["images", "images:\n python: evil/image:latest"],
186
+ ["mounts", "mounts:\n /etc: /host-etc"],
187
+ ["sandbox", "sandbox: noop"],
188
+ ] as const;
189
+
190
+ const specWith = (cfgKey: string, body: string) =>
191
+ `\nname: hello\ntarget: cli\nagent:\n model: m\n instructions: i\ntools:\n - python\ntool_config:\n ${cfgKey}:\n ${body}\n`;
192
+
193
+ for (const cfgKey of codeExecKeys) {
194
+ for (const [label, body] of overrideKeys) {
195
+ test(`rejects sandbox-override key "${label}" under tool_config.${cfgKey}`, () => {
196
+ expect(() => parseSpec(specWith(cfgKey, body))).toThrow(SpecParseError);
197
+ });
198
+ }
199
+
200
+ test(`allows non-security knobs under tool_config.${cfgKey}`, () => {
201
+ const spec = parseSpec(specWith(cfgKey, "defaultTimeoutMs: 5000\n warmPoolSize: 2"));
202
+ if (spec.target !== "cli") expect.unreachable();
203
+ expect(spec.tool_config?.[cfgKey]).toEqual({
204
+ defaultTimeoutMs: 5000,
205
+ warmPoolSize: 2,
206
+ });
207
+ });
208
+ }
209
+
210
+ test("does not constrain non-code-execution tool configs (fetch stays opaque)", () => {
211
+ const spec = parseSpec(
212
+ "\nname: hello\ntarget: cli\nagent:\n model: m\n instructions: i\ntools:\n - fetch\ntool_config:\n fetch:\n allowedImages:\n - anything\n backend: whatever\n",
213
+ );
214
+ if (spec.target !== "cli") expect.unreachable();
215
+ // `fetch` is not a code-execution tool, so its config is forwarded
216
+ // verbatim — these keys are meaningless there and harmless.
217
+ expect(spec.tool_config?.["fetch"]).toEqual({
218
+ allowedImages: ["anything"],
219
+ backend: "whatever",
220
+ });
221
+ });
222
+ });
223
+
141
224
  describe("Spec schema", () => {
142
225
  test("schema is exported as a runtime value (Zod)", () => {
143
226
  expect(typeof Spec.safeParse).toBe("function");
@@ -1009,3 +1092,327 @@ compaction:
1009
1092
  ).toThrow(SpecParseError);
1010
1093
  });
1011
1094
  });
1095
+
1096
+ // FR-004 — Pillar 3 security block (intent-gate judge selection).
1097
+ describe("parseSpec security.justification", () => {
1098
+ test("parses a cli spec with security.justification.judge=claude + model", () => {
1099
+ const spec = parseSpec(`
1100
+ name: hello
1101
+ target: cli
1102
+ agent:
1103
+ model: m
1104
+ instructions: i
1105
+ security:
1106
+ justification:
1107
+ judge: claude
1108
+ model: claude-haiku-4-5
1109
+ `);
1110
+ if (spec.target !== "cli") expect.unreachable();
1111
+ expect(spec.security?.justification?.judge).toBe("claude");
1112
+ expect(spec.security?.justification?.model).toBe("claude-haiku-4-5");
1113
+ });
1114
+
1115
+ test("judge defaults to rule-based when the justification block omits it", () => {
1116
+ const spec = parseSpec(`
1117
+ name: hello
1118
+ target: cli
1119
+ agent:
1120
+ model: m
1121
+ instructions: i
1122
+ security:
1123
+ justification: {}
1124
+ `);
1125
+ if (spec.target !== "cli") expect.unreachable();
1126
+ expect(spec.security?.justification?.judge).toBe("rule-based");
1127
+ });
1128
+
1129
+ test("rejects an unknown judge enum value", () => {
1130
+ expect(() =>
1131
+ parseSpec(`
1132
+ name: hello
1133
+ target: cli
1134
+ agent:
1135
+ model: m
1136
+ instructions: i
1137
+ security:
1138
+ justification:
1139
+ judge: gpt-omniscient
1140
+ `),
1141
+ ).toThrow(SpecParseError);
1142
+ });
1143
+
1144
+ test("rejects unknown keys inside the security block (strict)", () => {
1145
+ expect(() =>
1146
+ parseSpec(`
1147
+ name: hello
1148
+ target: cli
1149
+ agent:
1150
+ model: m
1151
+ instructions: i
1152
+ security:
1153
+ enableTelepathy: true
1154
+ `),
1155
+ ).toThrow(SpecParseError);
1156
+ });
1157
+
1158
+ test("security block is optional — a spec without it still parses", () => {
1159
+ const spec = parseSpec(`
1160
+ name: hello
1161
+ target: cli
1162
+ agent:
1163
+ model: m
1164
+ instructions: i
1165
+ `);
1166
+ if (spec.target !== "cli") expect.unreachable();
1167
+ expect(spec.security).toBeUndefined();
1168
+ });
1169
+ });
1170
+
1171
+ // FR-006 — Pillar 3 sink-side fabric (egress matcher selector).
1172
+ describe("parseSpec security.egressMatcher", () => {
1173
+ test("parses a cli spec with security.egressMatcher: semantic", () => {
1174
+ const spec = parseSpec(`
1175
+ name: hello
1176
+ target: cli
1177
+ agent:
1178
+ model: m
1179
+ instructions: i
1180
+ security:
1181
+ egressMatcher: semantic
1182
+ `);
1183
+ if (spec.target !== "cli") expect.unreachable();
1184
+ expect(spec.security?.egressMatcher).toBe("semantic");
1185
+ });
1186
+
1187
+ test("parses security.egressMatcher: substring (the explicit default)", () => {
1188
+ const spec = parseSpec(`
1189
+ name: hello
1190
+ target: cli
1191
+ agent:
1192
+ model: m
1193
+ instructions: i
1194
+ security:
1195
+ egressMatcher: substring
1196
+ `);
1197
+ if (spec.target !== "cli") expect.unreachable();
1198
+ expect(spec.security?.egressMatcher).toBe("substring");
1199
+ });
1200
+
1201
+ test("rejects an unknown egressMatcher enum value (strict)", () => {
1202
+ expect(() =>
1203
+ parseSpec(`
1204
+ name: hello
1205
+ target: cli
1206
+ agent:
1207
+ model: m
1208
+ instructions: i
1209
+ security:
1210
+ egressMatcher: telepathic
1211
+ `),
1212
+ ).toThrow(SpecParseError);
1213
+ });
1214
+
1215
+ test("egressMatcher coexists with justification in the same security block", () => {
1216
+ const spec = parseSpec(`
1217
+ name: hello
1218
+ target: cli
1219
+ agent:
1220
+ model: m
1221
+ instructions: i
1222
+ security:
1223
+ justification:
1224
+ judge: claude
1225
+ egressMatcher: semantic
1226
+ `);
1227
+ if (spec.target !== "cli") expect.unreachable();
1228
+ expect(spec.security?.justification?.judge).toBe("claude");
1229
+ expect(spec.security?.egressMatcher).toBe("semantic");
1230
+ });
1231
+
1232
+ test("egressMatcher is optional — a security block without it still parses", () => {
1233
+ const spec = parseSpec(`
1234
+ name: hello
1235
+ target: cli
1236
+ agent:
1237
+ model: m
1238
+ instructions: i
1239
+ security:
1240
+ justification: {}
1241
+ `);
1242
+ if (spec.target !== "cli") expect.unreachable();
1243
+ expect(spec.security?.egressMatcher).toBeUndefined();
1244
+ });
1245
+ });
1246
+
1247
+ describe("parseSpec pipeline target — vector backend (Section 21)", () => {
1248
+ const PIPELINE = (retrieve: string) => `
1249
+ name: doc-bot
1250
+ target: pipeline
1251
+ agent:
1252
+ model: claude-sonnet-4-6
1253
+ instructions: answer using Retrieve
1254
+ retrieve:
1255
+ ${retrieve}
1256
+ indexing:
1257
+ chunkStrategy: fixed
1258
+ chunkSize: 200
1259
+ chunkOverlap: 0
1260
+ documents:
1261
+ - id: doc-1
1262
+ text: the quick brown fox
1263
+ `;
1264
+
1265
+ test("defaults vectorBackend to in-memory when omitted", () => {
1266
+ const spec = parseSpec(PIPELINE(" embedderModel: mock/det"));
1267
+ if (spec.target !== "pipeline") expect.unreachable();
1268
+ expect(spec.retrieve.vectorBackend).toBe("in-memory");
1269
+ });
1270
+
1271
+ test("accepts the file backend (lance) with no extra config", () => {
1272
+ const spec = parseSpec(PIPELINE(" embedderModel: mock/det\n vectorBackend: lance"));
1273
+ if (spec.target !== "pipeline") expect.unreachable();
1274
+ expect(spec.retrieve.vectorBackend).toBe("lance");
1275
+ });
1276
+
1277
+ test("accepts an http backend with url + collection + apiKey", () => {
1278
+ const spec = parseSpec(
1279
+ PIPELINE(
1280
+ [
1281
+ " embedderModel: mock/det",
1282
+ " vectorBackend: qdrant",
1283
+ " url: https://qdrant.example",
1284
+ " collection: docs",
1285
+ " apiKey: $QDRANT_API_KEY",
1286
+ ].join("\n"),
1287
+ ),
1288
+ );
1289
+ if (spec.target !== "pipeline") expect.unreachable();
1290
+ expect(spec.retrieve.vectorBackend).toBe("qdrant");
1291
+ expect(spec.retrieve.url).toBe("https://qdrant.example");
1292
+ expect(spec.retrieve.collection).toBe("docs");
1293
+ expect(spec.retrieve.apiKey).toBe("$QDRANT_API_KEY");
1294
+ });
1295
+
1296
+ test("rejects an unknown backend id", () => {
1297
+ expect(() => parseSpec(PIPELINE(" embedderModel: mock/det\n vectorBackend: faiss"))).toThrow(
1298
+ SpecParseError,
1299
+ );
1300
+ });
1301
+
1302
+ test("rejects an http backend missing url", () => {
1303
+ expect(() =>
1304
+ parseSpec(PIPELINE(" embedderModel: mock/det\n vectorBackend: qdrant\n collection: docs")),
1305
+ ).toThrow(/requires retrieve\.url/);
1306
+ });
1307
+
1308
+ test("rejects an http backend missing collection", () => {
1309
+ expect(() =>
1310
+ parseSpec(
1311
+ PIPELINE(
1312
+ " embedderModel: mock/det\n vectorBackend: pinecone\n url: https://pinecone.example",
1313
+ ),
1314
+ ),
1315
+ ).toThrow(/requires retrieve\.collection/);
1316
+ });
1317
+ });
1318
+
1319
+ describe("parseSpec crew target cross-field invariants (Section 22)", () => {
1320
+ // Two-role crew with a configurable `entry:` line and an optional trailing
1321
+ // routing block, so each post-parse invariant is exercised through a real
1322
+ // Zod-valid spec (the cross-field checks run only after safeParse succeeds).
1323
+ const CREW = (entry: string, routing = "") => `
1324
+ name: team
1325
+ target: crew
1326
+ model: m
1327
+ entry: ${entry}
1328
+ roles:
1329
+ lead:
1330
+ instructions: coordinate the crew
1331
+ worker:
1332
+ instructions: do the work
1333
+ ${routing}`;
1334
+
1335
+ test("parses a valid crew with match routing and threads roles/entry/routing", () => {
1336
+ const spec = parseSpec(
1337
+ CREW(
1338
+ "lead",
1339
+ [
1340
+ "routing:",
1341
+ " kind: match",
1342
+ " match:",
1343
+ " lead:",
1344
+ " - contains: help",
1345
+ " to: worker",
1346
+ ].join("\n"),
1347
+ ),
1348
+ );
1349
+ if (spec.target !== "crew") expect.unreachable();
1350
+ expect(Object.keys(spec.roles)).toEqual(["lead", "worker"]);
1351
+ expect(spec.entry).toBe("lead");
1352
+ expect(spec.routing).toEqual({
1353
+ kind: "match",
1354
+ match: { lead: [{ contains: "help", to: "worker" }] },
1355
+ });
1356
+ });
1357
+
1358
+ test("accepts llm routing (no match block) — the match-validation loop is skipped", () => {
1359
+ const spec = parseSpec(CREW("lead", ["routing:", " kind: llm"].join("\n")));
1360
+ if (spec.target !== "crew") expect.unreachable();
1361
+ expect(spec.routing).toEqual({ kind: "llm" });
1362
+ });
1363
+
1364
+ test("accepts a crew with no routing block at all", () => {
1365
+ const spec = parseSpec(CREW("worker"));
1366
+ if (spec.target !== "crew") expect.unreachable();
1367
+ expect(spec.routing).toBeUndefined();
1368
+ expect(spec.entry).toBe("worker");
1369
+ });
1370
+
1371
+ test("rejects a crew whose roles record is empty", () => {
1372
+ expect(() => parseSpec("name: t\ntarget: crew\nmodel: m\nentry: lead\nroles: {}\n")).toThrow(
1373
+ /crew target requires at least one role/,
1374
+ );
1375
+ });
1376
+
1377
+ test("rejects a crew whose entry does not name a declared role", () => {
1378
+ expect(() => parseSpec(CREW("ghost"))).toThrow(
1379
+ /crew\.entry "ghost" must name one of crew\.roles \(got: lead, worker\)/,
1380
+ );
1381
+ });
1382
+
1383
+ test("rejects routing whose match source role is not a declared role", () => {
1384
+ expect(() =>
1385
+ parseSpec(
1386
+ CREW(
1387
+ "lead",
1388
+ [
1389
+ "routing:",
1390
+ " kind: match",
1391
+ " match:",
1392
+ " ghost:",
1393
+ " - contains: x",
1394
+ " to: worker",
1395
+ ].join("\n"),
1396
+ ),
1397
+ ),
1398
+ ).toThrow(/crew\.routing\.match\["ghost"\]: source role not in crew\.roles/);
1399
+ });
1400
+
1401
+ test("rejects routing whose match target role is not a declared role", () => {
1402
+ expect(() =>
1403
+ parseSpec(
1404
+ CREW(
1405
+ "lead",
1406
+ [
1407
+ "routing:",
1408
+ " kind: match",
1409
+ " match:",
1410
+ " lead:",
1411
+ " - contains: x",
1412
+ " to: ghost",
1413
+ ].join("\n"),
1414
+ ),
1415
+ ),
1416
+ ).toThrow(/crew\.routing\.match\["lead"\]\.to = "ghost" — target role not in crew\.roles/);
1417
+ });
1418
+ });
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
  }