@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 +6 -11
- package/src/index.test.ts +407 -0
- package/src/index.ts +221 -23
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crewhaus/spec",
|
|
3
|
-
"version": "0.1.
|
|
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.
|
|
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@
|
|
23
|
-
"url": "https://
|
|
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": "
|
|
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(
|
|
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
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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(
|
|
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:
|
|
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:
|
|
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(
|
|
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:
|
|
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(
|
|
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(
|
|
800
|
+
vectorBackend: z.enum(VECTOR_BACKENDS).optional(),
|
|
622
801
|
})
|
|
623
802
|
.strict();
|
|
624
803
|
|
|
625
804
|
const researchSchema = z
|
|
626
805
|
.object({
|
|
627
|
-
name:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
}
|