jules-orchestrator-kit 0.32.5 → 0.32.6

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.
@@ -92,8 +92,9 @@ To maximize the ratio of mergeable PRs vs. failed or hallucinated sessions, adhe
92
92
 
93
93
  - **Multi-Agent Mutex Lock Protocol**: Prevent concurrent file modification collisions. Check and acquire locks before modifying paths:
94
94
  ```bash
95
- node scripts/lock-manager.mjs acquire <agent_name> <task_id> <file_paths...> --unattended
95
+ agentctl lock acquire <agent_name> <task_id> <file_path...>
96
96
  ```
97
+ Inspect holders with `agentctl lock status` and hand back with `agentctl lock release <task_id>`. A conflicting acquire exits `1` and names the current holder.
97
98
  - **The Baton Pass Protocol**: Write handover documents when a session pauses or hands off work (e.g. `.agent/history/YYYY-MM-DD-handover-[task_id].md`).
98
99
 
99
100
  ### Standard Jules Guardrails Footer
package/README.md CHANGED
@@ -88,7 +88,7 @@ Autonomous coding agents can write software at 100× human speed—but unconstra
88
88
 
89
89
  `jules-orchestrator-kit` provides the missing **Safety, Orchestration, and Verification Kernel** for high-reliability AI agent deployments:
90
90
 
91
- * **🔥 Warm Multi-Turn Session Resumption (`v0.31.0`):** Streams OODA repair prompts directly into active Google Jules session streams via `POST /v1alpha/sessions/{id}:sendMessage`, saving 60–80% context tokens while preserving reasoning context.
91
+ * **🔥 Warm Multi-Turn Session Resumption (`v0.31.0`):** `agentctl resume <sessionId> --response "<reply>"` streams an engineer's reply directly into an active Google Jules session via `POST /v1alpha/sessions/{id}:sendMessage`, preserving reasoning context instead of paying to rebuild it, with fail-soft cold-dispatch fallback on HTTP 400/404. This is the asynchronous HITL unblocking path; the automatic OODA repair loop currently opens a fresh session per attempt (see [architecture.md](docs/architecture.md#on-warm-session-resumption)).
92
92
 
93
93
  * **🧪 Automated TDD Red-to-Green Harness (`agentctl test-gen`):** Scaffolds falsifiable unit tests from bug specs, verifies **RED** failure state, locks the test file in `scope.deny`, and tasks Jules with making it pass (**GREEN** state).
94
94
 
@@ -102,7 +102,7 @@ Autonomous coding agents can write software at 100× human speed—but unconstra
102
102
 
103
103
  * **🔒 Zero Runtime Dependencies:** Built exclusively on Node.js 20+ built-ins (`node:fs`, `node:child_process`, `node:crypto`, `node:path`, `node:http`, `node:tty`, `node:test`). Zero third-party npm packages mean zero supply-chain CVE risk.
104
104
 
105
- * **🛡️ Fail-Closed Security Gatekeeper:** Unconditionally evaluates explicit Deny rules *before* Allow rules, redacts high-entropy secrets and PII from dry-runs and git diffs, blocks unsupported Node.js native module imports in Edge environments (Cloudflare Workers, Vercel Edge, Netlify Edge), and rejects PRs exceeding the 75 KB Diff Payload governor.
105
+ * **🛡️ Fail-Closed Security Gatekeeper:** Unconditionally evaluates explicit Deny rules *before* Allow rules, matching against **canonicalised, case-folded paths** so `./`, `..`, mixed separators or a `.GitHub/` spelling cannot walk past a rule (the same repo is checked out on case-insensitive macOS and Windows filesystems). Redacts high-entropy secrets and PII from dry-runs and git diffs, blocks unsupported Node.js native module imports in Edge environments (Cloudflare Workers, Vercel Edge, Netlify Edge), and rejects PRs exceeding the 75 KB Diff Payload governor.
106
106
 
107
107
  * **🔄 Autonomous OODA Self-Healing:** Captures test stderr/stdout, normalizes failure fingerprints, and feeds structured error contexts back into repair iterations (up to 3 automatic attempts) before human escalation.
108
108
 
@@ -120,7 +120,7 @@ Autonomous coding agents can write software at 100× human speed—but unconstra
120
120
 
