jules-orchestrator-kit 0.9.4 → 0.20.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/README.md +69 -274
- package/bin/agentctl.mjs +1 -1
- package/index.mjs +11 -2
- package/package.json +1 -1
- package/scripts/jules-merge-swarm.mjs +179 -11
- package/src/config.mjs +34 -0
- package/src/engine.mjs +119 -3
- package/src/execution_envelope.mjs +103 -0
- package/src/git.mjs +20 -2
- package/src/mcp.mjs +98 -12
- package/src/process-group.mjs +80 -0
- package/src/risk.mjs +3 -2
- package/src/rules_budget.mjs +102 -0
- package/src/security.mjs +45 -6
- package/src/state.mjs +160 -23
- package/src/webhook.mjs +17 -3
package/src/rules_budget.mjs
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
import { readFileSync, readdirSync, statSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
3
4
|
|
|
4
5
|
const DEFAULT_MAX_CHARS = 10000; // Safe threshold below Antigravity 12k & Claude 25k limits
|
|
5
6
|
const DEFAULT_MAX_LINES = 250;
|
|
6
7
|
|
|
8
|
+
export const SENTINEL_BEGIN = "JULES_RULES_SENTINEL BEGIN";
|
|
9
|
+
export const SENTINEL_END = "JULES_RULES_SENTINEL END";
|
|
10
|
+
|
|
7
11
|
/**
|
|
8
12
|
* Audit character and line counts of compiled instruction files to prevent truncation.
|
|
9
13
|
*
|
|
@@ -68,6 +72,103 @@ export function checkRulesBudget(root = process.cwd(), opts = {}) {
|
|
|
68
72
|
};
|
|
69
73
|
}
|
|
70
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
|
+
|
|
71
172
|
function existsSync(p) {
|
|
72
173
|
try {
|
|
73
174
|
return statSync(p).isFile();
|
|
@@ -75,3 +176,4 @@ function existsSync(p) {
|
|
|
75
176
|
return false;
|
|
76
177
|
}
|
|
77
178
|
}
|
|
179
|
+
|
package/src/security.mjs
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { openSync, writeSync, fsyncSync, closeSync, renameSync, realpathSync, existsSync, lstatSync } from "node:fs";
|
|
2
|
+
import { dirname, join, basename } from "node:path";
|
|
3
|
+
import { randomBytes } from "node:crypto";
|
|
1
4
|
import { normalizePath } from "./config.mjs";
|
|
2
5
|
|
|
3
6
|
export const HIGH_CONFIDENCE_PATTERNS = [
|
|
@@ -6,34 +9,70 @@ export const HIGH_CONFIDENCE_PATTERNS = [
|
|
|
6
9
|
/\bghu_[A-Za-z0-9_]{36,255}\b/g,
|
|
7
10
|
/\bghs_[A-Za-z0-9_]{36,255}\b/g,
|
|
8
11
|
/\bghr_[A-Za-z0-9_]{36,255}\b/g,
|
|
12
|
+
/\bgithub_pat_[A-Za-z0-9_]{22,255}\b/g,
|
|
9
13
|
|
|
10
14
|
/\bAKIA[0-9A-Z]{16}\b/g,
|
|
11
15
|
/\bASIA[0-9A-Z]{16}\b/g,
|
|
12
16
|
/\baws_secret_access_key\s*=\s*['"]?[A-Za-z0-9\/+=]{40}['"]?/g,
|
|
13
17
|
|
|
14
|
-
/-----BEGIN
|
|
18
|
+
/-----BEGIN (?:RSA|DSA|EC|OPENSSH|PRIVATE) KEY-----[\s\S]*?-----END (?:RSA|DSA|EC|OPENSSH|PRIVATE) KEY-----/g,
|
|
15
19
|
/PuTTY-User-Key-File-[0-9]:[^\n]+/g,
|
|
16
20
|
|
|
17
21
|
/\bsk_live_[0-9a-zA-Z]{24,99}\b/g,
|
|
18
22
|
/\brk_live_[0-9a-zA-Z]{24,99}\b/g,
|
|
19
23
|
/\bnpm_[0-9a-zA-Z]{36}\b/g,
|
|
20
|
-
/\bglpat-[0-9a-zA-Z_-]{20,}\b/g,
|
|
21
|
-
/\bGOCSPX-[0-9a-zA-Z_-]{28}\b/g,
|
|
24
|
+
/\bglpat-[0-9a-zA-Z_-]{20,99}\b/g,
|
|
25
|
+
/\bGOCSPX-[0-9a-zA-Z_-]{28,99}\b/g,
|
|
22
26
|
|
|
23
27
|
/\bAIzaSy[A-Za-z0-9_-]{33}\b/g,
|
|
28
|
+
/\bya29\.[A-Za-z0-9_-]{20,255}\b/g,
|
|
29
|
+
/\b(?:sk-ant-api03-|sk-proj-)[A-Za-z0-9_-]{32,255}\b/g,
|
|
24
30
|
|
|
25
|
-
/https:\/\/hooks\.slack\.com\/services\/T[A-Za-z0-9_]
|
|
31
|
+
/https:\/\/hooks\.slack\.com\/services\/T[A-Za-z0-9_]{8,}\/B[A-Za-z0-9_]{8,}\/[A-Za-z0-9_]{24,}/g,
|
|
32
|
+
/\bxox[baprs]-[0-9a-zA-Z-]{10,48}\b/g,
|
|
26
33
|
|
|
27
34
|
/\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g,
|
|
28
35
|
];
|
|
29
36
|
|
|
30
37
|
export const LOW_CONFIDENCE_PATTERNS = [
|
|
31
|
-
/Bearer\s+[A-Za-z0-9._~+/-]{10,}/gi,
|
|
32
|
-
/Authorization:\s*Bearer\s+[A-Za-z0-9._~+/-]
|
|
38
|
+
/Bearer\s+[A-Za-z0-9._~+/-]{10,255}/gi,
|
|
39
|
+
/Authorization:\s*Bearer\s+[A-Za-z0-9._~+/-]{10,255}/gi,
|
|
33
40
|
/\bsk_test_[0-9a-zA-Z]{24,99}\b/g,
|
|
34
41
|
/(?:api[_-]?key|secret|password|passwd|token|auth[_-]?token)\s*[:=]\s*['"]([^'"]{8,128})['"]/gi,
|
|
35
42
|
];
|
|
36
43
|
|
|
44
|
+
/**
|
|
45
|
+
* TOCTOU-safe atomic file write using O_CREAT|O_EXCL|O_WRONLY temp file in target dir.
|
|
46
|
+
*/
|
|
47
|
+
export function safeAtomicWrite(filePath, content, options = {}) {
|
|
48
|
+
const mode = options.mode || 0o644;
|
|
49
|
+
const encoding = options.encoding || "utf-8";
|
|
50
|
+
|
|
51
|
+
if (existsSync(filePath)) {
|
|
52
|
+
const stat = lstatSync(filePath);
|
|
53
|
+
if (stat.isSymbolicLink()) {
|
|
54
|
+
const realPath = realpathSync(filePath);
|
|
55
|
+
if (options.rejectSymlinks) {
|
|
56
|
+
throw new Error(`TOCTOU Guard: Refusing to write to symlink ${filePath} -> ${realPath}`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const dir = dirname(filePath);
|
|
62
|
+
const tempFile = join(dir, `.tmp-${basename(filePath)}-${randomBytes(6).toString("hex")}`);
|
|
63
|
+
|
|
64
|
+
const fd = openSync(tempFile, "wx", mode);
|
|
65
|
+
try {
|
|
66
|
+
writeSync(fd, content, null, encoding);
|
|
67
|
+
fsyncSync(fd);
|
|
68
|
+
} finally {
|
|
69
|
+
closeSync(fd);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
renameSync(tempFile, filePath);
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
|
|
37
76
|
export function shannonEntropy(str) {
|
|
38
77
|
if (!str || typeof str !== "string") return 0;
|
|
39
78
|
const len = str.length;
|
package/src/state.mjs
CHANGED
|
@@ -1,15 +1,18 @@
|
|
|
1
1
|
import {
|
|
2
2
|
readFileSync,
|
|
3
|
-
appendFileSync,
|
|
4
3
|
existsSync,
|
|
5
4
|
mkdirSync,
|
|
5
|
+
rmdirSync,
|
|
6
6
|
readdirSync,
|
|
7
7
|
openSync,
|
|
8
8
|
writeSync,
|
|
9
9
|
closeSync,
|
|
10
|
+
fsyncSync,
|
|
10
11
|
unlinkSync,
|
|
11
12
|
} from "node:fs";
|
|
12
13
|
import { join } from "node:path";
|
|
14
|
+
import { createHash } from "node:crypto";
|
|
15
|
+
import { hostname } from "node:os";
|
|
13
16
|
import { resolveRoot } from "./config.mjs";
|
|
14
17
|
|
|
15
18
|
export function ensureDir(dirPath) {
|
|
@@ -43,17 +46,111 @@ export function getDailyLedgerPath(rootOrOpts = resolveRoot()) {
|
|
|
43
46
|
}
|
|
44
47
|
|
|
45
48
|
/**
|
|
46
|
-
*
|
|
49
|
+
* Executes a function inside a kernel-level VFS directory mutex for strict linearizability.
|
|
50
|
+
*/
|
|
51
|
+
export function withVfsMutex(mutexDir, fn, opts = {}) {
|
|
52
|
+
const maxRetries = opts.maxRetries || 50;
|
|
53
|
+
const retryDelayMs = opts.retryDelayMs || 10;
|
|
54
|
+
|
|
55
|
+
for (let i = 0; i < maxRetries; i++) {
|
|
56
|
+
try {
|
|
57
|
+
mkdirSync(mutexDir);
|
|
58
|
+
try {
|
|
59
|
+
return fn();
|
|
60
|
+
} finally {
|
|
61
|
+
try {
|
|
62
|
+
rmdirSync(mutexDir);
|
|
63
|
+
} catch (_) {}
|
|
64
|
+
}
|
|
65
|
+
} catch (err) {
|
|
66
|
+
if (err.code === "EEXIST") {
|
|
67
|
+
const deadline = Date.now() + retryDelayMs;
|
|
68
|
+
while (Date.now() < deadline) {}
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
throw err;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return fn();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Appends a structured audit event to the daily session ledger with SHA-256 hash-chain (VFS Mutex Linearized).
|
|
47
79
|
*/
|
|
48
80
|
export function appendLedger(entry, rootOrOpts = resolveRoot()) {
|
|
49
81
|
const root = typeof rootOrOpts === "string" ? rootOrOpts : resolveRoot();
|
|
50
82
|
const filePath = getDailyLedgerPath(root);
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
83
|
+
const mutexDir = join(getStateDir(root), ".ledger.mutex");
|
|
84
|
+
|
|
85
|
+
return withVfsMutex(mutexDir, () => {
|
|
86
|
+
let prevHash = "0".repeat(64);
|
|
87
|
+
if (existsSync(filePath)) {
|
|
88
|
+
try {
|
|
89
|
+
const raw = readFileSync(filePath, "utf-8");
|
|
90
|
+
const lines = raw.split("\n").filter(Boolean);
|
|
91
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
92
|
+
try {
|
|
93
|
+
const lastObj = JSON.parse(lines[i]);
|
|
94
|
+
if (lastObj.hash) {
|
|
95
|
+
prevHash = lastObj.hash;
|
|
96
|
+
break;
|
|
97
|
+
}
|
|
98
|
+
} catch (_) {}
|
|
99
|
+
}
|
|
100
|
+
} catch (_) {}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const timestamp = new Date().toISOString();
|
|
104
|
+
const rawPayload = { timestamp, ...entry, prevHash };
|
|
105
|
+
const hash = createHash("sha256").update(JSON.stringify(rawPayload)).digest("hex");
|
|
106
|
+
const payload = { ...rawPayload, hash };
|
|
107
|
+
|
|
108
|
+
const fd = openSync(filePath, "a");
|
|
109
|
+
try {
|
|
110
|
+
writeSync(fd, JSON.stringify(payload) + "\n", "utf-8");
|
|
111
|
+
fsyncSync(fd);
|
|
112
|
+
} finally {
|
|
113
|
+
closeSync(fd);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return payload;
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Verifies the SHA-256 cryptographic hash-chain integrity of a ledger file.
|
|
122
|
+
*/
|
|
123
|
+
export function verifyLedgerIntegrity(filePath) {
|
|
124
|
+
if (!filePath || !existsSync(filePath)) return { ok: false, count: 0, error: "FILE_NOT_FOUND" };
|
|
125
|
+
try {
|
|
126
|
+
const raw = readFileSync(filePath, "utf-8");
|
|
127
|
+
const lines = raw.split("\n").filter(Boolean);
|
|
128
|
+
let expectedPrevHash = "0".repeat(64);
|
|
129
|
+
|
|
130
|
+
for (let i = 0; i < lines.length; i++) {
|
|
131
|
+
let obj;
|
|
132
|
+
try {
|
|
133
|
+
obj = JSON.parse(lines[i]);
|
|
134
|
+
} catch (err) {
|
|
135
|
+
return { ok: false, line: i + 1, error: "TORN_WRITE_CORRUPTION", detail: err.message };
|
|
136
|
+
}
|
|
137
|
+
if (!obj.hash || !obj.prevHash) {
|
|
138
|
+
return { ok: false, line: i + 1, error: "MISSING_HASH_FIELDS" };
|
|
139
|
+
}
|
|
140
|
+
if (obj.prevHash !== expectedPrevHash) {
|
|
141
|
+
return { ok: false, line: i + 1, error: "BROKEN_PREV_HASH", expected: expectedPrevHash, actual: obj.prevHash };
|
|
142
|
+
}
|
|
143
|
+
const { hash, ...rest } = obj;
|
|
144
|
+
const recomputed = createHash("sha256").update(JSON.stringify(rest)).digest("hex");
|
|
145
|
+
if (recomputed !== hash) {
|
|
146
|
+
return { ok: false, line: i + 1, error: "CORRUPTED_ENTRY_HASH", expected: recomputed, actual: hash };
|
|
147
|
+
}
|
|
148
|
+
expectedPrevHash = hash;
|
|
149
|
+
}
|
|
150
|
+
return { ok: true, count: lines.length, lastHash: expectedPrevHash };
|
|
151
|
+
} catch (err) {
|
|
152
|
+
return { ok: false, count: 0, error: err.message };
|
|
153
|
+
}
|
|
57
154
|
}
|
|
58
155
|
|
|
59
156
|
export function readLedger(filePath) {
|
|
@@ -72,9 +169,6 @@ export function readLedger(filePath) {
|
|
|
72
169
|
}
|
|
73
170
|
}
|
|
74
171
|
|
|
75
|
-
/**
|
|
76
|
-
* Enforces a daily token budget.
|
|
77
|
-
*/
|
|
78
172
|
export function checkDailyBudget(arg1 = resolveRoot(), arg2 = 300) {
|
|
79
173
|
let root = typeof arg1 === "string" ? arg1 : resolveRoot();
|
|
80
174
|
let limit = typeof arg1 === "number" ? arg1 : typeof arg2 === "number" ? arg2 : 300;
|
|
@@ -106,17 +200,35 @@ export class BudgetError extends Error {
|
|
|
106
200
|
}
|
|
107
201
|
}
|
|
108
202
|
|
|
109
|
-
export
|
|
203
|
+
export function reserveBudget(rootOrOpts = resolveRoot(), limit = 300) {
|
|
204
|
+
const root = typeof rootOrOpts === "string" ? rootOrOpts : resolveRoot();
|
|
110
205
|
const budget = checkDailyBudget(root, limit);
|
|
111
206
|
if (!budget.ok) {
|
|
112
207
|
throw new BudgetError(`Daily budget exhausted (${budget.used}/${budget.budget} tasks executed)`);
|
|
113
208
|
}
|
|
114
|
-
|
|
209
|
+
|
|
210
|
+
const reservationId = `res-${Date.now()}-${Math.random().toString(36).substring(2, 8)}`;
|
|
211
|
+
appendLedger({ event: "budget_reserved", reservationId, budget: limit }, root);
|
|
212
|
+
return { ok: true, reservationId, remaining: Math.max(0, budget.remaining - 1) };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export function commitBudgetReservation(rootOrOpts = resolveRoot(), reservationId = "") {
|
|
216
|
+
const root = typeof rootOrOpts === "string" ? rootOrOpts : resolveRoot();
|
|
217
|
+
return appendLedger({ event: "budget_committed", reservationId }, root);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export async function withBudget(fn, root = resolveRoot(), limit = 300) {
|
|
221
|
+
const reservation = reserveBudget(root, limit);
|
|
222
|
+
try {
|
|
223
|
+
const result = await fn();
|
|
224
|
+
commitBudgetReservation(root, reservation.reservationId);
|
|
225
|
+
return result;
|
|
226
|
+
} catch (err) {
|
|
227
|
+
appendLedger({ event: "budget_reservation_failed", reservationId: reservation.reservationId, error: err.message }, root);
|
|
228
|
+
throw err;
|
|
229
|
+
}
|
|
115
230
|
}
|
|
116
231
|
|
|
117
|
-
/**
|
|
118
|
-
* Atomic Advisory Lock File Manager with PID Vitality Check & Stale Lock Reaper (CWE-367 Fix).
|
|
119
|
-
*/
|
|
120
232
|
export function getLockDir(rootOrOpts = resolveRoot()) {
|
|
121
233
|
const root = typeof rootOrOpts === "string" ? rootOrOpts : resolveRoot();
|
|
122
234
|
const dir = join(getStateDir(root), "locks");
|
|
@@ -124,14 +236,39 @@ export function getLockDir(rootOrOpts = resolveRoot()) {
|
|
|
124
236
|
return dir;
|
|
125
237
|
}
|
|
126
238
|
|
|
127
|
-
|
|
239
|
+
/**
|
|
240
|
+
* Checks if a PID is alive with optional PID-recycling start time validation on Linux.
|
|
241
|
+
*/
|
|
242
|
+
export function isPidAlive(pid, expectedStartTime = null) {
|
|
128
243
|
if (!pid || typeof pid !== "number") return false;
|
|
129
244
|
try {
|
|
130
245
|
process.kill(pid, 0);
|
|
131
|
-
return true;
|
|
132
246
|
} catch (_) {
|
|
133
247
|
return false;
|
|
134
248
|
}
|
|
249
|
+
|
|
250
|
+
if (expectedStartTime && process.platform === "linux") {
|
|
251
|
+
try {
|
|
252
|
+
const stat = readFileSync(`/proc/${pid}/stat`, "utf-8");
|
|
253
|
+
const parts = stat.split(" ");
|
|
254
|
+
const startTime = parts[21];
|
|
255
|
+
if (startTime && String(startTime) !== String(expectedStartTime)) {
|
|
256
|
+
return false;
|
|
257
|
+
}
|
|
258
|
+
} catch (_) {}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
return true;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function getProcessStartTime(pid) {
|
|
265
|
+
if (process.platform === "linux") {
|
|
266
|
+
try {
|
|
267
|
+
const stat = readFileSync(`/proc/${pid}/stat`, "utf-8");
|
|
268
|
+
return stat.split(" ")[21] || null;
|
|
269
|
+
} catch (_) {}
|
|
270
|
+
}
|
|
271
|
+
return null;
|
|
135
272
|
}
|
|
136
273
|
|
|
137
274
|
export function acquireLock(agentName, taskId, files = [], rootOrOpts = resolveRoot()) {
|
|
@@ -139,15 +276,13 @@ export function acquireLock(agentName, taskId, files = [], rootOrOpts = resolveR
|
|
|
139
276
|
const lockDir = getLockDir(root);
|
|
140
277
|
const lockFile = join(lockDir, `${taskId}.json`);
|
|
141
278
|
|
|
142
|
-
// Stale Lock Reaper Check
|
|
143
279
|
if (existsSync(lockFile)) {
|
|
144
280
|
try {
|
|
145
281
|
const existing = JSON.parse(readFileSync(lockFile, "utf-8"));
|
|
146
|
-
const isAlive = isPidAlive(existing.pid);
|
|
282
|
+
const isAlive = isPidAlive(existing.pid, existing.processStartTime);
|
|
147
283
|
const isExpired = existing.acquiredAt && Date.now() - new Date(existing.acquiredAt).getTime() > 7200000;
|
|
148
284
|
|
|
149
285
|
if (!isAlive || isExpired) {
|
|
150
|
-
// Dead or expired holder; safely reap lock
|
|
151
286
|
try { unlinkSync(lockFile); } catch (_) {}
|
|
152
287
|
} else {
|
|
153
288
|
return { ok: false, holder: existing.agent, taskId, pid: existing.pid };
|
|
@@ -162,13 +297,15 @@ export function acquireLock(agentName, taskId, files = [], rootOrOpts = resolveR
|
|
|
162
297
|
taskId,
|
|
163
298
|
files,
|
|
164
299
|
pid: process.pid,
|
|
300
|
+
processStartTime: getProcessStartTime(process.pid),
|
|
301
|
+
hostname: hostname(),
|
|
165
302
|
acquiredAt: new Date().toISOString(),
|
|
166
303
|
};
|
|
167
304
|
|
|
168
|
-
// Atomic file creation via 'wx' flag (Fixes TOCTOU Race Condition)
|
|
169
305
|
try {
|
|
170
306
|
const fd = openSync(lockFile, "wx");
|
|
171
307
|
writeSync(fd, JSON.stringify(payload, null, 2), "utf-8");
|
|
308
|
+
fsyncSync(fd);
|
|
172
309
|
closeSync(fd);
|
|
173
310
|
return { ok: true, lockFile };
|
|
174
311
|
} catch (err) {
|
|
@@ -191,7 +328,7 @@ export function releaseLock(taskId, rootOrOpts = resolveRoot()) {
|
|
|
191
328
|
|
|
192
329
|
if (existsSync(lockFile)) {
|
|
193
330
|
try {
|
|
194
|
-
unlinkSync(lockFile);
|
|
331
|
+
unlinkSync(lockFile);
|
|
195
332
|
return true;
|
|
196
333
|
} catch (_) {}
|
|
197
334
|
}
|
package/src/webhook.mjs
CHANGED
|
@@ -9,7 +9,7 @@ import { createServer } from "node:http";
|
|
|
9
9
|
* @returns {boolean} True if signature matches
|
|
10
10
|
*/
|
|
11
11
|
export function verifySignature(payload, signatureHeader, secret) {
|
|
12
|
-
if (!secret) return
|
|
12
|
+
if (!secret) return false; // Fail closed if secret is not configured
|
|
13
13
|
if (!signatureHeader || !signatureHeader.startsWith("sha256=")) return false;
|
|
14
14
|
|
|
15
15
|
const hmac = createHmac("sha256", secret);
|
|
@@ -104,15 +104,29 @@ export function createWebhookServer({ _port = 8787, secret = process.env.JULES_W
|
|
|
104
104
|
return;
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
+
const MAX_BODY = 2 * 1024 * 1024; // 2MB payload cap
|
|
107
108
|
const chunks = [];
|
|
108
|
-
|
|
109
|
+
let totalSize = 0;
|
|
110
|
+
|
|
111
|
+
req.on("data", (chunk) => {
|
|
112
|
+
totalSize += chunk.length;
|
|
113
|
+
if (totalSize > MAX_BODY) {
|
|
114
|
+
res.writeHead(413, { "Content-Type": "application/json" });
|
|
115
|
+
res.end(JSON.stringify({ error: "Payload Too Large" }));
|
|
116
|
+
req.destroy();
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
chunks.push(chunk);
|
|
120
|
+
});
|
|
121
|
+
|
|
109
122
|
req.on("end", () => {
|
|
123
|
+
if (totalSize > MAX_BODY) return;
|
|
110
124
|
const bodyBuf = Buffer.concat(chunks);
|
|
111
125
|
const sigHeader = req.headers["x-hub-signature-256"];
|
|
112
126
|
const eventType = req.headers["x-github-event"];
|
|
113
127
|
const contentType = req.headers["content-type"] || "application/json";
|
|
114
128
|
|
|
115
|
-
if (
|
|
129
|
+
if (!verifySignature(bodyBuf, sigHeader, secret)) {
|
|
116
130
|
log(`[Webhook] Invalid signature received from ${req.socket.remoteAddress}`);
|
|
117
131
|
res.writeHead(401, { "Content-Type": "application/json" });
|
|
118
132
|
res.end(JSON.stringify({ error: "Invalid signature" }));
|