jules-orchestrator-kit 0.42.0 → 0.52.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/.agent/prompts/A11y.md +23 -0
- package/.agent/prompts/Alchemist.md +23 -0
- package/.agent/prompts/Scribe.md +23 -0
- package/.agent/prompts/Spectator.md +24 -0
- package/JULES_RULES_TEMPLATE.md +10 -8
- package/README.md +11 -3
- package/bin/agentctl.mjs +428 -1
- package/index.mjs +36 -1
- package/package.json +1 -1
- package/scripts/asset-integrity-check.mjs +1 -1
- package/scripts/rules-lint.mjs +1 -1
- package/src/assertions.mjs +244 -0
- package/src/config.mjs +25 -0
- package/src/coverage.mjs +328 -0
- package/src/engine.mjs +20 -4
- package/src/git.mjs +131 -9
- package/src/mcp.mjs +277 -0
- package/src/mutation.mjs +739 -0
- package/src/ops/checkpoint.mjs +31 -3
- package/src/perf.mjs +92 -0
- package/src/provider.mjs +144 -6
- package/src/remediation.mjs +154 -1
- package/src/rules-budget.mjs +8 -2
- package/src/scaffold.mjs +1 -1
- package/src/security.mjs +286 -28
- package/src/session-ops.mjs +312 -0
- package/src/stability.mjs +86 -0
- package/src/web-templates.mjs +172 -0
- package/src/asset_integrity.mjs +0 -1
- package/src/execution_envelope.mjs +0 -1
- package/src/rules_budget.mjs +0 -1
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 { canonicalizePath } from "./config.mjs";
|
|
4
|
+
import { canonicalizePath, isWindowsAbsolutePath } from "./config.mjs";
|
|
5
5
|
import { detectCrossPackageBoundaryViolations } from "./stack-detector.mjs";
|
|
6
6
|
|
|
7
7
|
export const HIGH_CONFIDENCE_PATTERNS = [
|
|
@@ -208,34 +208,118 @@ export function anonymizePii(text) {
|
|
|
208
208
|
* @param {string} globPattern
|
|
209
209
|
* @param {{ caseInsensitive?: boolean }} [opts]
|
|
210
210
|
*/
|
|
211
|
+
/**
|
|
212
|
+
* Matches one `/`-free glob segment against one `/`-free path segment.
|
|
213
|
+
*
|
|
214
|
+
* Only `*` (zero or more characters) and `?` (exactly one) are special; every
|
|
215
|
+
* other character — including regex metacharacters like `(`, `+`, `.`, `[` —
|
|
216
|
+
* is matched literally, preserving the escaping behaviour the old regex
|
|
217
|
+
* translation had. A segment that is exactly `*` keeps its historical one-or-
|
|
218
|
+
* more semantics, so `*` cannot match an empty segment.
|
|
219
|
+
*
|
|
220
|
+
* Implemented with the classic greedy-star wildcard algorithm rather than a
|
|
221
|
+
* compiled regex: a segment like `*a*a*a*a*a*a*b` translated to
|
|
222
|
+
* `^[^/]*a[^/]*a…$` and backtracked exponentially on a long run of `a`s, so
|
|
223
|
+
* this path must never build a regex. The algorithm scans each character a
|
|
224
|
+
* bounded number of times and cannot blow up the way the regex could.
|
|
225
|
+
*
|
|
226
|
+
* @param {string} str
|
|
227
|
+
* @param {string} pattern
|
|
228
|
+
* @param {boolean} [caseInsensitive]
|
|
229
|
+
* @returns {boolean}
|
|
230
|
+
*/
|
|
231
|
+
function matchGlobSegment(str, pattern, caseInsensitive = false) {
|
|
232
|
+
if (pattern === "*") return str.length > 0;
|
|
233
|
+
|
|
234
|
+
let s = caseInsensitive ? str.toLowerCase() : str;
|
|
235
|
+
let p = caseInsensitive ? pattern.toLowerCase() : pattern;
|
|
236
|
+
|
|
237
|
+
let si = 0;
|
|
238
|
+
let pi = 0;
|
|
239
|
+
let star = -1;
|
|
240
|
+
let matchIdx = 0;
|
|
241
|
+
|
|
242
|
+
while (si < s.length) {
|
|
243
|
+
if (pi < p.length && (p[pi] === "?" || p[pi] === s[si])) {
|
|
244
|
+
si++;
|
|
245
|
+
pi++;
|
|
246
|
+
} else if (pi < p.length && p[pi] === "*") {
|
|
247
|
+
star = pi;
|
|
248
|
+
matchIdx = si;
|
|
249
|
+
pi++;
|
|
250
|
+
} else if (star !== -1) {
|
|
251
|
+
pi = star + 1;
|
|
252
|
+
matchIdx++;
|
|
253
|
+
si = matchIdx;
|
|
254
|
+
} else {
|
|
255
|
+
return false;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
while (pi < p.length && p[pi] === "*") pi++;
|
|
260
|
+
return pi === p.length;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Linear-time glob matcher over `/`-split segments.
|
|
265
|
+
*
|
|
266
|
+
* `**` matches zero or more whole segments; every other pattern segment
|
|
267
|
+
* matches exactly one path segment via `matchGlobSegment`. This is a
|
|
268
|
+
* bottom-up dynamic program with a rolling array: O(n·m) time and O(m) memory
|
|
269
|
+
* for n path and m pattern segments, and it builds no regex at all.
|
|
270
|
+
*
|
|
271
|
+
* The previous implementation translated `**` into overlapping dot-star and
|
|
272
|
+
* start-anchored `(?: … |^)` alternations (`SEC-01`). Anchored against `$`, a
|
|
273
|
+
* pattern like `*a*a*a*a*a*a*a*a*b` or a chain of globstars caused catastrophic
|
|
274
|
+
* backtracking — the match time grew exponentially with input length and a
|
|
275
|
+
* hostile deny rule or file list could stall the dispatch gate. The DP
|
|
276
|
+
* replaces every one of those constructs with a bounded scan.
|
|
277
|
+
*
|
|
278
|
+
* @param {string[]} pathSegs
|
|
279
|
+
* @param {string[]} patSegs
|
|
280
|
+
* @param {boolean} [caseInsensitive]
|
|
281
|
+
* @returns {boolean}
|
|
282
|
+
*/
|
|
283
|
+
function matchGlobSegments(pathSegs, patSegs, caseInsensitive = false) {
|
|
284
|
+
const n = pathSegs.length;
|
|
285
|
+
const m = patSegs.length;
|
|
286
|
+
|
|
287
|
+
// next[j] answers "does pathSegs[i+1..] match patSegs[j..]?". Seeded for the
|
|
288
|
+
// empty-path row (i = n): only true when every remaining pattern segment is
|
|
289
|
+
// `**`, since those are the only segments that can match zero path segments.
|
|
290
|
+
let next = new Array(m + 1).fill(false);
|
|
291
|
+
next[m] = true;
|
|
292
|
+
for (let j = m - 1; j >= 0; j--) {
|
|
293
|
+
next[j] = patSegs[j] === "**" && next[j + 1];
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
for (let i = n - 1; i >= 0; i--) {
|
|
297
|
+
const cur = new Array(m + 1).fill(false);
|
|
298
|
+
// cur[m] stays false: a path segment remains but the pattern is exhausted.
|
|
299
|
+
for (let j = m - 1; j >= 0; j--) {
|
|
300
|
+
if (patSegs[j] === "**") {
|
|
301
|
+
// Consume this segment and keep `**` (next[j]), or match zero segments
|
|
302
|
+
// and move on (cur[j + 1]).
|
|
303
|
+
cur[j] = next[j] || cur[j + 1];
|
|
304
|
+
} else if (matchGlobSegment(pathSegs[i], patSegs[j], caseInsensitive)) {
|
|
305
|
+
cur[j] = next[j + 1];
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
next = cur;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
return next[0];
|
|
312
|
+
}
|
|
313
|
+
|
|
211
314
|
export function matchesGlob(filePath, globPattern, opts = {}) {
|
|
212
315
|
if (!filePath || !globPattern) return false;
|
|
213
316
|
const file = canonicalizePath(filePath);
|
|
214
317
|
const pattern = canonicalizePath(globPattern);
|
|
215
|
-
const
|
|
216
|
-
|
|
217
|
-
if (opts.caseInsensitive ? file.toLowerCase() === pattern.toLowerCase() : file === pattern) return true;
|
|
318
|
+
const caseInsensitive = Boolean(opts.caseInsensitive);
|
|
218
319
|
|
|
219
|
-
|
|
220
|
-
const regexParts = parts.map((part) => {
|
|
221
|
-
if (part === "**") return "___GLOBSTAR___";
|
|
222
|
-
if (part === "*") return "[^/]+";
|
|
223
|
-
const escaped = part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
224
|
-
return escaped.replace(/\\\*/g, "[^/]*").replace(/\\\?/g, ".");
|
|
225
|
-
});
|
|
320
|
+
if (caseInsensitive ? file.toLowerCase() === pattern.toLowerCase() : file === pattern) return true;
|
|
226
321
|
|
|
227
|
-
|
|
228
|
-
regexStr = regexStr
|
|
229
|
-
.replace(/^___GLOBSTAR___\//g, "(?:.*/|^)")
|
|
230
|
-
.replace(/\/___GLOBSTAR___$/g, "(?:/.*|$)")
|
|
231
|
-
.replace(/\/___GLOBSTAR___\//g, "(?:/|/.+/|/)")
|
|
232
|
-
.replace(/___GLOBSTAR___/g, ".*");
|
|
233
|
-
|
|
234
|
-
try {
|
|
235
|
-
return new RegExp(`^${regexStr}$`, flags).test(file);
|
|
236
|
-
} catch (_) {
|
|
237
|
-
return false;
|
|
238
|
-
}
|
|
322
|
+
return matchGlobSegments(file.split("/"), pattern.split("/"), caseInsensitive);
|
|
239
323
|
}
|
|
240
324
|
|
|
241
325
|
export function isForbiddenPath(filePath, config = {}) {
|
|
@@ -256,8 +340,19 @@ export function checkScope(files = [], scope = {}, opts = {}) {
|
|
|
256
340
|
const file = canonicalizePath(rawFile);
|
|
257
341
|
|
|
258
342
|
// A path that climbs out of the repository root can never be legitimate and
|
|
259
|
-
// must not be silently pattern-matched against repo-relative rules.
|
|
260
|
-
|
|
343
|
+
// must not be silently pattern-matched against repo-relative rules. This
|
|
344
|
+
// covers every spelling: POSIX absolute (`/etc/passwd`) and traversal
|
|
345
|
+
// (`../`, `..`), Windows drive-qualified and drive-relative (`C:\...`,
|
|
346
|
+
// `C:/...`, `C:foo`), and UNC (`\\server\share`, `//server/share`). The raw
|
|
347
|
+
// spelling is checked as well as the canonical one, because canonicalisation
|
|
348
|
+
// folds a leading `//` UNC into `/` and the check must fail on both.
|
|
349
|
+
if (
|
|
350
|
+
file === ".." ||
|
|
351
|
+
file.startsWith("../") ||
|
|
352
|
+
file.startsWith("/") ||
|
|
353
|
+
isWindowsAbsolutePath(rawFile) ||
|
|
354
|
+
isWindowsAbsolutePath(file)
|
|
355
|
+
) {
|
|
261
356
|
violations.push({ file, reason: "Path escapes the repository root", rule: "deny", pattern: "<traversal>" });
|
|
262
357
|
continue;
|
|
263
358
|
}
|
|
@@ -393,6 +488,59 @@ export function checkCrossPackageImports(diffOrText = "", root = process.cwd(),
|
|
|
393
488
|
// regex without changing how the value renders, copies, or authenticates.
|
|
394
489
|
const INVISIBLE_CHARS = /[\u00AD\u200B-\u200F\u2028\u2029\u202A-\u202E\u2060-\u2064\uFEFF]/g;
|
|
395
490
|
|
|
491
|
+
// Unicode lookalikes that NFKD does NOT decompose. Full-width and other
|
|
492
|
+
// compatibility forms are handled by String#normalize("NFKD") below; these are
|
|
493
|
+
// the Cyrillic/Greek/Latin homoglyphs that survive NFKD because they are
|
|
494
|
+
// distinct code points with no compatibility decomposition. A credential
|
|
495
|
+
// scanner without this table can be defeated by a single substituted glyph,
|
|
496
|
+
// e.g. `ghp_` spelled with Cyrillic `р`.
|
|
497
|
+
const CONFUSABLE_TO_ASCII = new Map([
|
|
498
|
+
// Cyrillic
|
|
499
|
+
["А", "A"], ["В", "B"], ["Е", "E"], ["К", "K"], ["М", "M"], ["Н", "H"],
|
|
500
|
+
["О", "O"], ["Р", "P"], ["С", "C"], ["Т", "T"], ["У", "Y"], ["Х", "X"],
|
|
501
|
+
["а", "a"], ["е", "e"], ["о", "o"], ["р", "p"], ["с", "c"], ["у", "y"],
|
|
502
|
+
["х", "x"], ["і", "i"], ["ј", "j"], ["ѕ", "s"],
|
|
503
|
+
// Greek
|
|
504
|
+
["Α", "A"], ["Β", "B"], ["Ε", "E"], ["Ζ", "Z"], ["Η", "H"], ["Ι", "I"],
|
|
505
|
+
["Κ", "K"], ["Μ", "M"], ["Ν", "N"], ["Ο", "O"], ["Ρ", "P"], ["Τ", "T"],
|
|
506
|
+
["Υ", "Y"], ["Χ", "X"], ["ο", "o"], ["ι", "i"], ["ν", "v"], ["υ", "u"],
|
|
507
|
+
["ρ", "p"], ["τ", "t"], ["χ", "x"],
|
|
508
|
+
// Other Unicode lookalikes
|
|
509
|
+
["ſ", "s"], // U+017F LATIN SMALL LETTER LONG S
|
|
510
|
+
["K", "K"], // U+212A KELVIN SIGN
|
|
511
|
+
]);
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* Reduces the confusable spellings a credential can hide behind to plain
|
|
515
|
+
* ASCII before the secret patterns run (`SEC-04`).
|
|
516
|
+
*
|
|
517
|
+
* 1. NFKD decomposes full-width and other compatibility forms
|
|
518
|
+
* (`ghp_…` → `ghp_…`).
|
|
519
|
+
* 2. Combining marks the decomposition may leave behind are stripped
|
|
520
|
+
* (`e\u0301` → `e`).
|
|
521
|
+
* 3. The curated lookalike table maps Cyrillic/Greek/Latin homoglyphs that
|
|
522
|
+
* NFKD cannot see through to their ASCII target.
|
|
523
|
+
*
|
|
524
|
+
* This is the zero-dependency subset of Unicode TR39 confusable handling; the
|
|
525
|
+
* full confusables data table is intentionally omitted so the kit keeps
|
|
526
|
+
* shipping with no runtime dependencies.
|
|
527
|
+
*
|
|
528
|
+
* @param {string} str
|
|
529
|
+
* @returns {string}
|
|
530
|
+
*/
|
|
531
|
+
function normalizeSecretText(str) {
|
|
532
|
+
if (!str || typeof str !== "string") return str;
|
|
533
|
+
let out = str;
|
|
534
|
+
try {
|
|
535
|
+
out = out.normalize("NFKD");
|
|
536
|
+
} catch (_) {}
|
|
537
|
+
out = out.replace(/[\u0300-\u036f]/g, "");
|
|
538
|
+
for (const [from, to] of CONFUSABLE_TO_ASCII) {
|
|
539
|
+
out = out.split(from).join(to);
|
|
540
|
+
}
|
|
541
|
+
return out;
|
|
542
|
+
}
|
|
543
|
+
|
|
396
544
|
// A credential split across a source-level string concatenation is invisible to
|
|
397
545
|
// a line-oriented scanner. This is not only an evasion technique — formatters
|
|
398
546
|
// wrap long string literals exactly this way, so it also happens by accident.
|
|
@@ -442,9 +590,14 @@ function secretScanVariants(addedLines) {
|
|
|
442
590
|
|
|
443
591
|
const hexDecoded = tryHexDecodeTokens(dejoined);
|
|
444
592
|
const pctDecoded = tryPercentDecode(dejoined);
|
|
593
|
+
// Confusable / NFKD normalisation runs over both the raw text and the
|
|
594
|
+
// concatenation-collapsed text, so a credential that is both split across a
|
|
595
|
+
// source-level join AND spelled with homoglyphs still surfaces.
|
|
596
|
+
const confusable = normalizeSecretText(stripped);
|
|
597
|
+
const confusableDejoined = normalizeSecretText(dejoined);
|
|
445
598
|
|
|
446
599
|
return {
|
|
447
|
-
all: [...new Set([addedLines, stripped, dejoined, hexDecoded, pctDecoded])],
|
|
600
|
+
all: [...new Set([addedLines, stripped, dejoined, hexDecoded, pctDecoded, confusable, confusableDejoined])],
|
|
448
601
|
normalized: dejoined,
|
|
449
602
|
};
|
|
450
603
|
}
|
|
@@ -600,7 +753,7 @@ function splitDiffByFile(diffText) {
|
|
|
600
753
|
};
|
|
601
754
|
|
|
602
755
|
for (const line of diffText.split("\n")) {
|
|
603
|
-
if (line.startsWith("+++")) {
|
|
756
|
+
if ((line.startsWith("+++ ") || line.startsWith("+++ b/") || line.startsWith("+++ /dev/null")) && !line.startsWith("++++")) {
|
|
604
757
|
const name = line.slice(3).split("\t")[0].trim().replace(/^b\//, "");
|
|
605
758
|
select(name && name !== "/dev/null" ? name : null);
|
|
606
759
|
lineNo = null;
|
|
@@ -673,6 +826,104 @@ function locateFindingLine(lines, type) {
|
|
|
673
826
|
return null;
|
|
674
827
|
}
|
|
675
828
|
|
|
829
|
+
/**
|
|
830
|
+
* Detects test file assertion tampering, weakening, or test skips.
|
|
831
|
+
*
|
|
832
|
+
* @param {string} diffOrText - Unified git diff
|
|
833
|
+
* @param {Object} [options]
|
|
834
|
+
* @param {boolean} [options.allowTestModifications=false]
|
|
835
|
+
* @returns {{ ok: boolean, violations: Array<{ file: string, type: string, line?: number, reason: string }> }}
|
|
836
|
+
*/
|
|
837
|
+
export function checkTestTampering(diffOrText = "", options = {}) {
|
|
838
|
+
if (!diffOrText || typeof diffOrText !== "string") return { ok: true, violations: [] };
|
|
839
|
+
if (options.allowTestModifications === true) return { ok: true, violations: [] };
|
|
840
|
+
|
|
841
|
+
const violations = [];
|
|
842
|
+
const lines = diffOrText.split("\n");
|
|
843
|
+
let currentFile = null;
|
|
844
|
+
let currentLineNo = null;
|
|
845
|
+
|
|
846
|
+
const isTestFile = (f) => {
|
|
847
|
+
if (!f) return false;
|
|
848
|
+
const n = f.replace(/\\/g, "/").toLowerCase();
|
|
849
|
+
return (
|
|
850
|
+
n.includes(".test.") ||
|
|
851
|
+
n.includes(".spec.") ||
|
|
852
|
+
n.includes("_test.") ||
|
|
853
|
+
n.includes("/test/") ||
|
|
854
|
+
n.includes("/tests/") ||
|
|
855
|
+
n.includes("/__tests__/")
|
|
856
|
+
);
|
|
857
|
+
};
|
|
858
|
+
|
|
859
|
+
const SKIP_INJECTIONS = [
|
|
860
|
+
{ pattern: /\b(?:it|test|describe|context)\.skip\s*\(/i, desc: "Injected test skip (.skip())" },
|
|
861
|
+
{ pattern: /\b(?:xit|xtest|xdescribe)\s*\(/i, desc: "Injected disabled test (xit/xtest/xdescribe)" },
|
|
862
|
+
{ pattern: /@pytest\.mark\.skip/i, desc: "Injected Pytest skip decorator (@pytest.mark.skip)" },
|
|
863
|
+
{ pattern: /\bt\.Skip\s*\(/i, desc: "Injected Go test skip (t.Skip())" },
|
|
864
|
+
{ pattern: /#\[ignore\]/i, desc: "Injected Rust test ignore attribute (#[ignore])" },
|
|
865
|
+
{ pattern: /\/\/\s*@ts-ignore/i, desc: "Injected TypeScript ignore comment (// @ts-ignore)" },
|
|
866
|
+
{ pattern: /\/\/\s*@ts-nocheck/i, desc: "Injected TypeScript nocheck directive (// @ts-nocheck)" },
|
|
867
|
+
];
|
|
868
|
+
|
|
869
|
+
const COMMENTED_ASSERTION = /^\+\s*(?:\/\/|\/\*)\s*(?:expect\(|assert\.|assert\(|t\.expect|t\.assert)/i;
|
|
870
|
+
|
|
871
|
+
for (let i = 0; i < lines.length; i++) {
|
|
872
|
+
const line = lines[i];
|
|
873
|
+
if ((line.startsWith("+++ ") || line.startsWith("+++ b/") || line.startsWith("+++ /dev/null")) && !line.startsWith("++++")) {
|
|
874
|
+
const target = line.slice(3).split("\t")[0].trim().replace(/^b\//, "");
|
|
875
|
+
currentFile = target && target !== "/dev/null" ? target : null;
|
|
876
|
+
currentLineNo = null;
|
|
877
|
+
continue;
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
const hunkMatch = /^@@ -\d+(?:,\d+)? \+(\d+)/.exec(line);
|
|
881
|
+
if (hunkMatch) {
|
|
882
|
+
currentLineNo = Number(hunkMatch[1]);
|
|
883
|
+
continue;
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
if (!currentFile || !isTestFile(currentFile)) {
|
|
887
|
+
continue;
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
if (line.startsWith("+") && !line.startsWith("+++")) {
|
|
891
|
+
const addedText = line.slice(1);
|
|
892
|
+
|
|
893
|
+
// Check skip injections
|
|
894
|
+
for (const skipRule of SKIP_INJECTIONS) {
|
|
895
|
+
if (skipRule.pattern.test(addedText)) {
|
|
896
|
+
violations.push({
|
|
897
|
+
file: currentFile,
|
|
898
|
+
line: currentLineNo,
|
|
899
|
+
type: "TEST_SKIP_INJECTION",
|
|
900
|
+
reason: `Test Tamper Guard: ${skipRule.desc} found in ${currentFile}${currentLineNo ? `:${currentLineNo}` : ""}`,
|
|
901
|
+
});
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
// Check commented-out assertions
|
|
906
|
+
if (COMMENTED_ASSERTION.test(line)) {
|
|
907
|
+
violations.push({
|
|
908
|
+
file: currentFile,
|
|
909
|
+
line: currentLineNo,
|
|
910
|
+
type: "COMMENTED_ASSERTION",
|
|
911
|
+
reason: `Test Tamper Guard: Commented-out test assertion detected in ${currentFile}${currentLineNo ? `:${currentLineNo}` : ""}`,
|
|
912
|
+
});
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
if (currentLineNo !== null) currentLineNo++;
|
|
916
|
+
} else if (currentLineNo !== null && !line.startsWith("-") && !line.startsWith("\\")) {
|
|
917
|
+
currentLineNo++;
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
return {
|
|
922
|
+
ok: violations.length === 0,
|
|
923
|
+
violations,
|
|
924
|
+
};
|
|
925
|
+
}
|
|
926
|
+
|
|
676
927
|
export function scanDiff(diffTextStr = "", options = {}) {
|
|
677
928
|
if (!diffTextStr) return { ok: true, findings: [] };
|
|
678
929
|
|
|
@@ -727,8 +978,15 @@ export function scanDiff(diffTextStr = "", options = {}) {
|
|
|
727
978
|
}
|
|
728
979
|
}
|
|
729
980
|
|
|
981
|
+
const tamperingRes = checkTestTampering(diffTextStr, options);
|
|
982
|
+
if (!tamperingRes.ok) {
|
|
983
|
+
for (const v of tamperingRes.violations) {
|
|
984
|
+
findings.push({ severity: "CRITICAL", type: "TEST_TAMPERING_DETECTED", file: v.file ?? null, line: v.line ?? null, description: v.reason });
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
|
|
730
988
|
return {
|
|
731
|
-
ok: secretsOk && edgeRes.ok && crossPkgRes.ok,
|
|
989
|
+
ok: secretsOk && edgeRes.ok && crossPkgRes.ok && tamperingRes.ok,
|
|
732
990
|
findings,
|
|
733
991
|
};
|
|
734
992
|
}
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { writeFileSync } from "node:fs";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
import { createProvider } from "./provider.mjs";
|
|
5
|
+
import { loadConfig, resolveRoot } from "./config.mjs";
|
|
6
|
+
import { redactSecrets } from "./security.mjs";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Parses human-readable duration strings (e.g. "7d", "24h", "30m", "60s") into milliseconds.
|
|
10
|
+
*
|
|
11
|
+
* @param {string|number} duration
|
|
12
|
+
* @returns {number} Milliseconds
|
|
13
|
+
*/
|
|
14
|
+
export function parseAgeDuration(duration) {
|
|
15
|
+
if (typeof duration === "number" && Number.isFinite(duration)) return duration;
|
|
16
|
+
if (!duration || typeof duration !== "string") return 0;
|
|
17
|
+
const match = duration.trim().match(/^(\d+(?:\.\d+)?)\s*([smhdw])$/i);
|
|
18
|
+
if (!match) {
|
|
19
|
+
const num = Number(duration);
|
|
20
|
+
return Number.isFinite(num) ? num : 0;
|
|
21
|
+
}
|
|
22
|
+
const val = parseFloat(match[1]);
|
|
23
|
+
const unit = match[2].toLowerCase();
|
|
24
|
+
switch (unit) {
|
|
25
|
+
case "s": return val * 1000;
|
|
26
|
+
case "m": return val * 60 * 1000;
|
|
27
|
+
case "h": return val * 60 * 60 * 1000;
|
|
28
|
+
case "d": return val * 24 * 60 * 60 * 1000;
|
|
29
|
+
case "w": return val * 7 * 24 * 60 * 60 * 1000;
|
|
30
|
+
default: return 0;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Extracts git diff patch, PR details, and modified files from a Jules session.
|
|
36
|
+
*
|
|
37
|
+
* @param {string} sessionId
|
|
38
|
+
* @param {object} opts
|
|
39
|
+
* @returns {Promise<{ ok: boolean, id: string, patch: string, pr: object|null, files: string[], error?: string }>}
|
|
40
|
+
*/
|
|
41
|
+
export async function extractSessionPatch(sessionId, opts = {}) {
|
|
42
|
+
if (!sessionId || typeof sessionId !== "string") {
|
|
43
|
+
throw new TypeError("extractSessionPatch requires a valid sessionId string");
|
|
44
|
+
}
|
|
45
|
+
const root = opts.root || resolveRoot();
|
|
46
|
+
const config = opts.config || loadConfig(root);
|
|
47
|
+
const provider = opts.provider || createProvider(config.provider || "jules", config);
|
|
48
|
+
|
|
49
|
+
const session = await provider.getSession(sessionId, opts);
|
|
50
|
+
const activitiesRes = await provider.listActivities(sessionId, opts).catch(() => ({ activities: [] }));
|
|
51
|
+
const activities = activitiesRes.activities || [];
|
|
52
|
+
|
|
53
|
+
let patch = "";
|
|
54
|
+
let pr = null;
|
|
55
|
+
const files = new Set();
|
|
56
|
+
|
|
57
|
+
// 1. Inspect session outputs
|
|
58
|
+
const outputs = session?.raw?.outputs || session?.outputs || [];
|
|
59
|
+
for (const out of outputs) {
|
|
60
|
+
if (out.pullRequest) {
|
|
61
|
+
pr = out.pullRequest;
|
|
62
|
+
}
|
|
63
|
+
if (out.gitPatch || out.patch || out.diff) {
|
|
64
|
+
patch = out.gitPatch || out.patch || out.diff;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// 2. Inspect activity artifacts and logs if patch wasn't in top-level output
|
|
69
|
+
for (const act of activities) {
|
|
70
|
+
if (act.pullRequest) pr = act.pullRequest;
|
|
71
|
+
if (act.gitPatch || act.patch || act.diff) {
|
|
72
|
+
patch = patch || act.gitPatch || act.patch || act.diff;
|
|
73
|
+
}
|
|
74
|
+
if (act.artifacts && Array.isArray(act.artifacts)) {
|
|
75
|
+
for (const art of act.artifacts) {
|
|
76
|
+
if (art.patch || art.diff || (typeof art.content === "string" && art.content.startsWith("diff --git"))) {
|
|
77
|
+
patch = patch || art.patch || art.diff || art.content;
|
|
78
|
+
}
|
|
79
|
+
if (art.path || art.filename) {
|
|
80
|
+
files.add(art.path || art.filename);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
if (act.changedFiles && Array.isArray(act.changedFiles)) {
|
|
85
|
+
act.changedFiles.forEach((f) => files.add(f));
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Extract changed files from patch headers if files set is empty
|
|
90
|
+
if (patch && files.size === 0) {
|
|
91
|
+
const fileMatches = patch.matchAll(/^diff --git a\/(.+?) b\/(.+?)$/gm);
|
|
92
|
+
for (const m of fileMatches) {
|
|
93
|
+
files.add(m[2]);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
ok: Boolean(patch || pr),
|
|
99
|
+
id: sessionId,
|
|
100
|
+
patch: patch ? (patch.endsWith("\n") ? patch : patch + "\n") : "",
|
|
101
|
+
pr,
|
|
102
|
+
files: Array.from(files),
|
|
103
|
+
rawSession: session.raw,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Applies a Jules session's patch to the local working tree with git apply safety checks.
|
|
109
|
+
*
|
|
110
|
+
* @param {string} sessionId
|
|
111
|
+
* @param {object} opts
|
|
112
|
+
* @returns {Promise<{ ok: boolean, patchApplied: boolean, checkPassed: boolean, patch: string, files: string[], error?: string }>}
|
|
113
|
+
*/
|
|
114
|
+
export async function applySessionPatch(sessionId, opts = {}) {
|
|
115
|
+
const root = opts.root || resolveRoot();
|
|
116
|
+
const res = await extractSessionPatch(sessionId, { ...opts, root });
|
|
117
|
+
|
|
118
|
+
if (!res.patch) {
|
|
119
|
+
return {
|
|
120
|
+
ok: false,
|
|
121
|
+
patchApplied: false,
|
|
122
|
+
checkPassed: false,
|
|
123
|
+
patch: "",
|
|
124
|
+
files: res.files || [],
|
|
125
|
+
error: "No git patch found in session output or activities.",
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (opts.save) {
|
|
130
|
+
const savePath = resolve(root, opts.save);
|
|
131
|
+
writeFileSync(savePath, res.patch, "utf-8");
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// 1. Dry run: git apply --check
|
|
135
|
+
const checkRes = spawnSync("git", ["apply", "--check", "-"], {
|
|
136
|
+
cwd: root,
|
|
137
|
+
input: res.patch,
|
|
138
|
+
encoding: "utf-8",
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
if (checkRes.status !== 0) {
|
|
142
|
+
const stderr = (checkRes.stderr || "").trim();
|
|
143
|
+
return {
|
|
144
|
+
ok: false,
|
|
145
|
+
patchApplied: false,
|
|
146
|
+
checkPassed: false,
|
|
147
|
+
patch: res.patch,
|
|
148
|
+
files: res.files,
|
|
149
|
+
error: `git apply --check failed: ${stderr || "Patch does not cleanly apply to working directory"}`,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// 2. If apply requested, execute git apply
|
|
154
|
+
if (opts.apply) {
|
|
155
|
+
const applyRes = spawnSync("git", ["apply", "-"], {
|
|
156
|
+
cwd: root,
|
|
157
|
+
input: res.patch,
|
|
158
|
+
encoding: "utf-8",
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
if (applyRes.status !== 0) {
|
|
162
|
+
const stderr = (applyRes.stderr || "").trim();
|
|
163
|
+
return {
|
|
164
|
+
ok: false,
|
|
165
|
+
patchApplied: false,
|
|
166
|
+
checkPassed: true,
|
|
167
|
+
patch: res.patch,
|
|
168
|
+
files: res.files,
|
|
169
|
+
error: `git apply failed: ${stderr}`,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return {
|
|
174
|
+
ok: true,
|
|
175
|
+
patchApplied: true,
|
|
176
|
+
checkPassed: true,
|
|
177
|
+
patch: res.patch,
|
|
178
|
+
files: res.files,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return {
|
|
183
|
+
ok: true,
|
|
184
|
+
patchApplied: false,
|
|
185
|
+
checkPassed: true,
|
|
186
|
+
patch: res.patch,
|
|
187
|
+
files: res.files,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Gathers failure context and error output from a failed session and synthesizes a new OODA retry session.
|
|
193
|
+
*
|
|
194
|
+
* @param {string} sessionId
|
|
195
|
+
* @param {object} opts
|
|
196
|
+
* @returns {Promise<{ ok: boolean, originalSessionId: string, newSession: object, failureReason: string }>}
|
|
197
|
+
*/
|
|
198
|
+
export async function retrySession(sessionId, opts = {}) {
|
|
199
|
+
if (!sessionId || typeof sessionId !== "string") {
|
|
200
|
+
throw new TypeError("retrySession requires a valid sessionId string");
|
|
201
|
+
}
|
|
202
|
+
const root = opts.root || resolveRoot();
|
|
203
|
+
const config = opts.config || loadConfig(root);
|
|
204
|
+
const provider = opts.provider || createProvider(config.provider || "jules", config);
|
|
205
|
+
|
|
206
|
+
const session = await provider.getSession(sessionId, opts);
|
|
207
|
+
const activitiesRes = await provider.listActivities(sessionId, opts).catch(() => ({ activities: [] }));
|
|
208
|
+
const activities = activitiesRes.activities || [];
|
|
209
|
+
|
|
210
|
+
// Extract failure diagnostics
|
|
211
|
+
const errors = [];
|
|
212
|
+
for (const act of activities) {
|
|
213
|
+
if (act.error) errors.push(typeof act.error === "string" ? act.error : JSON.stringify(act.error));
|
|
214
|
+
if (act.executionOutput && (act.exitCode !== 0 || act.status === "FAILED")) {
|
|
215
|
+
errors.push(act.executionOutput);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const rawPrompt = session?.raw?.prompt || session?.prompt || "";
|
|
220
|
+
const title = session?.raw?.title || `Retry of Session ${sessionId}`;
|
|
221
|
+
const failureReason = errors.join("\n").slice(0, 4000) || "Previous session did not complete cleanly.";
|
|
222
|
+
|
|
223
|
+
let synthesizedPrompt = rawPrompt;
|
|
224
|
+
if (opts.withFailure !== false && failureReason) {
|
|
225
|
+
synthesizedPrompt = `${rawPrompt}\n\n[PREVIOUS_ATTEMPT_FAILURE_DIAGNOSTIC]\nThe previous automated session failed with the following error output:\n\`\`\`\n${redactSecrets(failureReason)}\n\`\`\`\nPlease analyze this failure, fix the root cause, and ensure all tests pass.\n`;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const source = session?.raw?.sourceContext?.source || opts.source;
|
|
229
|
+
const branch = session?.raw?.sourceContext?.githubRepoContext?.startingBranch || opts.branch || "main";
|
|
230
|
+
|
|
231
|
+
const dispatchRes = await provider.dispatch(
|
|
232
|
+
{
|
|
233
|
+
title: opts.title || title,
|
|
234
|
+
prompt: synthesizedPrompt,
|
|
235
|
+
role: opts.role,
|
|
236
|
+
source,
|
|
237
|
+
branch,
|
|
238
|
+
},
|
|
239
|
+
opts
|
|
240
|
+
);
|
|
241
|
+
|
|
242
|
+
return {
|
|
243
|
+
ok: true,
|
|
244
|
+
originalSessionId: sessionId,
|
|
245
|
+
newSession: dispatchRes,
|
|
246
|
+
failureReason,
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Queries and batch-archives old or completed Jules sessions based on age and state filters.
|
|
252
|
+
*
|
|
253
|
+
* @param {object} opts
|
|
254
|
+
* @returns {Promise<{ ok: boolean, matchedCount: number, archivedCount: number, sessions: Array<{ id: string, state: string, createTime: string, action: string }> }>}
|
|
255
|
+
*/
|
|
256
|
+
export async function pruneSessions(opts = {}) {
|
|
257
|
+
const root = opts.root || resolveRoot();
|
|
258
|
+
const config = opts.config || loadConfig(root);
|
|
259
|
+
const provider = opts.provider || createProvider(config.provider || "jules", config);
|
|
260
|
+
|
|
261
|
+
const listRes = await provider.listSessions({ ...opts, pageSize: opts.pageSize || 100 });
|
|
262
|
+
const allSessions = listRes.sessions || [];
|
|
263
|
+
|
|
264
|
+
const maxAgeMs = opts.age ? parseAgeDuration(opts.age) : 0;
|
|
265
|
+
const now = Date.now();
|
|
266
|
+
const targetState = opts.state ? String(opts.state).toUpperCase() : null;
|
|
267
|
+
|
|
268
|
+
const matched = [];
|
|
269
|
+
for (const s of allSessions) {
|
|
270
|
+
const state = (s.state || s.status || "UNKNOWN").toUpperCase();
|
|
271
|
+
if (targetState && state !== targetState) continue;
|
|
272
|
+
|
|
273
|
+
if (maxAgeMs > 0) {
|
|
274
|
+
const timeStr = s.createTime || s.updateTime;
|
|
275
|
+
const sessionTime = timeStr ? new Date(timeStr).getTime() : 0;
|
|
276
|
+
if (sessionTime > 0 && now - sessionTime < maxAgeMs) {
|
|
277
|
+
continue; // Session is newer than the cutoff age
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
matched.push(s);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const results = [];
|
|
285
|
+
for (const s of matched) {
|
|
286
|
+
const id = s.id || (s.name ? s.name.split("/").pop() : "");
|
|
287
|
+
if (!id) continue;
|
|
288
|
+
|
|
289
|
+
if (opts.dryRun) {
|
|
290
|
+
results.push({ id, state: s.state || s.status, createTime: s.createTime, action: "DRY_RUN_SKIP" });
|
|
291
|
+
} else {
|
|
292
|
+
try {
|
|
293
|
+
if (opts.delete) {
|
|
294
|
+
await provider.deleteSession(id, opts);
|
|
295
|
+
results.push({ id, state: s.state || s.status, createTime: s.createTime, action: "DELETED" });
|
|
296
|
+
} else {
|
|
297
|
+
await provider.archiveSession(id, opts);
|
|
298
|
+
results.push({ id, state: s.state || s.status, createTime: s.createTime, action: "ARCHIVED" });
|
|
299
|
+
}
|
|
300
|
+
} catch (err) {
|
|
301
|
+
results.push({ id, state: s.state || s.status, createTime: s.createTime, action: `ERROR: ${err.message}` });
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
return {
|
|
307
|
+
ok: true,
|
|
308
|
+
matchedCount: matched.length,
|
|
309
|
+
archivedCount: results.filter((r) => r.action === "ARCHIVED" || r.action === "DELETED").length,
|
|
310
|
+
sessions: results,
|
|
311
|
+
};
|
|
312
|
+
}
|