121
121
  * **🚀 Zero-Test Bootstrapping (`agentctl bootstrap`):** Synthesizes deterministic syntax-check and smoke-test verification oracles for untested legacy repositories so agents always operate against a falsifiable feedback loop.
122
122
 
123
- * **📈 Proven Scale & Reliability:** Empirically tested with **421 unit tests across 59 suites passing in < 8.0s**, supporting 300+ daily agent sessions per repository.
123
+ * **📈 Proven Scale & Reliability:** Empirically tested with **458 unit tests across 66 suites passing in < 10.0s**, supporting 300+ daily agent sessions per repository. An adversarial red-team suite (`test/adversarial-claims.test.mjs`) continuously attempts to falsify the safety guarantees documented above — including cross-platform probes for the case-insensitive filesystems on macOS and Windows — and a documentation-sync gate (`scripts/doc-sync-check.mjs`) blocks any release whose docs have drifted from the code.
124
124
 
125
125
  <br/>
126
126
 
@@ -470,8 +470,8 @@ npx jules-orchestrator-kit mcp
470
470
 
471
471
  | Feature | Module / Command | Architectural Description | Target Release |
472
472
  | :--- | :--- | :--- | :---: |
473
- | **Dynamic Complexity & Cost Router** | `src/router.mjs`, `router:` in `.agent/config.yml` | Provider-agnostic, zero-dependency heuristic classifier routing trivial tasks to a fast/cheap provider (`gemini-flash` preset included) and complex/safety-sensitive tasks to the primary provider; opt-in, `--tier` override. | **Unreleased** *(main)* |
474
- | **DAG Task Queue, Specialist Roles & Evidence Ledger** | `src/dag-engine.mjs`, `src/evidence.mjs`, `agentctl evidence` | Kahn's-algorithm dependency-ordered queue execution (`queue --dag`), `--role` specialist prompt resolution, and SHA-256 cryptographic evidence manifests with test-tamper locking. | **Unreleased** *(main)* |
473
+ | **Dynamic Complexity & Cost Router** | `src/router.mjs`, `router:` in `.agent/config.yml` | Provider-agnostic, zero-dependency heuristic classifier routing trivial tasks to a fast/cheap provider (`gemini-flash` preset included) and complex/safety-sensitive tasks to the primary provider; opt-in, `--tier` override. | **v0.32.5** *(Shipped)* |
474
+ | **DAG Task Queue, Specialist Roles & Evidence Ledger** | `src/dag-engine.mjs`, `src/evidence.mjs`, `agentctl evidence` | Kahn's-algorithm dependency-ordered queue execution (`queue --dag`), `--role` specialist prompt resolution, and SHA-256 cryptographic evidence manifests with test-tamper locking. | **v0.32.5** *(Shipped)* |
475
475
  | **Warm Session Resumption & PR Bundler** | `src/provider.mjs`, `src/engine.mjs` | Multi-turn warm session context streaming via `POST /v1alpha/sessions/{id}:sendMessage` & evidence PR descriptions. | **v0.31.0** *(Shipped)* |
476
476
  | **TDD Harness & Prompt Falsifiability Linter** | `agentctl test-gen`, `agentctl task optimize` | Automated RED-state test generator, `scope.deny` test locking, and prompt testability linter with fuzzy path resolution. | **v0.31.0** *(Shipped)* |
477
477
  | **Atomic Git Checkpoint & Rollback** | `agentctl rollback` (`src/ops/checkpoint.mjs`) | Pre-flight git HEAD/stash snapshotting, atomic rollback restoration, and 10-session pruning rotation. | **v0.31.0** *(Shipped)* |
package/bin/agentctl.mjs CHANGED
@@ -12,11 +12,11 @@ import { reapOrphanedIntents, reapStaleMutexDirs } from "../src/journal.mjs";
12
12
  const args = process.argv.slice(2);
13
13
  const command = args[0];
14
14
 
15
- export const VERSION = "0.32.5";
15
+ export const VERSION = "0.32.6";
16
16
 
