jules-orchestrator-kit 0.38.0 → 0.38.2

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.
Files changed (44) hide show
  1. package/.agent/rules/jules-protocol.md +3 -0
  2. package/JULES_RULES_TEMPLATE.md +3 -1
  3. package/README.md +163 -307
  4. package/bin/agentctl.mjs +177 -7
  5. package/bin/init.js +8 -8
  6. package/index.mjs +14 -2
  7. package/package.json +1 -1
  8. package/scripts/command-resolver.mjs +1 -2
  9. package/scripts/doc-sync-check.mjs +36 -1
  10. package/scripts/jules-create.mjs +1 -1
  11. package/scripts/jules-dispatch.mjs +1 -2
  12. package/scripts/jules-nightly.mjs +2 -2
  13. package/scripts/jules-patch.mjs +2 -2
  14. package/scripts/jules-queue-runner.mjs +1 -1
  15. package/scripts/jules-scan-todos.mjs +1 -1
  16. package/scripts/jules-self-audit.mjs +1 -2
  17. package/scripts/jules-status.mjs +1 -1
  18. package/scripts/release.mjs +55 -0
  19. package/scripts/run-tests.mjs +15 -1
  20. package/scripts/utils.mjs +14 -9
  21. package/src/asset-integrity.mjs +76 -0
  22. package/src/asset_integrity.mjs +1 -76
  23. package/src/config.mjs +42 -23
  24. package/src/dag-engine.mjs +44 -6
  25. package/src/engine.mjs +18 -11
  26. package/src/execution-envelope.mjs +111 -0
  27. package/src/execution_envelope.mjs +1 -111
  28. package/src/git.mjs +6 -3
  29. package/src/merge-blocks.mjs +178 -1
  30. package/src/ops/command-registry.mjs +25 -0
  31. package/src/ops/handover.mjs +411 -0
  32. package/src/ops/ide-scaffold.mjs +3 -3
  33. package/src/ops/next-step.mjs +6 -2
  34. package/src/prompt-guard.mjs +34 -3
  35. package/src/provider.mjs +191 -86
  36. package/src/role-resolver.mjs +31 -0
  37. package/src/rules-budget.mjs +179 -0
  38. package/src/rules_budget.mjs +1 -179
  39. package/src/security.mjs +98 -56
  40. package/src/state.mjs +11 -6
  41. package/src/task-optimizer.mjs +10 -2
  42. package/src/tui.mjs +1 -1
  43. package/src/webhook.mjs +2 -2
  44. package/src/wizard-task.mjs +4 -30
