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.
- package/.agent/rules/jules-protocol.md +3 -0
- package/JULES_RULES_TEMPLATE.md +3 -1
- package/README.md +163 -307
- package/bin/agentctl.mjs +177 -7
- package/bin/init.js +8 -8
- package/index.mjs +14 -2
- package/package.json +1 -1
- package/scripts/command-resolver.mjs +1 -2
- package/scripts/doc-sync-check.mjs +36 -1
- package/scripts/jules-create.mjs +1 -1
- package/scripts/jules-dispatch.mjs +1 -2
- package/scripts/jules-nightly.mjs +2 -2
- package/scripts/jules-patch.mjs +2 -2
- package/scripts/jules-queue-runner.mjs +1 -1
- package/scripts/jules-scan-todos.mjs +1 -1
- package/scripts/jules-self-audit.mjs +1 -2
- package/scripts/jules-status.mjs +1 -1
- package/scripts/release.mjs +55 -0
- package/scripts/run-tests.mjs +15 -1
- package/scripts/utils.mjs +14 -9
- package/src/asset-integrity.mjs +76 -0
- package/src/asset_integrity.mjs +1 -76
- package/src/config.mjs +42 -23
- package/src/dag-engine.mjs +44 -6
- package/src/engine.mjs +18 -11
- package/src/execution-envelope.mjs +111 -0
- package/src/execution_envelope.mjs +1 -111
- package/src/git.mjs +6 -3
- package/src/merge-blocks.mjs +178 -1
- package/src/ops/command-registry.mjs +25 -0
- package/src/ops/handover.mjs +411 -0
- package/src/ops/ide-scaffold.mjs +3 -3
- package/src/ops/next-step.mjs +6 -2
- package/src/prompt-guard.mjs +34 -3
- package/src/provider.mjs +191 -86
- package/src/role-resolver.mjs +31 -0
- package/src/rules-budget.mjs +179 -0
- package/src/rules_budget.mjs +1 -179
- package/src/security.mjs +98 -56
- package/src/state.mjs +11 -6
- package/src/task-optimizer.mjs +10 -2
- package/src/tui.mjs +1 -1
- package/src/webhook.mjs +2 -2
- package/src/wizard-task.mjs +4 -30
package/src/rules_budget.mjs
CHANGED
|
@@ -1,179 +1 @@
|
|
|
1
|
-
|
|
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
|
|
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_-]{
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
402
|
-
|
|
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.
|
|
408
|
-
|
|
409
|
-
|
|
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)
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
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(
|
|
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,
|
|
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
|
-
|
|
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:
|
|
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}-${
|
|
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 };
|
package/src/task-optimizer.mjs
CHANGED
|
@@ -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.
|
|
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({
|
|
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
|
|
package/src/wizard-task.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { writeFileSync,
|
|
2
|
-
import { resolve
|
|
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
|
-
|
|
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.
|