17
17
  export function printHelp() {
18
18
  console.log(`
19
- 🚀 agentctl v0.32.5 — Universal Agent Orchestrator & Safety Gatekeeper
19
+ 🚀 agentctl v0.32.6 — Universal Agent Orchestrator & Safety Gatekeeper
20
20
 
21
21
  Usage: agentctl <command> [options]
22
22
 
@@ -69,7 +69,7 @@ async function main() {
69
69
  }
70
70
 
71
71
  if (command === "version" || command === "--version" || command === "-v") {
72
- console.log("agentctl v0.32.5");
72
+ console.log("agentctl v0.32.6");
73
73
  process.exit(0);
74
74
  }
75
75
 
package/index.mjs CHANGED
@@ -5,7 +5,7 @@
5
5
  * security auditing, repo gating, and state operations.
6
6
  */
7
7
 
8
- export { loadConfig, parseYaml, detectStack, resolveVerify, resolveRoot, normalizePath, TIER_PRESETS } from "./src/config.mjs";
8
+ export { loadConfig, parseYaml, detectStack, resolveVerify, resolveRoot, normalizePath, canonicalizePath, TIER_PRESETS } from "./src/config.mjs";
9
9
  export {
10
10
  shannonEntropy,
11
11
  redactSecrets,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jules-orchestrator-kit",
3
- "version": "0.32.5",
3
+ "version": "0.32.6",
4
4
  "description": "Orchestration kit for running Google Jules autonomous agents.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -53,6 +53,7 @@
53
53
  "jules:check-asset-integrity": "node scripts/asset-integrity-check.mjs",
54
54
  "jules:risk-tier": "node scripts/risk-tier.mjs",
55
55
  "jules:rules-lint": "node scripts/rules-lint.mjs",
56
+ "jules:doc-sync": "node scripts/doc-sync-check.mjs",
56
57
  "release": "node scripts/release.mjs",
57
58
  "lint": "eslint ."
58
59
  },
@@ -0,0 +1,234 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Documentation / version consistency gate.
5
+ *
6
+ * Implements the `doc-sync-sentinel` preset advertised in src/wizard-init.mjs:
7
+ * asserts that package.json, bin/agentctl.mjs, README.md, ROADMAP_V1.md and
8
+ * CHANGELOG.md all agree on the current version, and that README's advertised
9
+ * test counts match what the suite actually reports.
10
+ *
11
+ * Runs as a blocking step in scripts/release.mjs. Standalone usage:
12
+ * node scripts/doc-sync-check.mjs # runs the suite for counts
13
+ * node scripts/doc-sync-check.mjs --tests 429 --suites 59
14
+ * node scripts/doc-sync-check.mjs --json
15
+ *
16
+ * Exit codes: 0 = in sync, 1 = drift detected.
17
+ */
18
+
19
+ import { readFileSync, existsSync } from "node:fs";
20
+ import { join } from "node:path";
21
+ import { execSync } from "node:child_process";
22
+ import { resolveRoot } from "../src/config.mjs";
23
+
24
+ /**
25
+ * Runs the unit suite and extracts the authoritative counts.
26
+ *
27
+ * README advertises *passing* tests, so `pass` — not `tests` — is the number
28
+ * the documentation claim is compared against. The two diverge as soon as the
29
+ * adversarial suite records a `todo` probe for a known gap.
30
+ *
31
+ * @param {string} root
32
+ * @returns {{ tests: number|null, pass: number|null, suites: number|null, todo: number|null }}
33
+ */
34
+ export function measureTestCounts(root = process.cwd()) {
35
+ let out = "";
36
+ try {
37
+ out = execSync("npm test", { cwd: root, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] });
38
+ } catch (err) {
39
+ // A failing suite still prints its summary; parse whatever we got.
40
+ out = `${err.stdout || ""}${err.stderr || ""}`;
41
+ }
42
+ return parseTestCounts(out);
43
+ }
44
+
45
+ /**
46
+ * Parses node:test summary counters out of an already-captured run.
47
+ * Accepts both the spec reporter ("ℹ tests 452") and tap ("# tests 452").
48
+ * @param {string} out
49
+ * @returns {{ tests: number|null, pass: number|null, suites: number|null, todo: number|null }}
50
+ */
51
+ export function parseTestCounts(out = "") {
52
+ const num = (key) => {
53
+ const m = String(out).match(new RegExp(`^[^\\n]*?(?:ℹ|#)\\s*${key}\\s+(\\d+)\\s*$`, "m"));
54
+ return m ? Number(m[1]) : null;
55
+ };
56
+ return { tests: num("tests"), pass: num("pass"), suites: num("suites"), todo: num("todo") };
57
+ }
58
+
59
+ function readIfExists(path) {
60
+ return existsSync(path) ? readFileSync(path, "utf-8") : null;
61
+ }
62
+
63
+ /**
64
+ * Verifies documentation is in sync with package.json and the real test counts.
65
+ * @param {string} [root]
66
+ * @param {object} [opts]
67
+ * @param {number} [opts.tests] Actual passing test count (measured if omitted).
68
+ * @param {number} [opts.suites] Actual suite count (measured if omitted).
69
+ * @returns {{ ok: boolean, version: string, checks: Array<{name: string, ok: boolean, detail: string}> }}
70
+ */
71
+ export function checkDocSync(root = process.cwd(), opts = {}) {
72
+ const checks = [];
73
+ const add = (name, ok, detail) => checks.push({ name, ok, detail });
74
+
75
+ const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf-8"));
76
+ const version = pkg.version;
77
+
78
+ // 1. bin/agentctl.mjs — VERSION const, help banner, `version` command output.
79
+ const cli = readIfExists(join(root, "bin", "agentctl.mjs"));
80
+ if (cli === null) {
81
+ add("agentctl present", false, "bin/agentctl.mjs not found");
82
+ } else {
83
+ const constMatch = cli.match(/export const VERSION\s*=\s*"([^"]+)"/);
84
+ add(
85
+ "agentctl VERSION const",
86
+ constMatch?.[1] === version,
87
+ constMatch ? `found "${constMatch[1]}", expected "${version}"` : "no `export const VERSION` found"
88
+ );
89
+
90
+ const stale = [...cli.matchAll(/agentctl v(\d+\.\d+\.\d+)/g)].map((m) => m[1]).filter((v) => v !== version);
91
+ add(
92
+ "agentctl banner/version strings",
93
+ stale.length === 0,
94
+ stale.length ? `stale version string(s): ${[...new Set(stale)].join(", ")}` : `all reference v${version}`
95
+ );
96
+ }
97
+
98
+ // 2. ROADMAP_V1.md — milestone header must name the shipped version, and no
99
+ // already-released version may still be labelled "(Unreleased)".
100
+ const roadmap = readIfExists(join(root, "ROADMAP_V1.md"));
101
+ if (roadmap === null) {
102
+ add("ROADMAP present", false, "ROADMAP_V1.md not found");
103
+ } else {
104
+ const current = roadmap.match(/v(\d+\.\d+\.\d+)\s*\(Current Stable\)/);
105
+ add(
106
+ "ROADMAP Current Stable",
107
+ current?.[1] === version,
108
+ current ? `found v${current[1]}, expected v${version}` : "no `(Current Stable)` marker found"
109
+ );
110
+
111
+ const shipped = roadmap.match(/Shipped Milestones\s*\(v[\d.]+\s*[–-]\s*v(\d+\.\d+\.\d+)\)/);
112
+ add(
113
+ "ROADMAP Shipped range",
114
+ shipped?.[1] === version,
115
+ shipped ? `ends at v${shipped[1]}, expected v${version}` : "no `Shipped Milestones (…)` range found"
116
+ );
117
+
118
+ const unreleased = [...roadmap.matchAll(/v(\d+\.\d+\.\d+)\s*\(Unreleased\)/g)]
119
+ .map((m) => m[1])
120
+ .filter((v) => compareSemver(v, version) <= 0);
121
+ add(
122
+ "ROADMAP no stale (Unreleased)",
123
+ unreleased.length === 0,
124
+ unreleased.length
125
+ ? `v${unreleased.join(", v")} marked (Unreleased) but <= shipped v${version}`
126
+ : "no released version left marked (Unreleased)"
127
+ );
128
+ }
129
+
130
+ // 3. CHANGELOG.md — a released version needs a matching entry (release.mjs
131
+ // extracts its notes from here, and silently falls back if it is missing).
132
+ const changelog = readIfExists(join(root, "CHANGELOG.md"));
133
+ if (changelog === null) {
134
+ add("CHANGELOG present", false, "CHANGELOG.md not found");
135
+ } else {
136
+ add(
137
+ "CHANGELOG entry",
138
+ changelog.includes(`## [${version}]`),
139
+ changelog.includes(`## [${version}]`) ? `## [${version}] found` : `no "## [${version}]" section`
140
+ );
141
+ }
142
+
143
+ // 4. README.md — advertised test counts and roadmap-table release labels.
144
+ const readme = readIfExists(join(root, "README.md"));
145
+ if (readme === null) {
146
+ add("README present", false, "README.md not found");
147
+ } else {
148
+ const claim = readme.match(/(\d+)\s+unit tests across\s+(\d+)\s+suites/);
149
+ const claimedTests = claim ? Number(claim[1]) : null;
150
+ const claimedSuites = claim ? Number(claim[2]) : null;
151
+
152
+ let { tests, suites } = opts;
153
+ if (tests === undefined || suites === undefined) {
154
+ const measured = measureTestCounts(root);
155
+ tests = tests ?? measured.pass;
156
+ suites = suites ?? measured.suites;
157
+ }
158
+
159
+ if (claimedTests === null) {
160
+ add("README test count", false, "no `N unit tests across M suites` claim found");
161
+ } else if (tests === null || suites === null) {
162
+ add("README test count", false, "could not measure actual test counts");
163
+ } else {
164
+ add(
165
+ "README test count",
166
+ claimedTests === tests && claimedSuites === suites,
167
+ `README claims ${claimedTests}/${claimedSuites} passing, suite reports ${tests}/${suites}`
168
+ );
169
+ }
170
+
171
+ const unreleasedRows = (readme.match(/\|\s*\*\*Unreleased\*\*\s*\*\(main\)\*\s*\|/g) || []).length;
172
+ add(
173
+ "README roadmap table labels",
174
+ unreleasedRows === 0,
175
+ unreleasedRows ? `${unreleasedRows} row(s) still labelled "Unreleased (main)"` : "no stale Unreleased rows"
176
+ );
177
+ }
178
+
179
+ // 5. Agent rule-file budgets. rules-lint has existed unwired, which is how
180
+ // AGENTS.md silently drifted past its 10k character budget — agent rule
181
+ // files are truncated by the model host, so an over-budget file loses
182
+ // directives from the end without any error surfacing.
183
+ if (opts.skipRulesLint !== true) {
184
+ try {
185
+ execSync("node scripts/rules-lint.mjs", { cwd: root, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] });
186
+ add("agent rule budgets", true, "AGENTS.md and .agent/rules/ within char/line budgets");
187
+ } catch (err) {
188
+ const detail = `${err.stdout || ""}${err.stderr || ""}`
189
+ .split("\n")
190
+ .filter((l) => l.trim().startsWith("-"))
191
+ .map((l) => l.trim().replace(/^-\s*/, ""))
192
+ .join("; ");
193
+ add("agent rule budgets", false, detail || "rules-lint reported violations");
194
+ }
195
+ }
196
+
197
+ return { ok: checks.every((c) => c.ok), version, checks };
198
+ }
199
+
200
+ function compareSemver(a, b) {
201
+ const pa = a.split(".").map(Number);
202
+ const pb = b.split(".").map(Number);
203
+ for (let i = 0; i < 3; i++) {
204
+ if ((pa[i] || 0) !== (pb[i] || 0)) return (pa[i] || 0) - (pb[i] || 0);
205
+ }
206
+ return 0;
207
+ }
208
+
209
+ // ---- CLI ----
210
+ const isMain = process.argv[1] && process.argv[1].endsWith("doc-sync-check.mjs");
211
+ if (isMain) {
212
+ const argv = process.argv.slice(2);
213
+ const flag = (name) => {
214
+ const i = argv.indexOf(`--${name}`);
215
+ return i !== -1 && argv[i + 1] ? Number(argv[i + 1]) : undefined;
216
+ };
217
+
218
+ const root = resolveRoot();
219
+ const res = checkDocSync(root, { tests: flag("tests"), suites: flag("suites") });
220
+
221
+ if (argv.includes("--json")) {
222
+ console.log(JSON.stringify(res, null, 2));
223
+ } else {
224
+ console.log(`\n📐 Documentation Sync Gate (v${res.version})`);
225
+ console.log("-------------------------------------------------------");
226
+ for (const c of res.checks) {
227
+ console.log(` ${c.ok ? "✅" : "❌"} ${c.name.padEnd(32)} ${c.detail}`);
228
+ }
229
+ console.log("-------------------------------------------------------");
230
+ console.log(res.ok ? "✅ Documentation is in sync.\n" : "❌ DOC SYNC GATE FAIL: documentation has drifted from package.json / test suite.\n");
231
+ }
232
+
233
+ process.exit(res.ok ? 0 : 1);
234
+ }
@@ -26,16 +26,37 @@ const tagName = `v${version}`;
26
26
  console.log(`🚀 Automated Release Pipeline for ${pkg.name} (${tagName})`);
