jules-orchestrator-kit 0.54.1 → 0.55.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.
package/README.md CHANGED
@@ -198,11 +198,12 @@ To maximize PR merge rates, dispatch tasks according to deterministic boundaries
198
198
  * **Cross-Platform Parity:** Verified 100% green across Linux, macOS (Darwin), and Windows on Node 20, 22, and 24.
199
199
  * **Autonomous Self-Healing Loop:** Captures test stderr/stdout, fingerprints error traces, and feeds structured context back into automated repair turns (up to 3 attempts) before human escalation.
200
200
  * **Fail-Closed Verification:** A change that ran no verification command at all is rejected, not approved — "nothing to run" is not a pass. Repositories using only the scope and secret phases opt out explicitly with `verify.required: false`.
201
+ * **Anti-Tamper That Reads Semantics:** Counting assertions cannot see a value check swapped for a truthiness check. The guard tracks assertions that name an expected value, so weakening a test is a violation even when the line count is unchanged.
201
202
  * **Binary-Aware Scanning:** Files git renders as `Binary files ... differ` are read directly for structured credentials, and their real size is charged against the diff ceiling, so a leading NUL byte cannot hide a token and a committed blob cannot walk past the payload governor.
202
203
  * **Fail-Closed Security & Secret Redaction:** Evaluates explicit Deny rules before Allow rules against canonicalized, case-folded paths. Redacts high-entropy keys and base64-encoded credentials (such as Kubernetes `Secret` manifests).
203
204
  * **Complexity & Cost Router:** Zero-dependency heuristic classifier (`src/router.mjs`) routing mechanical tasks to lightweight models while reserving primary models for complex refactors, with a `node --check` syntax-verification gate that transparently escalates a FAST-tier result to the primary provider if it left broken JS on disk.
204
205
  * **Terminal UI & Diagnostic Matrix (`agentctl doctor`):** Interactive terminal dashboard, task sidecar manager, and automated transactional self-repair.
205
- * **Verified Test Suite:** Tested with **835 unit tests across 109 suites passing in < 14.0s**.
206
+ * **Verified Test Suite:** Tested with **842 unit tests across 112 suites passing in < 14.0s**.
206
207
 
207
208
  <br/>
208
209
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jules-orchestrator-kit",
3
- "version": "0.54.1",
3
+ "version": "0.55.0",
4
4
  "description": "Zero-dependency safety gatekeeper, test oracle generator, and multi-agent coordination protocol for autonomous coding agents — Google Jules, Claude Code, Codex and Gemini CLI.",
5
5
  "repository": {
6
6
  "type": "git",
package/src/engine.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  import { loadConfig, parseYaml, normalizeScope } from "./config.mjs";
2
2
  import { checkScope, scanDiff, scanBinaryPayloads, redactSecrets } from "./security.mjs";
3
- import { changedFiles, diffBytes, diffText, binaryDiffEntries, showFromOrigin, runCmd } from "./git.mjs";
3
+ import { changedFiles, diffBytes, diffText, binaryDiffEntries, symlinkChanges, showFromOrigin, runCmd } from "./git.mjs";
4
4
  import { createProvider, ProviderRateLimitError, ProviderUnavailableError } from "./provider.mjs";
5
5
  import { resolveRoutedProvider } from "./router.mjs";
6
6
  import { withBudget, appendLedger, getQueueDir, ensureDir, rollbackBudgetReservation, isConcurrencyGroupLocked, checkDailyBudget } from "./state.mjs";
@@ -213,9 +213,37 @@ export async function gate(opts = {}) {
213
213
  }
214
214
  }
215
215
 
