@agentique.io/validator 0.1.0

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.
@@ -0,0 +1,363 @@
1
+ import { createHash } from "node:crypto";
2
+ import { promises as fs } from "node:fs";
3
+ import path from "node:path";
4
+ import Ajv from "ajv/dist/2020.js";
5
+ import addFormats from "ajv-formats";
6
+
7
+ const schemaFiles = [
8
+ "distribution-mode.schema.json",
9
+ "context-bundle.schema.json",
10
+ "output-contract.schema.json",
11
+ "package-manifest.schema.json",
12
+ "permission-risk.schema.json",
13
+ "public-readback.schema.json",
14
+ "resource-manifest.schema.json",
15
+ "skill-metadata.schema.json",
16
+ "surfacing-metadata.schema.json",
17
+ "tool-listing.schema.json",
18
+ "workflow-metadata.schema.json"
19
+ ];
20
+
21
+ const blockedExtensions = new Set([
22
+ ".bat",
23
+ ".cmd",
24
+ ".com",
25
+ ".exe",
26
+ ".msi",
27
+ ".ps1",
28
+ ".sh"
29
+ ]);
30
+
31
+ const sensitivePathSegments = new Set(["private", ".env", ".git", ".cache", "node_modules"]);
32
+
33
+ const internalDotDirs = ["planning", "sessions"].map((name) => `\\.${name}`).join("|");
34
+ const internalReferenceDocs = ["reference", "docs"].join("\\/");
35
+ const internalPathPattern = new RegExp(
36
+ `(?:^|[\\\\/\\s])(?:${internalDotDirs}|${internalReferenceDocs}|REFERENCE)(?:[\\\\/\\s]|$)`,
37
+ "i"
38
+ );
39
+
40
+ const forbiddenTextPatterns = [
41
+ { id: "internal-path", pattern: internalPathPattern },
42
+ { id: "local-absolute-path", pattern: /(?:[A-Za-z]:\\|\/home\/|\/Users\/|\/mnt\/)/ },
43
+ { id: "private-directory", pattern: /(?:^|[\\/\s])private(?:[\\/\s]|$)/i },
44
+ {
45
+ id: "unbounded-context",
46
+ pattern: /\b(?:all[-\s]?catalog|full[-\s]?catalog|entire[-\s]?catalog|default[-\s]?unbounded|include everything|load everything|expose everything)\b/i
47
+ },
48
+ {
49
+ id: "strong-claim",
50
+ pattern: new RegExp(
51
+ [
52
+ "\\b(?:platform[-\\s]?approved|approved by validator|",
53
+ "certified\\s+safe|safety certification|guaranteed safe|guaranteed compatible|guaranteed execution)\\b"
54
+ ].join(""),
55
+ "i"
56
+ )
57
+ }
58
+ ];
59
+
60
+ const secretLikePatterns = [
61
+ { id: "private-key", pattern: /-----BEGIN [A-Z ]*PRIVATE KEY-----/ },
62
+ { id: "openai-key", pattern: /\bsk-[A-Za-z0-9_-]{20,}\b/ },
63
+ { id: "github-token", pattern: /\b(?:ghp|github_pat)_[A-Za-z0-9_]{20,}\b/ },
64
+ { id: "aws-access-key", pattern: /\bAKIA[0-9A-Z]{16}\b/ },
65
+ { id: "bearer-token", pattern: /\bBearer\s+[A-Za-z0-9._~+/=-]{20,}\b/i },
66
+ {
67
+ id: "assignment-secret",
68
+ pattern: /\b(?:api[_-]?(?:key|token)|secret|password|token)\b\s*[:=]\s*["'][^"']+["']/i
69
+ },
70
+ { id: "database-url", pattern: /\b(?:postgres(?:ql)?|mysql|mongodb(?:\+srv)?|redis):\/\/[^\s"'<>]+/i },
71
+ { id: "credential-url", pattern: /\b[a-z][a-z0-9+.-]*:\/\/[^/\s"'<>:]+:[^@\s"'<>]+@[^\s"'<>]+/i }
72
+ ];
73
+
74
+ export async function validatePackage(options) {
75
+ const command = options.command ?? "validate";
76
+ const packageDir = path.resolve(options.packageDir);
77
+ const schemasDir = path.resolve(options.schemasDir);
78
+ const findings = [];
79
+
80
+ const manifestPath = path.join(packageDir, "manifest.json");
81
+ const manifest = await readJsonFile(manifestPath, findings, "manifest");
82
+ if (!manifest) {
83
+ return createReport({ ok: false, command, packageDir, manifest: null, inventory: [], findings });
84
+ }
85
+
86
+ const ajv = await createAjv(schemasDir);
87
+ const validateManifest = ajv.getSchema("https://schemas.agentique.io/resource-manifest.schema.json");
88
+ if (!validateManifest) {
89
+ findings.push(finding("schema-loader", "Resource manifest schema was not loaded.", "schema"));
90
+ } else if (!validateManifest(manifest)) {
91
+ for (const error of validateManifest.errors ?? []) {
92
+ findings.push(finding("schema", `${error.instancePath || "/"} ${error.message ?? "is invalid"}`, "manifest"));
93
+ }
94
+ }
95
+ validateManifestContracts(manifest, findings);
96
+
97
+ const packageFiles = Array.isArray(manifest?.package?.files) ? manifest.package.files : [];
98
+ const packageHashes = isRecord(manifest?.package?.hashes) ? manifest.package.hashes : {};
99
+
100
+ for (const rel of packageFiles) {
101
+ validatePackagePath(rel, findings);
102
+ }
103
+
104
+ for (const rel of Object.keys(packageHashes)) {
105
+ validatePackagePath(rel, findings);
106
+ if (!packageFiles.includes(rel)) {
107
+ findings.push(finding("hash-without-file", "Hash entry does not have a matching package file.", rel));
108
+ }
109
+ }
110
+
111
+ for (const rel of packageFiles) {
112
+ await inspectPackageFile({ packageDir, rel, expectedHash: packageHashes[rel], findings, ajv });
113
+ }
114
+
115
+ scanJsonValue(manifest, "manifest", findings);
116
+
117
+ const inventory = [];
118
+ for (const rel of packageFiles) {
119
+ const filePath = resolveInside(packageDir, rel);
120
+ if (!filePath) continue;
121
+ try {
122
+ const bytes = await fs.readFile(filePath);
123
+ inventory.push({
124
+ path: rel,
125
+ sha256: createHash("sha256").update(bytes).digest("hex"),
126
+ bytes: bytes.length
127
+ });
128
+ } catch {
129
+ // Missing files are already reported by inspectPackageFile.
130
+ }
131
+ }
132
+
133
+ return createReport({
134
+ ok: findings.length === 0,
135
+ command,
136
+ packageDir,
137
+ manifest: manifest
138
+ ? {
139
+ name: typeof manifest.name === "string" ? manifest.name : null,
140
+ formatVersion: typeof manifest.formatVersion === "string" ? manifest.formatVersion : null
141
+ }
142
+ : null,
143
+ inventory,
144
+ findings
145
+ });
146
+ }
147
+
148
+ export async function defaultSchemasDir(fromDir = process.cwd()) {
149
+ const candidate = path.resolve(fromDir, "..", "agentique-schemas", "schemas");
150
+ try {
151
+ const stat = await fs.stat(candidate);
152
+ if (stat.isDirectory()) return candidate;
153
+ } catch {
154
+ // Caller will report a clearer CLI error if no explicit schemas dir exists.
155
+ }
156
+ return candidate;
157
+ }
158
+
159
+ async function createAjv(schemasDir) {
160
+ const ajv = new Ajv({ allErrors: true, strict: true });
161
+ addFormats(ajv);
162
+ for (const file of schemaFiles) {
163
+ let schema;
164
+ try {
165
+ schema = await readRequiredJson(path.join(schemasDir, file));
166
+ } catch (error) {
167
+ const message = error instanceof Error ? error.message : "unknown error";
168
+ throw new Error(`Unable to load schema ${file} from ${schemasDir}: ${message}`);
169
+ }
170
+ ajv.addSchema(schema);
171
+ }
172
+ return ajv;
173
+ }
174
+
175
+ async function readRequiredJson(filePath) {
176
+ const raw = await fs.readFile(filePath, "utf8");
177
+ return JSON.parse(raw);
178
+ }
179
+
180
+ async function readJsonFile(filePath, findings, location) {
181
+ try {
182
+ const raw = await fs.readFile(filePath, "utf8");
183
+ return JSON.parse(raw);
184
+ } catch (error) {
185
+ findings.push(finding("json-read", `Unable to read valid JSON: ${error.code ?? "parse_error"}`, location));
186
+ return null;
187
+ }
188
+ }
189
+
190
+ function validatePackagePath(rel, findings) {
191
+ if (typeof rel !== "string" || rel.trim() !== rel || rel.length === 0) {
192
+ findings.push(finding("invalid-path", "Package path must be a non-empty normalized string.", "package.files"));
193
+ return;
194
+ }
195
+ if (path.isAbsolute(rel) || /^[A-Za-z]:/.test(rel) || rel.includes("\\") || rel.split("/").includes("..")) {
196
+ findings.push(finding("unsafe-path", "Package path must stay relative and cannot traverse directories.", rel));
197
+ }
198
+ const segments = rel.split("/");
199
+ const sensitiveSegment = segments.find((segment) => sensitivePathSegments.has(segment));
200
+ if (sensitiveSegment) {
201
+ findings.push(finding("sensitive-path", `Package path cannot target ${sensitiveSegment} content.`, rel));
202
+ }
203
+ if (blockedExtensions.has(path.extname(rel).toLowerCase())) {
204
+ findings.push(finding("blocked-extension", "Executable payload extensions are not allowed in starter packages.", rel));
205
+ }
206
+ }
207
+
208
+ async function inspectPackageFile({ packageDir, rel, expectedHash, findings, ajv }) {
209
+ const filePath = resolveInside(packageDir, rel);
210
+ if (!filePath) {
211
+ findings.push(finding("unsafe-path", "Resolved file path escaped the package directory.", rel));
212
+ return;
213
+ }
214
+
215
+ let bytes;
216
+ try {
217
+ bytes = await fs.readFile(filePath);
218
+ } catch {
219
+ findings.push(finding("missing-file", "Package file listed in manifest is missing.", rel));
220
+ return;
221
+ }
222
+
223
+ const actualHash = `sha256:${createHash("sha256").update(bytes).digest("hex")}`;
224
+ if (expectedHash && expectedHash !== actualHash) {
225
+ findings.push(finding("hash-mismatch", "Package file hash does not match manifest.", rel));
226
+ }
227
+
228
+ const text = bytes.toString("utf8");
229
+ scanText(text, rel, findings);
230
+ inspectStructuredPackageFile({ rel, text, findings, ajv });
231
+ }
232
+
233
+ function validateManifestContracts(manifest, findings) {
234
+ if (!isRecord(manifest)) return;
235
+
236
+ if (!isRecord(manifest.permissionRisk)) {
237
+ findings.push(
238
+ finding(
239
+ "permission-risk-missing",
240
+ "Resource manifests must declare permission and risk metadata for agent-facing selection.",
241
+ "manifest.permissionRisk"
242
+ )
243
+ );
244
+ }
245
+
246
+ if (isRecord(manifest.skill) && !isRecord(manifest.skill.outputContract)) {
247
+ findings.push(
248
+ finding(
249
+ "output-contract-missing",
250
+ "Skill metadata must declare an output contract for bounded agent-facing use.",
251
+ "manifest.skill.outputContract"
252
+ )
253
+ );
254
+ }
255
+
256
+ if (isRecord(manifest.workflow) && !isRecord(manifest.workflow.outputContract)) {
257
+ findings.push(
258
+ finding(
259
+ "output-contract-missing",
260
+ "Workflow metadata must declare an output contract for bounded agent-facing use.",
261
+ "manifest.workflow.outputContract"
262
+ )
263
+ );
264
+ }
265
+ }
266
+
267
+ function inspectStructuredPackageFile({ rel, text, findings, ajv }) {
268
+ const normalized = rel.replaceAll("\\", "/").toLowerCase();
269
+ const schemaId = schemaIdForPackageJson(normalized);
270
+ if (!schemaId) return;
271
+
272
+ let value;
273
+ try {
274
+ value = JSON.parse(text);
275
+ } catch {
276
+ findings.push(finding("json-contract-read", "Package JSON contract file must contain valid JSON.", rel));
277
+ return;
278
+ }
279
+
280
+ const validate = ajv.getSchema(schemaId);
281
+ if (!validate) {
282
+ findings.push(finding("schema-loader", `Required package contract schema was not loaded: ${path.basename(schemaId)}`, rel));
283
+ return;
284
+ }
285
+
286
+ if (!validate(value)) {
287
+ for (const error of validate.errors ?? []) {
288
+ findings.push(
289
+ finding(
290
+ "contract-schema",
291
+ `${error.instancePath || "/"} ${error.message ?? "is invalid"}`,
292
+ rel
293
+ )
294
+ );
295
+ }
296
+ }
297
+ }
298
+
299
+ function schemaIdForPackageJson(normalizedRel) {
300
+ if (!normalizedRel.endsWith(".json")) return null;
301
+ if (normalizedRel.startsWith("tools/")) {
302
+ return "https://schemas.agentique.io/tool-listing.schema.json";
303
+ }
304
+ if (normalizedRel.includes("context-bundle")) {
305
+ return "https://schemas.agentique.io/context-bundle.schema.json";
306
+ }
307
+ return null;
308
+ }
309
+
310
+ function resolveInside(root, rel) {
311
+ const resolved = path.resolve(root, rel);
312
+ const rootWithSep = root.endsWith(path.sep) ? root : `${root}${path.sep}`;
313
+ return resolved === root || resolved.startsWith(rootWithSep) ? resolved : null;
314
+ }
315
+
316
+ function scanJsonValue(value, location, findings) {
317
+ if (typeof value === "string") {
318
+ scanText(value, location, findings);
319
+ return;
320
+ }
321
+ if (Array.isArray(value)) {
322
+ value.forEach((item, index) => scanJsonValue(item, `${location}[${index}]`, findings));
323
+ return;
324
+ }
325
+ if (isRecord(value)) {
326
+ for (const [key, entry] of Object.entries(value)) {
327
+ scanText(key, `${location}.${key}`, findings);
328
+ scanJsonValue(entry, `${location}.${key}`, findings);
329
+ }
330
+ }
331
+ }
332
+
333
+ function scanText(text, location, findings) {
334
+ for (const rule of forbiddenTextPatterns) {
335
+ if (rule.pattern.test(text)) {
336
+ findings.push(finding(rule.id, "Forbidden public-content term or path detected.", location));
337
+ }
338
+ }
339
+ for (const rule of secretLikePatterns) {
340
+ if (rule.pattern.test(text)) {
341
+ findings.push(finding(rule.id, "Secret-like value detected and redacted.", location));
342
+ }
343
+ }
344
+ }
345
+
346
+ function createReport({ ok, command, packageDir, manifest, inventory, findings }) {
347
+ return {
348
+ ok,
349
+ command,
350
+ packageDir: path.basename(packageDir),
351
+ manifest,
352
+ inventory,
353
+ findings
354
+ };
355
+ }
356
+
357
+ function finding(code, message, location) {
358
+ return { code, message, location };
359
+ }
360
+
361
+ function isRecord(value) {
362
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
363
+ }