jules-orchestrator-kit 0.22.6 ā 0.22.7
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 +10 -5
- package/bin/agentctl.mjs +5 -4
- package/index.mjs +1 -1
- package/package.json +1 -1
- package/src/engine.mjs +13 -4
- package/src/git.mjs +3 -0
- package/src/journal.mjs +55 -1
- package/src/mcp.mjs +3 -2
- package/src/prompt-guard.mjs +6 -2
- package/src/state.mjs +16 -7
package/README.md
CHANGED
|
@@ -68,8 +68,11 @@ Whether you are dispatching your first automated coding task or managing high-th
|
|
|
68
68
|
|
|
69
69
|
### Engine System Highlights
|
|
70
70
|
|
|
71
|
-
- **
|
|
72
|
-
- **
|
|
71
|
+
- **Native Task DAG Executor (`src/dag-engine.mjs`)**: Zero-dependency `DagExecutor` with Kahn's topological sort algorithm, SHA-256 interface fingerprinting post-task execution, and pre-execution cycle detection (`DagCycleError`).
|
|
72
|
+
- **Intent Journaling & Zombie Worktree Reaper (`src/journal.mjs`)**: Automatic boot-time scan (`reapOrphanedIntents`) in `agentctl` and MCP server that tracks git operations in `.agent/state/journal.jsonl` and prunes orphaned worktrees left by crashed/recycled processes.
|
|
73
|
+
- **Hermetic Network Egress Guard (`src/preload-net-guard.mjs`)**: Intercepts and blocks unmocked outbound HTTP/HTTPS egress during test execution (`NODE_OPTIONS="--import ./src/preload-net-guard.mjs"`), enforcing hermetic testing while allowing local loopback (`localhost`, `127.0.0.1`).
|
|
74
|
+
- **Linearizable VFS Mutex (`src/state.mjs`)**: Kernel-level directory mutex (`withVfsMutex`) guaranteeing serial linearizability for SHA-256 hash-chained session ledgers with atomic budget reservation (`reserveBudgetAtomic`).
|
|
75
|
+
- **PID Recycling & Stale Lock Protection (`src/state.mjs`)**: Linux `/proc/<pid>/stat` launch-time validation and random UUID nonces prevent false-positive lock reaps from recycled OS process IDs.
|
|
73
76
|
- **Memory-Bounded Content-Length MCP Streaming (`src/mcp.mjs`)**: Native MCP server over stdio streams using `McpFrameDecoder` with a 4 MB memory safety ceiling and panic boundaries to prevent stdout stack trace leaks.
|
|
74
77
|
- **Process Group Isolation (`src/process-group.mjs`)**: `ProcessGroupManager` creates isolated process groups (`detached: true`) and catches `SIGINT`/`SIGTERM`/`exit` signals to execute `process.kill(-pgid)`, guaranteeing zero zombie processes.
|
|
75
78
|
- **TOCTOU & Symlink Defense (`src/security.mjs`)**: `safeAtomicWrite()` uses `O_CREAT | O_EXCL | O_WRONLY` temp files with `fsyncSync` + `renameSync` and `lstatSync`/`realpathSync` symlink checks.
|
|
@@ -84,12 +87,14 @@ Whether you are dispatching your first automated coding task or managing high-th
|
|
|
84
87
|
|
|
85
88
|

