jules-orchestrator-kit 0.67.0 → 0.68.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/AGENTS.md +135 -0
- package/CHANGELOG.md +824 -0
- package/README.md +4 -3
- package/ROADMAP_V1.md +202 -0
- package/bin/agentctl.mjs +9 -0
- package/package.json +5 -2
- package/scripts/doc-sync-check.mjs +9 -0
- package/src/engine.mjs +39 -2
- package/src/guard-policy.mjs +45 -0
- package/src/security.mjs +94 -38
- package/src/stack-detector.mjs +46 -1
package/src/security.mjs
CHANGED
|
@@ -1191,6 +1191,15 @@ const SPECIFIC_ASSERTION = new RegExp(
|
|
|
1191
1191
|
"\\bexpect\\s*\\([\\s\\S]{0,240}?\\)\\s*\\.(?:to|not_to|to_not)\\s+(?:eq|eql|equal|be|be_within|match|include|contain_exactly|match_array|have_attributes|raise_error|start_with|end_with)\\b",
|
|
1192
1192
|
// XCTest
|
|
1193
1193
|
"\\bXCTAssert(?:Equal|NotEqual|EqualWithAccuracy|Identical|NotIdentical|GreaterThan|LessThan|GreaterThanOrEqual|LessThanOrEqual|ThrowsError|NoThrow)\\s*\\(",
|
|
1194
|
+
// chai — a dot chain, where RSpec's is a space. `expect(x).to.equal(3)`
|
|
1195
|
+
// never reached the RSpec branch, so swapping it for `.toBeDefined()`
|
|
1196
|
+
// lost no *specific* assertion and the weakening check stayed quiet.
|
|
1197
|
+
"\\bexpect\\s*\\([\\s\\S]{0,240}?\\)\\s*\\.to(?:\\.[a-z]+)*\\.(?:equal|equals|eql|eqls|closeTo|match|include|contain|members|throw|string|lengthOf|above|below|least|most|within)\\s*\\(",
|
|
1198
|
+
// node-tap and its relatives, where the assertion hangs off whatever the
|
|
1199
|
+
// sub-test callback named its argument — `ct` as often as `t`. Bounded to
|
|
1200
|
+
// a short receiver so `results.match(...)` on an ordinary object is not
|
|
1201
|
+
// mistaken for an assertion; a heuristic, and stated as one.
|
|
1202
|
+
"\\b[a-z_$][a-z0-9_$]{0,2}\\.(?:equal|equals|same|strictSame|deepEqual|notEqual|notSame|match|hasStrict|type|throws|rejects)\\s*\\(",
|
|
1194
1203
|
].join("|"),
|
|
1195
1204
|
"i"
|
|
1196
1205
|
);
|
|
@@ -2194,6 +2203,10 @@ export function checkTestTampering(diffOrText = "", options = {}) {
|
|
|
2194
2203
|
{ pattern: /\bXCTSkip(?:If|Unless|IfNot)?\s*\(/, desc: "Injected XCTest skip (XCTSkip())" },
|
|
2195
2204
|
{ pattern: /\b(?:xit|xdescribe|xcontext|xspecify)\b\s*["\x27]/i, desc: "Injected RSpec disabled example (xit)" },
|
|
2196
2205
|
{ pattern: /,\s*skip:\s*(?:true|["\x27])/i, desc: "Injected RSpec skip metadata (skip:)" },
|
|
2206
|
+
// node-tap, node:test and ava pass it as a property of an options object,
|
|
2207
|
+
// so the comma sits before the brace and not before the key.
|
|
2208
|
+
{ pattern: /\{[^}]*\bskip\s*:\s*true/i, desc: "Injected skip option ({ skip: true })" },
|
|
2209
|
+
{ pattern: /\{[^}]*\btodo\s*:\s*true/i, desc: "Injected todo option ({ todo: true })" },
|
|
2197
2210
|
{ pattern: /^\s*(?:skip|pending)\s*(?:["\x27(]|$)/i, desc: "Injected Minitest/RSpec skip statement" },
|
|
2198
2211
|
// Skipping from inside the body, which is how these ecosystems actually
|
|
2199
2212
|
// do it. Only the decorator and annotation forms were covered, so a test
|
|
@@ -2237,7 +2250,7 @@ export function checkTestTampering(diffOrText = "", options = {}) {
|
|
|
2237
2250
|
// indistinguishable from an untouched one. The lookahead keeps prose and
|
|
2238
2251
|
// identifiers — `assertion`, `asserts`, `asserted` — out of the count.
|
|
2239
2252
|
const ASSERTION_PATTERN =
|
|
2240
|
-
/(?:\b(?:assert(?!ion|ing|ed\b|s\b)[a-zA-Z0-9_$]*(?:\.[a-zA-Z0-9_$]+)?|refute[a-zA-Z0-9_$]*|XCTAssert[a-zA-Z0-9_$]*|XCTFail|expect|t\.(?:assert|expect|is|equal|true|false|Errorf|Fatalf)|require\.[a-zA-Z0-9_$]+)\b|assert!|assert_eq!|assert_ne!)/i;
|
|
2253
|
+
/(?:\b(?:assert(?!ion|ing|ed\b|s\b)[a-zA-Z0-9_$]*(?:\.[a-zA-Z0-9_$]+)?|refute[a-zA-Z0-9_$]*|XCTAssert[a-zA-Z0-9_$]*|XCTFail|expect|[a-z_$][a-z0-9_$]{0,2}\.(?:equal|equals|same|strictSame|deepEqual|notEqual|notSame|match|hasStrict|type|throws|rejects|ok|notOk)|t\.(?:assert|expect|is|equal|true|false|Errorf|Fatalf)|require\.[a-zA-Z0-9_$]+)\b|assert!|assert_eq!|assert_ne!)/i;
|
|
2241
2254
|
// The loose net. Not a verdict and never a block — its only job is to
|
|
2242
2255
|
// notice that a line was plainly an assertion in *some* dialect that
|
|
2243
2256
|
// ASSERTION_PATTERN did not recognise. Without it, adding the seventh
|
|
@@ -2380,6 +2393,59 @@ export function checkTestTampering(diffOrText = "", options = {}) {
|
|
|
2380
2393
|
}
|
|
2381
2394
|
}
|
|
2382
2395
|
|
|
2396
|
+
// An assertion is a statement, not a line.
|
|
2397
|
+
//
|
|
2398
|
+
// Counting `+`/`-` lines missed the commonest shape in every language with
|
|
2399
|
+
// multi-line calls: `self.assertEqual(` sits on an unchanged context line
|
|
2400
|
+
// and only its argument lines are edited. Nothing among the changed lines
|
|
2401
|
+
// matched an assertion pattern, nothing looked assertion-shaped either, so
|
|
2402
|
+
// the guard reported `assertionsSeen: 0` and — because no line looked
|
|
2403
|
+
// suspicious — a clean PASS. A five-element expected list rewritten to one
|
|
2404
|
+
// element to match broken output sailed through five green phases.
|
|
2405
|
+
//
|
|
2406
|
+
// The statement machinery that already exists for pairing knows better:
|
|
2407
|
+
// it assembles context lines together with changed ones. Ask it.
|
|
2408
|
+
for (const [file, stats] of fileAssertions.entries()) {
|
|
2409
|
+
const lang = langForTestFile(file);
|
|
2410
|
+
let touched = 0;
|
|
2411
|
+
let stmtSpecificRemoved = 0;
|
|
2412
|
+
let stmtSpecificAdded = 0;
|
|
2413
|
+
for (const hunk of stats.hunks) {
|
|
2414
|
+
const oldSlice = [];
|
|
2415
|
+
const newSlice = [];
|
|
2416
|
+
for (const L of hunk.lines) {
|
|
2417
|
+
if (L.kind !== "+") oldSlice.push(L);
|
|
2418
|
+
if (L.kind !== "-") newSlice.push(L);
|
|
2419
|
+
}
|
|
2420
|
+
// Specific assertions are counted per side, on the reassembled
|
|
2421
|
+
// statement. The weakening check was still line-based: `assert (` alone
|
|
2422
|
+
// on a line names no value, so Black or Ruff splitting one assertion
|
|
2423
|
+
// across three lines removed one specific assertion and added none, and
|
|
2424
|
+
// a reformat was reported as CRITICAL tampering. Collapsing the joined
|
|
2425
|
+
// statement is what lets the bare-comparison form be recognised at all
|
|
2426
|
+
// — its pattern cannot cross a newline.
|
|
2427
|
+
const sides = [
|
|
2428
|
+
{ stmts: assembleStatements(oldSlice, lang), kind: "-" },
|
|
2429
|
+
{ stmts: assembleStatements(newSlice, lang), kind: "+" },
|
|
2430
|
+
];
|
|
2431
|
+
for (const { stmts, kind } of sides) {
|
|
2432
|
+
for (const st of stmts) {
|
|
2433
|
+
const changed = kind === "-" ? (st.removedLines?.length || 0) > 0 : (st.addedLines?.length || 0) > 0;
|
|
2434
|
+
if (!changed) continue;
|
|
2435
|
+
const clean = stripComments(st.text, lang);
|
|
2436
|
+
if (ASSERTION_PATTERN.test(clean)) touched++;
|
|
2437
|
+
if (isSpecificAssertion(collapseWhitespace(clean))) {
|
|
2438
|
+
if (kind === "-") stmtSpecificRemoved++;
|
|
2439
|
+
else stmtSpecificAdded++;
|
|
2440
|
+
}
|
|
2441
|
+
}
|
|
2442
|
+
}
|
|
2443
|
+
}
|
|
2444
|
+
stats.statementAssertions = touched;
|
|
2445
|
+
stats.stmtSpecificRemoved = stmtSpecificRemoved;
|
|
2446
|
+
stats.stmtSpecificAdded = stmtSpecificAdded;
|
|
2447
|
+
}
|
|
2448
|
+
|
|
2383
2449
|
// Assertions added in some *other* file of this same diff.
|
|
2384
2450
|
//
|
|
2385
2451
|
// Tracking is strictly per file, so moving a test from one file to another
|
|
@@ -2446,7 +2512,12 @@ export function checkTestTampering(diffOrText = "", options = {}) {
|
|
|
2446
2512
|
const alreadyReportedSpecific = stats.removed
|
|
2447
2513
|
.slice(stats.added)
|
|
2448
2514
|
.filter((item) => isSpecificAssertion(item.text)).length;
|
|
2449
|
-
|
|
2515
|
+
// Believe whichever unit saw more arrive. A statement is the honest unit,
|
|
2516
|
+
// but the line count still carries cases the statement scanner cannot
|
|
2517
|
+
// assemble, so the loss is only what *both* agree was lost.
|
|
2518
|
+
const lineLost = Math.max(0, stats.removedSpecific.length - stats.addedSpecific);
|
|
2519
|
+
const stmtLost = Math.max(0, (stats.stmtSpecificRemoved || 0) - (stats.stmtSpecificAdded || 0));
|
|
2520
|
+
const specificLost = Math.min(lineLost, stmtLost);
|
|
2450
2521
|
const weakenedCount = Math.max(0, specificLost - alreadyReportedSpecific);
|
|
2451
2522
|
|
|
2452
2523
|
if (weakenedCount > 0) {
|
|
@@ -2485,38 +2556,6 @@ export function checkTestTampering(diffOrText = "", options = {}) {
|
|
|
2485
2556
|
// understood. `UNREADABLE` is the state that has no business being silent
|
|
2486
2557
|
// — assertion-shaped lines were present and none of them parsed, which
|
|
2487
2558
|
// means this repository speaks a dialect the guard does not.
|
|
2488
|
-
// An assertion is a statement, not a line.
|
|
2489
|
-
//
|
|
2490
|
-
// Counting `+`/`-` lines missed the commonest shape in every language with
|
|
2491
|
-
// multi-line calls: `self.assertEqual(` sits on an unchanged context line
|
|
2492
|
-
// and only its argument lines are edited. Nothing among the changed lines
|
|
2493
|
-
// matched an assertion pattern, nothing looked assertion-shaped either, so
|
|
2494
|
-
// the guard reported `assertionsSeen: 0` and — because no line looked
|
|
2495
|
-
// suspicious — a clean PASS. A five-element expected list rewritten to one
|
|
2496
|
-
// element to match broken output sailed through five green phases.
|
|
2497
|
-
//
|
|
2498
|
-
// The statement machinery that already exists for pairing knows better:
|
|
2499
|
-
// it assembles context lines together with changed ones. Ask it.
|
|
2500
|
-
for (const [file, stats] of fileAssertions.entries()) {
|
|
2501
|
-
const lang = langForTestFile(file);
|
|
2502
|
-
let touched = 0;
|
|
2503
|
-
for (const hunk of stats.hunks) {
|
|
2504
|
-
const oldSlice = [];
|
|
2505
|
-
const newSlice = [];
|
|
2506
|
-
for (const L of hunk.lines) {
|
|
2507
|
-
if (L.kind !== "+") oldSlice.push(L);
|
|
2508
|
-
if (L.kind !== "-") newSlice.push(L);
|
|
2509
|
-
}
|
|
2510
|
-
for (const stmts of [assembleStatements(oldSlice, lang), assembleStatements(newSlice, lang)]) {
|
|
2511
|
-
for (const st of stmts) {
|
|
2512
|
-
const changed = (st.removedLines?.length || 0) + (st.addedLines?.length || 0) > 0;
|
|
2513
|
-
if (changed && ASSERTION_PATTERN.test(stripComments(st.text, lang))) touched++;
|
|
2514
|
-
}
|
|
2515
|
-
}
|
|
2516
|
-
}
|
|
2517
|
-
stats.statementAssertions = touched;
|
|
2518
|
-
}
|
|
2519
|
-
|
|
2520
2559
|
let examined = 0;
|
|
2521
2560
|
let assertionsSeen = 0;
|
|
2522
2561
|
const unreadable = [];
|
|
@@ -2617,24 +2656,41 @@ export function scanDiff(diffTextStr = "", options = {}) {
|
|
|
2617
2656
|
// dialect warning into the latter meant it reached nobody. The guard
|
|
2618
2657
|
// computed `UNREADABLE`, and the operator was shown an unblemished pass.
|
|
2619
2658
|
// A boundary that is not reported is not a boundary.
|
|
2659
|
+
//
|
|
2660
|
+
// And it blocks, because the previous wording was the defect it described.
|
|
2661
|
+
// Printing "this change was NOT checked for tampering ... it is not an
|
|
2662
|
+
// approval either" and then returning APPROVED (Exit 0) is the exact shape
|
|
2663
|
+
// this project exists to refuse: a verdict from a check that examined
|
|
2664
|
+
// nothing, dressed as a pass. A second cold-start trial walked a tampered
|
|
2665
|
+
// test and broken production code straight through on node-tap, and again
|
|
2666
|
+
// on BATS. `verify.tamperGuard: "warn"` is how a repository whose dialect
|
|
2667
|
+
// is genuinely unsupported opts out — deliberately, and on the record.
|
|
2668
|
+
let unreadableBlocks = false;
|
|
2620
2669
|
if (tamperingRes.status === "UNREADABLE") {
|
|
2670
|
+
const mode = options.tamperGuard === "warn" || options.allowUnreadableTests === true ? "warn" : "block";
|
|
2671
|
+
unreadableBlocks = mode === "block";
|
|
2621
2672
|
const where = (tamperingRes.unreadable || []).map((u) => u.file);
|
|
2622
2673
|
const sample = tamperingRes.unreadable?.[0]?.samples?.[0];
|
|
2623
2674
|
findings.push({
|
|
2624
|
-
severity: "MEDIUM",
|
|
2675
|
+
severity: unreadableBlocks ? "CRITICAL" : "MEDIUM",
|
|
2625
2676
|
type: "TEST_DIALECT_UNREADABLE",
|
|
2626
2677
|
file: where[0] ?? null,
|
|
2627
2678
|
line: null,
|
|
2628
2679
|
description:
|
|
2629
2680
|
`Test Tamper Guard: changed ${tamperingRes.inputsSeen} line(s) in ${tamperingRes.filesSeen} test file(s) ` +
|
|
2630
2681
|
`and recognised no assertion among them${sample ? ` (e.g. ${JSON.stringify(sample)})` : ""}. ` +
|
|
2631
|
-
`This change was NOT checked for tampering
|
|
2632
|
-
|
|
2682
|
+
`This change was NOT checked for tampering${unreadableBlocks ? ", so it cannot be approved" : ""}. ` +
|
|
2683
|
+
(unreadableBlocks
|
|
2684
|
+
? `If this repository's assertion library is genuinely unsupported, say so once in .agent/config.yml ` +
|
|
2685
|
+
`with verify.tamperGuard: "warn", or allow this run with --allow-unreadable-tests. Reporting the ` +
|
|
2686
|
+
`dialect is more useful than either: the guard covers Node, pytest, Go, Rust, JUnit, RSpec, PHPUnit, ` +
|
|
2687
|
+
`Minitest, XCTest, chai and node-tap.`
|
|
2688
|
+
: `Reported only, because verify.tamperGuard is set to "warn".`),
|
|
2633
2689
|
});
|
|
2634
2690
|
}
|
|
2635
2691
|
|
|
2636
2692
|
return {
|
|
2637
|
-
ok: secretsOk && edgeRes.ok && crossPkgRes.ok && tamperingRes.ok,
|
|
2693
|
+
ok: secretsOk && edgeRes.ok && crossPkgRes.ok && tamperingRes.ok && !unreadableBlocks,
|
|
2638
2694
|
findings,
|
|
2639
2695
|
};
|
|
2640
2696
|
}
|
package/src/stack-detector.mjs
CHANGED
|
@@ -849,6 +849,36 @@ export function resolveWorkspaceBoundary(changedFiles = [], root = process.cwd()
|
|
|
849
849
|
};
|
|
850
850
|
}
|
|
851
851
|
|
|
852
|
+
/**
|
|
853
|
+
* Does this repository contain any JavaScript for a parse oracle to check?
|
|
854
|
+
*
|
|
855
|
+
* A `package.json` is not an answer. A CSS library, an icon set or a font
|
|
856
|
+
* package has one and no JavaScript at all, and `bootstrap` happily wrote a
|
|
857
|
+
* `node --test .agent/smoke.test.mjs` config into it — so the very next gate
|
|
858
|
+
* run died on the generated suite's own assertion, "No JavaScript sources
|
|
859
|
+
* found to verify". The assertion was right; it just arrived after the config
|
|
860
|
+
* had been written, which left the user deadlocked by the repair advice.
|
|
861
|
+
*/
|
|
862
|
+
function hasJsSources(root, dir = root, depth = 0) {
|
|
863
|
+
if (depth > 6) return false;
|
|
864
|
+
let entries;
|
|
865
|
+
try {
|
|
866
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
867
|
+
} catch (_) {
|
|
868
|
+
return false;
|
|
869
|
+
}
|
|
870
|
+
for (const entry of entries) {
|
|
871
|
+
if (entry.name.startsWith(".")) continue;
|
|
872
|
+
if (["node_modules", "dist", "build", "coverage", "vendor", "target"].includes(entry.name)) continue;
|
|
873
|
+
if (entry.isDirectory()) {
|
|
874
|
+
if (hasJsSources(root, join(dir, entry.name), depth + 1)) return true;
|
|
875
|
+
} else if (/\.(mjs|cjs|jsx?|tsx?)$/.test(entry.name)) {
|
|
876
|
+
return true;
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
return false;
|
|
880
|
+
}
|
|
881
|
+
|
|
852
882
|
export function bootstrapZeroTestRepo(root = process.cwd(), options = {}) {
|
|
853
883
|
const detected = detectPolyglotStack(root);
|
|
854
884
|
const configPath = join(root, ".agent", "config.yml");
|
|
@@ -898,9 +928,24 @@ export function bootstrapZeroTestRepo(root = process.cwd(), options = {}) {
|
|
|
898
928
|
testCmd = "go vet ./...";
|
|
899
929
|
} else if (existsSync(join(root, "tsconfig.json"))) {
|
|
900
930
|
testCmd = "npx tsc --noEmit";
|
|
901
|
-
} else {
|
|
931
|
+
} else if (hasJsSources(root)) {
|
|
902
932
|
const smokePath = generateSmokeTestScript(root);
|
|
903
933
|
testCmd = `node --test ${smokePath}`;
|
|
934
|
+
} else {
|
|
935
|
+
// Refuse rather than write a config whose first run cannot pass. Saying
|
|
936
|
+
// "I cannot build an oracle here" is a usable answer; deadlocking the
|
|
937
|
+
// user with one that asserts its own impossibility is not.
|
|
938
|
+
return {
|
|
939
|
+
bootstrapped: false,
|
|
940
|
+
reason: "NO_ORACLE_POSSIBLE",
|
|
941
|
+
testCmd: "",
|
|
942
|
+
detail:
|
|
943
|
+
"This repository has no JavaScript sources to parse-check and no recognised " +
|
|
944
|
+
"test toolchain, so there is nothing an automatic oracle could verify. Set " +
|
|
945
|
+
"verify.test in .agent/config.yml to a command that checks this project — a " +
|
|
946
|
+
"linter, a build, a schema validation — or set verify.required: false to run " +
|
|
947
|
+
"the scope and secret phases only, deliberately.",
|
|
948
|
+
};
|
|
904
949
|
}
|
|
905
950
|
|
|
906
951
|
try {
|