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 +4 -1
- package/bin/agentctl.mjs +9 -0
- package/package.json +1 -1
- package/src/config.mjs +5 -0
- package/src/engine.mjs +81 -8
- package/src/evidence.mjs +69 -2
- package/src/git.mjs +168 -3
- package/src/security.mjs +145 -2
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 **
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
246
|
-
|
|
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 (!
|
|
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
|
-
|
|
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
|
-
:
|
|
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
|
|
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
|
-
|
|
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 {
|