|
|
86
89
|
|
|
87
|
-
### The 4-Phase Safety Audit (`agentctl gate`)
|
|
90
|
+
### The 4-Phase Safety Audit & Security Boundary (`agentctl gate`)
|
|
88
91
|
|
|
89
92
|
1. **Scope Fencing (`forbidden_paths`)**: Ensures agents cannot modify protected files (`package.json`, `.github/`, deployment keys) without explicit overrides.
|
|
90
93
|
2. **Diff Payload Governor**: Rejects oversized diffs (> 75 KB) to prevent truncation and hidden payload injections.
|
|
91
94
|
3. **Secret Entropy Scanner**: Scans diffs for high-confidence secrets (AWS keys, Stripe keys, GitHub tokens, SSH private keys) using Shannon Entropy analysis (> 3.6 bits).
|
|
92
|
-
4. **Trusted Verification Suite**: Executes auto-detected unit tests and linters (`npm test`) to guarantee zero regressions before merging.
|
|
95
|
+
4. **Trusted Verification Suite**: Executes auto-detected unit tests and linters (`npm test`) inside a hermetic network sandbox to guarantee zero regressions before merging.
|
|
96
|
+
5. **Prompt Guard Boundary (`src/prompt-guard.mjs`)**: `sanitizeUntrustedData` strips bidi control characters, ANSI escape sequences, zero-width unicode, and neutralizes prompt injection tags (`<|im_start|>`, `[INST]`).
|
|
97
|
+
6. **MCP Stream Isolation (`src/mcp.mjs`)**: Seals `process.stdout.write` framing stream to prevent log output from corrupting JSON-RPC stdio frames.
|
|
93
98
|
|
|
94
99
|
> [!NOTE]
|
|
95
100
|
> All security rules are fetched strictly from `origin/main` (never untrusted PR branches) to prevent prompt-injection attacks from altering security rules.
|
|
@@ -178,7 +183,7 @@ steps:
|
|
|
178
183
|
| **`mcp`** | `agentctl mcp` | Starts stdio Model Context Protocol (MCP) JSON-RPC 2.0 server |
|
|
179
184
|
| **`doctor`** | `agentctl doctor` | Verifies stack configuration, environment keys, and daily token budget |
|
|
180
185
|
| **`scan`** | `agentctl scan` | Scans codebase for `TODO` and `FIXME` comments and generates task queue |
|
|
181
|
-
| **`
|
|
186
|
+
| **`clean`** | `agentctl clean` | Audits and cleans up stale git worktrees, orphaned intents, locks, and temporary state files |
|
|
182
187
|
|
|
183
188
|
</details>
|
|
184
189
|
|
package/bin/agentctl.mjs
CHANGED
|
@@ -7,14 +7,14 @@ import { loadConfig, resolveRoot, detectStack } from "../src/config.mjs";
|
|
|
7
7
|
import { gate, dispatch, run, isTaskFile } from "../src/engine.mjs";
|
|
8
8
|
import { acquireLock, releaseLock, lockStatus, checkDailyBudget, getQueueDir, ensureDir } from "../src/state.mjs";
|
|
9
9
|
import { worktreePrune } from "../src/git.mjs";
|
|
10
|
-
import { reapOrphanedIntents } from "../src/journal.mjs";
|
|
10
|
+
import { reapOrphanedIntents, reapStaleMutexDirs } from "../src/journal.mjs";
|
|
11
11
|
|
|
12
12
|
const args = process.argv.slice(2);
|
|
13
13
|
const command = args[0];
|
|
14
14
|
|
|
15
15
|
function printHelp() {
|
|
16
16
|
console.log(`
|
|
17
|
-
š agentctl v0.22.
|
|
17
|
+
š agentctl v0.22.7 ā Universal Agent Orchestrator & Safety Gatekeeper
|
|
18
18
|
|
|
19
19
|
Usage: agentctl <command> [options]
|
|
20
20
|
|
|
@@ -44,12 +44,13 @@ async function main() {
|
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
if (command === "version" || command === "--version" || command === "-v") {
|
|
47
|
-
console.log("agentctl v0.22.
|
|
47
|
+
console.log("agentctl v0.22.7");
|
|
48
48
|
process.exit(0);
|
|
49
49
|
}
|
|
50
50
|
|
|
51
51
|
const root = resolveRoot();
|
|
52
52
|
reapOrphanedIntents(root);
|
|
53
|
+
reapStaleMutexDirs(root);
|
|
53
54
|
const config = loadConfig(root);
|
|
54
55
|
|
|
55
56
|
switch (command) {
|
|
@@ -225,7 +226,7 @@ async function main() {
|
|
|
225
226
|
}
|
|
226
227
|
|
|
227
228
|
case "doctor": {
|
|
228
|
-
console.log(`\nš agentctl System Diagnostics (v0.22.
|
|
229
|
+
console.log(`\nš agentctl System Diagnostics (v0.22.7)`);
|
|
229
230
|
console.log(`--------------------------------------------------`);
|
|
230
231
|
console.log(` Project Root : ${root}`);
|
|
231
232
|
console.log(` Config File : ${config._file || "None (Using defaults)"}`);
|
package/index.mjs
CHANGED
|
@@ -50,7 +50,7 @@ export { checkAssetIntegrity } from "./src/asset_integrity.mjs";
|
|
|
50
50
|
export { classifyRiskTier, RISK_TIERS } from "./src/risk.mjs";
|
|
51
51
|
export { checkRulesBudget } from "./src/rules_budget.mjs";
|
|
52
52
|
export { DagExecutor, DagCycleError } from "./src/dag-engine.mjs";
|
|
53
|
-
export { journalIntent, journalDone, reapOrphanedIntents } from "./src/journal.mjs";
|
|
53
|
+
export { journalIntent, journalDone, reapOrphanedIntents, reapStaleMutexDirs } from "./src/journal.mjs";
|
|
54
54
|
|
|
55
55
|
// Legacy SDK shims for backward compatibility
|
|
56
56
|
export { resolveProjectCommands, resolveWorkspaceExecutionBoundary } from "./scripts/command-resolver.mjs";
|
package/package.json
CHANGED
package/src/engine.mjs
CHANGED
|
@@ -3,6 +3,7 @@ import { checkScope, scanDiff, redactSecrets } from "./security.mjs";
|
|
|
3
3
|
import { changedFiles, diffBytes, diffText, showFromOrigin, runCmd } from "./git.mjs";
|
|
4
4
|
import { createProvider } from "./provider.mjs";
|
|
5
5
|
import { withBudget, appendLedger, getQueueDir, ensureDir } from "./state.mjs";
|
|
6
|
+
import { sanitizeUntrustedData, buildAgentEnvelope } from "./prompt-guard.mjs";
|
|
6
7
|
import { readdirSync, readFileSync, renameSync, existsSync } from "node:fs";
|
|
7
8
|
import { join } from "node:path";
|
|
8
9
|
import { createHash } from "node:crypto";
|
|
@@ -164,8 +165,9 @@ export async function gate(opts = {}) {
|
|
|
164
165
|
let buildResult = { ok: true, status: 0 };
|
|
165
166
|
|
|
166
167
|
const existingNodeOptions = process.env.NODE_OPTIONS || "";
|
|
167
|
-
const
|
|
168
|
-
const
|
|
168
|
+
const netGuardUrl = new URL("./preload-net-guard.mjs", import.meta.url).href;
|
|
169
|
+
const netGuardFlag = `--import ${netGuardUrl}`;
|
|
170
|
+
const guardNodeOptions = existingNodeOptions && !existingNodeOptions.includes(netGuardFlag) && !existingNodeOptions.includes("preload-net-guard.mjs")
|
|
169
171
|
? `${existingNodeOptions} ${netGuardFlag}`
|
|
170
172
|
: existingNodeOptions ? existingNodeOptions : netGuardFlag;
|
|
171
173
|
|
|
@@ -354,8 +356,15 @@ export async function dispatch(task, opts = {}) {
|
|
|
354
356
|
}
|
|
355
357
|
|
|
356
358
|
// Redact secrets in prompt before dispatching
|
|
357
|
-
const cleanPrompt = redactSecrets(task.prompt);
|
|
358
|
-
|
|
359
|
+
const cleanPrompt = redactSecrets(task.prompt || "");
|
|
360
|
+
|
|
361
|
+
// Wire Prompt Guard & Envelope to wrap raw task arguments
|
|
362
|
+
const untrustedData = task.untrustedData || [cleanPrompt];
|
|
363
|
+
const systemPolicy = task.systemPolicy || config.systemPolicy || "";
|
|
364
|
+
const taskInstructions = task.taskInstructions || task.title || "Autonomous Task Execution";
|
|
365
|
+
const envelopedPrompt = buildAgentEnvelope(systemPolicy, taskInstructions, untrustedData);
|
|
366
|
+
|
|
367
|
+
const cleanTask = { ...task, prompt: envelopedPrompt };
|
|
359
368
|
|
|
360
369
|
return withBudget(
|
|
361
370
|
() => provider.dispatch(cleanTask, { root, dryRun: opts.dryRun }),
|
package/src/git.mjs
CHANGED
|
@@ -2,6 +2,9 @@ import { execFileSync } from "node:child_process";
|
|
|
2
2
|
import { sep } from "node:path";
|
|
3
3
|
import { journalIntent, journalDone } from "./journal.mjs";
|
|
4
4
|
|
|
5
|
+
export const NET_GUARD_PRELOAD_URL = new URL("./preload-net-guard.mjs", import.meta.url).href;
|
|
6
|
+
export const NET_GUARD_FLAG = `--import ${NET_GUARD_PRELOAD_URL}`;
|
|
7
|
+
|
|
5
8
|
export class GateError extends Error {
|
|
6
9
|
constructor(message, opts = {}) {
|
|
7
10
|
super(message);
|
package/src/journal.mjs
CHANGED
|
@@ -1,9 +1,54 @@
|
|
|
1
|
-
import { openSync, writeSync, fsyncSync, closeSync, existsSync, readFileSync, unlinkSync, readdirSync } from "node:fs";
|
|
1
|
+
import { openSync, writeSync, fsyncSync, closeSync, existsSync, readFileSync, unlinkSync, readdirSync, statSync, renameSync, rmdirSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { randomUUID } from "node:crypto";
|
|
4
4
|
import { getStateDir, getProcessStartTime, isPidAlive, getLockDir } from "./state.mjs";
|
|
5
5
|
import { worktreeRemove, worktreePrune } from "./git.mjs";
|
|
6
6
|
|
|
7
|
+
/**
|
|
8
|
+
* Scans .agent/state/ for .mutex directories.
|
|
9
|
+
* If empty AND older than ttlMs, renames to a temporary .grave-<pid> path and rmdirSyncs it (atomic CAS pattern).
|
|
10
|
+
* @param {string} root - Project root directory
|
|
11
|
+
* @param {Object} [opts] - Options object
|
|
12
|
+
* @param {number} [opts.ttlMs=30000] - Time to live in milliseconds
|
|
13
|
+
* @returns {{ reapedCount: number, reaped: Array<string> }}
|
|
14
|
+
*/
|
|
15
|
+
export function reapStaleMutexDirs(root, { ttlMs = 30000 } = {}) {
|
|
16
|
+
const stateDir = getStateDir(root);
|
|
17
|
+
if (!existsSync(stateDir)) {
|
|
18
|
+
return { reapedCount: 0, reaped: [] };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const reaped = [];
|
|
22
|
+
try {
|
|
23
|
+
const entries = readdirSync(stateDir, { withFileTypes: true });
|
|
24
|
+
const now = Date.now();
|
|
25
|
+
for (const entry of entries) {
|
|
26
|
+
if (entry.isDirectory() && entry.name.endsWith(".mutex")) {
|
|
27
|
+
const mutexPath = join(stateDir, entry.name);
|
|
28
|
+
try {
|
|
29
|
+
const contents = readdirSync(mutexPath);
|
|
30
|
+
if (contents.length === 0) {
|
|
31
|
+
const stat = statSync(mutexPath);
|
|
32
|
+
const age = now - stat.mtimeMs;
|
|
33
|
+
if (age >= ttlMs) {
|
|
34
|
+
const gravePath = join(stateDir, `.grave-${process.pid}-${randomUUID()}`);
|
|
35
|
+
try {
|
|
36
|
+
renameSync(mutexPath, gravePath);
|
|
37
|
+
try {
|
|
38
|
+
rmdirSync(gravePath);
|
|
39
|
+
reaped.push(entry.name);
|
|
40
|
+
} catch (_) {}
|
|
41
|
+
} catch (_) {}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
} catch (_) {}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
} catch (_) {}
|
|
48
|
+
|
|
49
|
+
return { reapedCount: reaped.length, reaped };
|
|
50
|
+
}
|
|
51
|
+
|
|
7
52
|
/**
|
|
8
53
|
* Appends an intent record to .agent/state/journal.jsonl before a mutating operation.
|
|
9
54
|
* @param {string} root - Project root directory
|
|
@@ -138,6 +183,14 @@ export function reapOrphanedIntents(root) {
|
|
|
138
183
|
const lockPath = join(lockDir, file);
|
|
139
184
|
try {
|
|
140
185
|
const lockContent = JSON.parse(readFileSync(lockPath, "utf-8"));
|
|
186
|
+
const lockPid = lockContent.pid;
|
|
187
|
+
const lockStartTime = lockContent.processStartTime ?? lockContent.starttime ?? null;
|
|
188
|
+
|
|
189
|
+
// Do NOT unlink if the PID exists AND its start time matches a live process
|
|
190
|
+
if (lockPid && isPidAlive(lockPid, lockStartTime)) {
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
|
|
141
194
|
if (
|
|
142
195
|
lockContent.pid === intent.pid ||
|
|
143
196
|
(intent.targetPath && Array.isArray(lockContent.files) && lockContent.files.includes(intent.targetPath))
|
|
@@ -174,3 +227,4 @@ export function reapOrphanedIntents(root) {
|
|
|
174
227
|
|
|
175
228
|
return { reapedCount: reaped.length, reaped };
|
|
176
229
|
}
|
|
230
|
+
|
package/src/mcp.mjs
CHANGED
|
@@ -3,11 +3,11 @@ import { loadConfig, resolveRoot, detectStack } from "./config.mjs";
|
|
|
3
3
|
import { gate, dispatch } from "./engine.mjs";
|
|
4
4
|
import { classifyRiskTier } from "./risk.mjs";
|
|
5
5
|
import { checkDailyBudget, lockStatus } from "./state.mjs";
|
|
6
|
-
import { reapOrphanedIntents } from "./journal.mjs";
|
|
6
|
+
import { reapOrphanedIntents, reapStaleMutexDirs } from "./journal.mjs";
|
|
7
7
|
|
|
8
8
|
export const MCP_SERVER_INFO = {
|
|
9
9
|
name: "jules-orchestrator-kit",
|
|
10
|
-
version: "0.22.
|
|
10
|
+
version: "0.22.7",
|
|
11
11
|
};
|
|
12
12
|
|
|
13
13
|
export const MAX_MCP_FRAME_SIZE = 4 * 1024 * 1024; // 4 MB memory safety ceiling
|
|
@@ -355,6 +355,7 @@ export function writeMcpFrame(targetOutput, data) {
|
|
|
355
355
|
export function startMcpServer(input = process.stdin, output = process.stdout, opts = {}) {
|
|
356
356
|
const root = opts.root || resolveRoot();
|
|
357
357
|
reapOrphanedIntents(root);
|
|
358
|
+
reapStaleMutexDirs(root);
|
|
358
359
|
isolateMcpStdout(process.stdout, opts);
|
|
359
360
|
if (output && output !== process.stdout) {
|
|
360
361
|
isolateMcpStdout(output, opts);
|
package/src/prompt-guard.mjs
CHANGED
|
@@ -78,8 +78,12 @@ export function buildAgentEnvelope(systemPolicy = "", taskInstructions = "", unt
|
|
|
78
78
|
if (Array.isArray(untrustedDataArray)) {
|
|
79
79
|
for (const item of untrustedDataArray) {
|
|
80
80
|
if (typeof item === "string") {
|
|
81
|
-
|
|
82
|
-
|
|
81
|
+
const trimmed = item.trim();
|
|
82
|
+
const match = /^<<<UNTRUSTED-DATA-BEGIN(?:\s+source="([^"]+)")?>\n?([\s\S]*?)\n?<<<UNTRUSTED-DATA-END>>>$/.exec(trimmed);
|
|
83
|
+
if (match) {
|
|
84
|
+
const src = match[1] || "untrusted";
|
|
85
|
+
const body = match[2];
|
|
86
|
+
sanitizedBlocks.push(sanitizeUntrustedData(body, src));
|
|
83
87
|
} else {
|
|
84
88
|
sanitizedBlocks.push(sanitizeUntrustedData(item, "untrusted"));
|
|
85
89
|
}
|
package/src/state.mjs
CHANGED
|
@@ -188,7 +188,15 @@ export function checkDailyBudget(arg1 = resolveRoot(), arg2 = 300) {
|
|
|
188
188
|
try {
|
|
189
189
|
const content = readFileSync(filePath, "utf-8");
|
|
190
190
|
const lines = content.split("\n").filter(Boolean);
|
|
191
|
-
|
|
191
|
+
let count = 0;
|
|
192
|
+
for (const line of lines) {
|
|
193
|
+
try {
|
|
194
|
+
const entry = JSON.parse(line);
|
|
195
|
+
if (entry && entry.event === "budget_reserved") {
|
|
196
|
+
count++;
|
|
197
|
+
}
|
|
198
|
+
} catch (_) {}
|
|
199
|
+
}
|
|
192
200
|
return {
|
|
193
201
|
ok: count < limit,
|
|
194
202
|
used: count,
|
|
@@ -224,13 +232,14 @@ export function reserveBudgetAtomic(stateDirOrRoot = resolveRoot(), limit = 300,
|
|
|
224
232
|
try {
|
|
225
233
|
const raw = readFileSync(filePath, "utf-8");
|
|
226
234
|
const lines = raw.split("\n").filter(Boolean);
|
|
227
|
-
|
|
228
|
-
for (let i = lines.length - 1; i >= 0; i--) {
|
|
235
|
+
for (const line of lines) {
|
|
229
236
|
try {
|
|
230
|
-
const
|
|
231
|
-
if (
|
|
232
|
-
|
|
233
|
-
|
|
237
|
+
const entry = JSON.parse(line);
|
|
238
|
+
if (entry && entry.event === "budget_reserved") {
|
|
239
|
+
count++;
|
|
240
|
+
}
|
|
241
|
+
if (entry && entry.hash) {
|
|
242
|
+
prevHash = entry.hash;
|
|
234
243
|
}
|
|
235
244
|
} catch (_) {}
|
|
236
245
|
}
|