jules-orchestrator-kit 0.54.0 → 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
@@ -197,10 +197,13 @@ 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
+ * **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.
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.
200
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).
201
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.
202
205
  * **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**.
206
+ * **Verified Test Suite:** Tested with **842 unit tests across 112 suites passing in < 14.0s**.
204
207
 
205
208
  <br/>
206
209
 
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.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/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, 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";
@@ -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
  }
@@ -210,9 +213,37 @@ export async function gate(opts = {}) {
210
213
  }
211
214
  }
212
215
 
213
- 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, {
214
236
  allowProtected: opts.allowProtected || process.env.JULES_ALLOW_COMMAND_FILE_CHANGES === "true",
215
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
+ }
216
247
 
217
248
  phases.push({ phase: "scope", ok: scopeResult.ok, violations: scopeResult.violations });
218
249
  appendTelemetry(root, "gate_phase", { phase: "scope", ok: scopeResult.ok });
@@ -242,13 +273,25 @@ export async function gate(opts = {}) {
242
273
 
243
274
  // Phase 3: Diff Secret Scanner & Security Checks
244
275
  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 });
276
+ // A binary file reaches the scanner as one summary line, so its contents were
277
+ // never looked at — a NUL byte in front of a token was enough to hide it.
278
+ // Inspect those files directly and fold the verdict in.
279
+ let binaryFindings = [];
280
+ try {
281
+ binaryFindings = scanBinaryPayloads(binaryDiffEntries(root, base, mode), root);
282
+ } catch (_) {
283
+ // Never let the extra pass break a gate that would otherwise have run; the
284
+ // text scan above has already been applied.
285
+ }
286
+ const allSecretFindings = [...(secretResult.findings || []), ...binaryFindings];
287
+ const secretsOk = secretResult.ok && binaryFindings.length === 0;
288
+ phases.push({ phase: "secrets", ok: secretsOk, findings: allSecretFindings });
289
+ appendTelemetry(root, "gate_phase", { phase: "secrets", ok: secretsOk });
247
290
  if (progressBus && progressToken) {
248
291
  progressBus.reportProgress(progressToken, 75, 100, "Phase 3/4: Diff Secret Scanner check complete");
249
292
  }
250
293
 
251
- if (!secretResult.ok) {
294
+ if (!secretsOk) {
252
295
  appendTelemetry(root, "gate_finished", { ok: false, code: 6 });
253
296
  return { ok: false, code: 6, phases };
254
297
  }
@@ -415,7 +458,23 @@ export async function gate(opts = {}) {
415
458
  }
416
459
  }
417
460
 
418
- const verifyOk = !failingCmd && !testTampered;
461
+ // A gate that ran no verification at all must not report APPROVED.
462
+ //
463
+ // `testResult` starts optimistic and the stage loop skips a stage with no
464
+ // command, so a repository with no test oracle produced zero execution
465
+ // records and a clean bill of health — syntactically broken code included.
466
+ // That is the product's central claim inverted: the whole point is that a
467
+ // change is verified before it is approved, and "nothing to run" is not
468
+ // verification. Repositories that deliberately use only the scope and secret
469
+ // phases opt out with `verify.required: false`.
470
+ // Assertions are guards, not oracles: `assert:test-integrity` proves the diff
471
+ // did not weaken a test, which says nothing about whether the code works. The
472
+ // question is whether any command was executed against the change at all.
473
+ const verificationRequired = trustedVerify.required !== false;
474
+ const ranNoVerification = !executionRecords.some((r) => r && r.kind !== "assert");
475
+ const missingOracle = verificationRequired && ranNoVerification;
476
+
477
+ const verifyOk = !failingCmd && !testTampered && !missingOracle;
419
478
 
420
479
  // What actually broke. Without this the verify phase reported `ok: false` and
421
480
  // nothing else — not the stage, not the exit code, not a line of output — so
@@ -448,7 +507,21 @@ export async function gate(opts = {}) {
448
507
  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
508
  diagnostics: [],
450
509
  }
451
- : null;
510
+ : missingOracle
511
+ ? {
512
+ stageId: "oracle",
513
+ command: null,
514
+ exitCode: null,
515
+ stdout: "",
516
+ stderr:
517
+ "No verification command ran, so nothing about this change was checked. " +
518
+ "Set verify.test in .agent/config.yml (or run `agentctl bootstrap` to generate one). " +
519
+ "If this repository intentionally uses only the scope and secret phases, set verify.required: false.",
520
+ diagnostics: [
521
+ "The gate approves a change because verification passed. Zero stages executed is not a pass.",
522
+ ],
523
+ }
524
+ : null;
452
525
 
453
526
  // Generate & persist Evidence Manifest
