jules-orchestrator-kit 0.9.3 → 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/src/mcp.mjs ADDED
@@ -0,0 +1,297 @@
1
+ import { Transform } from "node:stream";
2
+ import { loadConfig, resolveRoot, detectStack } from "./config.mjs";
3
+ import { gate, dispatch } from "./engine.mjs";
4
+ import { classifyRiskTier } from "./risk.mjs";
5
+ import { checkDailyBudget, lockStatus } from "./state.mjs";
6
+
7
+ export const MCP_SERVER_INFO = {
8
+ name: "jules-orchestrator-kit",
9
+ version: "0.20.0",
10
+ };
11
+
12
+ export const MAX_MCP_FRAME_SIZE = 4 * 1024 * 1024; // 4 MB memory safety ceiling
13
+
14
+ /**
15
+ * Memory-bounded MCP Frame Decoder supporting both Content-Length headers and line-delimited JSON-RPC messages.
16
+ */
17
+ export class McpFrameDecoder extends Transform {
18
+ constructor(options = {}) {
19
+ super({ ...options, readableObjectMode: true });
20
+ this.buffer = Buffer.alloc(0);
21
+ this.maxFrameSize = options.maxFrameSize || MAX_MCP_FRAME_SIZE;
22
+ }
23
+
24
+ _transform(chunk, encoding, callback) {
25
+ this.buffer = Buffer.concat([this.buffer, chunk]);
26
+
27
+ if (this.buffer.length > this.maxFrameSize * 2) {
28
+ this.emit("error", new Error(`MCP Stdio buffer exceeded max limit of ${this.maxFrameSize} bytes`));
29
+ this.buffer = Buffer.alloc(0);
30
+ return callback();
31
+ }
32
+
33
+ while (this.buffer.length > 0) {
34
+ const str = this.buffer.toString("utf-8");
35
+ const headerMatch = /Content-Length:\s*(\d+)\r?\n\r?\n/i.exec(str);
36
+
37
+ if (headerMatch) {
38
+ const contentLength = parseInt(headerMatch[1], 10);
39
+ if (contentLength > this.maxFrameSize) {
40
+ this.emit("error", new Error(`Frame Content-Length (${contentLength}) exceeds limit (${this.maxFrameSize})`));
41
+ this.buffer = Buffer.alloc(0);
42
+ return callback();
43
+ }
44
+
45
+ const headerLength = headerMatch.index + headerMatch[0].length;
46
+ const totalFrameLength = headerLength + contentLength;
47
+
48
+ if (this.buffer.length >= totalFrameLength) {
49
+ const payloadBuf = this.buffer.subarray(headerLength, totalFrameLength);
50
+ this.buffer = this.buffer.subarray(totalFrameLength);
51
+ try {
52
+ const parsed = JSON.parse(payloadBuf.toString("utf-8"));
53
+ this.push(parsed);
54
+ } catch (err) {
55
+ this.emit("error", new Error(`Malformed JSON payload in framed message: ${err.message}`));
56
+ }
57
+ continue;
58
+ } else {
59
+ break;
60
+ }
61
+ }
62
+
63
+ const newlineIndex = this.buffer.indexOf(0x0a);
64
+ if (newlineIndex !== -1) {
65
+ const lineBuf = this.buffer.subarray(0, newlineIndex);
66
+ this.buffer = this.buffer.subarray(newlineIndex + 1);
67
+
68
+ const lineStr = lineBuf.toString("utf-8").trim();
69
+ if (!lineStr) continue;
70
+
71
+ if (/^Content-Length:\s*\d+/i.test(lineStr)) continue;
72
+
73
+ try {
74
+ const parsed = JSON.parse(lineStr);
75
+ this.push(parsed);
76
+ } catch (err) {
77
+ this.emit("error", new Error(`Malformed JSON in line-delimited message: ${err.message}`));
78
+ }
79
+ continue;
80
+ }
81
+
82
+ break;
83
+ }
84
+
85
+ callback();
86
+ }
87
+ }
88
+
89
+ export const MCP_TOOLS = [
90
+ {
91
+ name: "dispatch_jules_task",
92
+ description: "Dispatch an autonomous task to the Jules AI agent provider with security bounds and budget check.",
93
+ inputSchema: {
94
+ type: "object",
95
+ properties: {
96
+ title: { type: "string", description: "Short descriptive title for the task" },
97
+ prompt: { type: "string", description: "Detailed task instructions and prompt" },
98
+ },
99
+ required: ["prompt"],
100
+ },
101
+ },
102
+ {
103
+ name: "audit_jules_gate",
104
+ description: "Run the 4-phase safety gatekeeper (Scope, Payload, Secrets, Verify) against the current workspace branch.",
105
+ inputSchema: {
106
+ type: "object",
107
+ properties: {
108
+ base: { type: "string", description: "Base branch to diff against (defaults to main)" },
109
+ fix: { type: "boolean", description: "Attempt OODA auto-repair loop on verification failure" },
110
+ allowProtected: { type: "boolean", description: "Allow edits to protected files" },
111
+ },
112
+ },
113
+ },
114
+ {
115
+ name: "check_risk_tier",
116
+ description: "Classify changed files into Risk Tiers (R0 Cosmetic, R1 Routine, R2 Consequential, R3 Restricted).",
117
+ inputSchema: {
118
+ type: "object",
119
+ properties: {
120
+ files: {
121
+ type: "array",
122
+ items: { type: "string" },
123
+ description: "List of file paths changed",
124
+ },
125
+ diffLines: { type: "number", description: "Total lines changed in diff" },
126
+ },
127
+ required: ["files"],
128
+ },
129
+ },
130
+ {
131
+ name: "get_jules_status",
132
+ description: "Retrieve orchestrator status including daily task budget, active locks, and stack diagnostics.",
133
+ inputSchema: {
134
+ type: "object",
135
+ properties: {},
136
+ },
137
+ },
138
+ ];
139
+
140
+ export async function handleMcpRequest(request, opts = {}) {
141
+ const root = opts.root || resolveRoot();
142
+ const config = opts.config || loadConfig(root);
143
+ const { id, method, params } = request;
144
+
145
+ if (method === "ping") {
146
+ return { jsonrpc: "2.0", id, result: {} };
147
+ }
148
+
149
+ if (method === "initialize") {
150
+ return {
151
+ jsonrpc: "2.0",
152
+ id,
153
+ result: {
154
+ protocolVersion: "2024-11-05",
155
+ capabilities: { tools: {} },
156
+ serverInfo: MCP_SERVER_INFO,
157
+ },
158
+ };
159
+ }
160
+
161
+ if (method === "notifications/initialized") {
162
+ return null;
163
+ }
164
+
165
+ if (method === "tools/list") {
166
+ return {
167
+ jsonrpc: "2.0",
168
+ id,
169
+ result: { tools: MCP_TOOLS },
170
+ };
171
+ }
172
+
173
+ if (method === "tools/call") {
174
+ const toolName = params?.name;
175
+ const args = params?.arguments || {};
176
+
177
+ try {
178
+ if (toolName === "dispatch_jules_task") {
179
+ const session = await dispatch(
180
+ { title: args.title || "MCP Task Dispatch", prompt: args.prompt },
181
+ { root, config }
182
+ );
183
+ return {
184
+ jsonrpc: "2.0",
185
+ id,
186
+ result: {
187
+ content: [{ type: "text", text: JSON.stringify({ ok: true, session }, null, 2) }],
188
+ },
189
+ };
190
+ }
191
+
192
+ if (toolName === "audit_jules_gate") {
193
+ const res = await gate({
194
+ root,
195
+ config,
196
+ base: args.base || config.baseBranch || "main",
197
+ fix: args.fix || false,
198
+ allowProtected: args.allowProtected || false,
199
+ });
200
+ return {
201
+ jsonrpc: "2.0",
202
+ id,
203
+ result: {
204
+ content: [{ type: "text", text: JSON.stringify(res, null, 2) }],
205
+ },
206
+ };
207
+ }
208
+
209
+ if (toolName === "check_risk_tier") {
210
+ const tierResult = classifyRiskTier(args.files || [], { diffLines: args.diffLines || 0 });
211
+ return {
212
+ jsonrpc: "2.0",
213
+ id,
214
+ result: {
215
+ content: [{ type: "text", text: JSON.stringify(tierResult, null, 2) }],
216
+ },
217
+ };
218
+ }
219
+
220
+ if (toolName === "get_jules_status") {
221
+ const stackInfo = detectStack(root);
222
+ const budget = checkDailyBudget(root, config.limits.dailyTasks);
223
+ const locks = lockStatus(root);
224
+ const status = {
225
+ version: MCP_SERVER_INFO.version,
226
+ root,
227
+ stack: stackInfo.stack,
228
+ budget: { used: budget.used, limit: budget.budget, remaining: budget.budget - budget.used },
229
+ activeLocksCount: locks.length,
230
+ locks,
231
+ };
232
+ return {
233
+ jsonrpc: "2.0",
234
+ id,
235
+ result: {
236
+ content: [{ type: "text", text: JSON.stringify(status, null, 2) }],
237
+ },
238
+ };
239
+ }
240
+
241
+ return {
242
+ jsonrpc: "2.0",
243
+ id,
244
+ error: { code: -32601, message: `Unknown tool: ${toolName}` },
245
+ };
246
+ } catch (err) {
247
+ return {
248
+ jsonrpc: "2.0",
249
+ id,
250
+ result: {
251
+ content: [{ type: "text", text: JSON.stringify({ ok: false, error: err.message }, null, 2) }],
252
+ isError: true,
253
+ },
254
+ };
255
+ }
256
+ }
257
+
258
+ return {
259
+ jsonrpc: "2.0",
260
+ id,
261
+ error: { code: -32601, message: `Method not found: ${method}` },
262
+ };
263
+ }
264
+
265
+ export function startMcpServer(input = process.stdin, output = process.stdout, opts = {}) {
266
+ const decoder = new McpFrameDecoder({ maxFrameSize: opts.maxFrameSize || MAX_MCP_FRAME_SIZE });
267
+
268
+ decoder.on("data", async (request) => {
269
+ try {
270
+ const response = await handleMcpRequest(request, opts);
271
+ if (response !== null && response !== undefined) {
272
+ output.write(JSON.stringify(response) + "\n");
273
+ }
274
+ } catch (err) {
275
+ // Panic boundary: Catch unhandled async exceptions and send valid JSON-RPC error
276
+ output.write(
277
+ JSON.stringify({
278
+ jsonrpc: "2.0",
279
+ id: request?.id || null,
280
+ error: { code: -32603, message: `Internal server panic: ${err.message}` },
281
+ }) + "\n"
282
+ );
283
+ }
284
+ });
285
+
286
+ decoder.on("error", (err) => {
287
+ output.write(
288
+ JSON.stringify({
289
+ jsonrpc: "2.0",
290
+ id: null,
291
+ error: { code: -32700, message: `MCP Stream framing error: ${err.message}` },
292
+ }) + "\n"
293
+ );
294
+ });
295
+
296
+ input.pipe(decoder);
297
+ }
@@ -0,0 +1,80 @@
1
+ import { spawn } from "node:child_process";
2
+
3
+ /**
4
+ * ProcessGroupManager manages child processes in isolated process groups
5
+ * and ensures 100% leak-free cleanup on process exit or termination signals.
6
+ */
7
+ export class ProcessGroupManager {
8
+ constructor() {
9
+ this.activePgids = new Set();
10
+ this.activePids = new Set();
11
+ this.installed = false;
12
+ this.installSignalHandlers();
13
+ }
14
+
15
+ installSignalHandlers() {
16
+ if (this.installed) return;
17
+ this.installed = true;
18
+
19
+ const cleanup = (signal) => {
20
+ this.killAll(signal || "SIGTERM");
21
+ };
22
+
23
+ process.once("SIGINT", () => {
24
+ cleanup("SIGINT");
25
+ process.exit(130);
26
+ });
27
+
28
+ process.once("SIGTERM", () => {
29
+ cleanup("SIGTERM");
30
+ process.exit(143);
31
+ });
32
+
33
+ process.once("exit", () => {
34
+ this.killAll("SIGKILL");
35
+ });
36
+ }
37
+
38
+ spawnProcess(command, args = [], options = {}) {
39
+ const opts = {
40
+ ...options,
41
+ detached: true,
42
+ };
43
+
44
+ const child = spawn(command, args, opts);
45
+
46
+ if (child.pid) {
47
+ const pgid = child.pid;
48
+ this.activePgids.add(pgid);
49
+ this.activePids.add(child.pid);
50
+
51
+ child.on("exit", () => {
52
+ this.activePgids.delete(pgid);
53
+ this.activePids.delete(child.pid);
54
+ });
55
+
56
+ child.on("error", () => {
57
+ this.activePgids.delete(pgid);
58
+ this.activePids.delete(child.pid);
59
+ });
60
+ }
61
+
62
+ return child;
63
+ }
64
+
65
+ killAll(signal = "SIGTERM") {
66
+ for (const pgid of this.activePgids) {
67
+ try {
68
+ process.kill(-pgid, signal);
69
+ } catch (_) {
70
+ try {
71
+ process.kill(pgid, signal);
72
+ } catch (_) {}
73
+ }
74
+ }
75
+ this.activePgids.clear();
76
+ this.activePids.clear();
77
+ }
78
+ }
79
+
80
+ export const processGroupManager = new ProcessGroupManager();
package/src/risk.mjs CHANGED
@@ -1,4 +1,5 @@
1
1
  import { normalizePath } from "./config.mjs";