27
27
  console.log("-------------------------------------------------------");
28
28
 
29
- // 1. Verify test suite
29
+ // 1. Verify test suite. Output is captured (not inherited) so the doc-sync gate
30
+ // below can reuse the counts instead of running the suite a second time.
30
31
  console.log("1. Running unit test verification suite...");
32
+ let testOutput = "";
31
33
  try {
32
- execSync("npm test", { cwd: root, stdio: "inherit" });
34
+ testOutput = execSync("npm test", { cwd: root, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] });
35
+ const summary = testOutput.match(/^[^\n]*?ℹ\s*(?:tests|suites|pass|fail|todo)\s+\d+\s*$/gm) || [];
36
+ summary.forEach((l) => console.log(` ${l.trim()}`));
33
37
  console.log(" ✅ Test suite passed cleanly.\n");
34
38
  } catch (err) {
39
+ console.error(`${err.stdout || ""}${err.stderr || ""}`);
35
40
  console.error("❌ Release Aborted: Test suite failed.");
36
41
  process.exit(1);
37
42
  }
38
43
 
44
+ // 1b. Documentation / version consistency gate (blocking).
45
+ console.log("1b. Verifying documentation is in sync with package.json & test suite...");
46
+ {
47
+ const { checkDocSync, parseTestCounts } = await import("./doc-sync-check.mjs");
48
+ const counts = parseTestCounts(testOutput);
49
+ const docRes = checkDocSync(root, { tests: counts.pass, suites: counts.suites });
50
+ for (const c of docRes.checks) {
51
+ console.log(` ${c.ok ? "✅" : "❌"} ${c.name.padEnd(32)} ${c.detail}`);
52
+ }
53
+ if (!docRes.ok) {
54
+ console.error("\n❌ Release Aborted: documentation has drifted. Fix the ❌ rows above and re-run.");
55
+ process.exit(1);
56
+ }
57
+ console.log(" ✅ Documentation is in sync.\n");
58
+ }
59
+
39
60
  // 2. Extract release notes from CHANGELOG.md