454
527
  const evidenceManifest = generateEvidenceManifest(root, {
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 } 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;
@@ -394,9 +394,174 @@ 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 parseRawDiff(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 out = [];
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 file = fields[i + 1];
437
+ i += 1;
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
+ }
451
+
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;
492
+ }
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;
525
+ }
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));
529
+ }
530
+
531
+ // Only the paths git itself refused to render as text are relevant; a file
532
+ // that diffed normally is already counted in the diff text.
533
+ const binaryPaths = new Set();
534
+ const text = diffText(root, base, mode);
535
+ for (const line of text.split("\n")) {
536
+ const m = line.match(/^Binary files (?:a\/(.+) and )?(?:b\/(.+)|\/dev\/null) differ$/);
537
+ if (m) binaryPaths.add(normalizePath(m[2] || m[1] || ""));
538
+ const m2 = line.match(/^Binary files \/dev\/null and b\/(.+) differ$/);
539
+ if (m2) binaryPaths.add(normalizePath(m2[1]));
540
+ }
541
+
542
+ return [...entries.entries()]
543
+ .filter(([file]) => binaryPaths.has(normalizePath(file)))
544
+ .map(([file, bytes]) => ({ file, bytes }));
545
+ }
546
+
547
+ /**
548
+ * Total bytes this change actually carries.
549
+ *
550
+ * The diff text plus the real size of every binary blob it only summarised.
551
+ * Without the second term the payload governor could be walked straight past
552
+ * with a committed binary of any size.
553
+ */
397
554
  export function diffBytes(root = process.cwd(), base = "main", mode = "committed") {
398
555
  const text = diffText(root, base, mode);
399
- return Buffer.byteLength(text, "utf-8");
556
+ let bytes = Buffer.byteLength(text, "utf-8");
557
+ try {
558
+ for (const entry of binaryDiffEntries(root, base, mode)) bytes += entry.bytes;
559
+ } catch (_) {
560
+ // A payload figure that is too low is the dangerous direction, but throwing
561
+ // here would break every gate on a repo git cannot describe. The text-only
562
+ // number is still returned.
563
+ }
564
+ return bytes;
400
565
  }
401
566
 
402
567
  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.
@@ -1024,6 +1120,24 @@ export function checkTestTampering(diffOrText = "", options = {}) {
1024
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;
1025
1121
  const isCommentLine = (str) => /^\s*(?:\/\/|\/\*|\*|#|--|;)/.test(str);
1026
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
+
1027
1141
  const fileAssertions = new Map();
1028
1142
 
1029
1143
  for (let i = 0; i < lines.length; i++) {
@@ -1055,7 +1169,7 @@ export function checkTestTampering(diffOrText = "", options = {}) {
1055
1169
  }
1056
1170
 
1057
1171
  if (!fileAssertions.has(currentFile)) {
1058
- fileAssertions.set(currentFile, { removed: [], added: 0 });
1172
+ fileAssertions.set(currentFile, { removed: [], added: 0, removedSpecific: [], addedSpecific: 0 });
1059
1173
  }
1060
1174
  const fileStats = fileAssertions.get(currentFile);
1061
1175
 
@@ -1063,6 +1177,9 @@ export function checkTestTampering(diffOrText = "", options = {}) {
1063
1177
  const deletedText = line.slice(1);
1064
1178
  if (!isCommentLine(deletedText) && ASSERTION_PATTERN.test(deletedText)) {
1065
1179
  fileStats.removed.push({ line: currentOldLineNo, text: deletedText });
1180
+ if (isSpecificAssertion(deletedText)) {
1181
+ fileStats.removedSpecific.push({ line: currentOldLineNo, text: deletedText });
1182
+ }
1066
1183
  }
1067
1184
  if (currentOldLineNo !== null) currentOldLineNo++;
1068
1185
  } else if (line.startsWith("+") && !line.startsWith("+++")) {
@@ -1109,6 +1226,7 @@ export function checkTestTampering(diffOrText = "", options = {}) {
1109
1226
  // If valid non-vacuous, non-commented assertion is added, increment added count
1110
1227
  if (!isVacuous && !isCommented && !isCommentLine(addedText) && ASSERTION_PATTERN.test(addedText)) {
1111
1228
  fileStats.added++;
1229
+ if (isSpecificAssertion(addedText)) fileStats.addedSpecific++;
1112
1230
  }
1113
1231
 
1114
1232
  if (currentNewLineNo !== null) currentNewLineNo++;
@@ -1130,6 +1248,31 @@ export function checkTestTampering(diffOrText = "", options = {}) {
1130
1248
  });
1131
1249
  }
1132
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
+ }
1133
1276
  }
1134
1277
 
1135
1278
  return {