jules-orchestrator-kit 0.54.0 → 0.54.1

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
@@ -197,10 +197,12 @@ To maximize PR merge rates, dispatch tasks according to deterministic boundaries
197
197
  * **Zero Runtime Dependencies:** Built exclusively on Node.js 20+ built-in modules (`node:fs`, `node:child_process`, `node:crypto`, `node:path`, `node:http`, `node:tty`, `node:test`).
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
+ * **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
+ * **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.
200
202
  * **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).
201
203
  * **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.
202
204
  * **Terminal UI & Diagnostic Matrix (`agentctl doctor`):** Interactive terminal dashboard, task sidecar manager, and automated transactional self-repair.
203
- * **Verified Test Suite:** Tested with **827 unit tests across 107 suites passing in < 14.0s**.
205
+ * **Verified Test Suite:** Tested with **835 unit tests across 109 suites passing in < 14.0s**.
204
206
 
205
207
  <br/>
206
208
 
package/bin/agentctl.mjs CHANGED
@@ -484,6 +484,15 @@ async function main() {
484
484
  console.log(`💡 Remediation Hint (Exit 188 Offline Network Violation / Infrastructure):`);
485
485
  console.log(` • An unmocked network request was blocked by the preload network guard during verification.`);
486
486
  console.log(` • Ensure dependencies are installed locally (run: npm install) and all network calls in tests are mocked.\n`);
487
+ } else if (res.phases.find((p) => p.phase === "verify" && !p.ok)?.failure?.stageId === "oracle") {
488
+ // Nothing exited non-zero here and --fix cannot help: there was no
489
+ // command to run. Offering the repair loop would send an agent to
490
+ // fix a failure that does not exist.
491
+ console.log(`💡 Remediation Hint (Exit ${res.code} No Verification Oracle):`);
492
+ console.log(` • Nothing was executed against this change, so the gate cannot approve it.`);
493
+ console.log(` • Give it a command: agentctl bootstrap (generates one for this stack)`);
494
+ console.log(` • Or set it by hand: verify.test in ${config._file || ".agent/config.yml"}`);
495
+ console.log(` • Scope- and secret-scanning only, on purpose? Set verify.required: false there.\n`);
487
496
  } else if (failedPhase === "verify" || failedPhase === "evidence") {
488
497
  console.log(`💡 Remediation Hint (Exit ${res.code} Verification Failed):`);
489
498
  console.log(` • The stage above exited non-zero. Reproduce it locally, then re-run the gate.`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jules-orchestrator-kit",
3
- "version": "0.54.0",
3
+ "version": "0.54.1",
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/config.mjs CHANGED
@@ -522,6 +522,11 @@ export function loadConfig(root = resolveRoot(), explicitPath = null) {
522
522
  teardown: rawTeardown ?? autoVerify.teardown ?? "",
523
523
  build: rawBuild ?? autoVerify.build,
524
524
  policy: parsed.verify?.policy ?? autoVerify.policy,
525
+ // Whether the gate may approve a change that ran no verification at all.
526
+ // True by default: "nothing to run" is not a pass, and a security tool that
527
+ // says APPROVED after checking nothing is worse than no tool. A repository
528
+ // that deliberately uses only the scope and secret phases sets this false.
529
+ required: parsed.verify?.required !== false,
525
530
  };
526
531
 
527
532
  // A hand-written `verify.stages:` is the operator being explicit and always
package/src/engine.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  import { loadConfig, parseYaml, normalizeScope } from "./config.mjs";
2
- import { checkScope, scanDiff, redactSecrets } from "./security.mjs";
3
- import { changedFiles, diffBytes, diffText, showFromOrigin, runCmd } from "./git.mjs";
2
+ import { checkScope, scanDiff, scanBinaryPayloads, redactSecrets } from "./security.mjs";
3
+ import { changedFiles, diffBytes, diffText, binaryDiffEntries, 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";
@@ -198,6 +198,9 @@ export async function gate(opts = {}) {
198
198
  build: parsed.verify?.build || parsed.build_cmd || config.verify.build,
199
199
  stages: parsed.verify?.stages || config.verify.stages,
200
200
  policy: parsed.verify?.policy || config.verify.policy,
201
+ // Read from the base commit like every other trusted field: an
202
+ // uncommitted `required: false` must not be able to switch the gate off.
203
+ required: parsed.verify?.required !== undefined ? parsed.verify.required !== false : config.verify.required !== false,
201
204
  timeoutMs: parsed.verify?.timeoutMs || parsed.verify?.timeout_ms || config.verify.timeoutMs,
202
205
  };
203
206
  }
@@ -242,13 +245,25 @@ export async function gate(opts = {}) {
242
245
 
243
246
  // Phase 3: Diff Secret Scanner & Security Checks
244
247
  const secretResult = scanDiff(diffStr, { root });
245
- phases.push({ phase: "secrets", ok: secretResult.ok, findings: secretResult.findings });
246
- appendTelemetry(root, "gate_phase", { phase: "secrets", ok: secretResult.ok });
248
+ // A binary file reaches the scanner as one summary line, so its contents were
249
+ // never looked at — a NUL byte in front of a token was enough to hide it.
250
+ // Inspect those files directly and fold the verdict in.
251
+ let binaryFindings = [];
252
+ try {
253
+ binaryFindings = scanBinaryPayloads(binaryDiffEntries(root, base, mode), root);
254
+ } catch (_) {
255
+ // Never let the extra pass break a gate that would otherwise have run; the
256
+ // text scan above has already been applied.
257
+ }
258
+ const allSecretFindings = [...(secretResult.findings || []), ...binaryFindings];
259
+ const secretsOk = secretResult.ok && binaryFindings.length === 0;
260
+ phases.push({ phase: "secrets", ok: secretsOk, findings: allSecretFindings });
261
+ appendTelemetry(root, "gate_phase", { phase: "secrets", ok: secretsOk });
247
262
  if (progressBus && progressToken) {
248
263
  progressBus.reportProgress(progressToken, 75, 100, "Phase 3/4: Diff Secret Scanner check complete");
249
264
  }
250
265
 
251
- if (!secretResult.ok) {
266
+ if (!secretsOk) {
252
267
  appendTelemetry(root, "gate_finished", { ok: false, code: 6 });
253
268
  return { ok: false, code: 6, phases };
254
269
  }
@@ -415,7 +430,23 @@ export async function gate(opts = {}) {
415
430
  }
416
431
  }
417
432
 
418
- const verifyOk = !failingCmd && !testTampered;
433
+ // A gate that ran no verification at all must not report APPROVED.
434
+ //
435
+ // `testResult` starts optimistic and the stage loop skips a stage with no
436
+ // command, so a repository with no test oracle produced zero execution
437
+ // records and a clean bill of health — syntactically broken code included.
438
+ // That is the product's central claim inverted: the whole point is that a
439
+ // change is verified before it is approved, and "nothing to run" is not
440
+ // verification. Repositories that deliberately use only the scope and secret
441
+ // phases opt out with `verify.required: false`.
442
+ // Assertions are guards, not oracles: `assert:test-integrity` proves the diff
443
+ // did not weaken a test, which says nothing about whether the code works. The
444
+ // question is whether any command was executed against the change at all.
445
+ const verificationRequired = trustedVerify.required !== false;
446
+ const ranNoVerification = !executionRecords.some((r) => r && r.kind !== "assert");
447
+ const missingOracle = verificationRequired && ranNoVerification;
448
+
449
+ const verifyOk = !failingCmd && !testTampered && !missingOracle;
419
450
 
420
451
  // What actually broke. Without this the verify phase reported `ok: false` and
421
452
  // nothing else — not the stage, not the exit code, not a line of output — so
@@ -448,7 +479,21 @@ export async function gate(opts = {}) {
448
479
  stderr: `Test files changed during the run (${preTestHash.slice(0, 12)} → ${postTestHash.slice(0, 12)}). evidence.strictTestLock treats a passing suite that the diff also rewrote as unproven.`,
449
480
  diagnostics: [],
450
481
  }
451
- : null;
482
+ : missingOracle
483
+ ? {
484
+ stageId: "oracle",
485
+ command: null,
486
+ exitCode: null,
487
+ stdout: "",
488
+ stderr:
489
+ "No verification command ran, so nothing about this change was checked. " +
490
+ "Set verify.test in .agent/config.yml (or run `agentctl bootstrap` to generate one). " +
491
+ "If this repository intentionally uses only the scope and secret phases, set verify.required: false.",
492
+ diagnostics: [
493
+ "The gate approves a change because verification passed. Zero stages executed is not a pass.",
494
+ ],
495
+ }
496
+ : null;
452
497
 
453
498
  // Generate & persist Evidence Manifest
454
499
  const evidenceManifest = generateEvidenceManifest(root, {
package/src/git.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  import { execFileSync, execSync } from "node:child_process";
2
- import { readFileSync, existsSync } from "node:fs";
2
+ import { readFileSync, existsSync, statSync } from "node:fs";
3
3
  import { join, delimiter } from "node:path";
4
4
  import { normalizePath } from "./config.mjs";
5
5
 
@@ -394,9 +394,104 @@ export function diffText(root = process.cwd(), base = "main", mode = "committed"
394
394
  return git(["diff", `${resolvedRef}...HEAD`], { cwd: root, raw: true });
395
395
  }
396
396
 
397
+ /**
398
+ * Paths git summarised as binary in this diff, with the size of what changed.
399
+ *
400
+ * `git diff` prints one 43-byte line for a binary file — "Binary files a/x and
401
+ * b/x differ" — regardless of whether x grew by a byte or by half a megabyte.
402
+ * Everything downstream reads the diff *text*, so a binary file is invisible to
403
+ * both the payload governor and the secret scanner: a 500 KB blob measured 250
404
+ * bytes, and a credential with a leading NUL byte was never looked at.
405
+ *
406
+ * Sizes come from the object git actually recorded where there is one, and from
407
+ * the working file otherwise, so both `committed` and `working-tree` modes get a
408
+ * real number.
409
+ *
410
+ * @param {string} root
411
+ * @param {string} base
412
+ * @param {string} mode
413
+ * @returns {Array<{ file: string, bytes: number }>}
414
+ */
415
+ export function binaryDiffEntries(root = process.cwd(), base = "main", mode = "committed") {
416
+ const resolvedRef = resolveBase(root, base);
417
+ const ranges =
418
+ mode === "working-tree" || mode === "working"
419
+ ? [[`${resolvedRef}...HEAD`], ["HEAD"]]
420
+ : [[`${resolvedRef}...HEAD`]];
421
+
422
+ const entries = new Map();
423
+ for (const range of ranges) {
424
+ let raw = "";
425
+ try {
426
+ raw = git(["diff", "--raw", "--no-renames", "-z", ...range], { cwd: root, raw: true, ignoreError: true }) || "";
427
+ } catch (_) {
428
+ continue;
429
+ }
430
+ // `--raw -z` emits ":<srcmode> <dstmode> <srcsha> <dstsha> <status>\0<path>\0".
431
+ const fields = raw.split("\0").filter(Boolean);
432
+ for (let i = 0; i < fields.length; i++) {
433
+ const meta = fields[i];
434
+ if (!meta.startsWith(":")) continue;
435
+ const parts = meta.slice(1).split(/\s+/);
436
+ const dstSha = parts[3];
437
+ const status = (parts[4] || "").charAt(0);
438
+ const file = fields[i + 1];
439
+ i += 1;
440
+ if (!file || status === "D") continue;
441
+
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;
446
+ }
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
+ }
455
+ }
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
+ }
459
+ }
460
+
461
+ // Only the paths git itself refused to render as text are relevant; a file
462
+ // that diffed normally is already counted in the diff text.
463
+ const binaryPaths = new Set();
464
+ const text = diffText(root, base, mode);
465
+ for (const line of text.split("\n")) {
466
+ const m = line.match(/^Binary files (?:a\/(.+) and )?(?:b\/(.+)|\/dev\/null) differ$/);
467
+ if (m) binaryPaths.add(normalizePath(m[2] || m[1] || ""));
468
+ const m2 = line.match(/^Binary files \/dev\/null and b\/(.+) differ$/);
469
+ if (m2) binaryPaths.add(normalizePath(m2[1]));
470
+ }
471
+
472
+ return [...entries.entries()]
473
+ .filter(([file]) => binaryPaths.has(normalizePath(file)))
474
+ .map(([file, bytes]) => ({ file, bytes }));
475
+ }
476
+
477
+ /**
478
+ * Total bytes this change actually carries.
479
+ *
480
+ * The diff text plus the real size of every binary blob it only summarised.
481
+ * Without the second term the payload governor could be walked straight past
482
+ * with a committed binary of any size.
483
+ */
397
484
  export function diffBytes(root = process.cwd(), base = "main", mode = "committed") {
398
485
  const text = diffText(root, base, mode);
399
- return Buffer.byteLength(text, "utf-8");
486
+ let bytes = Buffer.byteLength(text, "utf-8");
487
+ try {
488
+ for (const entry of binaryDiffEntries(root, base, mode)) bytes += entry.bytes;
489
+ } catch (_) {
490
+ // A payload figure that is too low is the dangerous direction, but throwing
491
+ // here would break every gate on a repo git cannot describe. The text-only
492
+ // number is still returned.
493
+ }
494
+ return bytes;
400
495
  }
401
496
 
402
497
  export function showFromOrigin(root = process.cwd(), base = "main", filePath = "") {
package/src/security.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { openSync, writeSync, fsyncSync, closeSync, renameSync, realpathSync, existsSync, lstatSync, unlinkSync } from "node:fs";
1
+ import { openSync, readFileSync, writeSync, fsyncSync, closeSync, renameSync, realpathSync, existsSync, lstatSync, unlinkSync } from "node:fs";
2
2
  import { dirname, join, basename } from "node:path";
3
3
  import { randomBytes } from "node:crypto";
4
4
  import { canonicalizePath, isWindowsAbsolutePath } from "./config.mjs";
@@ -911,6 +911,102 @@ export function hasHighEntropyToken(text = "", file = null) {
911
911
  return false;
912
912
  }
913
913
 
914
+ /** Bytes of any one binary file the scanner will read. */
915
+ const BINARY_SCAN_CAP_BYTES = 8 * 1024 * 1024;
916
+
917
+ /** Runs of printable ASCII at least this long are worth classifying. */
918
+ const BINARY_STRING_MIN_RUN = 8;
919
+
920
+ /**
921
+ * Scan the contents of files git summarised as "Binary files ... differ".
922
+ *
923
+ * Everything else in this module reads the diff *text*, and git renders a
924
+ * binary file as one 43-byte summary line — so a credential became invisible to
925
+ * the entire scanner by prefixing the file with a single NUL byte. That is not
926
+ * a theoretical bypass: `printf '\0ghp_...' > secret.dat` walked a live GitHub
927
+ * token straight through a green gate.
928
+ *
929
+ * Only the *structured* high-confidence patterns are applied here, never
930
+ * entropy. A real PNG is full of high-entropy bytes and would fail every gate
931
+ * it touched; a string matching `ghp_[A-Za-z0-9]{36}` inside a file claiming to
932
+ * be an image is not a coincidence.
933
+ *
934
+ * @param {Array<{ file: string, bytes: number }>} entries
935
+ * @param {string} root
936
+ * @param {object} [opts]
937
+ * @param {number} [opts.capBytes] - per-file read ceiling
938
+ * @returns {Array<{ severity: string, type: string, file: string, line: null, description: string }>}
939
+ */
940
+ export function scanBinaryPayloads(entries = [], root = process.cwd(), opts = {}) {
941
+ const cap = Number.isFinite(opts.capBytes) ? opts.capBytes : BINARY_SCAN_CAP_BYTES;
942
+ const findings = [];
943
+ // `entries` comes from a git call that returns null on failure, and the
944
+ // default parameter only covers `undefined`.
945
+ const list = Array.isArray(entries) ? entries : [];
946
+
947
+ for (const entry of list) {
948
+ if (!entry || !entry.file) continue;
949
+ // A file too large to read is reported rather than skipped: silence here is
950
+ // exactly the hole being closed.
951
+ if (entry.bytes > cap) {
952
+ findings.push({
953
+ severity: "HIGH",
954
+ type: "BINARY_PAYLOAD_UNSCANNED",
955
+ file: entry.file,
956
+ line: null,
957
+ description: `Binary file ${entry.file} is ${Math.round(entry.bytes / 1024)} KB, above the ${Math.round(cap / 1024)} KB scan ceiling, and was not inspected for credentials`,
958
+ });
959
+ continue;
960
+ }
961
+
962
+ let buf;
963
+ try {
964
+ buf = readFileSync(join(root, entry.file));
965
+ } catch (_) {
966
+ continue;
967
+ }
968
+
969
+ // Extract printable runs the way `strings(1)` does: a credential inside a
970
+ // binary is still ASCII, and decoding the whole buffer as UTF-8 would let
971
+ // replacement characters split the token apart.
972
+ const runs = [];
973
+ let current = "";
974
+ for (const byte of buf) {
975
+ if (byte >= 0x20 && byte <= 0x7e) {
976
+ current += String.fromCharCode(byte);
977
+ } else {
978
+ if (current.length >= BINARY_STRING_MIN_RUN) runs.push(current);
979
+ current = "";
980
+ }
981
+ }
982
+ if (current.length >= BINARY_STRING_MIN_RUN) runs.push(current);
983
+ if (runs.length === 0) continue;
984
+
985
+ const text = runs.join("\n");
986
+ if (hasHighConfidenceSecret(text)) {
987
+ findings.push({
988
+ severity: "CRITICAL",
989
+ type: "HIGH_CONFIDENCE_SECRET",
990
+ file: entry.file,
991
+ line: null,
992
+ description: `High-confidence secret pattern found inside binary file ${entry.file}, which the diff renders only as "Binary files ... differ"`,
993
+ });
994
+ continue;
995
+ }
996
+ if (hasEncodedSecret(text)) {
997
+ findings.push({
998
+ severity: "CRITICAL",
999
+ type: "HIGH_CONFIDENCE_SECRET",
1000
+ file: entry.file,
1001
+ line: null,
1002
+ description: `Base64-encoded secret found inside binary file ${entry.file}`,
1003
+ });
1004
+ }
1005
+ }
1006
+
1007
+ return findings;
1008
+ }
1009
+
914
1010
  /**
915
1011
  * Classify a block of added lines. Returns the single most severe finding, or
916
1012
  * null when the block is clean.