40
61
  console.log(`2. Extracting release notes for ${tagName} from CHANGELOG.md...`);
41
62
  let notes = "";
package/src/config.mjs CHANGED
@@ -68,6 +68,41 @@ export function normalizePath(p) {
68
68
  return p.split(sep).join("/").replace(/\\/g, "/");
69
69
  }
70
70
 
71
+ /**
72
+ * Reduces a repo-relative path to a canonical form for pattern matching:
73
+ * separators normalised, duplicate slashes collapsed, `.` segments dropped,
74
+ * `..` segments resolved, and any leading `./` or trailing `/` removed.
75
+ *
76
+ * Purely lexical — it never touches the filesystem, because the paths being
77
+ * matched may not exist locally (they can come from a diff or a task envelope).
78
+ * Leading `..` segments that would escape the repo root are preserved so the
79
+ * caller can still recognise and reject them.
80
+ *
81
+ * @param {string} p
82
+ * @returns {string}
83
+ */
84
+ export function canonicalizePath(p) {
85
+ const normalized = normalizePath(p).replace(/\/+/g, "/");
86
+ if (!normalized) return "";
87
+
88
+ const isAbsolutePosix = normalized.startsWith("/");
89
+ const out = [];
90
+ for (const segment of normalized.split("/")) {
91
+ if (segment === "" || segment === ".") continue;
92
+ if (segment === "..") {
93
+ if (out.length > 0 && out[out.length - 1] !== "..") {
94
+ out.pop();
95
+ } else if (!isAbsolutePosix) {
96
+ out.push("..");
97
+ }
98
+ continue;
99
+ }
100
+ out.push(segment);
101
+ }
102
+
103
+ return (isAbsolutePosix ? "/" : "") + out.join("/");
104
+ }
105
+
71
106
  export function resolveRoot(cwd = process.cwd()) {
72
107
  try {
73
108
  return execSync("git rev-parse --show-toplevel", { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
package/src/router.mjs CHANGED
@@ -73,7 +73,12 @@ const SENSITIVE_PATH_PATTERNS = [
73
73
  ];
74
74
 
75
75
  function collectReferencedPaths(task) {
76
- const fromPrompt = extractPathTokens(task.prompt || "");
76
+ // extractPathTokens only recognises "/" as a separator, but a Windows author
77
+ // naturally writes "src\auth\session.mjs" in a prompt. Without this the
78
+ // sensitive-path guard below never sees the path and the task can be routed
79
+ // to the cheap tier. targetFiles are separately normalised by normalizePath.
80
+ const promptText = String(task.prompt || "").replace(/\\/g, "/");
81
+ const fromPrompt = extractPathTokens(promptText);
77
82
  const explicit = Array.isArray(task.targetFiles)
78
83
  ? task.targetFiles
79
84
  : Array.isArray(task.referenced_paths)
package/src/security.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  import { openSync, 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
- import { normalizePath } from "./config.mjs";
4
+ import { canonicalizePath } from "./config.mjs";
5
5
  import { detectCrossPackageBoundaryViolations } from "./stack-detector.mjs";
6
6
 
7
7
  export const HIGH_CONFIDENCE_PATTERNS = [
@@ -165,12 +165,28 @@ export function anonymizePii(text) {
165
165
  return sanitized;
166
166
  }
167
167
 
168
- export function matchesGlob(filePath, globPattern) {
168
+ /**
169
+ * Glob matcher.
170
+ *
171
+ * `caseInsensitive` exists because the same repository is checked out on
172
+ * Linux, macOS and Windows. On APFS and NTFS, `.GitHub/` and `.github/` are
173
+ * the *same directory*, but git records whichever case was committed — so a
174
+ * case-sensitive deny rule can be walked straight past on two of the three
175
+ * target platforms. Deny and protect matching therefore folds case; allow
176
+ * matching deliberately does not, so that a case mismatch fails closed
177
+ * (unmatched by allow = violation) rather than opening a hole.
178
+ *
179
+ * @param {string} filePath
180
+ * @param {string} globPattern
181
+ * @param {{ caseInsensitive?: boolean }} [opts]
182
+ */
183
+ export function matchesGlob(filePath, globPattern, opts = {}) {
169
184
  if (!filePath || !globPattern) return false;
170
- const file = normalizePath(filePath);
171
- const pattern = normalizePath(globPattern);
185
+ const file = canonicalizePath(filePath);
186
+ const pattern = canonicalizePath(globPattern);
187
+ const flags = opts.caseInsensitive ? "i" : "";
172
188
 
173
- if (file === pattern) return true;
189
+ if (opts.caseInsensitive ? file.toLowerCase() === pattern.toLowerCase() : file === pattern) return true;
174
190
 
175
191
  const parts = pattern.split("/");
176
192
  const regexParts = parts.map((part) => {
@@ -188,16 +204,16 @@ export function matchesGlob(filePath, globPattern) {
188
204
  .replace(/___GLOBSTAR___/g, ".*");
189
205
 
190
206
  try {
191
- return new RegExp(`^${regexStr}$`).test(file);
207
+ return new RegExp(`^${regexStr}$`, flags).test(file);
192
208
  } catch (_) {
193
209
  return false;
194
210
  }
195
211
  }
196
212
 
197
213
  export function isForbiddenPath(filePath, config = {}) {
198
- const normFile = normalizePath(filePath);
214
+ const normFile = canonicalizePath(filePath);
199
215
  const forbidden = config.scope?.deny || config.forbidden_paths || [];
200
- return forbidden.some((pattern) => matchesGlob(normFile, pattern));
216
+ return forbidden.some((pattern) => matchesGlob(normFile, pattern, { caseInsensitive: true }));
201
217
  }
202
218
 
203
219
  export function checkScope(files = [], scope = {}, opts = {}) {
@@ -207,14 +223,27 @@ export function checkScope(files = [], scope = {}, opts = {}) {
207
223
  const protect = scope.protect || [];
208
224
 
209
225
  for (const rawFile of files) {
210
- const file = normalizePath(rawFile);
226
+ // Canonicalised so that "./x", "a/../x" and "a//x" cannot present the same
227
+ // file under a spelling the deny patterns do not literally match.
228
+ const file = canonicalizePath(rawFile);
229
+
230
+ // A path that climbs out of the repository root can never be legitimate and
231
+ // must not be silently pattern-matched against repo-relative rules.
232
+ if (file === ".." || file.startsWith("../") || file.startsWith("/")) {
233
+ violations.push({ file, reason: "Path escapes the repository root", rule: "deny", pattern: "<traversal>" });
234
+ continue;
235
+ }
211
236
 
212
- const matchedDeny = deny.find((pat) => matchesGlob(file, pat));
237
+ // Deny folds case: on macOS/Windows ".GitHub/" resolves to the same
238
+ // directory as ".github/", so a case-sensitive deny is bypassable there.
239
+ const matchedDeny = deny.find((pat) => matchesGlob(file, pat, { caseInsensitive: true }));
213
240
  if (matchedDeny) {
214
241
  violations.push({ file, reason: `Forbidden path restriction matched pattern "${matchedDeny}"`, rule: "deny", pattern: matchedDeny });
215
242
  continue;
216
243
  }
217
244
 
245
+ // Allow stays case-sensitive on purpose: a case mismatch here yields "not
246
+ // allowed" (a violation), which is the fail-closed direction.
218
247
  if (allow.length > 0) {
219
248
  const isExplicitlyAllowed = allow.some((pat) => matchesGlob(file, pat));
220
249
  if (!isExplicitlyAllowed) {
@@ -224,7 +253,7 @@ export function checkScope(files = [], scope = {}, opts = {}) {
224
253
  }
225
254
 
226
255
  if (!opts.allowProtected) {
227
- const matchedProtect = protect.find((pat) => matchesGlob(file, pat));
256
+ const matchedProtect = protect.find((pat) => matchesGlob(file, pat, { caseInsensitive: true }));
228
257
  if (matchedProtect) {
229
258
  violations.push({ file, reason: `Protected file modification restriction matched pattern "${matchedProtect}"`, rule: "protect", pattern: matchedProtect });
230
259
  }
@@ -332,6 +361,29 @@ export function checkCrossPackageImports(diffOrText = "", root = process.cwd(),
332
361
  };
333
362
  }
334
363
 
364
+ // Zero-width and bidi-control characters. Inserting one mid-token defeats a
365
+ // regex without changing how the value renders, copies, or authenticates.
366
+ const INVISIBLE_CHARS = /[\u00AD\u200B-\u200F\u2028\u2029\u202A-\u202E\u2060-\u2064\uFEFF]/g;
367
+
368
+ // A credential split across a source-level string concatenation is invisible to
369
+ // a line-oriented scanner. This is not only an evasion technique — formatters
370
+ // wrap long string literals exactly this way, so it also happens by accident.
371
+ const STRING_CONCAT_JOIN = /(["'`])\s*\+\s*(["'`])/g;
372
+
373
+ /**
374
+ * Produces the variants of the added-line text that secret patterns are run
375
+ * against: as-written, with invisible characters stripped, and with
376
+ * source-level string concatenation collapsed.
377
+ * @param {string} addedLines
378
+ * @returns {string[]}
379
+ */
380
+ function secretScanVariants(addedLines) {
381
+ const stripped = addedLines.replace(INVISIBLE_CHARS, "");
382
+ // Collapse `"AAA" +\n "BBB"` into `"AAABBB"` before matching.
383
+ const dejoined = stripped.replace(/\s*\n\s*/g, " ").replace(STRING_CONCAT_JOIN, "");
384
+ return [...new Set([addedLines, stripped, dejoined])];
385
+ }
386
+
335
387
  export function scanDiff(diffTextStr = "", options = {}) {
336
388
  if (!diffTextStr) return { ok: true, findings: [] };
337
389
  const addedLines = diffTextStr
@@ -340,8 +392,9 @@ export function scanDiff(diffTextStr = "", options = {}) {
340
392
  .map((line) => line.slice(1))
341
393
  .join("\n");
342
394
 
343
- const hasHigh = hasHighConfidenceSecret(addedLines);
344
- const hasLow = !hasHigh && hasLowConfidenceSecret(addedLines);
395
+ const variants = secretScanVariants(addedLines);
396
+ const hasHigh = variants.some((v) => hasHighConfidenceSecret(v));
397
+ const hasLow = !hasHigh && variants.some((v) => hasLowConfidenceSecret(v));
345
398
  const findings = [];
346
399
 
347
400
  if (hasHigh) {