@@ -1,179 +1 @@
1
- import { readFileSync, readdirSync, statSync } from "node:fs";
2
- import { join } from "node:path";
3
- import { createHash } from "node:crypto";
4
-
5
- const DEFAULT_MAX_CHARS = 10000; // Safe threshold below Antigravity 12k & Claude 25k limits
6
- const DEFAULT_MAX_LINES = 250;
7
-
8
- export const SENTINEL_BEGIN = "JULES_RULES_SENTINEL BEGIN";
9
- export const SENTINEL_END = "JULES_RULES_SENTINEL END";
10
-
11
- /**
12
- * Audit character and line counts of compiled instruction files to prevent truncation.
13
- *
14
- * @param {string} root - Project root directory
15
- * @param {Object} [opts] - Options (maxChars, maxLines)
16
- * @returns {{ ok: boolean, violations: Array<{ path: string, charCount: number, lineCount: number, reason: string }> }}
17
- */
18
- export function checkRulesBudget(root = process.cwd(), opts = {}) {
19
- const maxChars = opts.maxChars ?? DEFAULT_MAX_CHARS;
20
- const maxLines = opts.maxLines ?? DEFAULT_MAX_LINES;
21
- const violations = [];
22
-
23
- const candidateFiles = [
24
- "AGENTS.md",
25
- "CLAUDE.md",
26
- "GEMINI.md",
27
- ".cursorrules",
28
- "JULES_RULES_TEMPLATE.md",
29
- ];
30
-
31
- // Scan .agent/rules/ directory if it exists
32
- const rulesDir = join(root, ".agent", "rules");
33
- try {
34
- const files = readdirSync(rulesDir);
35
- for (const f of files) {
36
- if (f.endsWith(".md")) {
37
- candidateFiles.push(join(".agent", "rules", f));
38
- }
39
- }
40
- } catch (_) {}
41
-
42
- for (const relPath of candidateFiles) {
43
- const fullPath = join(root, relPath);
44
- if (!existsSync(fullPath)) continue;
45
-
46
- try {
47
- const content = readFileSync(fullPath, "utf-8");
48
- const charCount = content.length;
49
- const lineCount = content.split("\n").length;
50
-
51
- if (charCount > maxChars) {
52
- violations.push({
53
- path: relPath,
54
- charCount,
55
- lineCount,
56
- reason: `Exceeds max character budget of ${maxChars} chars (${charCount} chars). Antigravity silently truncates rules > 12,000 chars.`,
57
- });
58
- } else if (lineCount > maxLines) {
59
- violations.push({
60
- path: relPath,
61
- charCount,
62
- lineCount,
63
- reason: `Exceeds max line budget of ${maxLines} lines (${lineCount} lines). Claude Code auto-memory truncates > 200 lines.`,
64
- });
65
- }
66
- } catch (_) {}
67
- }
68
-
69
- return {
70
- ok: violations.length === 0,
71
- violations,
72
- };
73
- }
74
-
75
- /**
76
- * Compiles markdown/rules files into a single unified context block wrapped
77
- * in SHA-256 and byte-length anti-truncation sentinels.
78
- */
79
- export function compileRules(root = process.cwd(), _opts = {}) {
80
- const candidateFiles = [
81
- "AGENTS.md",
82
- "CLAUDE.md",
83
- "GEMINI.md",
84
- ".cursorrules",
85
- "JULES_RULES_TEMPLATE.md",
86
- ];
87
-
88
- const rulesDir = join(root, ".agent", "rules");
89
- try {
90
- const files = readdirSync(rulesDir);
91
- for (const f of files) {
92
- if (f.endsWith(".md")) {
93
- candidateFiles.push(join(".agent", "rules", f));
94
- }
95
- }
96
- } catch (_) {}
97
-
98
- const sources = [];
99
- const sections = [];
100
-
101
- for (const relPath of candidateFiles) {
102
- const fullPath = join(root, relPath);
103
- if (!existsSync(fullPath)) continue;
104
- try {
105
- const raw = readFileSync(fullPath, "utf-8");
106
- const body = raw.replace(/^\uFEFF?---\r?\n[\s\S]*?\r?\n---\r?\n?/, "").trim();
107
- if (!body) continue;
108
- sections.push(`<!-- source: ${relPath} -->\n${body}`);
109
- sources.push(relPath);
110
- } catch (_) {}
111
- }
112
-
113
- const body = sections.join("\n\n---\n\n");
114
- const bodyLen = Buffer.byteLength(body, "utf-8");
115
- const sha256 = createHash("sha256").update(body).digest("hex");
116
-
117
- const header = `<!-- ${SENTINEL_BEGIN} len=${bodyLen} sha256=${sha256} -->`;
118
- const footer = `<!-- ${SENTINEL_END} len=${bodyLen} sha256=${sha256} -->`;
119
- const compiled = `${header}\n${body}\n${footer}\n`;
120
-
121
- return { compiled, body, bodyLen, sha256, sources };
122
- }
123
-
124
- /**
125
- * Verifies that a compiled rule string has not been truncated or tampered with by downstream LLMs.
126
- */
127
- export function verifyRulesSentinel(compiled) {
128
- const errors = [];
129
- if (typeof compiled !== "string" || compiled.length === 0) {
130
- return { ok: false, errors: ["empty input"] };
131
- }
132
-
133
- const beginRe = new RegExp(`<!--\\s*${SENTINEL_BEGIN.replace(/ /g, "\\s+")}\\s+len=(\\d+)\\s+sha256=([0-9a-f]{64})\\s*-->`);
134
- const endRe = new RegExp(`<!--\\s*${SENTINEL_END.replace(/ /g, "\\s+")}\\s+len=(\\d+)\\s+sha256=([0-9a-f]{64})\\s*-->`);
135
-
136
- const beginMatch = beginRe.exec(compiled);
137
- const endMatch = endRe.exec(compiled);
138
-
139
- if (!beginMatch) { errors.push("missing or malformed BEGIN sentinel"); return { ok: false, errors }; }
140
- if (!endMatch) { errors.push("missing or malformed END sentinel"); return { ok: false, errors }; }
141
-
142
- const beginLen = Number(beginMatch[1]);
143
- const beginSha = beginMatch[2];
144
- const endLen = Number(endMatch[1]);
145
- const endSha = endMatch[2];
146
-
147
- if (beginLen !== endLen) errors.push(`length mismatch: BEGIN=${beginLen} END=${endLen}`);
148
- if (beginSha !== endSha) errors.push("checksum mismatch between BEGIN and END sentinels");
149
-
150
- const bodyStart = beginMatch.index + beginMatch[0].length;
151
- const bodyEnd = endMatch.index;
152
- const body = compiled.slice(bodyStart, bodyEnd).replace(/^\r?\n/, "").replace(/\r?\n$/, "");
153
- const actualLen = Buffer.byteLength(body, "utf-8");
154
-
155
- if (actualLen !== beginLen) {
156
- errors.push(`body length ${actualLen} != declared ${beginLen} (possible truncation or injection)`);
157
- }
158
-
159
- const recomputed = createHash("sha256").update(body).digest("hex");
160
- if (recomputed !== beginSha) {
161
- errors.push(`body checksum ${recomputed} != declared ${beginSha} (content tampered or truncated)`);
162
- }
163
-
164
- return {
165
- ok: errors.length === 0,
166
- errors,
167
- bodyLen: actualLen,
168
- sha256: recomputed,
169
- };
170
- }
171
-
172
- function existsSync(p) {
173
- try {
174
- return statSync(p).isFile();
175
- } catch (_) {
176
- return false;
177
- }
178
- }
179
-
1
+ export * from "./rules-budget.mjs";
package/src/security.mjs CHANGED
@@ -14,20 +14,21 @@ export const HIGH_CONFIDENCE_PATTERNS = [
14
14
 
15
15
  /\bAKIA[0-9A-Z]{16}\b/g,
16
16
  /\bASIA[0-9A-Z]{16}\b/g,
17
- /\baws_secret_access_key\s*=\s*['"]?[A-Za-z0-9\/+=]{40}['"]?/gi,
17
+ /\baws_secret_access_key\s*[:=]\s*['"]?[A-Za-z0-9\/+=]{40}['"]?/gi,
18
18
 
19
- /-----BEGIN (?:RSA|DSA|EC|OPENSSH|PRIVATE)(?:\s+PRIVATE)? KEY-----[\s\S]*?-----END (?:RSA|DSA|EC|OPENSSH|PRIVATE)(?:\s+PRIVATE)? KEY-----/g,
19
+ /-----BEGIN (?:RSA|DSA|EC|OPENSSH|ENCRYPTED|PRIVATE)(?:\s+PRIVATE)? KEY-----[\s\S]*?-----END (?:RSA|DSA|EC|OPENSSH|ENCRYPTED|PRIVATE)(?:\s+PRIVATE)? KEY-----/g,
20
20
  /PuTTY-User-Key-File-[0-9]:[^\n]+/g,
21
21
 
22
22
  /\bsk_live_[0-9a-zA-Z]{24,99}\b/g,
23
23
  /\brk_live_[0-9a-zA-Z]{24,99}\b/g,
24
24
  /\bnpm_[0-9a-zA-Z]{36}\b/g,
25
+ /\/\/[^/\s]+\/:_authToken=[A-Za-z0-9_-]{20,}/g,
25
26
  /\bglpat-[0-9a-zA-Z_-]{20,99}\b/g,
26
27
  /\bGOCSPX-[0-9a-zA-Z_-]{28,99}\b/g,
27
28
 
28
29
  /\bAIzaSy[A-Za-z0-9_-]{33}\b/g,
29
30
  /\bya29\.[A-Za-z0-9_-]{20,255}\b/g,
30
- /\b(?:sk-ant-api03-|sk-proj-)[A-Za-z0-9_-]{32,255}\b/g,
31
+ /\b(?:sk-ant-api03-|sk-proj-|sk-)[A-Za-z0-9_-]{20,255}\b/g,
31
32
 
32
33
  /https:\/\/hooks\.slack\.com\/services\/T[A-Za-z0-9_]{8,}\/B[A-Za-z0-9_]{8,}\/[A-Za-z0-9_]{24,}/g,
33
34
  /\bxox[baprs]-[0-9a-zA-Z-]{10,48}\b/g,
@@ -39,21 +40,35 @@ export const LOW_CONFIDENCE_PATTERNS = [
39
40
  /Bearer\s+[A-Za-z0-9._~+/-]{10,255}/gi,
40
41
  /Authorization:\s*Bearer\s+[A-Za-z0-9._~+/-]{10,255}/gi,
41
42
  /\bsk_test_[0-9a-zA-Z]{24,99}\b/g,
42
- /(?:api[_-]?key|secret|password|passwd|token|auth[_-]?token)\s*[:=]\s*['"]([^'"]{8,128})['"]/gi,
43
+ /(?:api[_-]?key|secret|password|passwd|token|auth[_-]?token)\s*[:=]\s*(?:['"`]([^'"`\n]{8,128})['"`]|([A-Za-z0-9._~+/-]{16,128}))/gi,
43
44
  ];
44
45
 
46
+ export function safeRenameSync(src, dst) {
47
+ try {
48
+ renameSync(src, dst);
49
+ } catch (err) {
50
+ if (process.platform === "win32" && (err.code === "EEXIST" || err.code === "EPERM")) {
51
+ try { unlinkSync(dst); } catch (_) {}
52
+ renameSync(src, dst);
53
+ } else {
54
+ throw err;
55
+ }
56
+ }
57
+ }
58
+
45
59
  /**
46
60
  * TOCTOU-safe atomic file write using O_CREAT|O_EXCL|O_WRONLY temp file in target dir.
47
61
  */
48
62
  export function safeAtomicWrite(filePath, content, options = {}) {
49
63
  const mode = options.mode || 0o644;
50
64
  const encoding = options.encoding || "utf-8";
65
+ const rejectSymlinks = options.rejectSymlinks !== false;
51
66
 
52
67
  if (existsSync(filePath)) {
53
68
  const stat = lstatSync(filePath);
54
69
  if (stat.isSymbolicLink()) {
55
70
  const realPath = realpathSync(filePath);
56
- if (options.rejectSymlinks) {
71
+ if (rejectSymlinks) {
57
72
  throw new Error(`TOCTOU Guard: Refusing to write to symlink ${filePath} -> ${realPath}`);
58
73
  }
59
74
  }
@@ -71,7 +86,7 @@ export function safeAtomicWrite(filePath, content, options = {}) {
71
86
  }
72
87
  closeSync(fd);
73
88
  fd = undefined;
74
- renameSync(tempFile, filePath);
89
+ safeRenameSync(tempFile, filePath);
75
90
  return true;
76
91
  } catch (err) {
77
92
  if (fd !== undefined) {
@@ -381,52 +396,69 @@ const INVISIBLE_CHARS = /[\u00AD\u200B-\u200F\u2028\u2029\u202A-\u202E\u2060-\u2
381
396
  // A credential split across a source-level string concatenation is invisible to
382
397
  // a line-oriented scanner. This is not only an evasion technique — formatters
383
398
  // wrap long string literals exactly this way, so it also happens by accident.
384
- const STRING_CONCAT_JOIN = /(["'`])\s*\+\s*(["'`])/g;
399
+ const STRING_CONCAT_JOIN = /(["'`])\s*(?:\/\*[\s\S]*?\*\/)?\s*\+\s*(?:\/\*[\s\S]*?\*\/)?\s*(["'`])/g;
400
+
401
+ function tryHexDecodeTokens(str) {
402
+ return str.replace(/\b([0-9a-fA-F]{24,})\b/g, (match) => {
403
+ if (match.length % 2 !== 0) return match;
404
+ try {
405
+ const decoded = Buffer.from(match, "hex").toString("utf-8");
406
+ if (printableRatio(decoded) >= 0.9) return decoded;
407
+ } catch (_) {}
408
+ return match;
409
+ });
410
+ }
411
+
412
+ function tryPercentDecode(str) {
413
+ try {
414
+ return decodeURIComponent(str);
415
+ } catch (_) {
416
+ return str.replace(/%([0-9a-fA-F]{2})/g, (_, hex) => {
417
+ try {
418
+ return String.fromCharCode(parseInt(hex, 16));
419
+ } catch (_) {
420
+ return _;
421
+ }
422
+ });
423
+ }
424
+ }
385
425
 
386
426
  /**
387
427
  * Produces the variants of the added-line text that secret patterns are run
388
428
  * against: as-written, with invisible characters stripped, and with
389
429
  * source-level string concatenation collapsed.
390
430
  *
391
- * `normalized` is the last of those — every normalisation applied. It is
392
- * returned separately for checks that are too expensive to run three times and
393
- * gain nothing from the intermediate forms.
394
- *
395
431
  * @param {string} addedLines
396
432
  * @returns {{ all: string[], normalized: string }}
397
433
  */
398
434
  function secretScanVariants(addedLines) {
399
435
  const stripped = addedLines.replace(INVISIBLE_CHARS, "");
400
436
  // Collapse `"AAA" +\n "BBB"` into `"AAABBB"` before matching.
401
- const dejoined = stripped.replace(/\s*\n\s*/g, " ").replace(STRING_CONCAT_JOIN, "");
402
- return { all: [...new Set([addedLines, stripped, dejoined])], normalized: dejoined };
437
+ let dejoined = stripped.replace(/\s*\n\s*/g, " ").replace(STRING_CONCAT_JOIN, "");
438
+ // Collapse template literal empty expressions `${""}` and `${"VALUE"}`
439
+ dejoined = dejoined.replace(/\$\{\s*["'`]{2}\s*\}/g, "").replace(/\$\{\s*["'`]([^"'`]+)["'`]\s*\}/g, "$1");
440
+ // Collapse method concatenations like .concat("...") or .join("")
441
+ dejoined = dejoined.replace(/\.concat\(\s*["'`]/g, "").replace(/\.join\(\s*["'`]{2}\s*\)/g, "");
442
+
443
+ const hexDecoded = tryHexDecodeTokens(dejoined);
444
+ const pctDecoded = tryPercentDecode(dejoined);
445
+
446
+ return {
447
+ all: [...new Set([addedLines, stripped, dejoined, hexDecoded, pctDecoded])],
448
+ normalized: dejoined,
449
+ };
403
450
  }
404
451
 
405
452
  // Base64 is less an evasion technique than a file format. Every value in a
406
453
  // Kubernetes Secret manifest is base64 by specification, and whole `.env` files
407
- // get encoded into a single CI variable. A credential arriving that way is
408
- // ordinary rather than adversarial — and a line-oriented scanner walks straight
409
- // past it, which makes this the encoding most likely to carry a live key
410
- // through the gate.
411
- const BASE64_CANDIDATE = /[A-Za-z0-9+/]{24,}={0,2}/g;
412
-
413
- // Decoding is cheap per blob and ruinous per diff if left unbounded. A patch
414
- // that checks in a binary, a source map or a bundled font is otherwise enough
415
- // to turn one scan into a memory event, so both the number of candidates and
416
- // the total decoded size are capped. Exceeding a cap skips the remainder; it
417
- // does not fail the scan, because a large diff is not evidence of a leak.
454
+ // get encoded into a single CI variable.
455
+ const BASE64_CANDIDATE = /[A-Za-z0-9+/\-_]{20,}={0,2}/g;
456
+
418
457
  const BASE64_MAX_CANDIDATES = 64;
419
458
  const BASE64_MAX_DECODED_BYTES = 64 * 1024;
420
459
 
421
460
  /**
422
461
  * Share of characters that are printable ASCII (plus tab/newline/return).
423
- *
424
- * `Buffer.from(str, "base64")` never throws — it discards what it cannot parse
425
- * and returns whatever it managed to decode. So a hex digest or a random
426
- * identifier of the right length "decodes" successfully into bytes that mean
427
- * nothing. A wrapped credential, on the other hand, decodes to text: keys,
428
- * PEM blocks and `.env` bodies are all ASCII. This ratio is what separates the
429
- * two, and it removes nearly all of the noise before any pattern runs.
430
462
  */
431
463
  function printableRatio(str) {
432
464
  if (!str) return 0;
@@ -443,33 +475,36 @@ function printableRatio(str) {
443
475
  *
444
476
  * @param {string} text
445
477
  * @param {(plain: string, blob: string) => void} [onDecoded] - Called per blob.
446
- * @returns {string[]}
478
+ * @returns {{ decoded: string[], capped: boolean }}
447
479
  */
448
480
  function decodeBase64Blobs(text, onDecoded) {
449
- if (!text) return [];
481
+ if (!text) return { decoded: [], capped: false };
450
482
  const decoded = [];
451
483
  let candidates = 0;
452
484
  let bytes = 0;
485
+ let capped = false;
453
486
 
454
487
  BASE64_CANDIDATE.lastIndex = 0;
455
488
  let match;
456
489
  while ((match = BASE64_CANDIDATE.exec(text)) !== null) {
457
- if (candidates++ >= BASE64_MAX_CANDIDATES) break;
458
- const blob = match[0];
459
- // Valid base64 is a multiple of four characters including padding. This
460
- // costs nothing and rejects three quarters of the alphanumeric runs — commit
461
- // hashes, minified identifiers — that would otherwise be decoded for nothing.
462
- if (blob.length % 4 !== 0) continue;
463
- // Check the budget against what this blob *would* cost, not against what
464
- // has already been spent — otherwise the first candidate decodes in full
465
- // however large it is, and a single checked-in binary costs more than the
466
- // cap was meant to allow. `continue`, not `break`: one oversized blob must
467
- // not hide the smaller ones after it.
468
- if (bytes + (blob.length * 3) / 4 > BASE64_MAX_DECODED_BYTES) continue;
490
+ if (candidates++ >= BASE64_MAX_CANDIDATES) {
491
+ capped = true;
492
+ break;
493
+ }
494
+ const rawBlob = match[0].replace(/[\s\r\n]+/g, "");
495
+ let stdBlob = rawBlob.replace(/-/g, "+").replace(/_/g, "/");
496
+ while (stdBlob.length % 4 !== 0) {
497
+ stdBlob += "=";
498
+ }
499
+
500
+ if (bytes + (stdBlob.length * 3) / 4 > BASE64_MAX_DECODED_BYTES) {
501
+ capped = true;
502
+ continue;
503
+ }
469
504
 
470
505
  let plain;
471
506
  try {
472
- plain = Buffer.from(blob, "base64").toString("utf-8");
507
+ plain = Buffer.from(stdBlob, "base64").toString("utf-8");
473
508
  } catch (_) {
474
509
  continue;
475
510
  }
@@ -477,28 +512,35 @@ function decodeBase64Blobs(text, onDecoded) {
477
512
  if (printableRatio(plain) < 0.9) continue;
478
513
 
479
514
  decoded.push(plain);
480
- if (onDecoded) onDecoded(plain, blob);
515
+ if (onDecoded) onDecoded(plain, rawBlob);
516
+
517
+ // Try 1 level of nested base64 decoding if printable
518
+ if (/[A-Za-z0-9+/\-_]{20,}={0,2}/.test(plain)) {
519
+ try {
520
+ let nestedStd = plain.trim().replace(/-/g, "+").replace(/_/g, "/");
521
+ while (nestedStd.length % 4 !== 0) nestedStd += "=";
522
+ const nestedPlain = Buffer.from(nestedStd, "base64").toString("utf-8");
523
+ if (printableRatio(nestedPlain) >= 0.9) {
524
+ decoded.push(nestedPlain);
525
+ }
526
+ } catch (_) {}
527
+ }
481
528
  }
482
529
  BASE64_CANDIDATE.lastIndex = 0;
483
- return decoded;
530
+ return { decoded, capped };
484
531
  }
485
532
 
486
533
  /**
487
534
  * True when a base64-encoded value on an added line decodes to a structured
488
535
  * credential.
489
536
  *
490
- * Deliberately runs the high-confidence patterns *only*. The low-confidence set
491
- * is entropy- and keyword-driven, and decoded bytes are high-entropy by
492
- * construction — pointing it at this output would flag close to every encoded
493
- * blob in every repository. `AKIA[0-9A-Z]{16}` cannot match decoded noise;
494
- * "looks secret-ish" always can. That asymmetry is the whole reason this check
495
- * is safe to enable by default.
496
- *
497
537
  * @param {string} text
498
538
  * @returns {boolean}
499
539
  */
500
540
  export function hasEncodedSecret(text) {
501
- return decodeBase64Blobs(text).some((plain) => hasHighConfidenceSecret(plain));
541
+ const result = decodeBase64Blobs(text);
542
+ if (result.capped) return true; // Fail closed if cap exceeded
543
+ return result.decoded.some((plain) => hasHighConfidenceSecret(plain));
502
544
  }
503
545
 
504
546
  export function scanDiff(diffTextStr = "", options = {}) {
package/src/state.mjs CHANGED
@@ -9,12 +9,13 @@ import {
9
9
  closeSync,
10
10
  fsyncSync,
11
11
  unlinkSync,
12
+ lstatSync,
12
13
  } from "node:fs";
13
14
  import { join, basename } from "node:path";
14
15
  import { createHash, randomUUID } from "node:crypto";
15
16
  import { hostname } from "node:os";
16
17
  import { execFileSync } from "node:child_process";
17
- import { resolveRoot } from "./config.mjs";
18
+ import { resolveRoot, normalizePath } from "./config.mjs";
18
19
  import { redactSecrets } from "./security.mjs";
19
20
 
20
21
  function scrubStateValue(value) {
@@ -38,14 +39,14 @@ export function ensureDir(dirPath) {
38
39
 
39
40
  export function getQueueDir(rootOrOpts = resolveRoot()) {
40
41
  const root = typeof rootOrOpts === "string" ? rootOrOpts : resolveRoot();
41
- const dir = join(root, ".agent/jules-queue");
42
+ const dir = normalizePath(root).endsWith(".agent/jules-queue") ? root : join(root, ".agent/jules-queue");
42
43
  ensureDir(dir);
43
44
  return dir;
44
45
  }
45
46
 
46
47
  export function getStateDir(rootOrOpts = resolveRoot()) {
47
48
  const root = typeof rootOrOpts === "string" ? rootOrOpts : resolveRoot();
48
- const dir = root.endsWith(".agent/state") ? root : join(root, ".agent/state");
49
+ const dir = normalizePath(root).endsWith(".agent/state") ? root : join(root, ".agent/state");
49
50
  ensureDir(dir);
50
51
  return dir;
51
52
  }
@@ -247,6 +248,10 @@ export function appendLedger(entry, rootOrOpts = resolveRoot()) {
247
248
  const hash = createHash("sha256").update(JSON.stringify(rawPayload)).digest("hex");
248
249
  const payload = { ...rawPayload, hash };
249
250
 
251
+ if (existsSync(filePath) && lstatSync(filePath).isSymbolicLink()) {
252
+ throw new Error(`Refusing to append to symbolic link: ${filePath}`);
253
+ }
254
+
250
255
  const fd = openSync(filePath, "a");
251
256
  try {
252
257
  writeSync(fd, JSON.stringify(payload) + "\n", "utf-8");
@@ -330,8 +335,8 @@ export function checkDailyBudget(arg1 = resolveRoot(), arg2 = 300, opts = {}) {
330
335
  remaining: Math.max(0, limit - scan.used),
331
336
  windowStart: scan.windowStart,
332
337
  };
333
- } catch (_) {
334
- return { ok: true, used: 0, budget: limit, remaining: limit };
338
+ } catch (err) {
339
+ return { ok: false, used: limit, budget: limit, remaining: 0, error: err.message };
335
340
  }
336
341
  }
337
342
 
@@ -385,7 +390,7 @@ export function reserveBudgetAtomic(stateDirOrRoot = resolveRoot(), limit = 300,
385
390
  }
386
391
 
387
392
  const timestamp = new Date(now).toISOString();
388
- const reservationId = `res-${now}-${Math.random().toString(36).substring(2, 8)}`;
393
+ const reservationId = `res-${now}-${randomUUID().slice(0, 8)}`;
389
394
  const rawPayload = { timestamp, event: "budget_reserved", reservationId, budget: limit, prevHash };
390
395
  const hash = createHash("sha256").update(JSON.stringify(rawPayload)).digest("hex");
391
396
  const payload = { ...rawPayload, hash };
@@ -3,6 +3,7 @@ import { join, relative, resolve } from "node:path";
3
3
  import { checkScope } from "./security.mjs";
4
4
  import { detectStackOracles } from "./wizard-oracle.mjs";
5
5
  import { loadConfig } from "./config.mjs";
6
+ import { sanitizePromptVocabulary } from "./prompt-guard.mjs";
6
7
 
7
8
  /**
8
9
  * Calculates Levenshtein distance between two strings.
@@ -243,7 +244,14 @@ export function scorePromptFalsifiability(promptText, options = {}) {
243
244
  suggestions.push(`Web domain detected (${webIntent.categories.join(", ")}). Consider incorporating exploration budget and critic agent checks.`);
244
245
  }
245
246
 
246
- // 5. Stack Oracle Detection
247
+ // 5. Positive Boundary / Negative Restriction Linting ("Pink Elephant" Principle)
248
+ const negativeMatches = rawPrompt.match(/\b(do not|never|don't|forbidden|must not)\b/gi) || [];
249
+ const positiveScopeMatch = /\b(only modify|strictly scoped to|scoped to|confined to)\b/i.test(rawPrompt);
250
+ if (negativeMatches.length >= 3 && !positiveScopeMatch) {
251
+ suggestions.push("Multiple negative constraints detected. Consider defining Airtight Positive Enclosures (e.g. 'ONLY modify [Target]') to prevent attention-drift.");
252
+ }
253
+
254
+ // 6. Stack Oracle Detection
247
255
  let verifyCmd = options.verifyCmd || null;
248
256
  let autoDetected = false;
249
257
  let isTrivial = false;
@@ -317,7 +325,7 @@ export function optimizeTaskPrompt(promptText, options = {}) {
317
325
  };
318
326
  }
319
327
 
320
- let promptBody = rawPrompt;
328
+ let promptBody = sanitizePromptVocabulary(rawPrompt);
321
329
 
322
330
  // Apply suggestions & path corrections to prompt body if requested
323
331
  for (const pathInfo of analysis.paths.found) {
package/src/tui.mjs CHANGED
@@ -27,7 +27,7 @@ export const ANSI = {
27
27
  * @returns {boolean}
28
28
  */
29
29
  export function isTTY(stream = process.stdin) {
30
- return Boolean(stream && (stream.isTTY || isatty(stream.fd)));
30
+ return Boolean(stream && (stream.isTTY || (typeof stream.fd === "number" && isatty(stream.fd))));
31
31
  }
32
32
 
33
33
  /**
package/src/webhook.mjs CHANGED
@@ -587,11 +587,11 @@ export function routeWebhookEvent(eventType, payload, handlers = {}) {
587
587
  * @param {function} [config.log]
588
588
  * @returns {import("node:http").Server}
589
589
  */
590
- export function createWebhookServer({ _port = 8787, secret = process.env.JULES_WEBHOOK_SECRET, handlers = {}, log = console.log } = {}) {
590
+ export function createWebhookServer({ port = 8787, secret = process.env.JULES_WEBHOOK_SECRET, handlers = {}, log = console.log } = {}) {
591
591
  const server = createServer((req, res) => {
592
592
  if (req.method === "GET" && req.url === "/health") {
593
593
  res.writeHead(200, { "Content-Type": "application/json" });
594
- res.end(JSON.stringify({ status: "ok", timestamp: new Date().toISOString() }));
594
+ res.end(JSON.stringify({ status: "ok", port, timestamp: new Date().toISOString() }));
595
595
  return;
596
596
  }
597
597
 
@@ -1,5 +1,5 @@
1
- import { writeFileSync, readFileSync, readdirSync, mkdirSync, existsSync } from "node:fs";
2
- import { resolve, join } from "node:path";
1
+ import { writeFileSync, mkdirSync, existsSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
3
  import { loadConfig } from "./config.mjs";
4
4
  import { gate } from "./engine.mjs";
5
5
  import { scanDiff, shannonEntropy } from "./security.mjs";
@@ -21,34 +21,8 @@ HARD CONSTRAINTS:
21
21
 
22
22
  const TRIVIAL_ORACLES = new Set(["true", "echo", ":", "false", "exit 0", "exit 1", "echo ok"]);
23
23
 
24
- /**
25
- * Resolves specialist agent role markdown prompt from .agent/prompts/
26
- * @param {string} [root=process.cwd()]
27
- * @param {string} [roleName=""]
28
- * @returns {{ role: string, path: string, content: string } | null}
29
- */
30
- export function resolveRolePrompt(root = process.cwd(), roleName = "") {
31
- if (!roleName || typeof roleName !== "string") return null;
32
- const cleanName = roleName.trim().toLowerCase();
33
- const promptsDir = join(root, ".agent", "prompts");
34
- if (!existsSync(promptsDir)) return null;
35
-
36
- try {
37
- const files = readdirSync(promptsDir);
38
- const matched = files.find(
39
- (f) => f.toLowerCase() === `${cleanName}.md` || f.toLowerCase() === cleanName
40
- );
41
- if (matched) {
42
- const fullPath = join(promptsDir, matched);
43
- return {
44
- role: matched.replace(/\.md$/i, ""),
45
- path: fullPath,
46
- content: readFileSync(fullPath, "utf-8").trim(),
47
- };
48
- }
49
- } catch (_) {}
50
- return null;
51
- }
24
+ export { resolveRolePrompt } from "./role-resolver.mjs";
25
+ import { resolveRolePrompt } from "./role-resolver.mjs";
52
26
 
53
27
  /**
54
28
  * Pure planning core for task creation & envelope synthesis.