jules-orchestrator-kit 0.52.3 → 0.52.5
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 +1 -1
- package/bin/agentctl.mjs +4 -1
- package/index.mjs +1 -0
- package/package.json +1 -1
- package/src/config.mjs +2 -2
- package/src/engine.mjs +23 -3
- package/src/git.mjs +19 -14
- package/src/security.mjs +191 -23
- package/src/wizard-init.mjs +36 -10
package/README.md
CHANGED
|
@@ -143,7 +143,7 @@ To maximize PR merge rates, dispatch tasks according to deterministic boundaries
|
|
|
143
143
|
* **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).
|
|
144
144
|
* **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.
|
|
145
145
|
* **Terminal UI & Diagnostic Matrix (`agentctl doctor`):** Interactive terminal dashboard, task sidecar manager, and automated transactional self-repair.
|
|
146
|
-
* **Verified Test Suite:** Tested with **
|
|
146
|
+
* **Verified Test Suite:** Tested with **828 unit tests across 91 suites passing in < 10.0s**.
|
|
147
147
|
|
|
148
148
|
<br/>
|
|
149
149
|
|
package/bin/agentctl.mjs
CHANGED
|
@@ -427,7 +427,10 @@ async function main() {
|
|
|
427
427
|
} else if (res.flakyVerdict?.verdict === "QUARANTINED") {
|
|
428
428
|
console.log(`💡 Remediation Hint (Exit 8 Flaky Test Quarantined):`);
|
|
429
429
|
console.log(` • This command has alternated between pass and fail across recent runs.`);
|
|
430
|
-
|
|
430
|
+
} else if (res.code === 188) {
|
|
431
|
+
console.log(`💡 Remediation Hint (Exit 188 Offline Network Violation / Infrastructure):`);
|
|
432
|
+
console.log(` • An unmocked network request was blocked by the preload network guard during verification.`);
|
|
433
|
+
console.log(` • Ensure dependencies are installed locally (run: npm install) and all network calls in tests are mocked.\n`);
|
|
431
434
|
} else if (failedPhase === "verify" || failedPhase === "evidence") {
|
|
432
435
|
console.log(`💡 Remediation Hint (Exit ${res.code} Verification Failed):`);
|
|
433
436
|
console.log(` • The stage above exited non-zero. Reproduce it locally, then re-run the gate.`);
|
package/index.mjs
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "jules-orchestrator-kit",
|
|
3
|
-
"version": "0.52.
|
|
3
|
+
"version": "0.52.5",
|
|
4
4
|
"description": "Zero-dependency safety gatekeeper, test oracle generator, and multi-agent coordination protocol for Google Jules (jules) autonomous agents.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
package/src/config.mjs
CHANGED
|
@@ -35,13 +35,13 @@ export const BUILTIN_DENY = [
|
|
|
35
35
|
"**/*.pem",
|
|
36
36
|
"**/*.key",
|
|
37
37
|
"**/id_rsa*",
|
|
38
|
-
".agent/config.yml",
|
|
39
|
-
".agent/jules.yml",
|
|
40
38
|
".agent/jules-queue/**",
|
|
41
39
|
".github/**",
|
|
42
40
|
];
|
|
43
41
|
|
|
44
42
|
export const BUILTIN_PROTECT = [
|
|
43
|
+
".agent/config.yml",
|
|
44
|
+
".agent/jules.yml",
|
|
45
45
|
".agent/rules/**",
|
|
46
46
|
".agent/SYSTEM_LEARNINGS.md",
|
|
47
47
|
".agent/knowledge/**",
|
package/src/engine.mjs
CHANGED
|
@@ -173,6 +173,7 @@ export async function gate(opts = {}) {
|
|
|
173
173
|
// Phase 1: Scope Guard (Fetch trusted config strictly from origin/base using single normalizeScope)
|
|
174
174
|
let trustedScope = config.scope;
|
|
175
175
|
let trustedVerify = config.verify;
|
|
176
|
+
let trustedDiffKb = 75;
|
|
176
177
|
|
|
177
178
|
const trustedConfigRaw = showFromOrigin(root, base, ".agent/config.yml") || showFromOrigin(root, base, ".agent/jules.yml");
|
|
178
179
|
if (trustedConfigRaw) {
|
|
@@ -180,6 +181,9 @@ export async function gate(opts = {}) {
|
|
|
180
181
|
const parsed = parseYaml(trustedConfigRaw);
|
|
181
182
|
// CRITICAL B1 FIX: normalizeScope ensures BUILTIN_DENY is ALWAYS merged with user deny rules
|
|
182
183
|
trustedScope = normalizeScope(parsed);
|
|
184
|
+
if (parsed.limits?.diff_kb || parsed.limits?.diffKb) {
|
|
185
|
+
trustedDiffKb = Number(parsed.limits.diff_kb || parsed.limits.diffKb) || 75;
|
|
186
|
+
}
|
|
183
187
|
if (parsed.verify || parsed.test_cmd || parsed.build_cmd) {
|
|
184
188
|
trustedVerify = {
|
|
185
189
|
setup: parsed.verify?.setup || config.verify.setup,
|
|
@@ -200,6 +204,9 @@ export async function gate(opts = {}) {
|
|
|
200
204
|
} else {
|
|
201
205
|
// CRITICAL H-c FIX: Fall back strictly to normalizeScope({}) (built-ins only), never HEAD config
|
|
202
206
|
trustedScope = normalizeScope({});
|
|
207
|
+
if (opts.config?.limits?.diffKb || opts.config?.limits?.diff_kb) {
|
|
208
|
+
trustedDiffKb = Number(opts.config.limits.diffKb || opts.config.limits.diff_kb) || 75;
|
|
209
|
+
}
|
|
203
210
|
}
|
|
204
211
|
|
|
205
212
|
const scopeResult = checkScope(files, trustedScope, {
|
|
@@ -218,7 +225,8 @@ export async function gate(opts = {}) {
|
|
|
218
225
|
}
|
|
219
226
|
|
|
220
227
|
// Phase 2: Diff Payload Governor
|
|
221
|
-
|
|
228
|
+
// Bound to trusted base commit config strictly; uncommitted disk config cannot raise limit in committed mode.
|
|
229
|
+
const limitBytes = trustedDiffKb * 1024;
|
|
222
230
|
const payloadOk = bytes <= limitBytes;
|
|
223
231
|
phases.push({ phase: "payload", ok: payloadOk, bytes, limitBytes });
|
|
224
232
|
appendTelemetry(root, "gate_phase", { phase: "payload", ok: payloadOk });
|
|
@@ -433,6 +441,14 @@ export async function gate(opts = {}) {
|
|
|
433
441
|
// the one gate failure the operator is expected to fix themselves was the
|
|
434
442
|
// only one that told them nothing about how. Both streams are already
|
|
435
443
|
// redacted at the point they were captured.
|
|
444
|
+
const isNetViolation = failingCmd?.status === 188;
|
|
445
|
+
const netViolationDiagnostics = isNetViolation
|
|
446
|
+
? [
|
|
447
|
+
"Offline Network Guard: Outbound network request was blocked (Exit 188).",
|
|
448
|
+
"Ensure dependencies are installed locally (run npm install) and tests do not perform unmocked network access.",
|
|
449
|
+
]
|
|
450
|
+
: [];
|
|
451
|
+
|
|
436
452
|
const verifyFailure = failingCmd
|
|
437
453
|
? {
|
|
438
454
|
stageId: failingCmd.stageId || failingCmd.phase || "verify",
|
|
@@ -440,7 +456,7 @@ export async function gate(opts = {}) {
|
|
|
440
456
|
exitCode: failingCmd.status ?? null,
|
|
441
457
|
stdout: failingCmd.stdout || "",
|
|
442
458
|
stderr: failingCmd.stderr || "",
|
|
443
|
-
diagnostics: failingCmd.diagnostics || [],
|
|
459
|
+
diagnostics: [...netViolationDiagnostics, ...(failingCmd.diagnostics || [])],
|
|
444
460
|
}
|
|
445
461
|
: testTampered
|
|
446
462
|
? {
|
|
@@ -510,6 +526,10 @@ export async function gate(opts = {}) {
|
|
|
510
526
|
}
|
|
511
527
|
|
|
512
528
|
if (!verifyOk && opts.fix && failingCmd) {
|
|
529
|
+
if (isNetViolation) {
|
|
530
|
+
appendTelemetry(root, "gate_finished", { ok: false, code: 188, networkViolation: true });
|
|
531
|
+
return { ok: false, code: 188, phases, evidence: evidenceManifest };
|
|
532
|
+
}
|
|
513
533
|
const repairs = await repair(failingCmd, { config, root, signal: opts.signal, progressBus, progressToken });
|
|
514
534
|
const finalOk = repairs.ok;
|
|
515
535
|
const finalCode = repairs.ok ? 0 : 4;
|
|
@@ -520,7 +540,7 @@ export async function gate(opts = {}) {
|
|
|
520
540
|
return { ok: false, code: 4, phases, repairs, evidence: evidenceManifest };
|
|
521
541
|
}
|
|
522
542
|
|
|
523
|
-
const code = verifyOk ? 0 : testTampered ? 3 : 4;
|
|
543
|
+
const code = verifyOk ? 0 : isNetViolation ? 188 : testTampered ? 3 : 4;
|
|
524
544
|
appendTelemetry(root, "gate_finished", { ok: verifyOk, code });
|
|
525
545
|
return { ok: verifyOk, code, phases, evidence: evidenceManifest };
|
|
526
546
|
}
|
package/src/git.mjs
CHANGED
|
@@ -306,7 +306,10 @@ export function ensureBaseFetched(root = process.cwd(), baseRef = "main") {
|
|
|
306
306
|
}
|
|
307
307
|
|
|
308
308
|
export function resolveBase(root = process.cwd(), baseRef = "main") {
|
|
309
|
-
const
|
|
309
|
+
const isHeadRef = baseRef === "HEAD" || baseRef.startsWith("HEAD~") || baseRef.startsWith("HEAD^") || baseRef.startsWith("HEAD@");
|
|
310
|
+
const candidates = isHeadRef
|
|
311
|
+
? [baseRef]
|
|
312
|
+
: [`origin/${baseRef}`, `refs/remotes/origin/${baseRef}`, baseRef];
|
|
310
313
|
for (const ref of candidates) {
|
|
311
314
|
try {
|
|
312
315
|
const res = execFileSync("git", ["rev-parse", "--verify", "--quiet", `${ref}^{commit}`], {
|
|
@@ -321,20 +324,22 @@ export function resolveBase(root = process.cwd(), baseRef = "main") {
|
|
|
321
324
|
} catch (_) {}
|
|
322
325
|
}
|
|
323
326
|
|
|
324
|
-
|
|
327
|
+
if (!isHeadRef) {
|
|
328
|
+
ensureBaseFetched(root, baseRef);
|
|
325
329
|
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
330
|
+
for (const ref of candidates) {
|
|
331
|
+
try {
|
|
332
|
+
const res = execFileSync("git", ["rev-parse", "--verify", "--quiet", `${ref}^{commit}`], {
|
|
333
|
+
cwd: root,
|
|
334
|
+
encoding: "utf-8",
|
|
335
|
+
shell: false,
|
|
336
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
337
|
+
});
|
|
338
|
+
if (res && res.trim()) {
|
|
339
|
+
return res.trim();
|
|
340
|
+
}
|
|
341
|
+
} catch (_) {}
|
|
342
|
+
}
|
|
338
343
|
}
|
|
339
344
|
|
|
340
345
|
throw new GateError(
|
package/src/security.mjs
CHANGED
|
@@ -588,6 +588,11 @@ function secretScanVariants(addedLines) {
|
|
|
588
588
|
// Collapse method concatenations like .concat("...") or .join("")
|
|
589
589
|
dejoined = dejoined.replace(/\.concat\(\s*["'`]/g, "").replace(/\.join\(\s*["'`]{2}\s*\)/g, "");
|
|
590
590
|
|
|
591
|
+
// Collapse whitespace/newlines between adjacent base64 characters (including line-wrapped PEM/base64, template literals, and quoted string chunks)
|
|
592
|
+
const base64Dejoined = stripped
|
|
593
|
+
.replace(/([A-Za-z0-9+/=_-])\s*[\r\n]+\s*(?=[A-Za-z0-9+/=_-])/g, "$1")
|
|
594
|
+
.replace(/([A-Za-z0-9+/=_-])["'`]\s*(?:\+\s*)?[\r\n]+\s*["'`]?([A-Za-z0-9+/=_-])/g, "$1$2");
|
|
595
|
+
|
|
591
596
|
const hexDecoded = tryHexDecodeTokens(dejoined);
|
|
592
597
|
const pctDecoded = tryPercentDecode(dejoined);
|
|
593
598
|
// Confusable / NFKD normalisation runs over both the raw text and the
|
|
@@ -597,8 +602,9 @@ function secretScanVariants(addedLines) {
|
|
|
597
602
|
const confusableDejoined = normalizeSecretText(dejoined);
|
|
598
603
|
|
|
599
604
|
return {
|
|
600
|
-
all: [...new Set([addedLines, stripped, dejoined, hexDecoded, pctDecoded, confusable, confusableDejoined])],
|
|
605
|
+
all: [...new Set([addedLines, stripped, dejoined, base64Dejoined, hexDecoded, pctDecoded, confusable, confusableDejoined])],
|
|
601
606
|
normalized: dejoined,
|
|
607
|
+
base64Normalized: base64Dejoined,
|
|
602
608
|
};
|
|
603
609
|
}
|
|
604
610
|
|
|
@@ -725,9 +731,22 @@ function decodeBase64Blobs(text, onDecoded) {
|
|
|
725
731
|
* @returns {boolean}
|
|
726
732
|
*/
|
|
727
733
|
export function hasEncodedSecret(text) {
|
|
734
|
+
if (!text) return false;
|
|
728
735
|
const result = decodeBase64Blobs(text);
|
|
729
736
|
if (result.capped) return true; // Fail closed if cap exceeded
|
|
730
|
-
|
|
737
|
+
if (result.decoded.some((plain) => hasHighConfidenceSecret(plain))) return true;
|
|
738
|
+
|
|
739
|
+
if (text.includes("\n") || text.includes("\r")) {
|
|
740
|
+
const collapsed = text
|
|
741
|
+
.replace(/([A-Za-z0-9+/=_-])\s*[\r\n]+\s*(?=[A-Za-z0-9+/=_-])/g, "$1")
|
|
742
|
+
.replace(/([A-Za-z0-9+/=_-])["'`]\s*(?:\+\s*)?[\r\n]+\s*["'`]?([A-Za-z0-9+/=_-])/g, "$1$2");
|
|
743
|
+
if (collapsed !== text) {
|
|
744
|
+
const collapsedResult = decodeBase64Blobs(collapsed);
|
|
745
|
+
if (collapsedResult.capped) return true;
|
|
746
|
+
if (collapsedResult.decoded.some((plain) => hasHighConfidenceSecret(plain))) return true;
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
return false;
|
|
731
750
|
}
|
|
732
751
|
|
|
733
752
|
/**
|
|
@@ -776,22 +795,93 @@ function splitDiffByFile(diffText) {
|
|
|
776
795
|
return [...byFile.values()].filter((s) => s.lines.length > 0);
|
|
777
796
|
}
|
|
778
797
|
|
|
798
|
+
const CANDIDATE_TOKEN_REGEX = /[A-Za-z0-9_-]{24,}/g;
|
|
799
|
+
|
|
800
|
+
/**
|
|
801
|
+
* Checks for high-entropy continuous tokens (>= 24 chars, entropy > 4.5) on added lines.
|
|
802
|
+
* Filters out URLs, SRI hashes (sha512-, sha256-, sha384-), and lockfiles to eliminate false positives.
|
|
803
|
+
*
|
|
804
|
+
* @param {string} text - Text to scan
|
|
805
|
+
* @param {string|null} [file=null] - File path associated with the text
|
|
806
|
+
* @returns {boolean}
|
|
807
|
+
*/
|
|
808
|
+
export function hasHighEntropyToken(text = "", file = null) {
|
|
809
|
+
if (!text || typeof text !== "string") return false;
|
|
810
|
+
if (
|
|
811
|
+
file &&
|
|
812
|
+
(file.endsWith(".lock") ||
|
|
813
|
+
file.endsWith(".lockb") ||
|
|
814
|
+
file.includes("package-lock.json") ||
|
|
815
|
+
file.includes("pnpm-lock.yaml") ||
|
|
816
|
+
file.includes("yarn.lock") ||
|
|
817
|
+
file.includes("Cargo.lock") ||
|
|
818
|
+
file.includes("composer.lock"))
|
|
819
|
+
) {
|
|
820
|
+
return false;
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
const lines = text.split("\n");
|
|
824
|
+
for (const rawLine of lines) {
|
|
825
|
+
if (
|
|
826
|
+
rawLine.includes("://") ||
|
|
827
|
+
rawLine.includes("data:image/") ||
|
|
828
|
+
rawLine.includes("sha512-") ||
|
|
829
|
+
rawLine.includes("sha256-") ||
|
|
830
|
+
rawLine.includes("sha384-")
|
|
831
|
+
) {
|
|
832
|
+
continue;
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
CANDIDATE_TOKEN_REGEX.lastIndex = 0;
|
|
836
|
+
let match;
|
|
837
|
+
while ((match = CANDIDATE_TOKEN_REGEX.exec(rawLine)) !== null) {
|
|
838
|
+
const token = match[0];
|
|
839
|
+
if (token.startsWith("sha512-") || token.startsWith("sha256-")) continue;
|
|
840
|
+
if (token.length >= 24) {
|
|
841
|
+
// If the token is a formatted base64 blob, distinguish binary assets and plain prose
|
|
842
|
+
if (token.length % 4 === 0 && /^[A-Za-z0-9+/]+={0,2}$/.test(token)) {
|
|
843
|
+
try {
|
|
844
|
+
const plain = Buffer.from(token, "base64").toString("utf-8");
|
|
845
|
+
const pr = printableRatio(plain);
|
|
846
|
+
if (pr < 0.9) {
|
|
847
|
+
// Ordinary binary asset (e.g. icon/font/wasm) - do not trip on binary entropy
|
|
848
|
+
continue;
|
|
849
|
+
}
|
|
850
|
+
// If it decodes to text, check decoded plain text entropy
|
|
851
|
+
if (shannonEntropy(plain) > 4.5) {
|
|
852
|
+
return true;
|
|
853
|
+
}
|
|
854
|
+
continue;
|
|
855
|
+
} catch (_) {}
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
const ent = shannonEntropy(token);
|
|
859
|
+
if (ent > 4.5) {
|
|
860
|
+
return true;
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
return false;
|
|
866
|
+
}
|
|
867
|
+
|
|
779
868
|
/**
|
|
780
869
|
* Classify a block of added lines. Returns the single most severe finding, or
|
|
781
870
|
* null when the block is clean.
|
|
782
871
|
*
|
|
783
872
|
* @param {string} addedLines
|
|
873
|
+
* @param {string|null} [file=null]
|
|
784
874
|
* @returns {{ severity: string, type: string, description: string, encoded: boolean }|null}
|
|
785
875
|
*/
|
|
786
|
-
function classifyAddedLines(addedLines) {
|
|
787
|
-
const { all: variants, normalized } = secretScanVariants(addedLines);
|
|
876
|
+
function classifyAddedLines(addedLines, file = null) {
|
|
877
|
+
const { all: variants, normalized, base64Normalized } = secretScanVariants(addedLines);
|
|
788
878
|
if (variants.some((v) => hasHighConfidenceSecret(v))) {
|
|
789
879
|
return { severity: "CRITICAL", type: "HIGH_CONFIDENCE_SECRET", encoded: false, description: "High-confidence secret pattern detected in added diff lines" };
|
|
790
880
|
}
|
|
791
881
|
// Only worth decoding when nothing was found in the clear, and only against
|
|
792
882
|
// the fully-normalised text: decoding is the expensive step, and the
|
|
793
883
|
// intermediate variants differ from it in ways base64 blobs do not care about.
|
|
794
|
-
if (hasEncodedSecret(normalized)) {
|
|
884
|
+
if (hasEncodedSecret(normalized) || (base64Normalized && hasEncodedSecret(base64Normalized))) {
|
|
795
885
|
// Same type as the cleartext case: every gate that blocks on
|
|
796
886
|
// HIGH_CONFIDENCE_SECRET should block on this too, and a new type would
|
|
797
887
|
// have silently passed through the ones not updated. The description
|
|
@@ -801,6 +891,9 @@ function classifyAddedLines(addedLines) {
|
|
|
801
891
|
if (variants.some((v) => hasLowConfidenceSecret(v))) {
|
|
802
892
|
return { severity: "HIGH", type: "LOW_CONFIDENCE_SECRET", encoded: false, description: "Low-confidence secret or authorization token detected in added diff lines" };
|
|
803
893
|
}
|
|
894
|
+
if (hasHighEntropyToken(addedLines, file)) {
|
|
895
|
+
return { severity: "HIGH", type: "HIGH_ENTROPY_TOKEN", encoded: false, description: "High-entropy token detected in added diff lines (potential unstructured secret or API key)" };
|
|
896
|
+
}
|
|
804
897
|
return null;
|
|
805
898
|
}
|
|
806
899
|
|
|
@@ -815,12 +908,13 @@ function classifyAddedLines(addedLines) {
|
|
|
815
908
|
*
|
|
816
909
|
* @param {Array<{ text: string, no: number|null }>} lines
|
|
817
910
|
* @param {string} type
|
|
911
|
+
* @param {string|null} [file=null]
|
|
818
912
|
* @returns {number|null}
|
|
819
913
|
*/
|
|
820
|
-
function locateFindingLine(lines, type) {
|
|
914
|
+
function locateFindingLine(lines, type, file = null) {
|
|
821
915
|
for (const line of lines) {
|
|
822
916
|
if (line.no === null) continue;
|
|
823
|
-
const hit = classifyAddedLines(line.text);
|
|
917
|
+
const hit = classifyAddedLines(line.text, file);
|
|
824
918
|
if (hit && hit.type === type) return line.no;
|
|
825
919
|
}
|
|
826
920
|
return null;
|
|
@@ -840,8 +934,10 @@ export function checkTestTampering(diffOrText = "", options = {}) {
|
|
|
840
934
|
|
|
841
935
|
const violations = [];
|
|
842
936
|
const lines = diffOrText.split("\n");
|
|
937
|
+
let lastOldFile = null;
|
|
843
938
|
let currentFile = null;
|
|
844
|
-
let
|
|
939
|
+
let currentOldLineNo = null;
|
|
940
|
+
let currentNewLineNo = null;
|
|
845
941
|
|
|
846
942
|
const isTestFile = (f) => {
|
|
847
943
|
if (!f) return false;
|
|
@@ -868,18 +964,43 @@ export function checkTestTampering(diffOrText = "", options = {}) {
|
|
|
868
964
|
|
|
869
965
|
const COMMENTED_ASSERTION = /^\+\s*(?:\/\/|\/\*)\s*(?:expect\(|assert\.|assert\(|t\.expect|t\.assert)/i;
|
|
870
966
|
|
|
967
|
+
const VACUOUS_ASSERTIONS = [
|
|
968
|
+
{ pattern: /\bassert(?:\.ok)?\s*\(\s*true\s*(?:,[^)]*)?\)/i, desc: "Vacuous truth assertion (assert.ok(true))" },
|
|
969
|
+
{ pattern: /\bassert\.(?:strictEqual|deepStrictEqual|equal|deepEqual)\s*\(\s*([^,]+?)\s*,\s*\1\s*(?:,[^)]*)?\)/i, desc: "Vacuous identity assertion (assert.equal(X, X))" },
|
|
970
|
+
{ pattern: /\bexpect\s*\(\s*true\s*\)\s*\.(?:toBe|toEqual)\s*\(\s*true\s*\)/i, desc: "Vacuous truth expectation (expect(true).toBe(true))" },
|
|
971
|
+
{ pattern: /\bexpect\s*\(\s*([^)]+?)\s*\)\s*\.(?:toBe|toEqual)\s*\(\s*\1\s*\)/i, desc: "Vacuous identity expectation (expect(X).toBe(X))" },
|
|
972
|
+
{ pattern: /\bexpect\s*\(\s*true\s*\)\s*\.toBeTruthy\s*\(/i, desc: "Vacuous truth expectation (expect(true).toBeTruthy())" },
|
|
973
|
+
{ pattern: /\bexpect\s*\(\s*false\s*\)\s*\.toBeFalsy\s*\(/i, desc: "Vacuous falsity expectation (expect(false).toBeFalsy())" },
|
|
974
|
+
{ pattern: /\bassert\.(?:isTrue|isOk)\s*\(\s*true\s*(?:,[^)]*)?\)/i, desc: "Vacuous truth assertion (assert.isTrue(true))" },
|
|
975
|
+
{ pattern: /\bassert\.(?:isFalse|isNotOk)\s*\(\s*false\s*(?:,[^)]*)?\)/i, desc: "Vacuous falsity assertion (assert.isFalse(false))" },
|
|
976
|
+
];
|
|
977
|
+
|
|
978
|
+
const ASSERTION_PATTERN = /(?:\b(?:assert(?:\.[a-zA-Z0-9_$]+)?|expect|t\.(?:assert|expect|is|equal|true|false|Errorf|Fatalf)|require\.[a-zA-Z0-9_$]+)\b|assert!|assert_eq!|assert_ne!)/i;
|
|
979
|
+
const isCommentLine = (str) => /^\s*(?:\/\/|\/\*|\*|#|--|;)/.test(str);
|
|
980
|
+
|
|
981
|
+
const fileAssertions = new Map();
|
|
982
|
+
|
|
871
983
|
for (let i = 0; i < lines.length; i++) {
|
|
872
984
|
const line = lines[i];
|
|
985
|
+
|
|
986
|
+
if ((line.startsWith("--- ") || line.startsWith("--- a/")) && !line.startsWith("----")) {
|
|
987
|
+
const orig = line.slice(3).split("\t")[0].trim().replace(/^a\//, "");
|
|
988
|
+
lastOldFile = orig && orig !== "/dev/null" ? orig : null;
|
|
989
|
+
continue;
|
|
990
|
+
}
|
|
991
|
+
|
|
873
992
|
if ((line.startsWith("+++ ") || line.startsWith("+++ b/") || line.startsWith("+++ /dev/null")) && !line.startsWith("++++")) {
|
|
874
993
|
const target = line.slice(3).split("\t")[0].trim().replace(/^b\//, "");
|
|
875
|
-
currentFile = target && target !== "/dev/null" ? target :
|
|
876
|
-
|
|
994
|
+
currentFile = target && target !== "/dev/null" ? target : lastOldFile;
|
|
995
|
+
currentOldLineNo = null;
|
|
996
|
+
currentNewLineNo = null;
|
|
877
997
|
continue;
|
|
878
998
|
}
|
|
879
999
|
|
|
880
|
-
const hunkMatch = /^@@
|
|
1000
|
+
const hunkMatch = /^@@ -(\d+)(?:,\d+)? \+(\d+)/.exec(line);
|
|
881
1001
|
if (hunkMatch) {
|
|
882
|
-
|
|
1002
|
+
currentOldLineNo = Number(hunkMatch[1]);
|
|
1003
|
+
currentNewLineNo = Number(hunkMatch[2]);
|
|
883
1004
|
continue;
|
|
884
1005
|
}
|
|
885
1006
|
|
|
@@ -887,34 +1008,81 @@ export function checkTestTampering(diffOrText = "", options = {}) {
|
|
|
887
1008
|
continue;
|
|
888
1009
|
}
|
|
889
1010
|
|
|
890
|
-
if (
|
|
1011
|
+
if (!fileAssertions.has(currentFile)) {
|
|
1012
|
+
fileAssertions.set(currentFile, { removed: [], added: 0 });
|
|
1013
|
+
}
|
|
1014
|
+
const fileStats = fileAssertions.get(currentFile);
|
|
1015
|
+
|
|
1016
|
+
if (line.startsWith("-") && !line.startsWith("---")) {
|
|
1017
|
+
const deletedText = line.slice(1);
|
|
1018
|
+
if (!isCommentLine(deletedText) && ASSERTION_PATTERN.test(deletedText)) {
|
|
1019
|
+
fileStats.removed.push({ line: currentOldLineNo, text: deletedText });
|
|
1020
|
+
}
|
|
1021
|
+
if (currentOldLineNo !== null) currentOldLineNo++;
|
|
1022
|
+
} else if (line.startsWith("+") && !line.startsWith("+++")) {
|
|
891
1023
|
const addedText = line.slice(1);
|
|
1024
|
+
let isVacuous = false;
|
|
892
1025
|
|
|
893
1026
|
// Check skip injections
|
|
894
1027
|
for (const skipRule of SKIP_INJECTIONS) {
|
|
895
1028
|
if (skipRule.pattern.test(addedText)) {
|
|
896
1029
|
violations.push({
|
|
897
1030
|
file: currentFile,
|
|
898
|
-
line:
|
|
1031
|
+
line: currentNewLineNo,
|
|
899
1032
|
type: "TEST_SKIP_INJECTION",
|
|
900
|
-
reason: `Test Tamper Guard: ${skipRule.desc} found in ${currentFile}${
|
|
1033
|
+
reason: `Test Tamper Guard: ${skipRule.desc} found in ${currentFile}${currentNewLineNo ? `:${currentNewLineNo}` : ""}`,
|
|
1034
|
+
});
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
// Check vacuous assertions
|
|
1039
|
+
for (const vacuousRule of VACUOUS_ASSERTIONS) {
|
|
1040
|
+
if (vacuousRule.pattern.test(addedText)) {
|
|
1041
|
+
isVacuous = true;
|
|
1042
|
+
violations.push({
|
|
1043
|
+
file: currentFile,
|
|
1044
|
+
line: currentNewLineNo,
|
|
1045
|
+
type: "VACUOUS_ASSERTION",
|
|
1046
|
+
reason: `Test Tamper Guard: ${vacuousRule.desc} detected in ${currentFile}${currentNewLineNo ? `:${currentNewLineNo}` : ""}`,
|
|
901
1047
|
});
|
|
902
1048
|
}
|
|
903
1049
|
}
|
|
904
1050
|
|
|
905
1051
|
// Check commented-out assertions
|
|
1052
|
+
let isCommented = false;
|
|
906
1053
|
if (COMMENTED_ASSERTION.test(line)) {
|
|
1054
|
+
isCommented = true;
|
|
907
1055
|
violations.push({
|
|
908
1056
|
file: currentFile,
|
|
909
|
-
line:
|
|
1057
|
+
line: currentNewLineNo,
|
|
910
1058
|
type: "COMMENTED_ASSERTION",
|
|
911
|
-
reason: `Test Tamper Guard: Commented-out test assertion detected in ${currentFile}${
|
|
1059
|
+
reason: `Test Tamper Guard: Commented-out test assertion detected in ${currentFile}${currentNewLineNo ? `:${currentNewLineNo}` : ""}`,
|
|
912
1060
|
});
|
|
913
1061
|
}
|
|
914
1062
|
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
1063
|
+
// If valid non-vacuous, non-commented assertion is added, increment added count
|
|
1064
|
+
if (!isVacuous && !isCommented && !isCommentLine(addedText) && ASSERTION_PATTERN.test(addedText)) {
|
|
1065
|
+
fileStats.added++;
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
if (currentNewLineNo !== null) currentNewLineNo++;
|
|
1069
|
+
} else if (!line.startsWith("\\")) {
|
|
1070
|
+
if (currentOldLineNo !== null) currentOldLineNo++;
|
|
1071
|
+
if (currentNewLineNo !== null) currentNewLineNo++;
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
for (const [file, stats] of fileAssertions.entries()) {
|
|
1076
|
+
if (stats.removed.length > stats.added) {
|
|
1077
|
+
const unreplaced = stats.removed.slice(stats.added);
|
|
1078
|
+
for (const item of unreplaced) {
|
|
1079
|
+
violations.push({
|
|
1080
|
+
file,
|
|
1081
|
+
line: item.line,
|
|
1082
|
+
type: "ASSERTION_REMOVAL",
|
|
1083
|
+
reason: `Test Tamper Guard: Assertion removed without replacement in ${file}${item.line ? `:${item.line}` : ""}: "${item.text.trim()}"`,
|
|
1084
|
+
});
|
|
1085
|
+
}
|
|
918
1086
|
}
|
|
919
1087
|
}
|
|
920
1088
|
|
|
@@ -931,9 +1099,9 @@ export function scanDiff(diffTextStr = "", options = {}) {
|
|
|
931
1099
|
const findings = [];
|
|
932
1100
|
|
|
933
1101
|
for (const segment of segments) {
|
|
934
|
-
const hit = classifyAddedLines(segment.lines.map((l) => l.text).join("\n"));
|
|
1102
|
+
const hit = classifyAddedLines(segment.lines.map((l) => l.text).join("\n"), segment.file);
|
|
935
1103
|
if (!hit) continue;
|
|
936
|
-
const line = segment.file ? locateFindingLine(segment.lines, hit.type) : null;
|
|
1104
|
+
const line = segment.file ? locateFindingLine(segment.lines, hit.type, segment.file) : null;
|
|
937
1105
|
const at = segment.file ? ` (${segment.file}${line ? `:${line}` : ""})` : "";
|
|
938
1106
|
findings.push({
|
|
939
1107
|
severity: hit.severity,
|
|
@@ -949,7 +1117,7 @@ export function scanDiff(diffTextStr = "", options = {}) {
|
|
|
949
1117
|
// detection for attribution, fall back to the joined text when every file
|
|
950
1118
|
// came back clean — the cost lands only on diffs with nothing to report.
|
|
951
1119
|
if (findings.length === 0 && segments.length > 1) {
|
|
952
|
-
const hit = classifyAddedLines(segments.flatMap((s) => s.lines.map((l) => l.text)).join("\n"));
|
|
1120
|
+
const hit = classifyAddedLines(segments.flatMap((s) => s.lines.map((l) => l.text)).join("\n"), null);
|
|
953
1121
|
if (hit) {
|
|
954
1122
|
findings.push({
|
|
955
1123
|
severity: hit.severity,
|
package/src/wizard-init.mjs
CHANGED
|
@@ -123,7 +123,7 @@ export function planInit(root = process.cwd(), options = {}) {
|
|
|
123
123
|
const tierName = options.tier || FALLBACK_TIER;
|
|
124
124
|
// An unrecognised name resolves the same way loadConfig() resolves it, so the
|
|
125
125
|
// scaffolded limits always match what the runtime will later enforce.
|
|
126
|
-
const
|
|
126
|
+
const tierPresetLimits = TIER_PROFILES[tierName] || TIER_PROFILES[FALLBACK_TIER];
|
|
127
127
|
|
|
128
128
|
// Preserve existing config if present
|
|
129
129
|
let existingConfig = {};
|
|
@@ -134,6 +134,21 @@ export function planInit(root = process.cwd(), options = {}) {
|
|
|
134
134
|
} catch (_) {}
|
|
135
135
|
}
|
|
136
136
|
|
|
137
|
+
// Preserve existing jules.yml if present
|
|
138
|
+
let existingJules = {};
|
|
139
|
+
const existingJulesPath = join(root, ".agent", "jules.yml");
|
|
140
|
+
if (existsSync(existingJulesPath)) {
|
|
141
|
+
try {
|
|
142
|
+
existingJules = parseYaml(readFileSync(existingJulesPath, "utf-8")) || {};
|
|
143
|
+
} catch (_) {}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const customLimits = options.limits || existingConfig.limits;
|
|
147
|
+
const isCustomLimits = customLimits && Object.entries(customLimits).some(
|
|
148
|
+
([k, v]) => v !== undefined && tierPresetLimits[k] !== undefined && Number(v) !== Number(tierPresetLimits[k])
|
|
149
|
+
);
|
|
150
|
+
const limits = isCustomLimits ? { ...tierPresetLimits, ...customLimits } : tierPresetLimits;
|
|
151
|
+
|
|
137
152
|
const verify = {
|
|
138
153
|
test: options.testCmd || existingConfig.verify?.test || oracle.candidates.testCmd || "",
|
|
139
154
|
build: options.buildCmd || existingConfig.verify?.build || oracle.candidates.buildCmd || "",
|
|
@@ -143,19 +158,17 @@ export function planInit(root = process.cwd(), options = {}) {
|
|
|
143
158
|
|
|
144
159
|
const selectedPresets = options.presets || existingConfig.presets || ["nightly-security-audit", "flaky-test-quarantine"];
|
|
145
160
|
|
|
161
|
+
const limitsBlock = isCustomLimits
|
|
162
|
+
? `\nlimits:\n concurrency: ${limits.concurrency}\n daily_tasks: ${limits.daily_tasks}\n stagger_ms: ${limits.stagger_ms}\n diff_kb: ${limits.diff_kb}\n`
|
|
163
|
+
: "";
|
|
164
|
+
|
|
146
165
|
const configYaml = `# Google Jules Orchestrator Kit Config (v${KIT_VERSION})
|
|
147
166
|
version: 1
|
|
148
167
|
provider: ${existingConfig.provider || "jules"}
|
|
149
168
|
tier: ${tierName}
|
|
150
169
|
base_branch: ${options.baseBranch || existingConfig.base_branch || "main"}
|
|
151
170
|
branch_prefix: ${existingConfig.branch_prefix || "agent/"}
|
|
152
|
-
|
|
153
|
-
limits:
|
|
154
|
-
concurrency: ${limits.concurrency}
|
|
155
|
-
daily_tasks: ${limits.daily_tasks}
|
|
156
|
-
stagger_ms: ${limits.stagger_ms}
|
|
157
|
-
diff_kb: ${limits.diff_kb}
|
|
158
|
-
|
|
171
|
+
${limitsBlock}
|
|
159
172
|
verify:
|
|
160
173
|
test: "${verify.test}"
|
|
161
174
|
build: "${verify.build}"
|
|
@@ -166,10 +179,23 @@ presets:
|
|
|
166
179
|
${selectedPresets.map((p) => ` - ${p}`).join("\n")}
|
|
167
180
|
`;
|
|
168
181
|
|
|
169
|
-
const
|
|
170
|
-
|
|
182
|
+
const forbiddenPaths = options.forbiddenPaths || existingJules.forbidden_paths || [
|
|
183
|
+
".github/**",
|
|
184
|
+
"**/secrets/**",
|
|
185
|
+
"**/*.pem",
|
|
186
|
+
"**/lock-manager/**",
|
|
187
|
+
"scripts/jules-self-audit.mjs",
|
|
188
|
+
".agent/jules.yml",
|
|
189
|
+
];
|
|
190
|
+
const allowPaths = options.allowPaths || existingJules.allow_paths || [];
|
|
191
|
+
|
|
192
|
+
const julesYaml = `# Google Jules Repository Configuration (Version 2)
|
|
193
|
+
version: 2
|
|
171
194
|
test_cmd: "${verify.test}"
|
|
172
195
|
build_cmd: "${verify.build}"
|
|
196
|
+
forbidden_paths:
|
|
197
|
+
${forbiddenPaths.map((p) => ` - "${p}"`).join("\n")}
|
|
198
|
+
allow_paths: ${allowPaths.length > 0 ? "\n" + allowPaths.map((p) => ` - "${p}"`).join("\n") : "[]"}
|
|
173
199
|
`;
|
|
174
200
|
|
|
175
201
|
return {
|