jules-orchestrator-kit 0.57.0 → 0.59.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 +3 -3
- package/bin/agentctl.mjs +56 -10
- package/package.json +1 -1
- package/src/config.mjs +73 -1
- package/src/coverage.mjs +2 -6
- package/src/engine.mjs +3 -5
- package/src/evidence.mjs +29 -18
- package/src/git.mjs +56 -6
- package/src/mutation.mjs +2 -8
- package/src/security.mjs +754 -42
- package/src/stack-detector.mjs +40 -3
- package/src/state.mjs +72 -16
- package/src/test-paths.mjs +68 -0
package/README.md
CHANGED
|
@@ -204,7 +204,7 @@ To maximize PR merge rates, dispatch tasks according to deterministic boundaries
|
|
|
204
204
|
* **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).
|
|
205
205
|
* **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.
|
|
206
206
|
* **Terminal UI & Diagnostic Matrix (`agentctl doctor`):** Interactive terminal dashboard, task sidecar manager, and automated transactional self-repair.
|
|
207
|
-
* **Verified Test Suite:** Tested with **
|
|
207
|
+
* **Verified Test Suite:** Tested with **940 unit tests across 130 suites passing in < 15.0s**.
|
|
208
208
|
|
|
209
209
|
<br/>
|
|
210
210
|
|
|
@@ -237,7 +237,7 @@ To maximize PR merge rates, dispatch tasks according to deterministic boundaries
|
|
|
237
237
|
| `doctor` | `agentctl doctor [--probe] [--json]` | Diagnostic check runner. `--probe` additionally starts the configured provider's CLI to confirm it answers, rather than only finding it on `PATH`. | `0` (Healthy), `1` (Failures) |
|
|
238
238
|
| `queue` | `agentctl queue [--dag] [--concurrency <n>] [--dry-run] [--json]` | Consumes and executes task envelopes in `.agent/jules-queue/` with Kahn's DAG dependency resolution. Non-task files (manifests, `README.md`) are skipped, and `--dry-run` previews without moving anything. | `0` (Complete) |
|
|
239
239
|
| `swarm` | `agentctl swarm [--json]` | Runs parallel multi-agent swarm across worker slots with PID liveness detection. | `0` (Complete) |
|
|
240
|
-
| `check` / `gate` / `audit`| `agentctl check [--mode working-tree] [--fix] [--json] [--json-report <path>]` | Runs security, secret scanning, rules budget audit, and tiered verification gates (with declarative assertion support) against working tree or branch. | `0` (Approved), `1` (Budget/Arg), `3` (Scope), `4` (Verify), `5` (Diff >75K), `6` (Secret), `8` (Flaky) |
|
|
240
|
+
| `check` / `gate` / `audit`| `agentctl check [--mode working-tree] [--fix] [--allow-protected] [--allow-test-modifications] [--json] [--json-report <path>]` | Runs security, secret scanning, rules budget audit, and tiered verification gates (with declarative assertion support) against working tree or branch. | `0` (Approved), `1` (Budget/Arg), `3` (Scope), `4` (Verify), `5` (Diff >75K), `6` (Secret), `8` (Flaky) |
|
|
241
241
|
| `mutate` / `mutation` | `agentctl mutate [--min-score <n>] [--max-mutants <n>] [--cmd <testCmd>] [--json]` | Runs zero-dependency diff mutation testing harness on changed hunks with operator inversion and safety rollback. | `0` (Passed), `1` (Score Low) |
|
|
242
242
|
| `coverage` | `agentctl coverage [--min <pct>] [--cmd <testCmd>] [--base <ref>] [--json]` | Runs native zero-dependency V8 diff coverage check against added diff lines. | `0` (Passed), `1` (Low Coverage) |
|
|
243
243
|
| `probe` / `stability` | `agentctl probe [--repeat <n>] [--min <passRate>] [--cmd <testCmd>] [--json]` | Probes test suite flakiness across N consecutive iterations with oscillation detection. | `0` (Passed), `1` (Flaky) |
|
|
@@ -249,7 +249,7 @@ To maximize PR merge rates, dispatch tasks according to deterministic boundaries
|
|
|
249
249
|
| `resume` | `agentctl resume <sessionId> --response "<reply>"` | Streams engineer response back into active Google Jules warm session context window. | `0` (Resumed), `1` (Error) |
|
|
250
250
|
| `test-gen` | `agentctl test-gen --title <t> --spec <s> [--run]` | Scaffolds falsifiable unit tests, verifies RED failure state, and locks test in `scope.deny`. | `0` (Scaffolded/Red) |
|
|
251
251
|
| `dashboard` | `agentctl dashboard [port]` | Starts zero-dependency local HTTP telemetry and audit visualizer dashboard. | `0` (Running) |
|
|
252
|
-
| `evidence` | `agentctl evidence <generate\|verify\|show>` | Generates, verifies, or prints SHA-256
|
|
252
|
+
| `evidence` | `agentctl evidence <generate\|verify\|show>` | Generates, verifies, or prints SHA-256 evidence manifests (unkeyed digests: tamper-evident, not signed) with test-tamper locking. | `0` (Verified), `1` (Tamper) |
|
|
253
253
|
| `flaky` | `agentctl flaky <status\|heal\|reset>` | Manages Wilson-quarantined tests (Exit Code 8) and dispatches automated anti-flakiness healing swarms. | `0` (Healed/Listed) |
|
|
254
254
|
| `mcp` | `agentctl mcp` | Starts stdio Model Context Protocol (MCP) server for Claude, Cursor, and Antigravity. | `0` / Stdio stream |
|
|
255
255
|
| `mcp init` | `agentctl mcp init [--target cursor\|vscode\|claude\|all]` | 1-click config scaffolding for Cursor (`.cursor/mcp.json`), VS Code tasks (`tasks.json`), and Claude Desktop. | `0` (Scaffolded) |
|
package/bin/agentctl.mjs
CHANGED
|
@@ -61,7 +61,7 @@ Commands:
|
|
|
61
61
|
swarm Run parallel task swarm
|
|
62
62
|
mcp Start stdio Model Context Protocol (MCP) server
|
|
63
63
|
clean Clean stale branches, worktrees, locks, and ledgers
|
|
64
|
-
lock <action> Manage mutex locks (acquire | release | status)
|
|
64
|
+
lock <action> Manage mutex locks (acquire [--ttl <min>] [--pid <n>] | release | status)
|
|
65
65
|
doctor Run system diagnostics and stack resolution checks
|
|
66
66
|
providers List agent providers and whether this machine can reach them (--json)
|
|
67
67
|
provider set <name> Switch the active provider in .agent/config.yml
|
|
@@ -383,6 +383,12 @@ async function main() {
|
|
|
383
383
|
committed: { type: "boolean" },
|
|
384
384
|
fix: { type: "boolean" },
|
|
385
385
|
"allow-protected": { type: "boolean" },
|
|
386
|
+
// The tamper guard has always had an override — `allowTestModifications`
|
|
387
|
+
// — and it was reachable only from JavaScript. So a legitimate change
|
|
388
|
+
// of spec, which necessarily rewrites what a test expects, hit a
|
|
389
|
+
// CRITICAL finding at exit 6 with no documented way past it. A guard
|
|
390
|
+
// with no override is not a guard, it is an outage.
|
|
391
|
+
"allow-test-modifications": { type: "boolean" },
|
|
386
392
|
json: { type: "boolean", short: "j" },
|
|
387
393
|
"json-report": { type: "string" },
|
|
388
394
|
"dry-run": { type: "boolean", short: "d" },
|
|
@@ -402,6 +408,7 @@ async function main() {
|
|
|
402
408
|
mode: selectedMode,
|
|
403
409
|
fix: values.fix,
|
|
404
410
|
allowProtected: values["allow-protected"],
|
|
411
|
+
allowTestModifications: values["allow-test-modifications"],
|
|
405
412
|
jsonReport: values["json-report"],
|
|
406
413
|
});
|
|
407
414
|
|
|
@@ -1278,14 +1285,46 @@ async function main() {
|
|
|
1278
1285
|
case "lock": {
|
|
1279
1286
|
const action = args[1];
|
|
1280
1287
|
if (action === "acquire") {
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1288
|
+
// A lock taken from the command line outlives the command. The process
|
|
1289
|
+
// that runs `lock acquire` writes the record and exits; the agent it
|
|
1290
|
+
// speaks for is somewhere else entirely. So this is a time-bounded
|
|
1291
|
+
// lease, not a pid the next acquire can test for liveness — that test
|
|
1292
|
+
// always said "dead" and handed the same files to the next caller.
|
|
1293
|
+
//
|
|
1294
|
+
// `--pid` is the escape hatch for a caller that *does* have a durable
|
|
1295
|
+
// process to point at: the lock then releases itself when that process
|
|
1296
|
+
// dies, exactly as an in-process acquire does.
|
|
1297
|
+
const flagIdx = args.findIndex((a, i) => i >= 2 && a.startsWith("--"));
|
|
1298
|
+
const positional = flagIdx === -1 ? args.slice(2) : args.slice(2, flagIdx);
|
|
1299
|
+
const { values } = parseArgs({
|
|
1300
|
+
args: flagIdx === -1 ? [] : args.slice(flagIdx),
|
|
1301
|
+
options: {
|
|
1302
|
+
ttl: { type: "string" },
|
|
1303
|
+
pid: { type: "string" },
|
|
1304
|
+
},
|
|
1305
|
+
allowPositionals: false,
|
|
1306
|
+
});
|
|
1307
|
+
const agent = positional[0] || "agent";
|
|
1308
|
+
const taskId = positional[1] || "task-1";
|
|
1309
|
+
const filePaths = positional.slice(2);
|
|
1310
|
+
const ttlMinutes = Number(values.ttl);
|
|
1311
|
+
const ownerPid = Number(values.pid);
|
|
1312
|
+
const bindsToProcess = Number.isInteger(ownerPid) && ownerPid > 0;
|
|
1313
|
+
const res = acquireLock(agent, taskId, filePaths, root, {
|
|
1314
|
+
lease: !bindsToProcess,
|
|
1315
|
+
ownerPid: bindsToProcess ? ownerPid : undefined,
|
|
1316
|
+
ttlMs: Number.isFinite(ttlMinutes) && ttlMinutes > 0 ? ttlMinutes * 60_000 : undefined,
|
|
1317
|
+
});
|
|
1285
1318
|
if (res.ok) {
|
|
1286
|
-
|
|
1319
|
+
const held = filePaths.length ? ` (${filePaths.length} path${filePaths.length === 1 ? "" : "s"})` : "";
|
|
1320
|
+
const until = bindsToProcess ? `bound to pid ${ownerPid}` : `expires ${new Date(Date.now() + (Number.isFinite(ttlMinutes) && ttlMinutes > 0 ? ttlMinutes : 120) * 60_000).toISOString()}`;
|
|
1321
|
+
console.log(`✅ Acquired lock for ${taskId}${held} — ${until}`);
|
|
1287
1322
|
} else {
|
|
1288
|
-
|
|
1323
|
+
const overlap = Array.isArray(res.conflictingFiles) && res.conflictingFiles.length
|
|
1324
|
+
? `\n Contested paths: ${res.conflictingFiles.join(", ")}`
|
|
1325
|
+
: "";
|
|
1326
|
+
const until = res.expiresAt ? `\n Held until ${res.expiresAt} (or until \`agentctl lock release ${res.taskId}\`).` : "";
|
|
1327
|
+
console.log(`❌ Lock conflict detected: held by ${res.holder} (task ${res.taskId})${overlap}${until}`);
|
|
1289
1328
|
process.exit(1);
|
|
1290
1329
|
}
|
|
1291
1330
|
} else if (action === "release") {
|
|
@@ -2672,9 +2711,16 @@ async function main() {
|
|
|
2672
2711
|
if (values.json) {
|
|
2673
2712
|
console.log(JSON.stringify(res, null, 2));
|
|
2674
2713
|
} else {
|
|
2675
|
-
|
|
2714
|
+
// "Signature" was the wrong word and the wrong promise. There is no
|
|
2715
|
+
// key anywhere in this system: the value is a SHA-256 digest of the
|
|
2716
|
+
// manifest, so anyone who can write the file can also recompute it
|
|
2717
|
+
// and have `evidence verify` agree. What it proves is that the
|
|
2718
|
+
// manifest has not been edited *since* it was written — tamper
|
|
2719
|
+
// evidence, not authorship. Calling that a signature invites an
|
|
2720
|
+
// operator to trust it for something it cannot do.
|
|
2721
|
+
console.log(`\n🛡️ Evidence Manifest Generated!`);
|
|
2676
2722
|
console.log(` Manifest ID : ${res.manifest.manifestId}`);
|
|
2677
|
-
console.log(`
|
|
2723
|
+
console.log(` Digest : ${res.manifest.evidenceHash}`);
|
|
2678
2724
|
console.log(` Location : ${res.manifestPath}`);
|
|
2679
2725
|
console.log(` Test Files : ${res.manifest.testIntegrity.testFileCount}`);
|
|
2680
2726
|
console.log(` Tampered : ${res.manifest.testIntegrity.tamperDetected ? "YES (FAILED)" : "NO (VERIFIED)"}\n`);
|
|
@@ -2690,7 +2736,7 @@ async function main() {
|
|
|
2690
2736
|
if (res.ok) {
|
|
2691
2737
|
console.log(`\n✅ Evidence Verification PASSED`);
|
|
2692
2738
|
console.log(` Manifest ID : ${res.manifestId}`);
|
|
2693
|
-
console.log(`
|
|
2739
|
+
console.log(` Digest : ${res.evidenceHash}\n`);
|
|
2694
2740
|
} else {
|
|
2695
2741
|
console.error(`\n❌ Evidence Verification FAILED`);
|
|
2696
2742
|
console.error(` Reason : ${res.reason}`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "jules-orchestrator-kit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.59.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
|
@@ -28,6 +28,34 @@ const DEFAULTS = {
|
|
|
28
28
|
baseBranch: "main",
|
|
29
29
|
};
|
|
30
30
|
|
|
31
|
+
// A CI definition is code the forge runs with the repository's own
|
|
32
|
+
// credentials, on the next push, without review. `.github/**` was the only one
|
|
33
|
+
// listed, so the gate's answer depended on which forge the project happened to
|
|
34
|
+
// use: an identical exfiltration job placed in `.gitlab-ci.yml` was approved
|
|
35
|
+
// where `.github/workflows/x.yml` was rejected. A safety gate that is only
|
|
36
|
+
// safe on GitHub is not a safety gate.
|
|
37
|
+
const CI_DEFINITIONS = [
|
|
38
|
+
".github/**",
|
|
39
|
+
".gitlab-ci.yml",
|
|
40
|
+
"**/.gitlab-ci.yml",
|
|
41
|
+
".gitlab/ci/**",
|
|
42
|
+
".circleci/**",
|
|
43
|
+
"azure-pipelines.yml",
|
|
44
|
+
"azure-pipelines.yaml",
|
|
45
|
+
"**/azure-pipelines.yml",
|
|
46
|
+
"Jenkinsfile",
|
|
47
|
+
"**/Jenkinsfile",
|
|
48
|
+
".travis.yml",
|
|
49
|
+
".drone.yml",
|
|
50
|
+
"bitbucket-pipelines.yml",
|
|
51
|
+
".buildkite/**",
|
|
52
|
+
".woodpecker.yml",
|
|
53
|
+
".woodpecker/**",
|
|
54
|
+
"appveyor.yml",
|
|
55
|
+
".teamcity/**",
|
|
56
|
+
".githooks/**",
|
|
57
|
+
];
|
|
58
|
+
|
|
31
59
|
export const BUILTIN_DENY = [
|
|
32
60
|
".git/**",
|
|
33
61
|
"**/.env",
|
|
@@ -36,7 +64,7 @@ export const BUILTIN_DENY = [
|
|
|
36
64
|
"**/*.key",
|
|
37
65
|
"**/id_rsa*",
|
|
38
66
|
".agent/jules-queue/**",
|
|
39
|
-
|
|
67
|
+
...CI_DEFINITIONS,
|
|
40
68
|
];
|
|
41
69
|
|
|
42
70
|
export const BUILTIN_PROTECT = [
|
|
@@ -59,6 +87,50 @@ export const BUILTIN_PROTECT = [
|
|
|
59
87
|
"**/Makefile",
|
|
60
88
|
"**/.npmrc",
|
|
61
89
|
"**/.netrc",
|
|
90
|
+
|
|
91
|
+
// Build definitions that execute arbitrary code at install or build time.
|
|
92
|
+
// The same tier as package.json: legitimately edited, so not denied, but not
|
|
93
|
+
// edited quietly either.
|
|
94
|
+
"setup.py",
|
|
95
|
+
"**/setup.py",
|
|
96
|
+
"setup.cfg",
|
|
97
|
+
"**/setup.cfg",
|
|
98
|
+
"build.rs",
|
|
99
|
+
"**/build.rs",
|
|
100
|
+
"pom.xml",
|
|
101
|
+
"**/pom.xml",
|
|
102
|
+
"build.gradle",
|
|
103
|
+
"**/build.gradle",
|
|
104
|
+
"build.gradle.kts",
|
|
105
|
+
"**/build.gradle.kts",
|
|
106
|
+
"CMakeLists.txt",
|
|
107
|
+
"**/CMakeLists.txt",
|
|
108
|
+
"Gemfile",
|
|
109
|
+
"**/Gemfile",
|
|
110
|
+
"Dockerfile",
|
|
111
|
+
"**/Dockerfile",
|
|
112
|
+
|
|
113
|
+
// Test-runner configuration decides which tests run and what counts as a
|
|
114
|
+
// pass. Rewriting it is the cheapest way to make a suite green without
|
|
115
|
+
// touching a single assertion — `--passWithNoTests`, an added ignore
|
|
116
|
+
// pattern, a conftest.py fixture that stubs the thing under test — and the
|
|
117
|
+
// tamper guard reads test *files*, so none of it was visible to anything.
|
|
118
|
+
"conftest.py",
|
|
119
|
+
"**/conftest.py",
|
|
120
|
+
"pytest.ini",
|
|
121
|
+
"**/pytest.ini",
|
|
122
|
+
"tox.ini",
|
|
123
|
+
"**/tox.ini",
|
|
124
|
+
"jest.config.*",
|
|
125
|
+
"**/jest.config.*",
|
|
126
|
+
"vitest.config.*",
|
|
127
|
+
"**/vitest.config.*",
|
|
128
|
+
"**/.mocharc.*",
|
|
129
|
+
"karma.conf.*",
|
|
130
|
+
"**/karma.conf.*",
|
|
131
|
+
"phpunit.xml",
|
|
132
|
+
"**/phpunit.xml",
|
|
133
|
+
"**/.nycrc*",
|
|
62
134
|
];
|
|
63
135
|
|
|
64
136
|
const BLOCKED_KEYS = new Set(["__proto__", "constructor", "prototype"]);
|
package/src/coverage.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { readFileSync, readdirSync, mkdtempSync, rmSync, existsSync, realpathSync } from "node:fs";
|
|
2
|
+
import { isTestPath } from "./test-paths.mjs";
|
|
2
3
|
import { join, resolve, relative, isAbsolute } from "node:path";
|
|
3
4
|
import { tmpdir } from "node:os";
|
|
4
5
|
import { fileURLToPath } from "node:url";
|
|
@@ -18,12 +19,7 @@ export function isExcludedFromCoverage(filePath = "") {
|
|
|
18
19
|
norm.startsWith(".agent/") ||
|
|
19
20
|
norm.startsWith(".github/") ||
|
|
20
21
|
norm.startsWith(".git/") ||
|
|
21
|
-
norm
|
|
22
|
-
norm.startsWith("tests/") ||
|
|
23
|
-
norm.includes("/__tests__/") ||
|
|
24
|
-
norm.includes(".test.") ||
|
|
25
|
-
norm.includes(".spec.") ||
|
|
26
|
-
norm.includes("_test.") ||
|
|
22
|
+
isTestPath(norm) ||
|
|
27
23
|
norm.endsWith(".md") ||
|
|
28
24
|
norm.endsWith(".json") ||
|
|
29
25
|
norm.endsWith(".yml") ||
|
package/src/engine.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { loadConfig, parseYaml, normalizeScope } from "./config.mjs";
|
|
2
|
+
import { isTestPath } from "./test-paths.mjs";
|
|
2
3
|
import { checkScope, scanDiff, scanBinaryPayloads, redactSecrets } from "./security.mjs";
|
|
3
4
|
import { changedFiles, diffBytes, diffText, binaryDiffEntries, symlinkChanges, showFromOrigin, runCmd } from "./git.mjs";
|
|
4
5
|
import { createProvider, ProviderRateLimitError, ProviderUnavailableError } from "./provider.mjs";
|
|
@@ -274,7 +275,7 @@ export async function gate(opts = {}) {
|
|
|
274
275
|
}
|
|
275
276
|
|
|
276
277
|
// Phase 3: Diff Secret Scanner & Security Checks
|
|
277
|
-
const secretResult = scanDiff(diffStr, { root });
|
|
278
|
+
const secretResult = scanDiff(diffStr, { root, allowTestModifications: opts.allowTestModifications === true });
|
|
278
279
|
// A binary file reaches the scanner as one summary line, so its contents were
|
|
279
280
|
// never looked at — a NUL byte in front of a token was enough to hide it.
|
|
280
281
|
// Inspect those files directly and fold the verdict in.
|
|
@@ -494,10 +495,7 @@ export async function gate(opts = {}) {
|
|
|
494
495
|
const postTestHash = postTestHashResult.treeHash;
|
|
495
496
|
let testTampered = false;
|
|
496
497
|
if (config.evidence?.strictTestLock && !opts.allowTestModifications && preTestHashResult.fileCount > 0) {
|
|
497
|
-
const changedTestFile = files.find((f) =>
|
|
498
|
-
const lower = f.toLowerCase();
|
|
499
|
-
return lower.startsWith("test/") || lower.startsWith("tests/") || lower.includes(".test.") || lower.includes(".spec.");
|
|
500
|
-
});
|
|
498
|
+
const changedTestFile = files.find((f) => isTestPath(f));
|
|
501
499
|
if (changedTestFile && preTestHash !== postTestHash) {
|
|
502
500
|
testTampered = true;
|
|
503
501
|
}
|
package/src/evidence.mjs
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
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
|
+
import { isTestPath } from "./test-paths.mjs";
|
|
17
18
|
|
|
18
19
|
/**
|
|
19
20
|
* Normalizes file path to POSIX slashes.
|
|
@@ -54,6 +55,30 @@ export function computeFileHash(filePath) {
|
|
|
54
55
|
* @param {string} [baseDir]
|
|
55
56
|
* @returns {string[]}
|
|
56
57
|
*/
|
|
58
|
+
/**
|
|
59
|
+
* Directories whose contents a build or a test run regenerates.
|
|
60
|
+
*
|
|
61
|
+
* These are not hashed. The test-integrity hash is taken before and after the
|
|
62
|
+
* verification command and any difference is reported as tampering — so a
|
|
63
|
+
* runner that writes its own cache *inside* the test tree accused itself. That
|
|
64
|
+
* is exactly what pytest does: it drops `tests/__pycache__/*.pyc` on first
|
|
65
|
+
* collection, the post-run hash no longer matched the pre-run hash, and every
|
|
66
|
+
* Python project on earth failed the gate with "test files changed during the
|
|
67
|
+
* run". A false accusation of tampering is worse than a missed one; it teaches
|
|
68
|
+
* the user to pass --allow-test-modifications by reflex.
|
|
69
|
+
*/
|
|
70
|
+
const ARTIFACT_DIRS = new Set([
|
|
71
|
+
".git", "node_modules", "target", "vendor",
|
|
72
|
+
"__pycache__", ".pytest_cache", ".mypy_cache", ".ruff_cache", ".tox",
|
|
73
|
+
".venv", "venv", ".gradle", ".nyc_output", "coverage", "htmlcov",
|
|
74
|
+
".next", ".nuxt", ".turbo", ".cache", ".parcel-cache",
|
|
75
|
+
]);
|
|
76
|
+
|
|
77
|
+
/** Compiled output. Regenerated from the sources that are hashed instead. */
|
|
78
|
+
const ARTIFACT_EXTENSIONS = new Set([
|
|
79
|
+
".pyc", ".pyo", ".pyd", ".class", ".o", ".obj", ".so", ".dll", ".dylib", ".exe", ".log",
|
|
80
|
+
]);
|
|
81
|
+
|
|
57
82
|
export function findFilesRecursively(dir, baseDir = dir) {
|
|
58
83
|
if (!existsSync(dir)) return [];
|
|
59
84
|
const results = [];
|
|
@@ -61,12 +86,11 @@ export function findFilesRecursively(dir, baseDir = dir) {
|
|
|
61
86
|
const entries = readdirSync(dir, { withFileTypes: true });
|
|
62
87
|
for (const entry of entries) {
|
|
63
88
|
const fullPath = join(dir, entry.name);
|
|
64
|
-
if (
|
|
65
|
-
continue;
|
|
66
|
-
}
|
|
89
|
+
if (ARTIFACT_DIRS.has(entry.name)) continue;
|
|
67
90
|
if (entry.isDirectory()) {
|
|
68
91
|
results.push(...findFilesRecursively(fullPath, baseDir));
|
|
69
92
|
} else if (entry.isFile()) {
|
|
93
|
+
if (ARTIFACT_EXTENSIONS.has(extname(entry.name).toLowerCase())) continue;
|
|
70
94
|
results.push(normalizePath(relative(baseDir, fullPath)));
|
|
71
95
|
}
|
|
72
96
|
}
|
|
@@ -141,20 +165,7 @@ export function computeDirectoryHash(root, options = {}) {
|
|
|
141
165
|
|
|
142
166
|
// Filter test files if specifically looking for test suites
|
|
143
167
|
if (options.testOnly) {
|
|
144
|
-
fileList = fileList.filter((f) =>
|
|
145
|
-
const lower = f.toLowerCase();
|
|
146
|
-
return (
|
|
147
|
-
lower.startsWith("test/") ||
|
|
148
|
-
lower.startsWith("tests/") ||
|
|
149
|
-
lower.startsWith("__tests__/") ||
|
|
150
|
-
lower.startsWith("spec/") ||
|
|
151
|
-
lower.includes(".test.") ||
|
|
152
|
-
lower.includes(".spec.") ||
|
|
153
|
-
lower.includes("_test.") ||
|
|
154
|
-
lower.endsWith("test.sol") ||
|
|
155
|
-
lower.endsWith(".t.sol")
|
|
156
|
-
);
|
|
157
|
-
});
|
|
168
|
+
fileList = fileList.filter((f) => isTestPath(f));
|
|
158
169
|
}
|
|
159
170
|
|
|
160
171
|
const fileHashes = {};
|
|
@@ -579,7 +590,7 @@ export function generateEvidenceMarkdown(manifest) {
|
|
|
579
590
|
const lines = [
|
|
580
591
|
"### 🛡️ Autonomous Verification & Evidence Proof",
|
|
581
592
|
"",
|
|
582
|
-
`> **Evidence Manifest**: \`${manifest.manifestId}\` | **
|
|
593
|
+
`> **Evidence Manifest**: \`${manifest.manifestId}\` | **Digest**: \`${manifest.evidenceHash?.slice(0, 16)}...\``,
|
|
583
594
|
"",
|
|
584
595
|
"| Gate / Policy | Status | Evidence Record |",
|
|
585
596
|
"| :--- | :---: | :--- |",
|
package/src/git.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { execFileSync, execSync } from "node:child_process";
|
|
2
|
-
import { readFileSync, existsSync, statSync, readlinkSync } from "node:fs";
|
|
2
|
+
import { readFileSync, existsSync, statSync, lstatSync, readlinkSync } from "node:fs";
|
|
3
3
|
import { join, delimiter } from "node:path";
|
|
4
4
|
import { normalizePath, canonicalizePath } from "./config.mjs";
|
|
5
5
|
|
|
@@ -382,6 +382,25 @@ export function diffText(root = process.cwd(), base = "main", mode = "committed"
|
|
|
382
382
|
for (const file of untrackedFiles) {
|
|
383
383
|
try {
|
|
384
384
|
const fullPath = join(root, file);
|
|
385
|
+
|
|
386
|
+
// A symlink is its target *path*, never its target's contents — that is
|
|
387
|
+
// how git stores one and how `git diff` renders one. Reading through
|
|
388
|
+
// the link instead pulled whatever it pointed at into the diff, so
|
|
389
|
+
// `ln -s /etc/passwd notes.md` shipped that file's contents to the
|
|
390
|
+
// provider as ordinary added lines under an innocent name. Emit the
|
|
391
|
+
// link the way a committed symlink is emitted, and let checkScope judge
|
|
392
|
+
// the target it resolves to.
|
|
393
|
+
let linkStat = null;
|
|
394
|
+
try {
|
|
395
|
+
linkStat = lstatSync(fullPath);
|
|
396
|
+
} catch (_) {}
|
|
397
|
+
if (linkStat && linkStat.isSymbolicLink()) {
|
|
398
|
+
const target = readlinkSync(fullPath);
|
|
399
|
+
untrackedDiff += `\ndiff --git a/${file} b/${file}\nnew file mode 120000\n--- /dev/null\n+++ b/${file}\n`;
|
|
400
|
+
untrackedDiff += `@@ -0,0 +1 @@\n+${target}\n\\n`;
|
|
401
|
+
continue;
|
|
402
|
+
}
|
|
403
|
+
|
|
385
404
|
if (existsSync(fullPath)) {
|
|
386
405
|
const content = readFileSync(fullPath, "utf-8");
|
|
387
406
|
const addedLines = content.split(/\r?\n/);
|
|
@@ -502,17 +521,48 @@ export function symlinkChanges(root = process.cwd(), base = "main", mode = "comm
|
|
|
502
521
|
}
|
|
503
522
|
if (!target) continue;
|
|
504
523
|
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
? normalizePath(target)
|
|
508
|
-
: canonicalizePath(linkDir ? `${linkDir}/${target}` : target);
|
|
524
|
+
results.push({ link: entry.file, target: resolveLinkTarget(entry.file, target) });
|
|
525
|
+
}
|
|
509
526
|
|
|
510
|
-
|
|
527
|
+
// An untracked symlink appears in no `git diff --raw` at all, so in
|
|
528
|
+
// working-tree mode — the default for `agentctl check` — the loop above sees
|
|
529
|
+
// nothing. `ln -s /etc/os-release leak.txt` therefore reached checkScope as
|
|
530
|
+
// the plain name `leak.txt` and passed; committing the identical link to a
|
|
531
|
+
// branch and checking that was caught. The gate's answer must not depend on
|
|
532
|
+
// whether the attacker ran `git add`.
|
|
533
|
+
if (mode === "working-tree" || mode === "working") {
|
|
534
|
+
const untrackedRaw =
|
|
535
|
+
git(["-c", "core.quotePath=false", "ls-files", "-z", "--others", "--exclude-standard"], {
|
|
536
|
+
cwd: root,
|
|
537
|
+
raw: true,
|
|
538
|
+
ignoreError: true,
|
|
539
|
+
}) || "";
|
|
540
|
+
for (const rel of untrackedRaw.split("\0").map(normalizePath).filter(Boolean)) {
|
|
541
|
+
if (seen.has(rel)) continue;
|
|
542
|
+
let target = "";
|
|
543
|
+
try {
|
|
544
|
+
if (!lstatSync(join(root, rel)).isSymbolicLink()) continue;
|
|
545
|
+
target = readlinkSync(join(root, rel));
|
|
546
|
+
} catch (_) {
|
|
547
|
+
continue;
|
|
548
|
+
}
|
|
549
|
+
if (!target) continue;
|
|
550
|
+
seen.add(rel);
|
|
551
|
+
results.push({ link: rel, target: resolveLinkTarget(rel, target) });
|
|
552
|
+
}
|
|
511
553
|
}
|
|
512
554
|
|
|
513
555
|
return results;
|
|
514
556
|
}
|
|
515
557
|
|
|
558
|
+
/** Where a symlink at `link` pointing at `target` lands, as a repo-relative or absolute path. */
|
|
559
|
+
function resolveLinkTarget(link, target) {
|
|
560
|
+
const linkDir = normalizePath(link).split("/").slice(0, -1).join("/");
|
|
561
|
+
return normalizePath(target).startsWith("/")
|
|
562
|
+
? normalizePath(target)
|
|
563
|
+
: canonicalizePath(linkDir ? `${linkDir}/${target}` : target);
|
|
564
|
+
}
|
|
565
|
+
|
|
516
566
|
export function binaryDiffEntries(root = process.cwd(), base = "main", mode = "committed") {
|
|
517
567
|
const entries = new Map();
|
|
518
568
|
for (const entry of parseRawDiff(root, base, mode)) {
|
package/src/mutation.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { readFileSync, writeFileSync, existsSync } from "node:fs";
|
|
2
|
+
import { isTestPath } from "./test-paths.mjs";
|
|
2
3
|
import { resolve, isAbsolute } from "node:path";
|
|
3
4
|
import { createHash } from "node:crypto";
|
|
4
5
|
import { diffText, runCmd } from "./git.mjs";
|
|
@@ -195,14 +196,7 @@ export function isExcludedFromMutation(filePath = "") {
|
|
|
195
196
|
const normalized = filePath.replace(/\\/g, "/").toLowerCase();
|
|
196
197
|
|
|
197
198
|
// Exclude test files
|
|
198
|
-
if (
|
|
199
|
-
normalized.includes(".test.") ||
|
|
200
|
-
normalized.includes(".spec.") ||
|
|
201
|
-
normalized.includes("_test.") ||
|
|
202
|
-
normalized.includes("/test/") ||
|
|
203
|
-
normalized.includes("/tests/") ||
|
|
204
|
-
normalized.includes("/__tests__/")
|
|
205
|
-
) {
|
|
199
|
+
if (isTestPath(normalized)) {
|
|
206
200
|
return true;
|
|
207
201
|
}
|
|
208
202
|
|