jules-orchestrator-kit 0.32.4 → 0.32.6
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/JULES_RULES_TEMPLATE.md +2 -1
- package/README.md +48 -12
- package/bin/agentctl.mjs +122 -13
- package/index.mjs +19 -4
- package/package.json +2 -1
- package/scripts/command-resolver.mjs +15 -1
- package/scripts/doc-sync-check.mjs +234 -0
- package/scripts/release.mjs +23 -2
- package/src/config.mjs +66 -0
- package/src/dag-engine.mjs +118 -0
- package/src/engine.mjs +237 -71
- package/src/evidence.mjs +435 -0
- package/src/mcp.mjs +39 -1
- package/src/ops/evidence-actions.mjs +74 -0
- package/src/provider.mjs +37 -7
- package/src/router.mjs +205 -0
- package/src/security.mjs +66 -13
- package/src/stack-detector.mjs +28 -0
- package/src/wizard-task.mjs +66 -5
package/src/evidence.mjs
ADDED
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
import {
|
|
2
|
+
readFileSync,
|
|
3
|
+
mkdirSync,
|
|
4
|
+
existsSync,
|
|
5
|
+
readdirSync,
|
|
6
|
+
statSync,
|
|
7
|
+
openSync,
|
|
8
|
+
writeSync,
|
|
9
|
+
fsyncSync,
|
|
10
|
+
closeSync,
|
|
11
|
+
renameSync,
|
|
12
|
+
unlinkSync,
|
|
13
|
+
} from "node:fs";
|
|
14
|
+
import { join, resolve, relative, isAbsolute, sep } from "node:path";
|
|
15
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
16
|
+
import { execSync } from "node:child_process";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Normalizes file path to POSIX slashes.
|
|
20
|
+
* @param {string} p
|
|
21
|
+
* @returns {string}
|
|
22
|
+
*/
|
|
23
|
+
export function normalizePath(p = "") {
|
|
24
|
+
return p.split(sep).join("/").replace(/\\/g, "/");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Computes SHA-256 hex digest of a string or buffer.
|
|
29
|
+
* @param {string | Buffer} content
|
|
30
|
+
* @returns {string}
|
|
31
|
+
*/
|
|
32
|
+
export function sha256(content) {
|
|
33
|
+
return createHash("sha256").update(content).digest("hex");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Computes SHA-256 of a single file on disk.
|
|
38
|
+
* @param {string} filePath
|
|
39
|
+
* @returns {string | null}
|
|
40
|
+
*/
|
|
41
|
+
export function computeFileHash(filePath) {
|
|
42
|
+
try {
|
|
43
|
+
if (!existsSync(filePath)) return null;
|
|
44
|
+
const content = readFileSync(filePath);
|
|
45
|
+
return "sha256:" + sha256(content);
|
|
46
|
+
} catch (_) {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Recursively scans a directory and finds all matching files.
|
|
53
|
+
* @param {string} dir
|
|
54
|
+
* @param {string} [baseDir]
|
|
55
|
+
* @returns {string[]}
|
|
56
|
+
*/
|
|
57
|
+
export function findFilesRecursively(dir, baseDir = dir) {
|
|
58
|
+
if (!existsSync(dir)) return [];
|
|
59
|
+
const results = [];
|
|
60
|
+
try {
|
|
61
|
+
const entries = readdirSync(dir, { withFileTypes: true });
|
|
62
|
+
for (const entry of entries) {
|
|
63
|
+
const fullPath = join(dir, entry.name);
|
|
64
|
+
if (entry.name === ".git" || entry.name === "node_modules" || entry.name === "target" || entry.name === "vendor") {
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
if (entry.isDirectory()) {
|
|
68
|
+
results.push(...findFilesRecursively(fullPath, baseDir));
|
|
69
|
+
} else if (entry.isFile()) {
|
|
70
|
+
results.push(normalizePath(relative(baseDir, fullPath)));
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
} catch (_) {}
|
|
74
|
+
return results.sort();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Deterministically computes SHA-256 tree hash over a directory or set of files.
|
|
79
|
+
* Ignores mtime and permissions to ensure byte-level determinism.
|
|
80
|
+
* @param {string} root
|
|
81
|
+
* @param {object} [options]
|
|
82
|
+
* @param {string[]} [options.paths] - Specific paths to hash (relative to root)
|
|
83
|
+
* @param {string[]} [options.directories] - Specific directory names to scan (e.g. ['test', 'tests'])
|
|
84
|
+
* @returns {{ treeHash: string, fileCount: number, fileHashes: Record<string, string> }}
|
|
85
|
+
*/
|
|
86
|
+
export function computeDirectoryHash(root, options = {}) {
|
|
87
|
+
let fileList = [];
|
|
88
|
+
|
|
89
|
+
if (Array.isArray(options.paths) && options.paths.length > 0) {
|
|
90
|
+
fileList = options.paths
|
|
91
|
+
.map((p) => normalizePath(p))
|
|
92
|
+
.filter((p) => existsSync(join(root, p)) && statSync(join(root, p)).isFile())
|
|
93
|
+
.sort();
|
|
94
|
+
} else {
|
|
95
|
+
const targetDirs = options.directories || ["test", "tests", "__tests__", "spec", "src"];
|
|
96
|
+
for (const dirName of targetDirs) {
|
|
97
|
+
const dirPath = join(root, dirName);
|
|
98
|
+
if (existsSync(dirPath)) {
|
|
99
|
+
const found = findFilesRecursively(dirPath, root);
|
|
100
|
+
fileList.push(...found);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
fileList = Array.from(new Set(fileList)).sort();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Filter test files if specifically looking for test suites
|
|
107
|
+
if (options.testOnly) {
|
|
108
|
+
fileList = fileList.filter((f) => {
|
|
109
|
+
const lower = f.toLowerCase();
|
|
110
|
+
return (
|
|
111
|
+
lower.startsWith("test/") ||
|
|
112
|
+
lower.startsWith("tests/") ||
|
|
113
|
+
lower.startsWith("__tests__/") ||
|
|
114
|
+
lower.startsWith("spec/") ||
|
|
115
|
+
lower.includes(".test.") ||
|
|
116
|
+
lower.includes(".spec.") ||
|
|
117
|
+
lower.includes("_test.") ||
|
|
118
|
+
lower.endsWith("test.sol") ||
|
|
119
|
+
lower.endsWith(".t.sol")
|
|
120
|
+
);
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const fileHashes = {};
|
|
125
|
+
const hashLines = [];
|
|
126
|
+
|
|
127
|
+
for (const relPath of fileList) {
|
|
128
|
+
const fullPath = join(root, relPath);
|
|
129
|
+
const hash = computeFileHash(fullPath);
|
|
130
|
+
if (hash) {
|
|
131
|
+
fileHashes[relPath] = hash;
|
|
132
|
+
hashLines.push(`${relPath}:${hash}`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const treeHash = "sha256:" + sha256(hashLines.join("\n"));
|
|
137
|
+
return {
|
|
138
|
+
treeHash,
|
|
139
|
+
fileCount: hashLines.length,
|
|
140
|
+
fileHashes,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Computes canonical hash for an evidence manifest (excluding the evidenceHash field).
|
|
146
|
+
* @param {object} manifest
|
|
147
|
+
* @returns {string}
|
|
148
|
+
*/
|
|
149
|
+
export function computeEvidenceHash(manifest) {
|
|
150
|
+
const canonicalPayload = JSON.stringify({
|
|
151
|
+
schema: manifest.schema,
|
|
152
|
+
manifestId: manifest.manifestId,
|
|
153
|
+
generatedAt: manifest.generatedAt,
|
|
154
|
+
intent: manifest.intent,
|
|
155
|
+
provenance: manifest.provenance,
|
|
156
|
+
testIntegrity: manifest.testIntegrity,
|
|
157
|
+
executionRecords: manifest.executionRecords,
|
|
158
|
+
securityChecks: manifest.securityChecks,
|
|
159
|
+
});
|
|
160
|
+
return "sha256:" + sha256(canonicalPayload);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Safely writes a file atomically.
|
|
165
|
+
* @param {string} filePath
|
|
166
|
+
* @param {string} content
|
|
167
|
+
*/
|
|
168
|
+
function writeFileAtomically(filePath, content) {
|
|
169
|
+
const tmpPath = `${filePath}.${randomUUID()}.tmp`;
|
|
170
|
+
let fd;
|
|
171
|
+
try {
|
|
172
|
+
fd = openSync(tmpPath, "wx", 0o600);
|
|
173
|
+
writeSync(fd, content, "utf-8");
|
|
174
|
+
fsyncSync(fd);
|
|
175
|
+
closeSync(fd);
|
|
176
|
+
fd = undefined;
|
|
177
|
+
renameSync(tmpPath, filePath);
|
|
178
|
+
} catch (err) {
|
|
179
|
+
if (fd !== undefined) {
|
|
180
|
+
try { closeSync(fd); } catch (_) {}
|
|
181
|
+
}
|
|
182
|
+
try { unlinkSync(tmpPath); } catch (_) {}
|
|
183
|
+
throw err;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Queries current git provenance data safely.
|
|
189
|
+
* @param {string} root
|
|
190
|
+
* @returns {object}
|
|
191
|
+
*/
|
|
192
|
+
export function getGitProvenance(root = process.cwd()) {
|
|
193
|
+
try {
|
|
194
|
+
const commitSha = execSync("git rev-parse HEAD", { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
195
|
+
let branch = "";
|
|
196
|
+
try {
|
|
197
|
+
branch = execSync("git rev-parse --abbrev-ref HEAD", { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
198
|
+
} catch (_) {}
|
|
199
|
+
|
|
200
|
+
let statusOutput = "";
|
|
201
|
+
try {
|
|
202
|
+
statusOutput = execSync("git status --porcelain", { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
203
|
+
} catch (_) {}
|
|
204
|
+
|
|
205
|
+
return {
|
|
206
|
+
commitSha,
|
|
207
|
+
branch: branch || "HEAD",
|
|
208
|
+
dirty: statusOutput.length > 0,
|
|
209
|
+
};
|
|
210
|
+
} catch (_) {
|
|
211
|
+
return {
|
|
212
|
+
commitSha: "unknown",
|
|
213
|
+
branch: "unknown",
|
|
214
|
+
dirty: false,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Generates an Evidence Manifest capturing deterministic build, test, and security evidence.
|
|
221
|
+
* @param {string} root
|
|
222
|
+
* @param {object} [options]
|
|
223
|
+
* @returns {object}
|
|
224
|
+
*/
|
|
225
|
+
export function generateEvidenceManifest(root = process.cwd(), options = {}) {
|
|
226
|
+
const manifestId = options.manifestId || `EVD-${Date.now()}-${randomUUID().slice(0, 8)}`;
|
|
227
|
+
const generatedAt = new Date().toISOString();
|
|
228
|
+
const gitInfo = getGitProvenance(root);
|
|
229
|
+
|
|
230
|
+
// Compute test tree integrity
|
|
231
|
+
const preTestHash = options.preTestHash || null;
|
|
232
|
+
const currentTestState = computeDirectoryHash(root, { testOnly: true });
|
|
233
|
+
const postTestHash = currentTestState.treeHash;
|
|
234
|
+
|
|
235
|
+
let tamperDetected = false;
|
|
236
|
+
if (preTestHash && preTestHash !== postTestHash) {
|
|
237
|
+
tamperDetected = true;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Intent metadata
|
|
241
|
+
const taskPrompt = options.prompt || options.task?.prompt || "";
|
|
242
|
+
const promptHash = taskPrompt ? "sha256:" + sha256(taskPrompt) : "sha256:empty";
|
|
243
|
+
const promptExcerpt = taskPrompt ? taskPrompt.slice(0, 140).replace(/[\r\n]+/g, " ") : "";
|
|
244
|
+
|
|
245
|
+
// Base manifest
|
|
246
|
+
const baseManifest = {
|
|
247
|
+
schema: "agentctl/evidence-manifest-v1",
|
|
248
|
+
manifestId,
|
|
249
|
+
generatedAt,
|
|
250
|
+
intent: {
|
|
251
|
+
taskId: options.taskId || options.task?.id || "local-task",
|
|
252
|
+
title: options.title || options.task?.title || "Verification Run",
|
|
253
|
+
promptHash,
|
|
254
|
+
promptExcerpt,
|
|
255
|
+
},
|
|
256
|
+
provenance: {
|
|
257
|
+
commitSha: gitInfo.commitSha,
|
|
258
|
+
branch: gitInfo.branch,
|
|
259
|
+
dirty: gitInfo.dirty,
|
|
260
|
+
repository: options.repository || "local",
|
|
261
|
+
},
|
|
262
|
+
testIntegrity: {
|
|
263
|
+
preTestHash: preTestHash || postTestHash,
|
|
264
|
+
postTestHash,
|
|
265
|
+
tamperDetected,
|
|
266
|
+
testFileCount: currentTestState.fileCount,
|
|
267
|
+
fileHashes: currentTestState.fileHashes,
|
|
268
|
+
},
|
|
269
|
+
executionRecords: options.executionRecords || [],
|
|
270
|
+
securityChecks: {
|
|
271
|
+
secretScanOk: options.secretScanOk ?? true,
|
|
272
|
+
diffKb: options.diffKb || 0,
|
|
273
|
+
maxDiffKb: options.maxDiffKb || 75,
|
|
274
|
+
protectedScopeOk: options.protectedScopeOk ?? true,
|
|
275
|
+
},
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
const evidenceHash = computeEvidenceHash(baseManifest);
|
|
279
|
+
return {
|
|
280
|
+
...baseManifest,
|
|
281
|
+
evidenceHash,
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Persists an evidence manifest to disk.
|
|
287
|
+
* @param {string} root
|
|
288
|
+
* @param {object} manifest
|
|
289
|
+
* @param {string} [customPath]
|
|
290
|
+
* @returns {string} Target path
|
|
291
|
+
*/
|
|
292
|
+
export function writeEvidenceManifest(root, manifest, customPath = null) {
|
|
293
|
+
const evidenceDir = join(root, ".agent", "evidence");
|
|
294
|
+
if (!existsSync(evidenceDir)) {
|
|
295
|
+
mkdirSync(evidenceDir, { recursive: true });
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const targetPath = customPath
|
|
299
|
+
? isAbsolute(customPath) ? customPath : join(root, customPath)
|
|
300
|
+
: join(evidenceDir, `${manifest.manifestId}.json`);
|
|
301
|
+
|
|
302
|
+
writeFileAtomically(targetPath, JSON.stringify(manifest, null, 2));
|
|
303
|
+
|
|
304
|
+
// Also maintain symlink/latest pointer
|
|
305
|
+
const latestPath = join(evidenceDir, "manifest.v1.json");
|
|
306
|
+
try {
|
|
307
|
+
writeFileAtomically(latestPath, JSON.stringify(manifest, null, 2));
|
|
308
|
+
} catch (_) {}
|
|
309
|
+
|
|
310
|
+
return targetPath;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Loads and validates an evidence manifest from disk.
|
|
315
|
+
* @param {string} root
|
|
316
|
+
* @param {string} [manifestIdOrPath]
|
|
317
|
+
* @returns {object}
|
|
318
|
+
*/
|
|
319
|
+
export function loadEvidenceManifest(root, manifestIdOrPath = "manifest.v1.json") {
|
|
320
|
+
const evidenceDir = resolve(root, ".agent", "evidence");
|
|
321
|
+
let targetPath;
|
|
322
|
+
|
|
323
|
+
if (manifestIdOrPath.endsWith(".json")) {
|
|
324
|
+
targetPath = isAbsolute(manifestIdOrPath) ? resolve(manifestIdOrPath) : resolve(evidenceDir, manifestIdOrPath);
|
|
325
|
+
if (!existsSync(targetPath)) {
|
|
326
|
+
targetPath = resolve(root, manifestIdOrPath);
|
|
327
|
+
}
|
|
328
|
+
} else {
|
|
329
|
+
targetPath = join(evidenceDir, `${manifestIdOrPath}.json`);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
if (!existsSync(targetPath)) {
|
|
333
|
+
throw new Error(`Evidence manifest not found: ${targetPath}`);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const raw = readFileSync(targetPath, "utf-8");
|
|
337
|
+
const manifest = JSON.parse(raw);
|
|
338
|
+
|
|
339
|
+
if (manifest.schema !== "agentctl/evidence-manifest-v1") {
|
|
340
|
+
throw new Error(`Invalid evidence manifest schema: ${manifest.schema}`);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const expectedHash = computeEvidenceHash(manifest);
|
|
344
|
+
if (manifest.evidenceHash !== expectedHash) {
|
|
345
|
+
throw new Error(`Tampered evidence manifest hash mismatch in ${targetPath}`);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
return manifest;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Validates current workspace state against an evidence manifest.
|
|
353
|
+
* Asserts zero test tampering and matching test suite tree hash.
|
|
354
|
+
* @param {string} root
|
|
355
|
+
* @param {object | string} manifestOrPath
|
|
356
|
+
* @returns {{ ok: boolean, reason?: string, details?: object }}
|
|
357
|
+
*/
|
|
358
|
+
export function verifyEvidenceManifest(root = process.cwd(), manifestOrPath = "manifest.v1.json") {
|
|
359
|
+
let manifest;
|
|
360
|
+
try {
|
|
361
|
+
manifest = typeof manifestOrPath === "object" && manifestOrPath !== null
|
|
362
|
+
? manifestOrPath
|
|
363
|
+
: loadEvidenceManifest(root, manifestOrPath);
|
|
364
|
+
} catch (err) {
|
|
365
|
+
return { ok: false, reason: `Failed to load evidence manifest: ${err.message}` };
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// 1. Verify schema & cryptographic hash
|
|
369
|
+
if (manifest.schema !== "agentctl/evidence-manifest-v1") {
|
|
370
|
+
return { ok: false, reason: `Invalid schema version: ${manifest.schema}` };
|
|
371
|
+
}
|
|
372
|
+
if (manifest.evidenceHash !== computeEvidenceHash(manifest)) {
|
|
373
|
+
return { ok: false, reason: "Evidence manifest hash signature mismatch (tampered file)" };
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// 2. Verify test integrity / No test weakening
|
|
377
|
+
const currentTestState = computeDirectoryHash(root, { testOnly: true });
|
|
378
|
+
if (manifest.testIntegrity?.tamperDetected) {
|
|
379
|
+
return {
|
|
380
|
+
ok: false,
|
|
381
|
+
reason: "Test suite tampering detected in evidence manifest",
|
|
382
|
+
details: { preTestHash: manifest.testIntegrity.preTestHash, postTestHash: manifest.testIntegrity.postTestHash },
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
if (manifest.testIntegrity?.postTestHash && currentTestState.treeHash !== manifest.testIntegrity.postTestHash) {
|
|
387
|
+
return {
|
|
388
|
+
ok: false,
|
|
389
|
+
reason: `Current test suite tree hash (${currentTestState.treeHash}) does not match manifest evidence (${manifest.testIntegrity.postTestHash})`,
|
|
390
|
+
details: { currentHash: currentTestState.treeHash, manifestHash: manifest.testIntegrity.postTestHash },
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// 3. Verify security checks
|
|
395
|
+
if (manifest.securityChecks?.secretScanOk === false) {
|
|
396
|
+
return { ok: false, reason: "Evidence manifest records secret scanning failure" };
|
|
397
|
+
}
|
|
398
|
+
if (manifest.securityChecks?.protectedScopeOk === false) {
|
|
399
|
+
return { ok: false, reason: "Evidence manifest records protected scope violation" };
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
return { ok: true, manifestId: manifest.manifestId, evidenceHash: manifest.evidenceHash };
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Renders PR-ready Markdown evidence section.
|
|
407
|
+
* @param {object} manifest
|
|
408
|
+
* @returns {string}
|
|
409
|
+
*/
|
|
410
|
+
export function generateEvidenceMarkdown(manifest) {
|
|
411
|
+
const lines = [
|
|
412
|
+
"### 🛡️ Autonomous Verification & Evidence Proof",
|
|
413
|
+
"",
|
|
414
|
+
`> **Evidence Manifest**: \`${manifest.manifestId}\` | **Signature**: \`${manifest.evidenceHash?.slice(0, 16)}...\``,
|
|
415
|
+
"",
|
|
416
|
+
"| Gate / Policy | Status | Evidence Record |",
|
|
417
|
+
"| :--- | :---: | :--- |",
|
|
418
|
+
`| **Test Suite Integrity** | ${manifest.testIntegrity?.tamperDetected ? "❌ Tampered" : "✅ Verified"} | \`${manifest.testIntegrity?.testFileCount || 0} test files\` (Tree: \`${manifest.testIntegrity?.postTestHash?.slice(0, 16)}...\`) |`,
|
|
419
|
+
`| **Secret Leak Scanner** | ${manifest.securityChecks?.secretScanOk ? "✅ Clean" : "❌ Detected"} | High-confidence entropy scan passed |`,
|
|
420
|
+
`| **Payload Governor** | ${manifest.securityChecks?.diffKb <= manifest.securityChecks?.maxDiffKb ? "✅ Bounded" : "❌ Exceeded"} | \`${manifest.securityChecks?.diffKb || 0} KB\` / \`${manifest.securityChecks?.maxDiffKb || 75} KB\` limit |`,
|
|
421
|
+
];
|
|
422
|
+
|
|
423
|
+
if (Array.isArray(manifest.executionRecords) && manifest.executionRecords.length > 0) {
|
|
424
|
+
lines.push("", "#### Executed Verification Stages");
|
|
425
|
+
lines.push("| Stage | Command | Policy | Exit Code | Duration |");
|
|
426
|
+
lines.push("| :--- | :--- | :---: | :---: | :---: |");
|
|
427
|
+
for (const rec of manifest.executionRecords) {
|
|
428
|
+
const statusIcon = rec.exitCode === 0 ? "✅" : "❌";
|
|
429
|
+
const policyStr = rec.networkAccess === "forbidden" ? "🔒 Offline" : rec.networkAccess === "read-only" ? "📖 Read-Only" : "🌐 Online";
|
|
430
|
+
lines.push(`| \`${rec.id || rec.kind}\` | \`${rec.cmd}\` | ${policyStr} | ${statusIcon} \`${rec.exitCode}\` | \`${rec.durationMs || 0}ms\` |`);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
return lines.join("\n");
|
|
435
|
+
}
|
package/src/mcp.mjs
CHANGED
|
@@ -98,6 +98,8 @@ export const MCP_TOOLS = [
|
|
|
98
98
|
properties: {
|
|
99
99
|
title: { type: "string", description: "Short descriptive title for the task" },
|
|
100
100
|
prompt: { type: "string", description: "Detailed task instructions and prompt" },
|
|
101
|
+
role: { type: "string", description: "Specialist agent role (overseer | bolt | sentinel | janitor)" },
|
|
102
|
+
tier: { type: "string", description: "Force the Cost Router tier when router.enabled (fast | complex); omit to let the heuristic classifier decide" },
|
|
101
103
|
},
|
|
102
104
|
required: ["prompt"],
|
|
103
105
|
},
|
|
@@ -178,6 +180,20 @@ export const MCP_TOOLS = [
|
|
|
178
180
|
},
|
|
179
181
|
},
|
|
180
182
|
},
|
|
183
|
+
{
|
|
184
|
+
name: "record_system_learning",
|
|
185
|
+
description: "Record a persistent system learning or platform quirk into .agent/SYSTEM_LEARNINGS.md (SPORE Memory).",
|
|
186
|
+
inputSchema: {
|
|
187
|
+
type: "object",
|
|
188
|
+
properties: {
|
|
189
|
+
trigger: { type: "string", description: "Trigger condition or symptom (e.g. 'Cloudflare Workers SSR')" },
|
|
190
|
+
solution: { type: "string", description: "Mandatory rule or resolution (e.g. 'Do not use node:fs in edge runtime')" },
|
|
191
|
+
agent: { type: "string", description: "Agent name (defaults to 'agent')" },
|
|
192
|
+
category: { type: "string", description: "Category (e.g. 'EDGE', 'DATABASE', 'GENERAL')" },
|
|
193
|
+
},
|
|
194
|
+
required: ["trigger", "solution"],
|
|
195
|
+
},
|
|
196
|
+
},
|
|
181
197
|
];
|
|
182
198
|
|
|
183
199
|
export async function handleMcpRequest(request, opts = {}) {
|
|
@@ -220,7 +236,12 @@ export async function handleMcpRequest(request, opts = {}) {
|
|
|
220
236
|
try {
|
|
221
237
|
if (toolName === "dispatch_jules_task") {
|
|
222
238
|
const session = await dispatch(
|
|
223
|
-
{
|
|
239
|
+
{
|
|
240
|
+
title: args.title || "MCP Task Dispatch",
|
|
241
|
+
prompt: args.prompt,
|
|
242
|
+
role: args.role,
|
|
243
|
+
tier: args.tier === "fast" || args.tier === "complex" ? args.tier : undefined,
|
|
244
|
+
},
|
|
224
245
|
{ root, config }
|
|
225
246
|
);
|
|
226
247
|
return {
|
|
@@ -232,6 +253,23 @@ export async function handleMcpRequest(request, opts = {}) {
|
|
|
232
253
|
};
|
|
233
254
|
}
|
|
234
255
|
|
|
256
|
+
if (toolName === "record_system_learning") {
|
|
257
|
+
const { recordLearning } = await import("./memory.mjs");
|
|
258
|
+
const result = recordLearning(root, {
|
|
259
|
+
trigger: args.trigger,
|
|
260
|
+
solution: args.solution,
|
|
261
|
+
agent: args.agent || "mcp-agent",
|
|
262
|
+
category: args.category || "GENERAL",
|
|
263
|
+
});
|
|
264
|
+
return {
|
|
265
|
+
jsonrpc: "2.0",
|
|
266
|
+
id,
|
|
267
|
+
result: {
|
|
268
|
+
content: [{ type: "text", text: JSON.stringify({ ok: true, ...result }, null, 2) }],
|
|
269
|
+
},
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
235
273
|
if (toolName === "audit_jules_gate") {
|
|
236
274
|
const res = await gate({
|
|
237
275
|
root,
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import {
|
|
2
|
+
generateEvidenceManifest,
|
|
3
|
+
writeEvidenceManifest,
|
|
4
|
+
loadEvidenceManifest,
|
|
5
|
+
verifyEvidenceManifest,
|
|
6
|
+
generateEvidenceMarkdown,
|
|
7
|
+
} from "../evidence.mjs";
|
|
8
|
+
import { resolveRoot } from "../config.mjs";
|
|
9
|
+
import { writeFileSync } from "node:fs";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Generates an evidence manifest and optional markdown summary.
|
|
14
|
+
* @param {string} root
|
|
15
|
+
* @param {object} [options]
|
|
16
|
+
* @returns {object}
|
|
17
|
+
*/
|
|
18
|
+
export function planEvidenceGenerate(root = resolveRoot(), options = {}) {
|
|
19
|
+
const manifest = generateEvidenceManifest(root, options);
|
|
20
|
+
const manifestPath = writeEvidenceManifest(root, manifest, options.output);
|
|
21
|
+
const markdown = generateEvidenceMarkdown(manifest);
|
|
22
|
+
|
|
23
|
+
if (options.markdownOutput) {
|
|
24
|
+
const mdPath = options.markdownOutput.endsWith(".md")
|
|
25
|
+
? options.markdownOutput
|
|
26
|
+
: join(root, ".agent", "evidence", "EVIDENCE.md");
|
|
27
|
+
try {
|
|
28
|
+
writeFileSync(mdPath, markdown, "utf-8");
|
|
29
|
+
} catch (_) {}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return {
|
|
33
|
+
ok: true,
|
|
34
|
+
manifest,
|
|
35
|
+
manifestPath,
|
|
36
|
+
markdown,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Verifies an evidence manifest against the current working directory.
|
|
42
|
+
* @param {string} root
|
|
43
|
+
* @param {object} [options]
|
|
44
|
+
* @returns {object}
|
|
45
|
+
*/
|
|
46
|
+
export function planEvidenceVerify(root = resolveRoot(), options = {}) {
|
|
47
|
+
const manifestTarget = options.manifest || "manifest.v1.json";
|
|
48
|
+
const result = verifyEvidenceManifest(root, manifestTarget);
|
|
49
|
+
return result;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Loads and displays the latest evidence manifest.
|
|
54
|
+
* @param {string} root
|
|
55
|
+
* @param {object} [options]
|
|
56
|
+
* @returns {object}
|
|
57
|
+
*/
|
|
58
|
+
export function planEvidenceShow(root = resolveRoot(), options = {}) {
|
|
59
|
+
const manifestTarget = options.manifest || "manifest.v1.json";
|
|
60
|
+
try {
|
|
61
|
+
const manifest = loadEvidenceManifest(root, manifestTarget);
|
|
62
|
+
const markdown = generateEvidenceMarkdown(manifest);
|
|
63
|
+
return {
|
|
64
|
+
ok: true,
|
|
65
|
+
manifest,
|
|
66
|
+
markdown,
|
|
67
|
+
};
|
|
68
|
+
} catch (err) {
|
|
69
|
+
return {
|
|
70
|
+
ok: false,
|
|
71
|
+
reason: err.message,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
}
|
package/src/provider.mjs
CHANGED
|
@@ -37,6 +37,19 @@ export const CODEX_PRESET = {
|
|
|
37
37
|
promptViaStdin: false,
|
|
38
38
|
};
|
|
39
39
|
|
|
40
|
+
// Google Gemini CLI (`npm i -g @google/gemini-cli`), headless mode. Reads the
|
|
41
|
+
// prompt from stdin (non-TTY input triggers headless mode without needing a
|
|
42
|
+
// dangling `-p` flag), auto-approves file/shell actions, and defaults to the
|
|
43
|
+
// Gemini 3.6 Flash model — a cheap, fast tier suited for trivial/mechanical
|
|
44
|
+
// tasks routed by src/router.mjs. Auth via GEMINI_API_KEY (env, not CLI arg).
|
|
45
|
+
export const GEMINI_PRESET = {
|
|
46
|
+
name: "gemini-flash",
|
|
47
|
+
type: "exec",
|
|
48
|
+
command: "gemini",
|
|
49
|
+
args: ["--model", "gemini-3.6-flash", "--approval-mode=yolo", "--output-format", "json"],
|
|
50
|
+
promptViaStdin: true,
|
|
51
|
+
};
|
|
52
|
+
|
|
40
53
|
export class MissingApiKeyError extends Error {
|
|
41
54
|
constructor(message = "JULES_API_KEY environment variable is required for Google Jules API dispatch.") {
|
|
42
55
|
super(message);
|
|
@@ -105,18 +118,33 @@ function interpolateDeep(node, data) {
|
|
|
105
118
|
/**
|
|
106
119
|
* Creates an HTTP or Exec provider adapter based on spec/config.
|
|
107
120
|
*/
|
|
121
|
+
const NAMED_PRESETS = {
|
|
122
|
+
jules: JULES_PRESET,
|
|
123
|
+
"claude-code": CLAUDE_PRESET,
|
|
124
|
+
codex: CODEX_PRESET,
|
|
125
|
+
"gemini-flash": GEMINI_PRESET,
|
|
126
|
+
"gemini-cli": GEMINI_PRESET,
|
|
127
|
+
};
|
|
128
|
+
|
|
108
129
|
export function createProvider(spec = "jules", config = {}) {
|
|
109
130
|
if (spec && typeof spec === "object" && typeof spec.dispatch === "function") {
|
|
110
131
|
return spec;
|
|
111
132
|
}
|
|
112
|
-
const providerSpec = typeof spec === "string"
|
|
113
|
-
? (spec === "claude-code" ? CLAUDE_PRESET : spec === "codex" ? CODEX_PRESET : JULES_PRESET)
|
|
114
|
-
: spec || JULES_PRESET;
|
|
133
|
+
const providerSpec = typeof spec === "string" ? (NAMED_PRESETS[spec] || JULES_PRESET) : spec || JULES_PRESET;
|
|
115
134
|
|
|
116
135
|
if (!providerSpec || typeof providerSpec !== "object") {
|
|
117
136
|
throw new TypeError("Provider specification must be a string or object");
|
|
118
137
|
}
|
|
119
138
|
|
|
139
|
+
if (providerSpec.type === "http") {
|
|
140
|
+
if (typeof providerSpec.url === "string" && providerSpec.url.includes("{token}")) {
|
|
141
|
+
throw new Error("CRITICAL: Insecure token interpolation in provider URL template. API tokens must be passed via headers, not URL paths or query params.");
|
|
142
|
+
}
|
|
143
|
+
if (typeof providerSpec.sendMessageUrl === "string" && providerSpec.sendMessageUrl.includes("{token}")) {
|
|
144
|
+
throw new Error("CRITICAL: Insecure token interpolation in provider sendMessageUrl template. API tokens must be passed via headers, not URL paths or query params.");
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
120
148
|
return {
|
|
121
149
|
name: providerSpec.name || "custom-provider",
|
|
122
150
|
|
|
@@ -165,11 +193,12 @@ export function createProvider(spec = "jules", config = {}) {
|
|
|
165
193
|
}
|
|
166
194
|
|
|
167
195
|
if (providerSpec.type === "http") {
|
|
168
|
-
const urlData = { ...data
|
|
196
|
+
const urlData = { ...data };
|
|
169
197
|
const url = interpolateString(providerSpec.url, urlData);
|
|
198
|
+
const headerData = { ...urlData, token: rawToken };
|
|
170
199
|
const headers = {};
|
|
171
200
|
for (const [k, v] of Object.entries(providerSpec.headers || {})) {
|
|
172
|
-
const val = interpolateString(v,
|
|
201
|
+
const val = interpolateString(v, headerData);
|
|
173
202
|
if (val.includes("\r") || val.includes("\n")) {
|
|
174
203
|
throw new Error(`CRITICAL: Header injection attempt detected in header "${k}"`);
|
|
175
204
|
}
|
|
@@ -335,12 +364,13 @@ export function createProvider(spec = "jules", config = {}) {
|
|
|
335
364
|
|
|
336
365
|
if (providerSpec.type === "http") {
|
|
337
366
|
const sendMessageUrlTemplate = providerSpec.sendMessageUrl || `${providerSpec.url}/${sessionId}:sendMessage`;
|
|
338
|
-
const urlData = { sessionId,
|
|
367
|
+
const urlData = { sessionId, ...ctx };
|
|
339
368
|
const url = interpolateString(sendMessageUrlTemplate, urlData);
|
|
340
369
|
|
|
370
|
+
const headerData = { ...urlData, token: rawToken };
|
|
341
371
|
const headers = {};
|
|
342
372
|
for (const [k, v] of Object.entries(providerSpec.headers || {})) {
|
|
343
|
-
const val = interpolateString(v,
|
|
373
|
+
const val = interpolateString(v, headerData);
|
|
344
374
|
if (val.includes("\r") || val.includes("\n")) {
|
|
345
375
|
throw new Error(`CRITICAL: Header injection attempt detected in header "${k}"`);
|
|
346
376
|
}
|