jules-orchestrator-kit 0.63.0 → 0.65.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 +6 -3
- package/bin/agentctl.mjs +5 -0
- package/package.json +3 -2
- package/scripts/guard-reach-check.mjs +68 -6
- package/scripts/package-integrity-check.mjs +251 -0
- package/scripts/release.mjs +16 -0
- package/src/assertions.mjs +16 -0
- package/src/engine.mjs +49 -1
- package/src/guard-policy.mjs +512 -0
- package/src/ops/test-collection.mjs +26 -9
- package/src/security.mjs +194 -14
- package/src/stack-detector.mjs +50 -0
- package/src/wizard-init.mjs +61 -11
package/README.md
CHANGED
|
@@ -49,12 +49,15 @@
|
|
|
49
49
|
<a id="quickstart"></a>
|
|
50
50
|
## Quickstart
|
|
51
51
|
|
|
52
|
-
Get running in any repository in 3 commands
|
|
52
|
+
Get running in any repository in 3 commands. `init` asks seven questions and
|
|
53
|
+
fills in a sensible answer for each; `--yes` accepts all of them, detects the
|
|
54
|
+
stack, and probes the test command it picked before writing it down.
|
|
53
55
|
|
|
54
56
|
```bash
|
|
55
57
|
# 1. Scaffold config, AGENTS.md, role prompts and guardrails
|
|
56
58
|
# (auto-detects Python, Rust, Go, Node, PHP, etc.)
|
|
57
|
-
|
|
59
|
+
# Drop --yes to choose provider, plan, profile and workflows yourself.
|
|
60
|
+
npx jules-orchestrator-kit init --yes
|
|
58
61
|
```
|
|
59
62
|
|
|
60
63
|
```bash
|
|
@@ -204,7 +207,7 @@ To maximize PR merge rates, dispatch tasks according to deterministic boundaries
|
|
|
204
207
|
* **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
208
|
* **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
209
|
* **Terminal UI & Diagnostic Matrix (`agentctl doctor`):** Interactive terminal dashboard, task sidecar manager, and automated transactional self-repair.
|
|
207
|
-
* **Verified Test Suite:** Tested with **
|
|
210
|
+
* **Verified Test Suite:** Tested with **1109 unit tests across 154 suites passing in < 15.0s**.
|
|
208
211
|
|
|
209
212
|
<br/>
|
|
210
213
|
|
package/bin/agentctl.mjs
CHANGED
|
@@ -429,6 +429,11 @@ async function main() {
|
|
|
429
429
|
if (p.violations) {
|
|
430
430
|
p.violations.forEach((v) => console.log(` - Violation: ${v.file} (Rule: ${v.rule})`));
|
|
431
431
|
}
|
|
432
|
+
if (p.setup) {
|
|
433
|
+
console.log(` - Setup: accepted ${p.setup.length} gate scaffold file(s) this repository did not have yet`);
|
|
434
|
+
p.setup.forEach((f) => console.log(` ${f}`));
|
|
435
|
+
console.log(` Commit them to the base branch and the full protect rules apply from then on.`);
|
|
436
|
+
}
|
|
432
437
|
if (p.findings) {
|
|
433
438
|
p.findings.forEach((f) => console.log(` - [${f.severity}] ${f.type}: ${f.description}`));
|
|
434
439
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "jules-orchestrator-kit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.65.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",
|
|
@@ -56,7 +56,8 @@
|
|
|
56
56
|
"jules:doc-sync": "node scripts/doc-sync-check.mjs",
|
|
57
57
|
"release": "node scripts/release.mjs",
|
|
58
58
|
"guard-reach": "node scripts/guard-reach-check.mjs",
|
|
59
|
-
"lint": "eslint ."
|
|
59
|
+
"lint": "eslint .",
|
|
60
|
+
"package-integrity": "node scripts/package-integrity-check.mjs"
|
|
60
61
|
},
|
|
61
62
|
"engines": {
|
|
62
63
|
"node": ">=20.0.0"
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
*
|
|
28
28
|
* Three steps, all in-process, no dependencies, well under a second:
|
|
29
29
|
*
|
|
30
|
-
* 1. POLICY — the hand-written witness table in
|
|
30
|
+
* 1. POLICY — the hand-written witness table in src/guard-policy.mjs
|
|
31
31
|
* must hold. It is derived from what the tool advertises, never
|
|
32
32
|
* from the regexes that implement it.
|
|
33
33
|
* 2. CANARIES — every known-bad input must produce the finding it names. A
|
|
@@ -51,15 +51,26 @@ import {
|
|
|
51
51
|
TAMPER_CANARIES,
|
|
52
52
|
PREDICATE_MUTANTS,
|
|
53
53
|
EMPTY_RUN_CANARIES,
|
|
54
|
+
COUNTED_RUN_CANARIES,
|
|
54
55
|
SCOPE_CANARIES,
|
|
55
|
-
|
|
56
|
+
INNOCENT_EDITS,
|
|
57
|
+
UNREADABLE_DIALECTS,
|
|
58
|
+
} from "../src/guard-policy.mjs";
|
|
56
59
|
|
|
57
|
-
/**
|
|
60
|
+
/**
|
|
61
|
+
* Build a unified diff for one canary.
|
|
62
|
+
*
|
|
63
|
+
* The context line carries the comment syntax of the file's own language.
|
|
64
|
+
* A `//` line in a `.py` fixture is not a comment, and a fixture that lies
|
|
65
|
+
* about the language under test measures the fixture rather than the guard —
|
|
66
|
+
* which is how two false results were once read as two defects.
|
|
67
|
+
*/
|
|
58
68
|
function canaryDiff(c) {
|
|
59
|
-
const
|
|
69
|
+
const ctx = c.context || "// context";
|
|
70
|
+
const lines = [`--- a/${c.file}`, `+++ b/${c.file}`, "@@ -1,20 +1,20 @@", ` ${ctx}`];
|
|
60
71
|
for (const l of c.removed) lines.push(`-${l}`);
|
|
61
72
|
for (const l of c.added) lines.push(`+${l}`);
|
|
62
|
-
lines.push(
|
|
73
|
+
lines.push(` ${ctx}`);
|
|
63
74
|
return lines.join("\n");
|
|
64
75
|
}
|
|
65
76
|
|
|
@@ -95,6 +106,17 @@ const add = (name, ok, detail) => {
|
|
|
95
106
|
|
|
96
107
|
{
|
|
97
108
|
const missed = EMPTY_RUN_CANARIES.filter((c) => parseCollectedTests(c.output, "").count !== 0);
|
|
109
|
+
const undercounted = COUNTED_RUN_CANARIES.filter((c) => {
|
|
110
|
+
const n = parseCollectedTests(c.output, "").count;
|
|
111
|
+
return n === null || n < c.atLeast;
|
|
112
|
+
});
|
|
113
|
+
add(
|
|
114
|
+
"policy: a stated count is never read as empty",
|
|
115
|
+
undercounted.length === 0,
|
|
116
|
+
undercounted.length
|
|
117
|
+
? undercounted.map((c) => `${c.id} (${c.why})`).join("; ")
|
|
118
|
+
: `${COUNTED_RUN_CANARIES.length} healthy runs counted, not rejected`
|
|
119
|
+
);
|
|
98
120
|
add(
|
|
99
121
|
"policy: empty-run detection",
|
|
100
122
|
missed.length === 0,
|
|
@@ -107,6 +129,7 @@ const canaryResults = new Map();
|
|
|
107
129
|
{
|
|
108
130
|
const silent = [];
|
|
109
131
|
const noDenominator = [];
|
|
132
|
+
const noAssertions = [];
|
|
110
133
|
for (const c of TAMPER_CANARIES) {
|
|
111
134
|
const res = checkTestTampering(canaryDiff(c));
|
|
112
135
|
const hit = (res.violations || []).some((v) => v.type === c.expect);
|
|
@@ -114,12 +137,51 @@ const canaryResults = new Map();
|
|
|
114
137
|
if (!hit) silent.push(`${c.id} expected ${c.expect}, got ${JSON.stringify((res.violations || []).map((v) => v.type))}`);
|
|
115
138
|
// A finding with no denominator is the shape this script exists to reject.
|
|
116
139
|
if (hit && !(res.inputsSeen > 0)) noDenominator.push(c.id);
|
|
140
|
+
// Counting lines was not enough: a JUnit diff reported one input examined
|
|
141
|
+
// and a clean PASS while every assertion in it went unrecognised. A rule
|
|
142
|
+
// about assertions has to say how many assertions it actually read.
|
|
143
|
+
if (hit && c.expect !== "TEST_SKIP_INJECTION" && !(res.assertionsSeen > 0)) {
|
|
144
|
+
noAssertions.push(`${c.id} (${res.assertionsSeen} assertions parsed)`);
|
|
145
|
+
}
|
|
117
146
|
}
|
|
118
147
|
add("canaries: every tamper rule still fires", silent.length === 0, silent.length ? silent.join("; ") : `${TAMPER_CANARIES.length} canaries red as required`);
|
|
119
148
|
add("canaries: every finding carries a denominator", noDenominator.length === 0, noDenominator.length ? noDenominator.join(", ") : "inputsSeen > 0 on every hit");
|
|
149
|
+
add("canaries: assertion rules parsed an assertion", noAssertions.length === 0, noAssertions.length ? noAssertions.join(", ") : "assertionsSeen > 0 on every assertion finding");
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// --- 3. The opposite failure: flagging what is innocent ---------------------
|
|
153
|
+
{
|
|
154
|
+
const noisy = [];
|
|
155
|
+
for (const e of INNOCENT_EDITS) {
|
|
156
|
+
const res = checkTestTampering(canaryDiff(e));
|
|
157
|
+
const types = (res.violations || []).map((v) => v.type);
|
|
158
|
+
if (types.length > 0) noisy.push(`${e.id} → ${JSON.stringify(types)} (${e.why})`);
|
|
159
|
+
}
|
|
160
|
+
add(
|
|
161
|
+
"innocent edits stay silent",
|
|
162
|
+
noisy.length === 0,
|
|
163
|
+
noisy.length
|
|
164
|
+
? noisy.join("; ")
|
|
165
|
+
: `${INNOCENT_EDITS.length} ordinary edits produce no finding`
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
{
|
|
170
|
+
const quiet = [];
|
|
171
|
+
for (const d of UNREADABLE_DIALECTS) {
|
|
172
|
+
const res = checkTestTampering(canaryDiff(d));
|
|
173
|
+
if (res.status !== "UNREADABLE") quiet.push(`${d.id} → ${res.status} (${d.why})`);
|
|
174
|
+
}
|
|
175
|
+
add(
|
|
176
|
+
"an unparsable dialect says so",
|
|
177
|
+
quiet.length === 0,
|
|
178
|
+
quiet.length
|
|
179
|
+
? `${quiet.join("; ")} — coverage ending is fine, ending silently is not`
|
|
180
|
+
: `${UNREADABLE_DIALECTS.length} unsupported dialects reported, not passed`
|
|
181
|
+
);
|
|
120
182
|
}
|
|
121
183
|
|
|
122
|
-
// ---
|
|
184
|
+
// --- 4. Predicate mutants ---------------------------------------------------
|
|
123
185
|
{
|
|
124
186
|
const survivors = [];
|
|
125
187
|
for (const mutant of PREDICATE_MUTANTS) {
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Does the package we publish actually work?
|
|
5
|
+
*
|
|
6
|
+
* Every test in this repository runs against the source tree, where every
|
|
7
|
+
* file is present by definition. What users install is a tarball built from
|
|
8
|
+
* the `files` list in package.json, and nothing checked that the two agreed.
|
|
9
|
+
*
|
|
10
|
+
* They did not. v0.63.0 shipped `scripts/guard-reach-check.mjs` — the check
|
|
11
|
+
* whose entire purpose is to prove no guard has silently gone missing —
|
|
12
|
+
* while leaving behind the policy contract it imports. Unpacked and run, it
|
|
13
|
+
* threw ERR_MODULE_NOT_FOUND. The CLI was fine, 1015 tests were green, nine
|
|
14
|
+
* CI cells passed, and the published artefact still had a hole in it,
|
|
15
|
+
* because every one of those signals was measured somewhere the file existed.
|
|
16
|
+
*
|
|
17
|
+
* This asks the packer what it would ship, and then resolves the import
|
|
18
|
+
* graph inside that answer.
|
|
19
|
+
*
|
|
20
|
+
* Usage: node scripts/package-integrity-check.mjs [--json]
|
|
21
|
+
* Exit codes: 0 = the tarball is self-contained, 1 = it is not.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { execFileSync } from "node:child_process";
|
|
25
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
26
|
+
import { resolve, dirname, relative, sep } from "node:path";
|
|
27
|
+
import { fileURLToPath } from "node:url";
|
|
28
|
+
|
|
29
|
+
import { IMPORT_EXTRACTION_CASES } from "../src/guard-policy.mjs";
|
|
30
|
+
|
|
31
|
+
const root = fileURLToPath(new URL("..", import.meta.url));
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Mark every character that sits inside a string literal or a comment.
|
|
35
|
+
*
|
|
36
|
+
* Without this, an import quoted *inside* a fixture string reads as an
|
|
37
|
+
* import of the file itself — this check's first run reported six broken
|
|
38
|
+
* imports in the policy contract, all of them example text. A guard that
|
|
39
|
+
* cries wolf about its own fixtures gets switched off in a week.
|
|
40
|
+
*
|
|
41
|
+
* Approximate on purpose, and biased on purpose: a regex literal holding a
|
|
42
|
+
* quote can open a phantom string, so the counts reported below exist to
|
|
43
|
+
* make a mask that swallowed the file visible rather than silent.
|
|
44
|
+
*/
|
|
45
|
+
function stringMask(src) {
|
|
46
|
+
const mask = new Uint8Array(src.length);
|
|
47
|
+
let i = 0;
|
|
48
|
+
let quote = null;
|
|
49
|
+
let comment = null;
|
|
50
|
+
while (i < src.length) {
|
|
51
|
+
const c = src[i];
|
|
52
|
+
const d = src[i + 1];
|
|
53
|
+
if (comment === "line") {
|
|
54
|
+
if (c === "\n") comment = null;
|
|
55
|
+
else mask[i] = 1;
|
|
56
|
+
i++;
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
if (comment === "block") {
|
|
60
|
+
mask[i] = 1;
|
|
61
|
+
if (c === "*" && d === "/") {
|
|
62
|
+
mask[i + 1] = 1;
|
|
63
|
+
comment = null;
|
|
64
|
+
i += 2;
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
i++;
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
if (quote !== null) {
|
|
71
|
+
mask[i] = 1;
|
|
72
|
+
if (c === "\\") {
|
|
73
|
+
if (i + 1 < src.length) mask[i + 1] = 1;
|
|
74
|
+
i += 2;
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if (c === quote) quote = null;
|
|
78
|
+
i++;
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
if (c === "/" && d === "/") { comment = "line"; mask[i] = 1; i++; continue; }
|
|
82
|
+
if (c === "/" && d === "*") { comment = "block"; mask[i] = 1; i++; continue; }
|
|
83
|
+
// A regex literal holding a quote — `/["']/` — opens a string that never
|
|
84
|
+
// closes, and everything after it is misread. This file's own subject
|
|
85
|
+
// matter is regexes full of quote characters, so the mask desynchronised
|
|
86
|
+
// and a `require("./calc")` written inside a comment was reported as a
|
|
87
|
+
// missing module. Deciding regex-versus-division on the previous token is
|
|
88
|
+
// the same approximation the diff scanner already makes.
|
|
89
|
+
if (c === "/") {
|
|
90
|
+
let k = i - 1;
|
|
91
|
+
while (k >= 0 && /\s/.test(src[k])) k--;
|
|
92
|
+
const prev = k >= 0 ? src[k] : "";
|
|
93
|
+
if (prev === "" || "(,=:[!&|?{};+-*%~^<>".includes(prev)) {
|
|
94
|
+
mask[i] = 1;
|
|
95
|
+
let j = i + 1;
|
|
96
|
+
let cls = false;
|
|
97
|
+
while (j < src.length) {
|
|
98
|
+
const e = src[j];
|
|
99
|
+
mask[j] = 1;
|
|
100
|
+
if (e === "\\") { if (j + 1 < src.length) mask[j + 1] = 1; j += 2; continue; }
|
|
101
|
+
if (e === "[") cls = true;
|
|
102
|
+
else if (e === "]") cls = false;
|
|
103
|
+
else if (e === "/" && !cls) { j++; break; }
|
|
104
|
+
else if (e === "\n") break;
|
|
105
|
+
j++;
|
|
106
|
+
}
|
|
107
|
+
i = j;
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
if (c === '"' || c === "'" || c === "`") { quote = c; mask[i] = 1; i++; continue; }
|
|
112
|
+
i++;
|
|
113
|
+
}
|
|
114
|
+
return mask;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Every module specifier in a source file.
|
|
119
|
+
*
|
|
120
|
+
* Deliberately newline-tolerant. A matcher bounded by `[^;\n]*?` cannot see
|
|
121
|
+
* a multi-line named import, which is exactly the import that was missing
|
|
122
|
+
* from the tarball, so the first version of this check certified the broken
|
|
123
|
+
* package as sound. The extraction cases in the policy contract exist to
|
|
124
|
+
* keep that from being a private mistake twice.
|
|
125
|
+
*/
|
|
126
|
+
export function extractSpecifiers(src, { skipQuoted = true } = {}) {
|
|
127
|
+
const mask = skipQuoted ? stringMask(src) : null;
|
|
128
|
+
const found = new Set();
|
|
129
|
+
const collect = (re) => {
|
|
130
|
+
for (const m of src.matchAll(re)) {
|
|
131
|
+
// What decides is the token immediately before the specifier — the
|
|
132
|
+
// `from`, or the `(` of a call. In a real import it is code; in a
|
|
133
|
+
// fixture it is the middle of a string. Testing the *keyword* instead
|
|
134
|
+
// was not enough: `export const CASES = [` at the top of a file
|
|
135
|
+
// matched lazily forward into the first `from "…"` inside an example,
|
|
136
|
+
// so a genuine keyword lent its authority to quoted text.
|
|
137
|
+
if (mask) {
|
|
138
|
+
let j = m.indices[1][0] - 2;
|
|
139
|
+
while (j >= 0 && /\s/.test(src[j])) j--;
|
|
140
|
+
if (j >= 0 && mask[j]) continue;
|
|
141
|
+
}
|
|
142
|
+
found.add(m[1]);
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
collect(/(?:^|[\s;}])(?:import|export)\s[\s\S]{0,500}?from\s*["']([^"']+)["']/gd);
|
|
146
|
+
collect(/(?:^|[\s;}])import\s*["']([^"']+)["']/gd);
|
|
147
|
+
collect(/\bimport\s*\(\s*["']([^"']+)["']/gd);
|
|
148
|
+
collect(/\brequire\s*\(\s*["']([^"']+)["']/gd);
|
|
149
|
+
return [...found];
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Run every integrity check and return the result.
|
|
154
|
+
*
|
|
155
|
+
* Exported as a function rather than run on import: a module that checks the
|
|
156
|
+
* package must not exit the process of anything that merely imports it.
|
|
157
|
+
*/
|
|
158
|
+
export function checkPackageIntegrity() {
|
|
159
|
+
const failures = [];
|
|
160
|
+
const checks = [];
|
|
161
|
+
const add = (name, ok, detail) => {
|
|
162
|
+
checks.push({ name, ok, detail });
|
|
163
|
+
if (!ok) failures.push(`${name}: ${detail}`);
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
// --- 1. The extractor must be able to see what it claims to look for -------
|
|
167
|
+
{
|
|
168
|
+
const wrong = [];
|
|
169
|
+
for (const c of IMPORT_EXTRACTION_CASES) {
|
|
170
|
+
const got = extractSpecifiers(c.src).sort();
|
|
171
|
+
const want = [...c.expect].sort();
|
|
172
|
+
if (JSON.stringify(got) !== JSON.stringify(want)) {
|
|
173
|
+
wrong.push(`${c.id}: found ${JSON.stringify(got)}, contract says ${JSON.stringify(want)}`);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
add("extractor: every import form is visible", wrong.length === 0, wrong.length ? wrong.join("; ") : `${IMPORT_EXTRACTION_CASES.length} forms found`);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// --- 2. Ask the packer what it would actually ship -------------------------
|
|
180
|
+
let shipped = new Set();
|
|
181
|
+
try {
|
|
182
|
+
const out = execFileSync("npm", ["pack", "--dry-run", "--json"], { cwd: root, encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] });
|
|
183
|
+
shipped = new Set(JSON.parse(out)[0].files.map((f) => f.path.split(/[\\/]/).join("/")));
|
|
184
|
+
} catch (err) {
|
|
185
|
+
add("packer: `npm pack --dry-run` answers", false, err.message);
|
|
186
|
+
}
|
|
187
|
+
add("packer: the tarball is not empty", shipped.size > 0, `${shipped.size} files`);
|
|
188
|
+
|
|
189
|
+
// --- 3. Resolve the import graph inside the tarball ------------------------
|
|
190
|
+
{
|
|
191
|
+
const broken = [];
|
|
192
|
+
let resolved = 0;
|
|
193
|
+
let scanned = 0;
|
|
194
|
+
|
|
195
|
+
for (const file of shipped) {
|
|
196
|
+
if (!/\.(mjs|cjs|js)$/.test(file)) continue;
|
|
197
|
+
let src;
|
|
198
|
+
try {
|
|
199
|
+
src = readFileSync(resolve(root, file), "utf-8");
|
|
200
|
+
} catch {
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
scanned++;
|
|
204
|
+
for (const spec of extractSpecifiers(src)) {
|
|
205
|
+
if (!spec.startsWith(".")) continue; // bare and node: specifiers are not ours to resolve
|
|
206
|
+
resolved++;
|
|
207
|
+
const target = relative(root, resolve(dirname(resolve(root, file)), spec)).split(sep).join("/");
|
|
208
|
+
if (!shipped.has(target)) {
|
|
209
|
+
broken.push(`${file} imports ${spec} — ${target} is not in the tarball (on disk: ${existsSync(resolve(root, target)) ? "yes" : "no"})`);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
add(
|
|
215
|
+
"tarball: every relative import resolves",
|
|
216
|
+
broken.length === 0,
|
|
217
|
+
broken.length ? broken.join("; ") : `${resolved} relative imports across ${scanned} shipped modules`
|
|
218
|
+
);
|
|
219
|
+
// A resolver that resolved nothing would report the same clean line.
|
|
220
|
+
add("tarball: the graph was actually walked", resolved > 0 && scanned > 0, `${scanned} modules, ${resolved} relative imports`);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// --- 4. Every advertised entry point has to be in the box ------------------
|
|
224
|
+
{
|
|
225
|
+
const pkg = JSON.parse(readFileSync(resolve(root, "package.json"), "utf-8"));
|
|
226
|
+
const entries = [...Object.values(pkg.bin || {}), pkg.main, pkg.module].filter(Boolean);
|
|
227
|
+
const missing = entries.map((e) => e.replace(/^\.\//, "")).filter((e) => !shipped.has(e));
|
|
228
|
+
add("entry points: every bin and main ships", missing.length === 0, missing.length ? missing.join(", ") : `${entries.length} entry points present`);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return { ok: failures.length === 0, checks, failures };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const isMain = process.argv[1] && process.argv[1].endsWith("package-integrity-check.mjs");
|
|
235
|
+
if (isMain) {
|
|
236
|
+
const { ok, checks: rows, failures: bad } = checkPackageIntegrity();
|
|
237
|
+
if (process.argv.includes("--json")) {
|
|
238
|
+
console.log(JSON.stringify({ ok, checks: rows }, null, 2));
|
|
239
|
+
} else {
|
|
240
|
+
console.log("\n📦 Package Integrity Check (what we actually publish)");
|
|
241
|
+
console.log("-------------------------------------------------------");
|
|
242
|
+
for (const c of rows) console.log(` ${c.ok ? "✅" : "❌"} ${c.name.padEnd(46)} ${c.detail}`);
|
|
243
|
+
console.log("-------------------------------------------------------");
|
|
244
|
+
console.log(
|
|
245
|
+
ok
|
|
246
|
+
? "✅ The published package is self-contained.\n"
|
|
247
|
+
: `\n❌ ${bad.length} problem(s). The tarball is not what the source tree looks like.\n`
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
process.exit(ok ? 0 : 1);
|
|
251
|
+
}
|
package/scripts/release.mjs
CHANGED
|
@@ -58,6 +58,22 @@ try {
|
|
|
58
58
|
process.exit(1);
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
+
// Step 1a proved the guards work here. Here is not where users run them.
|
|
62
|
+
//
|
|
63
|
+
// Every signal so far was measured in the source tree, where every file
|
|
64
|
+
// exists by construction. What ships is a tarball built from the `files`
|
|
65
|
+
// list, and v0.63.0 published the guard-reach check without the policy
|
|
66
|
+
// contract it imports — green suite, green matrix, green release, and a
|
|
67
|
+
// module that threw ERR_MODULE_NOT_FOUND the moment anyone installed it.
|
|
68
|
+
console.log("1a-2. Verifying the package we would publish is self-contained...");
|
|
69
|
+
try {
|
|
70
|
+
execSync("node scripts/package-integrity-check.mjs", { cwd: root, stdio: "inherit" });
|
|
71
|
+
console.log("");
|
|
72
|
+
} catch (_) {
|
|
73
|
+
console.error("\n❌ Release Aborted: the tarball is not what the source tree looks like.");
|
|
74
|
+
process.exit(1);
|
|
75
|
+
}
|
|
76
|
+
|
|
61
77
|
// 1b. Documentation / version consistency gate (blocking).
|
|
62
78
|
console.log("1b. Verifying documentation is in sync with package.json & test suite...");
|
|
63
79
|
{
|
package/src/assertions.mjs
CHANGED
|
@@ -561,12 +561,28 @@ export function assertTestIntegrity(config = {}, root = process.cwd()) {
|
|
|
561
561
|
const res = checkTestTampering(diffStr, config);
|
|
562
562
|
const diagnostics = (res.violations || []).map((v) => v.reason);
|
|
563
563
|
|
|
564
|
+
// A clean result from a guard that could not read the dialect is not a
|
|
565
|
+
// clean result, and it must not be reported as one. This does not fail the
|
|
566
|
+
// check — an unlisted assertion library is the user's normal, not their
|
|
567
|
+
// fault — but they get to know the guard is not covering them.
|
|
568
|
+
for (const u of res.unreadable || []) {
|
|
569
|
+
diagnostics.push(
|
|
570
|
+
`Test Tamper Guard: ${u.count} assertion-shaped line(s) in ${u.file} matched no known dialect, ` +
|
|
571
|
+
`so this file was not checked for tampering (e.g. ${JSON.stringify(u.samples[0])}). ` +
|
|
572
|
+
`The guard reports what it could not read rather than passing silently.`
|
|
573
|
+
);
|
|
574
|
+
}
|
|
575
|
+
|
|
564
576
|
return {
|
|
565
577
|
ok: res.ok,
|
|
566
578
|
violations: res.violations || [],
|
|
567
579
|
diagnostics,
|
|
568
580
|
metrics: {
|
|
569
581
|
violationCount: res.violations?.length || 0,
|
|
582
|
+
assertionsSeen: res.assertionsSeen ?? 0,
|
|
583
|
+
filesSeen: res.filesSeen ?? 0,
|
|
584
|
+
unreadableFiles: (res.unreadable || []).length,
|
|
585
|
+
status: res.status,
|
|
570
586
|
},
|
|
571
587
|
};
|
|
572
588
|
}
|
package/src/engine.mjs
CHANGED
|
@@ -204,6 +204,16 @@ export async function gate(opts = {}) {
|
|
|
204
204
|
// Read from the base commit like every other trusted field: an
|
|
205
205
|
// uncommitted `required: false` must not be able to switch the gate off.
|
|
206
206
|
required: parsed.verify?.required !== undefined ? parsed.verify.required !== false : config.verify.required !== false,
|
|
207
|
+
// The floor the collection check applies. Omitting it here meant
|
|
208
|
+
// `verify.minTests` was silently dropped and always defaulted to 1
|
|
209
|
+
// — while the failure message told the operator to set exactly
|
|
210
|
+
// that. A remediation hint that does nothing is worse than none.
|
|
211
|
+
minTests:
|
|
212
|
+
parsed.verify?.minTests !== undefined
|
|
213
|
+
? parsed.verify.minTests
|
|
214
|
+
: parsed.verify?.min_tests !== undefined
|
|
215
|
+
? parsed.verify.min_tests
|
|
216
|
+
: config.verify.minTests,
|
|
207
217
|
scope: parsed.verify?.scope || config.verify.scope || "global",
|
|
208
218
|
timeoutMs: parsed.verify?.timeoutMs || parsed.verify?.timeout_ms || config.verify.timeoutMs,
|
|
209
219
|
};
|
|
@@ -249,7 +259,45 @@ export async function gate(opts = {}) {
|
|
|
249
259
|
violation.file = link;
|
|
250
260
|
}
|
|
251
261
|
|
|
252
|
-
|
|
262
|
+
// Bootstrap: the files that bring a repository under the gate are not agent
|
|
263
|
+
// edits to the gate.
|
|
264
|
+
//
|
|
265
|
+
// `init` writes `.agent/**` and then tells the user to commit it. Doing
|
|
266
|
+
// exactly that produced Exit 3 on the very first run, because the base
|
|
267
|
+
// branch does not have the commit yet and every scaffolded path matches
|
|
268
|
+
// BUILTIN_PROTECT or BUILTIN_DENY. The advice printed alongside it was
|
|
269
|
+
// `--allow-protected` — so a newcomer's first lesson was how to switch the
|
|
270
|
+
// scope guard off. A gate that refuses its own installation is not strict,
|
|
271
|
+
// it is broken.
|
|
272
|
+
//
|
|
273
|
+
// Narrow on purpose, and only where it cannot weaken anything: the base
|
|
274
|
+
// commit must have no gate config at all — in which case `trustedScope` is
|
|
275
|
+
// already built-ins only and nothing in the added files is trusted — and
|
|
276
|
+
// every violating path must be scaffold that the base does not have. A
|
|
277
|
+
// repository already under the gate keeps the full rule, so an agent still
|
|
278
|
+
// cannot touch the policy it is governed by.
|
|
279
|
+
let acceptedScaffold = [];
|
|
280
|
+
if (!scopeResult.ok && !trustedConfigRaw) {
|
|
281
|
+
const violations = scopeResult.violations || [];
|
|
282
|
+
const isScaffold = (f) => typeof f === "string" && f.replace(/\\/g, "/").startsWith(".agent/");
|
|
283
|
+
if (
|
|
284
|
+
violations.length > 0 &&
|
|
285
|
+
violations.every((v) => isScaffold(v.file) && showFromOrigin(root, base, v.file) === null)
|
|
286
|
+
) {
|
|
287
|
+
acceptedScaffold = violations.map((v) => v.file);
|
|
288
|
+
scopeResult.violations = [];
|
|
289
|
+
scopeResult.ok = true;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
phases.push({
|
|
294
|
+
phase: "scope",
|
|
295
|
+
ok: scopeResult.ok,
|
|
296
|
+
violations: scopeResult.violations,
|
|
297
|
+
// Reported, never silent: the operator has to see that the gate accepted
|
|
298
|
+
// files it would otherwise have blocked, and why.
|
|
299
|
+
...(acceptedScaffold.length > 0 ? { setup: acceptedScaffold } : {}),
|
|
300
|
+
});
|
|
253
301
|
appendTelemetry(root, "gate_phase", { phase: "scope", ok: scopeResult.ok });
|
|
254
302
|
if (progressBus && progressToken) {
|
|
255
303
|
progressBus.reportProgress(progressToken, 25, 100, "Phase 1/4: Scope Guard verification complete");
|