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 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 **864 unit tests across 121 suites passing in < 15.0s**.
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 cryptographic evidence manifests with test-tamper locking. | `0` (Verified), `1` (Tamper) |
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
- const agent = args[2] || "agent";
1282
- const taskId = args[3] || "task-1";
1283
- const filePaths = args.slice(4);
1284
- const res = acquireLock(agent, taskId, filePaths, root);
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
- console.log(`✅ Acquired lock for ${taskId}`);
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
- console.log(`❌ Lock conflict detected: held by ${res.holder}`);
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
- console.log(`\n🛡️ Cryptographic Evidence Manifest Generated!`);
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(` Signature : ${res.manifest.evidenceHash}`);
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(` Signature : ${res.evidenceHash}\n`);
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.57.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
- ".github/**",
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.startsWith("test/") ||
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 (entry.name === ".git" || entry.name === "node_modules" || entry.name === "target" || entry.name === "vendor") {
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}\` | **Signature**: \`${manifest.evidenceHash?.slice(0, 16)}...\``,
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
- const linkDir = normalizePath(entry.file).split("/").slice(0, -1).join("/");
506
- const resolved = normalizePath(target).startsWith("/")
507
- ? normalizePath(target)
508
- : canonicalizePath(linkDir ? `${linkDir}/${target}` : target);
524
+ results.push({ link: entry.file, target: resolveLinkTarget(entry.file, target) });
525
+ }
509
526
 
510
- results.push({ link: entry.file, target: resolved });
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