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.
- package/JULES_RULES_TEMPLATE.md +2 -1
- package/README.md +5 -5
- package/bin/agentctl.mjs +3 -3
- package/index.mjs +1 -1
- package/package.json +2 -1
- package/scripts/doc-sync-check.mjs +234 -0
- package/scripts/release.mjs +23 -2
- package/src/config.mjs +35 -0
- package/src/router.mjs +6 -1
- package/src/security.mjs +66 -13
package/JULES_RULES_TEMPLATE.md
CHANGED
|
@@ -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
|
-
|
|
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`):**
|
|
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,
|
|
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 **
|
|
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. | **
|
|
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. | **
|
|
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.
|
|
15
|
+
export const VERSION = "0.32.6";
|
|
16
16
|
|
|
17
17
|
export function printHelp() {
|
|
18
18
|
console.log(`
|
|
19
|
-
🚀 agentctl v0.32.
|
|
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.
|
|
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.
|
|
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
|
+
}
|
package/scripts/release.mjs
CHANGED
|
@@ -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: "
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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 =
|
|
171
|
-
const pattern =
|
|
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}
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
344
|
-
const
|
|
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) {
|