216
- const scopeResult = checkScope(files, trustedScope, {
216
+ // A symlink is judged by its own name, so `notes.md -> .agent/config.yml`
217
+ // walked straight past a deny list that names the target. Judge both: the
218
+ // link because it is what the diff adds, and the path it resolves to because
219
+ // that is what it grants reach to.
220
+ let symlinks = [];
221
+ try {
222
+ symlinks = symlinkChanges(root, base, mode);
223
+ } catch (_) {
224
+ // Scope must still be enforced on the ordinary file list if git cannot
225
+ // describe the links.
226
+ }
227
+ const scopeCandidates = [...files];
228
+ const symlinkTargetOf = new Map();
229
+ for (const { link, target } of symlinks) {
230
+ if (!target || scopeCandidates.includes(target)) continue;
231
+ scopeCandidates.push(target);
232
+ symlinkTargetOf.set(target, link);
233
+ }
234
+
235
+ const scopeResult = checkScope(scopeCandidates, trustedScope, {
217
236
  allowProtected: opts.allowProtected || process.env.JULES_ALLOW_COMMAND_FILE_CHANGES === "true",
218
237
  });
238
+ // Report the violation against the link the change actually introduced, not
239
+ // against a path the diff never names — the operator has to be able to find it.
240
+ for (const violation of scopeResult.violations || []) {
241
+ const link = symlinkTargetOf.get(violation.file);
242
+ if (!link) continue;
243
+ violation.reason = `${violation.reason} (reached through symlink ${link})`;
244
+ violation.symlink = link;
245
+ violation.file = link;
246
+ }
219
247
 
220
248
  phases.push({ phase: "scope", ok: scopeResult.ok, violations: scopeResult.violations });
221
249
  appendTelemetry(root, "gate_phase", { phase: "scope", ok: scopeResult.ok });
package/src/evidence.mjs CHANGED
@@ -11,7 +11,7 @@ import {
11
11
  renameSync,
12
12
  unlinkSync,
13
13
  } from "node:fs";
14
- import { join, resolve, relative, isAbsolute, sep } from "node:path";
14
+ import { join, resolve, relative, isAbsolute, sep, extname } from "node:path";
15
15
  import { createHash, randomUUID } from "node:crypto";
16
16
  import { execSync } from "node:child_process";
17
17
 
@@ -83,6 +83,19 @@ export function findFilesRecursively(dir, baseDir = dir) {
83
83
  * @param {string[]} [options.directories] - Specific directory names to scan (e.g. ['test', 'tests'])
84
84
  * @returns {{ treeHash: string, fileCount: number, fileHashes: Record<string, string> }}
85
85
  */
86
+ /**
87
+ * Extensions that count as code when scanning the repository root.
88
+ *
89
+ * The directory walk above takes everything under `src/` and the test
90
+ * directories; this list only governs the loose files beside package.json,
91
+ * where a `.md` is documentation rather than something the evidence attests to.
92
+ */
93
+ const SOURCE_EXTENSIONS = new Set([
94
+ ".js", ".mjs", ".cjs", ".jsx", ".ts", ".tsx", ".mts", ".cts",
95
+ ".py", ".go", ".rs", ".rb", ".php", ".java", ".kt", ".kts", ".cs", ".fs",
96
+ ".swift", ".dart", ".ex", ".exs", ".c", ".h", ".cc", ".cpp", ".hpp", ".sol",
97
+ ]);
98
+
86
99
  export function computeDirectoryHash(root, options = {}) {
87
100
  let fileList = [];
88
101
 
@@ -100,6 +113,29 @@ export function computeDirectoryHash(root, options = {}) {
100
113
  fileList.push(...found);
101
114
  }
102
115
  }
116
+
117
+ // Plenty of projects keep `app.test.mjs` or `index.js` beside package.json
118
+ // rather than under one of the directories above, and those files were
119
+ // invisible to every hash computed here — a manifest could attest to a
120
+ // pristine test suite while the only test in the repository had been
121
+ // replaced with garbage.
122
+ //
123
+ // Depth one only: recursing from the root would walk node_modules and
124
+ // vendor trees. Source extensions only: the hash exists to bind the
125
+ // manifest to the code it verified, and pulling in README.md or the
126
+ // EVIDENCE.md this very command is about to write would make the hash churn
127
+ // on its own output.
128
+ try {
129
+ for (const entry of readdirSync(root, { withFileTypes: true })) {
130
+ if (!entry.isFile()) continue;
131
+ if (entry.name.startsWith(".")) continue;
132
+ if (!SOURCE_EXTENSIONS.has(extname(entry.name).toLowerCase())) continue;
133
+ fileList.push(normalizePath(entry.name));
134
+ }
135
+ } catch (_) {
136
+ // An unreadable root yields whatever the directory walk already found.
137
+ }
138
+
103
139
  fileList = Array.from(new Set(fileList)).sort();
104
140
  }
105
141
 
@@ -154,6 +190,7 @@ export function computeEvidenceHash(manifest) {
154
190
  intent: manifest.intent,
155
191
  provenance: manifest.provenance,
156
192
  testIntegrity: manifest.testIntegrity,
193
+ ...(manifest.sourceIntegrity ? { sourceIntegrity: manifest.sourceIntegrity } : {}),
157
194
  executionRecords: manifest.executionRecords,
158
195
  securityChecks: manifest.securityChecks,
159
196
  ...(manifest.status ? { status: manifest.status } : {}),
@@ -235,6 +272,7 @@ export function generateEvidenceManifest(root = process.cwd(), options = {}) {
235
272
  // Compute test tree integrity
236
273
  const preTestHash = options.preTestHash || null;
237
274
  const currentTestState = computeDirectoryHash(root, { testOnly: true });
275
+ const currentSourceState = computeDirectoryHash(root);
238
276
  const postTestHash = currentTestState.treeHash;
239
277
 
240
278
  let tamperDetected = false;
@@ -278,6 +316,14 @@ export function generateEvidenceManifest(root = process.cwd(), options = {}) {
278
316
  testFileCount: currentTestState.fileCount,
279
317
  fileHashes: currentTestState.fileHashes,
280
318
  },
319
+ // The manifest attested to the test files and to nothing else, so the code
320
+ // under test could be replaced wholesale after the fact and verification
321
+ // still passed. Evidence that survives the thing it attests to being
322
+ // rewritten is not evidence.
323
+ sourceIntegrity: {
324
+ treeHash: currentSourceState.treeHash,
325
+ fileCount: currentSourceState.fileCount,
326
+ },
281
327
  executionRecords: options.executionRecords || [],
282
328
  securityChecks: {
283
329
  secretScanOk: options.secretScanOk ?? true,
@@ -466,7 +512,28 @@ export function verifyEvidenceManifest(root = process.cwd(), manifestOrPath = "m
466
512
  };
467
513
  }
468
514
 
469
- // 3. Verify security checks
515
+ // 3. Verify the code the manifest attests to still is that code.
516
+ //
517
+ // Without this the manifest proved only that the *tests* had not changed,
518
+ // so `evidence generate` followed by rewriting src/ and committing left
519
+ // verification reporting PASSED over an implementation nobody had checked.
520
+ if (manifest.sourceIntegrity?.treeHash) {
521
+ const currentSourceState = computeDirectoryHash(root);
522
+ if (currentSourceState.treeHash !== manifest.sourceIntegrity.treeHash) {
523
+ return {
524
+ ok: false,
525
+ reason: `Source tree has changed since this evidence was generated (${manifest.sourceIntegrity.treeHash.slice(0, 12)} → ${currentSourceState.treeHash.slice(0, 12)}); the manifest no longer attests to what is on disk`,
526
+ details: {
527
+ currentHash: currentSourceState.treeHash,
528
+ manifestHash: manifest.sourceIntegrity.treeHash,
529
+ manifestCommit: manifest.provenance?.commitSha || null,
530
+ currentCommit: getGitProvenance(root).commitSha,
531
+ },
532
+ };
533
+ }
534
+ }
535
+
536
+ // 4. Verify security checks
470
537
  if (manifest.securityChecks?.secretScanOk === false) {
471
538
  return { ok: false, reason: "Evidence manifest records secret scanning failure" };
472
539
  }
package/src/git.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  import { execFileSync, execSync } from "node:child_process";
2
- import { readFileSync, existsSync, statSync } from "node:fs";
2
+ import { readFileSync, existsSync, statSync, readlinkSync } from "node:fs";
3
3
  import { join, delimiter } from "node:path";
4
- import { normalizePath } from "./config.mjs";
4
+ import { normalizePath, canonicalizePath } from "./config.mjs";
5
5
 
6
6
 
7
7
  export const NET_GUARD_PRELOAD_URL = new URL("./preload-net-guard.mjs", import.meta.url).href;
@@ -412,14 +412,14 @@ export function diffText(root = process.cwd(), base = "main", mode = "committed"
412
412
  * @param {string} mode
413
413
  * @returns {Array<{ file: string, bytes: number }>}
414
414
  */
415
- export function binaryDiffEntries(root = process.cwd(), base = "main", mode = "committed") {
415
+ export function parseRawDiff(root = process.cwd(), base = "main", mode = "committed") {
416
416
  const resolvedRef = resolveBase(root, base);
417
417
  const ranges =
418
418
  mode === "working-tree" || mode === "working"
419
419
  ? [[`${resolvedRef}...HEAD`], ["HEAD"]]
420
420
  : [[`${resolvedRef}...HEAD`]];
421
421
 
422
- const entries = new Map();
422
+ const out = [];
423
423
  for (const range of ranges) {
424
424
  let raw = "";
425
425
  try {
@@ -433,29 +433,99 @@ export function binaryDiffEntries(root = process.cwd(), base = "main", mode = "c
433
433
  const meta = fields[i];
434
434
  if (!meta.startsWith(":")) continue;
435
435
  const parts = meta.slice(1).split(/\s+/);
436
- const dstSha = parts[3];
437
- const status = (parts[4] || "").charAt(0);
438
436
  const file = fields[i + 1];
439
437
  i += 1;
440
- if (!file || status === "D") continue;
438
+ if (!file) continue;
439
+ out.push({
440
+ file,
441
+ srcMode: parts[0] || "",
442
+ dstMode: parts[1] || "",
443
+ srcSha: parts[2] || "",
444
+ dstSha: parts[3] || "",
445
+ status: (parts[4] || "").charAt(0),
446
+ });
447
+ }
448
+ }
449
+ return out;
450
+ }
441
451
 
442
- let bytes = 0;
443
- if (dstSha && !/^0+$/.test(dstSha)) {
444
- const size = git(["cat-file", "-s", dstSha], { cwd: root, ignoreError: true });
445
- bytes = Number(size) || 0;
452
+ /**
453
+ * Symlinks this change introduces, with the path each one points at.
454
+ *
455
+ * `checkScope` is purely lexical, by design — the paths it judges can come from
456
+ * a diff and need not exist on disk. But that made a symlink a hole straight
457
+ * through it: a link named `notes.md` pointing at `.agent/config.yml` is judged
458
+ * as `notes.md`, and the protected path it resolves to is never considered.
459
+ * Resolving here, rather than inside `checkScope`, keeps that function lexical
460
+ * and testable while letting the gate judge both names.
461
+ *
462
+ * The target is resolved lexically against the link's own directory: it may
463
+ * point outside the repository, and following it on disk would be the wrong
464
+ * thing to do with an untrusted path.
465
+ *
466
+ * @param {string} root
467
+ * @param {string} base
468
+ * @param {string} mode
469
+ * @returns {Array<{ link: string, target: string }>}
470
+ */
471
+ export function symlinkChanges(root = process.cwd(), base = "main", mode = "committed") {
472
+ const SYMLINK_MODE = "120000";
473
+ const results = [];
474
+ const seen = new Set();
475
+
476
+ for (const entry of parseRawDiff(root, base, mode)) {
477
+ if (entry.status === "D") continue;
478
+ if (entry.dstMode !== SYMLINK_MODE) continue;
479
+ if (seen.has(entry.file)) continue;
480
+ seen.add(entry.file);
481
+
482
+ // A symlink's blob content is its target path.
483
+ let target = "";
484
+ if (entry.dstSha && !/^0+$/.test(entry.dstSha)) {
485
+ target = (git(["cat-file", "blob", entry.dstSha], { cwd: root, ignoreError: true }) || "").trim();
486
+ }
487
+ if (!target) {
488
+ try {
489
+ target = readlinkSync(join(root, entry.file));
490
+ } catch (_) {
491
+ continue;
446
492
  }
447
- // An unstaged change has an all-zero destination sha; the working file is
448
- // the only place its size exists.
449
- if (!bytes) {
450
- try {
451
- bytes = statSync(join(root, file)).size;
452
- } catch (_) {
453
- bytes = 0;
454
- }
493
+ }
494
+ if (!target) continue;
495
+
496
+ const linkDir = normalizePath(entry.file).split("/").slice(0, -1).join("/");
497
+ const resolved = normalizePath(target).startsWith("/")
498
+ ? normalizePath(target)
499
+ : canonicalizePath(linkDir ? `${linkDir}/${target}` : target);
500
+
501
+ results.push({ link: entry.file, target: resolved });
502
+ }
503
+
504
+ return results;
505
+ }
506
+
507
+ export function binaryDiffEntries(root = process.cwd(), base = "main", mode = "committed") {
508
+ const entries = new Map();
509
+ for (const entry of parseRawDiff(root, base, mode)) {
510
+ if (entry.status === "D") continue;
511
+ const { file, dstSha } = entry;
512
+
513
+ let bytes = 0;
514
+ if (dstSha && !/^0+$/.test(dstSha)) {
515
+ const size = git(["cat-file", "-s", dstSha], { cwd: root, ignoreError: true });
516
+ bytes = Number(size) || 0;
517
+ }
518
+ // An unstaged change has an all-zero destination sha; the working file is
519
+ // the only place its size exists.
520
+ if (!bytes) {
521
+ try {
522
+ bytes = statSync(join(root, file)).size;
523
+ } catch (_) {
524
+ bytes = 0;
455
525
  }
456
- // Keep the largest observation: the same path can appear in both ranges.
457
- entries.set(file, Math.max(entries.get(file) || 0, bytes));
458
526
  }
527
+ // Keep the largest observation: the same path can appear in both ranges.
528
+ entries.set(file, Math.max(entries.get(file) || 0, bytes));
459
529
  }
460
530
 
461
531
  // Only the paths git itself refused to render as text are relevant; a file
package/src/security.mjs CHANGED
@@ -1120,6 +1120,24 @@ export function checkTestTampering(diffOrText = "", options = {}) {
1120
1120
  const ASSERTION_PATTERN = /(?:\b(?:assert(?:\.[a-zA-Z0-9_$]+)?|expect|t\.(?:assert|expect|is|equal|true|false|Errorf|Fatalf)|require\.[a-zA-Z0-9_$]+)\b|assert!|assert_eq!|assert_ne!)/i;
1121
1121
  const isCommentLine = (str) => /^\s*(?:\/\/|\/\*|\*|#|--|;)/.test(str);
1122
1122
 
1123
+ // An assertion that states a *specific* expected value. Counting assertions
1124
+ // alone let a test be gutted while looking untouched: swapping
1125
+ // `assert.strictEqual(add(2,3), 5)` for `assert.ok(add(2,3) !== undefined)`
1126
+ // removes one and adds one, so `removed > added` stayed false and the guard
1127
+ // said nothing — while the suite stopped checking the answer.
1128
+ const SPECIFIC_ASSERTION = new RegExp(
1129
+ [
1130
+ "\\bassert(?:\\.strict)?\\.?(?:strictEqual|deepStrictEqual|deepEqual|notStrictEqual|notDeepStrictEqual|equal|notEqual|match|doesNotMatch|throws|rejects|doesNotThrow)\\s*\\(",
1131
+ "\\bexpect\\s*\\([^)]*\\)\\s*\\.(?:toBe|toEqual|toStrictEqual|toMatch|toMatchObject|toContain|toHaveBeenCalledWith|toThrow|toHaveLength|toBeCloseTo)\\s*\\(",
1132
+ "\\bassert\\.(?:equals|deepEquals|include|lengthOf)\\s*\\(",
1133
+ "assert_eq!|assert_ne!",
1134
+ "\\bt\\.(?:Errorf|Fatalf)\\s*\\(",
1135
+ "\\brequire\\.(?:Equal|NotEqual|Len|Contains|Error|NoError)\\s*\\(",
1136
+ ].join("|"),
1137
+ "i"
1138
+ );
1139
+ const isSpecificAssertion = (str) => SPECIFIC_ASSERTION.test(str);
1140
+
1123
1141
  const fileAssertions = new Map();
1124
1142
 
1125
1143
  for (let i = 0; i < lines.length; i++) {
@@ -1151,7 +1169,7 @@ export function checkTestTampering(diffOrText = "", options = {}) {
1151
1169
  }
1152
1170
 
1153
1171
  if (!fileAssertions.has(currentFile)) {
1154
- fileAssertions.set(currentFile, { removed: [], added: 0 });
1172
+ fileAssertions.set(currentFile, { removed: [], added: 0, removedSpecific: [], addedSpecific: 0 });
1155
1173
  }
1156
1174
  const fileStats = fileAssertions.get(currentFile);
1157
1175
 
@@ -1159,6 +1177,9 @@ export function checkTestTampering(diffOrText = "", options = {}) {
1159
1177
  const deletedText = line.slice(1);
1160
1178
  if (!isCommentLine(deletedText) && ASSERTION_PATTERN.test(deletedText)) {
1161
1179
  fileStats.removed.push({ line: currentOldLineNo, text: deletedText });
1180
+ if (isSpecificAssertion(deletedText)) {
1181
+ fileStats.removedSpecific.push({ line: currentOldLineNo, text: deletedText });
1182
+ }
1162
1183
  }
1163
1184
  if (currentOldLineNo !== null) currentOldLineNo++;
1164
1185
  } else if (line.startsWith("+") && !line.startsWith("+++")) {
@@ -1205,6 +1226,7 @@ export function checkTestTampering(diffOrText = "", options = {}) {
1205
1226
  // If valid non-vacuous, non-commented assertion is added, increment added count
1206
1227
  if (!isVacuous && !isCommented && !isCommentLine(addedText) && ASSERTION_PATTERN.test(addedText)) {
1207
1228
  fileStats.added++;
1229
+ if (isSpecificAssertion(addedText)) fileStats.addedSpecific++;
1208
1230
  }
1209
1231
 
1210
1232
  if (currentNewLineNo !== null) currentNewLineNo++;
@@ -1226,6 +1248,31 @@ export function checkTestTampering(diffOrText = "", options = {}) {
1226
1248
  });
1227
1249
  }
1228
1250
  }
1251
+
1252
+ // Replacing an assertion is not the same as keeping one. Counting totals
1253
+ // let a specific expectation be swapped for a vague one at no cost — one
1254
+ // out, one in, guard silent, suite no longer checking the answer. What must
1255
+ // not fall is the number of assertions that name an expected value.
1256
+ //
1257
+ // Only the *replaced* ones are reported here. An assertion deleted outright
1258
+ // is already an ASSERTION_REMOVAL above, and emitting both would report the
1259
+ // same line twice under two names.
1260
+ const alreadyReportedSpecific = stats.removed
1261
+ .slice(stats.added)
1262
+ .filter((item) => isSpecificAssertion(item.text)).length;
1263
+ const specificLost = Math.max(0, stats.removedSpecific.length - stats.addedSpecific);
1264
+ const weakenedCount = Math.max(0, specificLost - alreadyReportedSpecific);
1265
+
1266
+ if (weakenedCount > 0) {
1267
+ for (const item of stats.removedSpecific.slice(stats.addedSpecific, stats.addedSpecific + weakenedCount)) {
1268
+ violations.push({
1269
+ file,
1270
+ line: item.line,
1271
+ type: "ASSERTION_WEAKENED",
1272
+ reason: `Test Tamper Guard: Assertion weakened in ${file}${item.line ? `:${item.line}` : ""} — an assertion naming an expected value was replaced by one that does not: "${item.text.trim()}"`,
1273
+ });
1274
+ }
1275
+ }
1229
1276
  }
1230
1277
 
1231
1278
  return {