2
+ import { matchesGlob } from "./security.mjs";
2
3
 
3
4
  export const RISK_TIERS = {
4
5
  R0: "R0_COSMETIC", // Docs, markdown, comments, safe devDep patches
@@ -54,7 +55,7 @@ export function classifyRiskTier(files = [], opts = {}) {
54
55
  for (const rawFile of files) {
55
56
  const file = normalizePath(rawFile);
56
57
  for (const pat of RESTRICTED_PATH_PATTERNS) {
57
- if (file.includes(pat.replace(/\*/g, "")) || file.endsWith(pat)) {
58
+ if (matchesGlob(file, pat) || file.endsWith(pat)) {
58
59
  return {
59
60
  tier: RISK_TIERS.R3,
60
61
  reason: `Matches restricted security/financial path pattern '${pat}'`,
@@ -78,7 +79,7 @@ export function classifyRiskTier(files = [], opts = {}) {
78
79
  for (const rawFile of files) {
79
80
  const file = normalizePath(rawFile);
80
81
  for (const pat of CONSEQUENTIAL_PATH_PATTERNS) {
81
- if (file.includes(pat.replace(/\*/g, ""))) {
82
+ if (matchesGlob(file, pat)) {
82
83
  return {
83
84
  tier: RISK_TIERS.R2,
84
85
  reason: `Matches consequential component/DB path pattern '${pat}'`,
@@ -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\s+(?:RSA|DSA|EC|OPENSSH|PRIVATE)\s+KEY-----[\s\S]*?-----END\s+(?:RSA|DSA|EC|OPENSSH|PRIVATE)\s+KEY-----/g,
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_]+\/B[A-Za-z0-9_]+\/[A-Za-z0-9_]+/g,
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._~+/-]+=*/gi,
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;