jules-orchestrator-kit 0.74.0 โ 0.75.1
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/CHANGELOG.md +27 -1
- package/JULES_RULES_TEMPLATE.md +3 -0
- package/README.md +4 -2
- package/bin/agentctl.mjs +395 -23
- package/package.json +1 -1
- package/scripts/ci-scope-guard.mjs +226 -23
- package/scripts/run-tests.mjs +5 -1
- package/src/bidi-guard.mjs +190 -15
- package/src/ci-templates.mjs +184 -21
- package/src/config.mjs +40 -1
- package/src/engine.mjs +342 -48
- package/src/evidence.mjs +22 -5
- package/src/git.mjs +15 -0
- package/src/migrate.mjs +493 -0
- package/src/ops/command-registry.mjs +93 -4
- package/src/ops/doctor-registry.mjs +184 -32
- package/src/ops/pr-harvest.mjs +22 -2
- package/src/ops/toolchain-diagnostics.mjs +253 -0
- package/src/ops/tui-menu.mjs +192 -0
- package/src/process-tree.mjs +146 -0
- package/src/prompt-guard.mjs +9 -0
- package/src/provider.mjs +14 -0
- package/src/scaffold.mjs +49 -1
- package/src/scope-guard.mjs +73 -5
- package/src/secret-scanner.mjs +14 -2
- package/src/security.mjs +41 -30
- package/src/self-audit.mjs +237 -3
- package/src/state.mjs +123 -13
- package/src/uninstall.mjs +227 -0
- package/src/wizard-init.mjs +11 -0
- package/src/wizard-task.mjs +17 -22
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminal Navigation Hub (TUI Menu).
|
|
3
|
+
* Zero-dependency interactive menu using native TTY raw mode.
|
|
4
|
+
*/
|
|
5
|
+
import { isTTY, select, input, confirm, ANSI } from "../tui.mjs";
|
|
6
|
+
import { KIT_VERSION } from "../version.mjs";
|
|
7
|
+
import { loadConfig } from "../config.mjs";
|
|
8
|
+
import { budgetStatus } from "../budget.mjs";
|
|
9
|
+
import { runDoctorChecks } from "./doctor-registry.mjs";
|
|
10
|
+
import { detectAvailableProviders } from "../provider-readiness.mjs";
|
|
11
|
+
import { runTaskCreateWizard } from "../wizard-task.mjs";
|
|
12
|
+
import { listWebTemplates, getWebTemplate } from "../web-templates.mjs";
|
|
13
|
+
import { scorePromptFalsifiability } from "../task-optimizer.mjs";
|
|
14
|
+
import { scanCodebaseForTodos } from "../todo-scanner.mjs";
|
|
15
|
+
import { gate } from "../engine.mjs";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Launch the interactive terminal navigation hub.
|
|
19
|
+
* @param {string} root - Repository root path
|
|
20
|
+
* @param {object} [options]
|
|
21
|
+
* @param {import("node:stream").Readable} [options.stdin]
|
|
22
|
+
* @param {import("node:stream").Writable} [options.stdout]
|
|
23
|
+
* @param {boolean} [options.singleAction]
|
|
24
|
+
* @returns {Promise<{ ok: boolean, action?: string, headless?: boolean }>}
|
|
25
|
+
*/
|
|
26
|
+
export async function runTuiMenu(root = process.cwd(), options = {}) {
|
|
27
|
+
const stdin = options.stdin || process.stdin;
|
|
28
|
+
const stdout = options.stdout || process.stdout;
|
|
29
|
+
|
|
30
|
+
if (!isTTY(stdin) && !options.allowHeadless) {
|
|
31
|
+
stdout.write("Interactive terminal menu requires a TTY terminal. Use 'agentctl --help' for CLI commands.\n");
|
|
32
|
+
return { ok: true, headless: true };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
while (true) {
|
|
36
|
+
stdout.write(`\n${ANSI.bold}${ANSI.cyan}๐ agentctl v${KIT_VERSION}${ANSI.reset} โ Interactive Terminal Hub\n`);
|
|
37
|
+
stdout.write(` ${ANSI.dim}Repository:${ANSI.reset} ${root}\n\n`);
|
|
38
|
+
|
|
39
|
+
const actions = [
|
|
40
|
+
{ label: "๐ก๏ธ Run Safety Gate", value: "gate", description: "Audit CI security, scope & verification" },
|
|
41
|
+
{ label: "๐ System Diagnostics", value: "doctor", description: "Run 13 health checks and provider checks" },
|
|
42
|
+
{ label: "๐ Operating Status", value: "status", description: "View daily budget, locks, pending tasks" },
|
|
43
|
+
{ label: "โจ Create Task Envelope", value: "task-create", description: "Interactive wizard for falsifiable tasks" },
|
|
44
|
+
{ label: "๐ Browse Task Templates", value: "task-template", description: "21 specialized templates: a11y, SEO, CWV..." },
|
|
45
|
+
{ label: "๐ฏ Optimize Task Prompt", value: "task-optimize", description: "Score prompt falsifiability and suggest fixes" },
|
|
46
|
+
{ label: "๐ Agent Providers", value: "providers", description: "Inspect Jules, Claude, Codex, Gemini readiness" },
|
|
47
|
+
{ label: "๐ Scan Codebase (TODOs)", value: "scan", description: "Find task candidates in code" },
|
|
48
|
+
{ label: "๐ Start Web Dashboard", value: "dashboard", description: "Start local web UI on port 4100" },
|
|
49
|
+
{ label: "๐ช Exit", value: "exit", description: "Quit menu" },
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
const choice = await select(actions, "Select an action:", { stdin, stdout, defaultIdx: 0 });
|
|
53
|
+
|
|
54
|
+
if (choice === "exit") {
|
|
55
|
+
stdout.write(`${ANSI.dim}Bye!${ANSI.reset}\n`);
|
|
56
|
+
return { ok: true, action: "exit" };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (choice === "gate") {
|
|
60
|
+
const modeChoice = await select(
|
|
61
|
+
[
|
|
62
|
+
{ label: "Working tree", value: "working-tree", description: "Default โ evaluate uncommitted changes" },
|
|
63
|
+
{ label: "Staged changes", value: "staged", description: "Evaluate git staged changes only" },
|
|
64
|
+
{ label: "Committed changes", value: "committed", description: "Evaluate committed changes on branch" },
|
|
65
|
+
{ label: "Dry-run simulation", value: "dry-run", description: "Simulate gate without persisting evidence" },
|
|
66
|
+
],
|
|
67
|
+
"Select gate evaluation mode:",
|
|
68
|
+
{ stdin, stdout, defaultIdx: 0 }
|
|
69
|
+
);
|
|
70
|
+
const isDry = modeChoice === "dry-run";
|
|
71
|
+
const mode = isDry ? "working-tree" : modeChoice;
|
|
72
|
+
stdout.write(`\nRunning Safety Gate (mode: ${mode})...\n`);
|
|
73
|
+
const res = await gate({ root, mode, dryRun: isDry });
|
|
74
|
+
stdout.write(`\nGate Result: ${res.ok ? `${ANSI.green}APPROVED (Exit 0)${ANSI.reset}` : `${ANSI.red}REJECTED (Exit ${res.code})${ANSI.reset}`}\n`);
|
|
75
|
+
for (const p of res.phases || []) {
|
|
76
|
+
const icon = p.ok ? `${ANSI.green}โ
PASS${ANSI.reset}` : `${ANSI.red}โ FAIL${ANSI.reset}`;
|
|
77
|
+
stdout.write(` Phase [${p.phase.toUpperCase()}] : ${icon}\n`);
|
|
78
|
+
}
|
|
79
|
+
} else if (choice === "doctor") {
|
|
80
|
+
stdout.write(`\nRunning system diagnostics...\n`);
|
|
81
|
+
const rep = await runDoctorChecks({ root });
|
|
82
|
+
stdout.write(`\nDiagnostics: ${rep.summary.pass} passed, ${rep.summary.warn} warning(s), ${rep.summary.fail} failure(s)\n`);
|
|
83
|
+
const iconMap = { pass: `${ANSI.green}โ
${ANSI.reset}`, warn: `${ANSI.yellow}โ ๏ธ${ANSI.reset}`, fail: `${ANSI.red}โ${ANSI.reset}` };
|
|
84
|
+
for (const r of rep.results || []) {
|
|
85
|
+
stdout.write(` ${iconMap[r.status] || "โข"} ${r.title}\n`);
|
|
86
|
+
if (r.status !== "pass" || r.alwaysShowSummary) {
|
|
87
|
+
stdout.write(` ${ANSI.dim}${r.summary}${ANSI.reset}\n`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
} else if (choice === "status") {
|
|
91
|
+
const config = loadConfig(root);
|
|
92
|
+
const b = budgetStatus(config, root);
|
|
93
|
+
stdout.write(`\n๐ Status Summary:\n`);
|
|
94
|
+
stdout.write(` Root: ${root}\n`);
|
|
95
|
+
stdout.write(` Config: ${config._file || "None"}\n`);
|
|
96
|
+
stdout.write(` Daily Budget: ${b.used} / ${b.limit} tasks in last 24h\n`);
|
|
97
|
+
} else if (choice === "task-create") {
|
|
98
|
+
await runTaskCreateWizard(root, { interactive: true, stdin, stdout });
|
|
99
|
+
} else if (choice === "task-template") {
|
|
100
|
+
const templates = listWebTemplates();
|
|
101
|
+
const tOptions = templates.map((t) => ({
|
|
102
|
+
label: t.id.padEnd(24),
|
|
103
|
+
value: t.id,
|
|
104
|
+
description: `${t.category} โ ${t.title}`,
|
|
105
|
+
}));
|
|
106
|
+
tOptions.push({ label: "โ Back", value: "__back__", description: "Return to main menu" });
|
|
107
|
+
const picked = await select(tOptions, "Select a task template to inspect:", { stdin, stdout });
|
|
108
|
+
if (picked !== "__back__") {
|
|
109
|
+
const tpl = getWebTemplate(picked);
|
|
110
|
+
if (tpl) {
|
|
111
|
+
stdout.write(`\n${ANSI.bold}${tpl.title}${ANSI.reset} [${tpl.category}]\n`);
|
|
112
|
+
stdout.write(`Default Oracle: ${tpl.defaultOracle}\n`);
|
|
113
|
+
stdout.write(`${tpl.description}\n\n`);
|
|
114
|
+
const action = await select(
|
|
115
|
+
[
|
|
116
|
+
{ label: "Create task from this template", value: "create" },
|
|
117
|
+
{ label: "View full template envelope", value: "view" },
|
|
118
|
+
{ label: "โ Back to menu", value: "back" },
|
|
119
|
+
],
|
|
120
|
+
"Template action:",
|
|
121
|
+
{ stdin, stdout }
|
|
122
|
+
);
|
|
123
|
+
if (action === "create") {
|
|
124
|
+
await runTaskCreateWizard(root, { template: tpl.id, interactive: true, stdin, stdout });
|
|
125
|
+
} else if (action === "view") {
|
|
126
|
+
const { synthesizeWebEnvelope } = await import("../web-templates.mjs");
|
|
127
|
+
const env = synthesizeWebEnvelope(tpl.id, { rootDir: root });
|
|
128
|
+
stdout.write(`\n${env}\n\n`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
} else if (choice === "task-optimize") {
|
|
133
|
+
const promptText = await input("Enter task prompt to score and optimize:", { stdin, stdout });
|
|
134
|
+
if (promptText && promptText.trim()) {
|
|
135
|
+
const analysis = scorePromptFalsifiability(promptText.trim(), { rootDir: root });
|
|
136
|
+
stdout.write(`\n${ANSI.bold}Prompt Score: ${analysis.score}/100 (Grade ${analysis.grade})${ANSI.reset}\n`);
|
|
137
|
+
stdout.write(`Falsifiable: ${analysis.falsifiable ? `${ANSI.green}YES${ANSI.reset}` : `${ANSI.red}NO${ANSI.reset}`}\n`);
|
|
138
|
+
if (analysis.issues && analysis.issues.length > 0) {
|
|
139
|
+
stdout.write(`Issues identified:\n`);
|
|
140
|
+
for (const issue of analysis.issues) {
|
|
141
|
+
stdout.write(` - [${issue.type}] ${issue.message}\n`);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
if (analysis.suggestions && analysis.suggestions.length > 0) {
|
|
145
|
+
stdout.write(`Suggestions:\n`);
|
|
146
|
+
for (const s of analysis.suggestions) {
|
|
147
|
+
stdout.write(` - ${s}\n`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
} else if (choice === "providers") {
|
|
152
|
+
const probes = detectAvailableProviders();
|
|
153
|
+
stdout.write(`\n๐ Detected Agent Providers:\n`);
|
|
154
|
+
for (const p of probes) {
|
|
155
|
+
const icon = p.ready ? `${ANSI.green}โ
${ANSI.reset}` : `${ANSI.dim}โฌ${ANSI.reset}`;
|
|
156
|
+
stdout.write(` ${icon} ${ANSI.bold}${p.name.padEnd(14)}${ANSI.reset} ${p.label}\n`);
|
|
157
|
+
stdout.write(` ${ANSI.dim}${p.reason}${ANSI.reset}\n`);
|
|
158
|
+
}
|
|
159
|
+
} else if (choice === "scan") {
|
|
160
|
+
stdout.write(`\nScanning codebase for TODO/FIXME annotations...\n`);
|
|
161
|
+
const todos = scanCodebaseForTodos(root);
|
|
162
|
+
stdout.write(`Found ${todos.length} annotation(s):\n`);
|
|
163
|
+
for (const item of todos.slice(0, 15)) {
|
|
164
|
+
stdout.write(` - ${item.file}:${item.line} [${item.tag}] ${item.text}\n`);
|
|
165
|
+
}
|
|
166
|
+
if (todos.length > 15) {
|
|
167
|
+
stdout.write(` ... and ${todos.length - 15} more.\n`);
|
|
168
|
+
}
|
|
169
|
+
} else if (choice === "dashboard") {
|
|
170
|
+
stdout.write(`\n๐ To start web dashboard, run:\n`);
|
|
171
|
+
stdout.write(` agentctl dashboard --port 4100\n`);
|
|
172
|
+
const startNow = await confirm("Start web dashboard now?", false, { stdin, stdout });
|
|
173
|
+
if (startNow) {
|
|
174
|
+
const { createDashboardServer } = await import("../dashboard.mjs");
|
|
175
|
+
createDashboardServer({ root, port: 4100, host: "127.0.0.1" });
|
|
176
|
+
stdout.write(`Dashboard running at http://127.0.0.1:4100\nPress Ctrl+C to exit.\n`);
|
|
177
|
+
return new Promise(() => {});
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (options.singleAction) {
|
|
182
|
+
return { ok: true, action: choice };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const again = await confirm("\nReturn to main menu?", true, { stdin, stdout });
|
|
186
|
+
if (!again) {
|
|
187
|
+
break;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return { ok: true };
|
|
192
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-platform process-tree cleanup for timed-out commands.
|
|
3
|
+
*
|
|
4
|
+
* Zero third-party deps. Used by `runCmd` when a spawnSync hits ETIMEDOUT so
|
|
5
|
+
* orphaned workers (children / grandchildren) do not linger after the parent
|
|
6
|
+
* is killed.
|
|
7
|
+
*
|
|
8
|
+
* Platform notes:
|
|
9
|
+
* - win32: `taskkill /F /T /PID` terminates the whole tree. `detached` is NOT
|
|
10
|
+
* set on Windows spawn options in runCmd โ taskkill does not need a separate
|
|
11
|
+
* process group, and detached consoles behave differently under cmd.exe.
|
|
12
|
+
* - POSIX (linux/darwin): prefer `process.kill(-pid, signal)` against the
|
|
13
|
+
* process group (runCmd sets `detached: true` so the child is group leader).
|
|
14
|
+
* Also walk descendants via `pgrep -P` so cleanup still works when the child
|
|
15
|
+
* was not started in its own group.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { spawnSync } from "node:child_process";
|
|
19
|
+
import process from "node:process";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @param {unknown} pid
|
|
23
|
+
* @returns {number} Positive integer pid, or 0 if invalid.
|
|
24
|
+
*/
|
|
25
|
+
function normalizePid(pid) {
|
|
26
|
+
const n = typeof pid === "number" ? pid : Number(pid);
|
|
27
|
+
if (!Number.isInteger(n) || n <= 0) return 0;
|
|
28
|
+
return n;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* List direct children of `pid` via `pgrep -P`. Empty on failure / none.
|
|
33
|
+
* @param {number} pid
|
|
34
|
+
* @returns {number[]}
|
|
35
|
+
*/
|
|
36
|
+
function listDirectChildren(pid) {
|
|
37
|
+
try {
|
|
38
|
+
const ret = spawnSync("pgrep", ["-P", String(pid)], {
|
|
39
|
+
encoding: "utf-8",
|
|
40
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
41
|
+
});
|
|
42
|
+
// pgrep exits 1 when there are no matches โ treat as empty, not failure.
|
|
43
|
+
if (ret.error || !ret.stdout) return [];
|
|
44
|
+
return ret.stdout
|
|
45
|
+
.split(/\n/)
|
|
46
|
+
.map((line) => line.trim())
|
|
47
|
+
.filter(Boolean)
|
|
48
|
+
.map(Number)
|
|
49
|
+
.filter((n) => Number.isInteger(n) && n > 0);
|
|
50
|
+
} catch {
|
|
51
|
+
return [];
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Breadth-first collect all descendants, returned deepest-first so parents are
|
|
57
|
+
* signalled after their children (reduces reparent races during cleanup).
|
|
58
|
+
* @param {number} pid
|
|
59
|
+
* @returns {number[]}
|
|
60
|
+
*/
|
|
61
|
+
function collectDescendants(pid) {
|
|
62
|
+
const out = [];
|
|
63
|
+
const queue = [pid];
|
|
64
|
+
const seen = new Set([pid]);
|
|
65
|
+
while (queue.length > 0) {
|
|
66
|
+
const current = queue.shift();
|
|
67
|
+
for (const child of listDirectChildren(current)) {
|
|
68
|
+
if (seen.has(child)) continue;
|
|
69
|
+
seen.add(child);
|
|
70
|
+
out.push(child);
|
|
71
|
+
queue.push(child);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return out.reverse();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Signal a single pid; never throws (ESRCH / EPERM / already-dead are quiet).
|
|
79
|
+
* @param {number} pid
|
|
80
|
+
* @param {NodeJS.Signals|number} signal
|
|
81
|
+
*/
|
|
82
|
+
function safeKill(pid, signal) {
|
|
83
|
+
try {
|
|
84
|
+
process.kill(pid, signal);
|
|
85
|
+
} catch (err) {
|
|
86
|
+
const code = err && err.code;
|
|
87
|
+
if (code === "ESRCH" || code === "EPERM") return;
|
|
88
|
+
// Any other failure is still swallowed: cleanup must not fail the caller.
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Kill `pid` and its descendants.
|
|
94
|
+
*
|
|
95
|
+
* Never throws for missing / already-dead processes (ESRCH, taskkill exit
|
|
96
|
+
* 128+, empty pgrep). Invalid pids are a no-op.
|
|
97
|
+
*
|
|
98
|
+
* @param {number|string} pid
|
|
99
|
+
* @param {NodeJS.Signals|number} [signal="SIGTERM"]
|
|
100
|
+
*/
|
|
101
|
+
export function killProcessTree(pid, signal = "SIGTERM") {
|
|
102
|
+
const n = normalizePid(pid);
|
|
103
|
+
if (!n) return;
|
|
104
|
+
|
|
105
|
+
if (process.platform === "win32") {
|
|
106
|
+
try {
|
|
107
|
+
// /T = tree, /F = force. Non-zero exit (e.g. 128 "not found") is fine.
|
|
108
|
+
spawnSync("taskkill", ["/F", "/T", "/PID", String(n)], {
|
|
109
|
+
stdio: "ignore",
|
|
110
|
+
windowsHide: true,
|
|
111
|
+
});
|
|
112
|
+
} catch {
|
|
113
|
+
// spawn failure or already gone โ ignore
|
|
114
|
+
}
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Snapshot the tree while parentโchild links still exist. After the parent
|
|
119
|
+
// dies, Linux reparents orphans to the subreaper and `pgrep -P` goes blind.
|
|
120
|
+
const descendants = collectDescendants(n);
|
|
121
|
+
|
|
122
|
+
// Process-group signal: effective when `pid` is a group leader (detached spawn).
|
|
123
|
+
try {
|
|
124
|
+
process.kill(-n, signal);
|
|
125
|
+
} catch (err) {
|
|
126
|
+
const code = err && err.code;
|
|
127
|
+
if (code !== "ESRCH" && code !== "EPERM") {
|
|
128
|
+
// fall through to per-pid cleanup
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Belt-and-suspenders: direct-child pkill (covers callers that skip the walk).
|
|
133
|
+
try {
|
|
134
|
+
const sigName = typeof signal === "string" ? signal.replace(/^SIG/i, "") : String(signal);
|
|
135
|
+
spawnSync("pkill", [`-${sigName}`, "-P", String(n)], {
|
|
136
|
+
stdio: "ignore",
|
|
137
|
+
});
|
|
138
|
+
} catch {
|
|
139
|
+
// pkill missing or no children โ ignore
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
for (const childPid of descendants) {
|
|
143
|
+
safeKill(childPid, signal);
|
|
144
|
+
}
|
|
145
|
+
safeKill(n, signal);
|
|
146
|
+
}
|
package/src/prompt-guard.mjs
CHANGED
|
@@ -5,6 +5,10 @@
|
|
|
5
5
|
* Strips zero-width unicode, bidi control characters, and ANSI escape sequences, normalizes UTF-8,
|
|
6
6
|
* neutralizes LLM control role markers and prompt injection patterns, and wraps inputs in strict tags.
|
|
7
7
|
*/
|
|
8
|
+
import { BIDI_CONTROL_REGEX, INVISIBLE_OBFUSCATION_REGEX } from "./bidi-guard.mjs";
|
|
9
|
+
import { CONFUSABLE_TO_ASCII } from "./secret-scanner.mjs";
|
|
10
|
+
|
|
11
|
+
const CONFUSABLE_REGEX = new RegExp([...CONFUSABLE_TO_ASCII.keys()].join("|"), "g");
|
|
8
12
|
|
|
9
13
|
const ZERO_WIDTH_AND_BIDI_REGEX = /[\u200B-\u200F\u202A-\u202E\u2060-\u206F\uFEFF]|[\u{E0000}-\u{E007F}]/gu;
|
|
10
14
|
const ANSI_ESCAPE_REGEX = /\u001B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g;
|
|
@@ -100,8 +104,13 @@ export function sanitizeUntrustedData(input, sourceName = "untrusted") {
|
|
|
100
104
|
// 1. Normalize UTF-8 string (NFKC)
|
|
101
105
|
let text = input.normalize("NFKC");
|
|
102
106
|
|
|
107
|
+
// 1.5 Convert confusables to ASCII (transliterate Cyrillic/Greek lookalikes)
|
|
108
|
+
text = text.replace(CONFUSABLE_REGEX, (m) => CONFUSABLE_TO_ASCII.get(m));
|
|
109
|
+
|
|
103
110
|
// 2. Strip zero-width Unicode and bidi control characters
|
|
104
111
|
text = text.replace(ZERO_WIDTH_AND_BIDI_REGEX, "");
|
|
112
|
+
text = text.replace(BIDI_CONTROL_REGEX, "");
|
|
113
|
+
text = text.replace(INVISIBLE_OBFUSCATION_REGEX, "");
|
|
105
114
|
|
|
106
115
|
// 3. Strip ANSI terminal control sequences
|
|
107
116
|
text = text.replace(ANSI_ESCAPE_REGEX, "");
|
package/src/provider.mjs
CHANGED
|
@@ -716,6 +716,10 @@ export function createProvider(spec = "jules", config = {}) {
|
|
|
716
716
|
throw new Error(`Unsupported provider type: ${providerSpec.type}`);
|
|
717
717
|
},
|
|
718
718
|
|
|
719
|
+
async sendMessage(sessionId, prompt = "", ctx = {}, task = null) {
|
|
720
|
+
return this.resume(sessionId, prompt, ctx, task);
|
|
721
|
+
},
|
|
722
|
+
|
|
719
723
|
async getSession(sessionId, ctx = {}) {
|
|
720
724
|
if (!ctx || typeof ctx !== "object") ctx = {};
|
|
721
725
|
// `listSources()` reuses this method purely as an authenticated GET, and
|
|
@@ -1126,6 +1130,10 @@ export function createFailoverProvider(providers = ["jules"], config = {}) {
|
|
|
1126
1130
|
}
|
|
1127
1131
|
},
|
|
1128
1132
|
|
|
1133
|
+
async sendMessage(sessionId, prompt, ctx = {}, task = null) {
|
|
1134
|
+
return this.resume(sessionId, prompt, ctx, task);
|
|
1135
|
+
},
|
|
1136
|
+
|
|
1129
1137
|
async getSession(sessionId, ctx = {}) {
|
|
1130
1138
|
const errors = [];
|
|
1131
1139
|
for (let i = 0; i < providerList.length; i++) {
|
|
@@ -1263,6 +1271,12 @@ export function createSyntaxVerifiedProvider(fastProvider, complexProvider, conf
|
|
|
1263
1271
|
return fastProvider.resume(...args);
|
|
1264
1272
|
},
|
|
1265
1273
|
|
|
1274
|
+
sendMessage(...args) {
|
|
1275
|
+
return typeof fastProvider.sendMessage === "function"
|
|
1276
|
+
? fastProvider.sendMessage(...args)
|
|
1277
|
+
: fastProvider.resume(...args);
|
|
1278
|
+
},
|
|
1279
|
+
|
|
1266
1280
|
getSession(...args) {
|
|
1267
1281
|
return fastProvider.getSession(...args);
|
|
1268
1282
|
},
|
package/src/scaffold.mjs
CHANGED
|
@@ -35,6 +35,9 @@ export const RUNTIME_GITIGNORE_ENTRIES = [
|
|
|
35
35
|
"!.agent/jules-queue/README.md",
|
|
36
36
|
];
|
|
37
37
|
|
|
38
|
+
/** Comment header `ensureGitignore` writes above runtime ignore entries. */
|
|
39
|
+
export const RUNTIME_GITIGNORE_HEADER = "# Jules Orchestrator runtime state & credentials";
|
|
40
|
+
|
|
38
41
|
/**
|
|
39
42
|
* Ensure `.gitignore` lists every runtime path in {@link RUNTIME_GITIGNORE_ENTRIES}.
|
|
40
43
|
*
|
|
@@ -57,12 +60,57 @@ export function ensureGitignore(root) {
|
|
|
57
60
|
const prefix = current && !current.endsWith("\n") ? "\n" : "";
|
|
58
61
|
appendFileSync(
|
|
59
62
|
gitignorePath,
|
|
60
|
-
`${prefix}\n
|
|
63
|
+
`${prefix}\n${RUNTIME_GITIGNORE_HEADER}\n${missing.join("\n")}\n`,
|
|
61
64
|
"utf-8"
|
|
62
65
|
);
|
|
63
66
|
return missing;
|
|
64
67
|
}
|
|
65
68
|
|
|
69
|
+
/**
|
|
70
|
+
* Remove kit-owned runtime ignore blocks from a `.gitignore` body.
|
|
71
|
+
*
|
|
72
|
+
* Only strips contiguous runs that begin with {@link RUNTIME_GITIGNORE_HEADER}
|
|
73
|
+
* and continue through lines that match {@link RUNTIME_GITIGNORE_ENTRIES}.
|
|
74
|
+
* User entries โ including a pre-existing `.env` that was never part of a kit
|
|
75
|
+
* block โ are left alone.
|
|
76
|
+
*
|
|
77
|
+
* @param {string} content
|
|
78
|
+
* @returns {{ text: string, stripped: boolean }}
|
|
79
|
+
*/
|
|
80
|
+
export function stripRuntimeGitignoreBlock(content) {
|
|
81
|
+
const entries = new Set(RUNTIME_GITIGNORE_ENTRIES);
|
|
82
|
+
const lines = String(content).split(/\r?\n/);
|
|
83
|
+
const out = [];
|
|
84
|
+
let stripped = false;
|
|
85
|
+
let i = 0;
|
|
86
|
+
while (i < lines.length) {
|
|
87
|
+
if (lines[i].trim() === RUNTIME_GITIGNORE_HEADER) {
|
|
88
|
+
stripped = true;
|
|
89
|
+
i += 1;
|
|
90
|
+
while (i < lines.length) {
|
|
91
|
+
const trimmed = lines[i].trim();
|
|
92
|
+
if (trimmed === "") {
|
|
93
|
+
// Trailing blank that ensureGitignore leaves after the block.
|
|
94
|
+
i += 1;
|
|
95
|
+
break;
|
|
96
|
+
}
|
|
97
|
+
if (entries.has(trimmed)) {
|
|
98
|
+
i += 1;
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
break;
|
|
102
|
+
}
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
out.push(lines[i]);
|
|
106
|
+
i += 1;
|
|
107
|
+
}
|
|
108
|
+
while (out.length > 0 && out[out.length - 1] === "") out.pop();
|
|
109
|
+
let text = out.join("\n");
|
|
110
|
+
if (text.length > 0) text += "\n";
|
|
111
|
+
return { text, stripped };
|
|
112
|
+
}
|
|
113
|
+
|
|
66
114
|
/**
|
|
67
115
|
* Copy every file from a directory shipped in the package into the target repo.
|
|
68
116
|
*
|
package/src/scope-guard.mjs
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import { basename } from "node:path";
|
|
12
|
-
import { canonicalizePath, isWindowsAbsolutePath } from "./config.mjs";
|
|
12
|
+
import { canonicalizePath, isWindowsAbsolutePath, CI_DEFINITIONS } from "./config.mjs";
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
15
|
* Glob matcher.
|
|
@@ -178,8 +178,8 @@ export const ENV_TEMPLATE_BASENAMES = new Set([
|
|
|
178
178
|
* @param {string} pattern - the deny pattern that matched
|
|
179
179
|
* @returns {boolean}
|
|
180
180
|
*/
|
|
181
|
-
export function isEnvTemplateException(file, pattern) {
|
|
182
|
-
if (!BUILTIN_ENV_DENY_PATTERNS.has(pattern)) return false;
|
|
181
|
+
export function isEnvTemplateException(file, pattern = "") {
|
|
182
|
+
if (pattern && !BUILTIN_ENV_DENY_PATTERNS.has(pattern)) return false;
|
|
183
183
|
const name = basename(file).toLowerCase();
|
|
184
184
|
if (ENV_TEMPLATE_BASENAMES.has(name)) return true;
|
|
185
185
|
// `.env.production.example`, `.env.test.sample`, ... โ the documented-template
|
|
@@ -187,6 +187,70 @@ export function isEnvTemplateException(file, pattern) {
|
|
|
187
187
|
return /^\.env\..+\.(example|sample|template|dist|defaults)$/.test(name);
|
|
188
188
|
}
|
|
189
189
|
|
|
190
|
+
/**
|
|
191
|
+
* Patterns in deny that are strictly non-waivable under any circumstances.
|
|
192
|
+
* Credentials, private keys, cloud auth configurations, lock-manager files,
|
|
193
|
+
* and secret roots must NEVER be permitted by a workflow or protected-path waiver.
|
|
194
|
+
*/
|
|
195
|
+
export const UNWAIVABLE_DENY_PATTERNS = [
|
|
196
|
+
".git/**",
|
|
197
|
+
"**/.env",
|
|
198
|
+
"**/.env.*",
|
|
199
|
+
"**/*.pem",
|
|
200
|
+
"**/*.key",
|
|
201
|
+
"**/id_rsa*",
|
|
202
|
+
".agent/jules-queue/**",
|
|
203
|
+
"**/.envrc",
|
|
204
|
+
"**/.git-credentials",
|
|
205
|
+
"**/.aws/**",
|
|
206
|
+
"**/.ssh/**",
|
|
207
|
+
"**/.kube/**",
|
|
208
|
+
"**/kubeconfig*",
|
|
209
|
+
"**/.docker/config.json",
|
|
210
|
+
"**/*.p12",
|
|
211
|
+
"**/*.pfx",
|
|
212
|
+
"**/*.p8",
|
|
213
|
+
"**/id_ed25519*",
|
|
214
|
+
"**/credentials.json",
|
|
215
|
+
"**/service-account*.json",
|
|
216
|
+
"**/*.tfstate",
|
|
217
|
+
"**/*.tfstate.*",
|
|
218
|
+
"**/secrets/**",
|
|
219
|
+
"**/lock-manager/**",
|
|
220
|
+
];
|
|
221
|
+
|
|
222
|
+
export function isUnwaivableForbiddenPath(file, pattern = "") {
|
|
223
|
+
const normFile = canonicalizePath(file);
|
|
224
|
+
if (isEnvTemplateException(normFile, pattern)) return false;
|
|
225
|
+
if (pattern && UNWAIVABLE_DENY_PATTERNS.some((pat) => matchesGlob(pattern, pat, { caseInsensitive: true }))) {
|
|
226
|
+
return true;
|
|
227
|
+
}
|
|
228
|
+
return UNWAIVABLE_DENY_PATTERNS.some((pat) =>
|
|
229
|
+
matchesGlob(normFile, pat, { caseInsensitive: true })
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* True when a deny hit is a forge CI workflow definition (e.g. .github/**)
|
|
235
|
+
* being modified under an active maintainer-approved waiver (opts.allowProtected === true).
|
|
236
|
+
* Unwaivable credentials and non-CI forbidden paths are never exempt.
|
|
237
|
+
*
|
|
238
|
+
* @param {string} file - canonicalised repo-relative path
|
|
239
|
+
* @param {string} pattern - the deny pattern that matched
|
|
240
|
+
* @param {{ allowProtected?: boolean }} [opts]
|
|
241
|
+
* @returns {boolean}
|
|
242
|
+
*/
|
|
243
|
+
export function isProtectedWorkflowException(file, pattern = "", opts = {}) {
|
|
244
|
+
if (!opts.allowProtected) return false;
|
|
245
|
+
if (isUnwaivableForbiddenPath(file, pattern)) return false;
|
|
246
|
+
|
|
247
|
+
const normFile = canonicalizePath(file);
|
|
248
|
+
const isCiDefinition = CI_DEFINITIONS.some((ciPat) =>
|
|
249
|
+
matchesGlob(normFile, ciPat, { caseInsensitive: true })
|
|
250
|
+
);
|
|
251
|
+
return isCiDefinition;
|
|
252
|
+
}
|
|
253
|
+
|
|
190
254
|
export function checkScope(files = [], scope = {}, opts = {}) {
|
|
191
255
|
const violations = [];
|
|
192
256
|
const deny = scope.deny || [];
|
|
@@ -220,8 +284,12 @@ export function checkScope(files = [], scope = {}, opts = {}) {
|
|
|
220
284
|
// directory as ".github/", so a case-sensitive deny is bypassable there.
|
|
221
285
|
const matchedDeny = deny.find((pat) => matchesGlob(file, pat, { caseInsensitive: true }));
|
|
222
286
|
if (matchedDeny && !isEnvTemplateException(file, matchedDeny)) {
|
|
223
|
-
|
|
224
|
-
|
|
287
|
+
if (opts.allowProtected && isProtectedWorkflowException(file, matchedDeny, opts)) {
|
|
288
|
+
// Human-reviewed and SHA-bound waiver explicitly authorizes this CI workflow definition.
|
|
289
|
+
} else {
|
|
290
|
+
violations.push({ file, reason: `Forbidden path restriction matched pattern "${matchedDeny}"`, rule: "deny", pattern: matchedDeny });
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
225
293
|
}
|
|
226
294
|
|
|
227
295
|
// Allow stays case-sensitive on purpose: a case mismatch here yields "not
|
package/src/secret-scanner.mjs
CHANGED
|
@@ -156,7 +156,7 @@ const INVISIBLE_CHARS = /[\u00AD\u200B-\u200F\u2028\u2029\u202A-\u202E\u2060-\u2
|
|
|
156
156
|
// distinct code points with no compatibility decomposition. A credential
|
|
157
157
|
// scanner without this table can be defeated by a single substituted glyph,
|
|
158
158
|
// e.g. `ghp_` spelled with Cyrillic `ั`.
|
|
159
|
-
const CONFUSABLE_TO_ASCII = new Map([
|
|
159
|
+
export const CONFUSABLE_TO_ASCII = new Map([
|
|
160
160
|
// Cyrillic
|
|
161
161
|
["ะ", "A"], ["ะ", "B"], ["ะ", "E"], ["ะ", "K"], ["ะ", "M"], ["ะ", "H"],
|
|
162
162
|
["ะ", "O"], ["ะ ", "P"], ["ะก", "C"], ["ะข", "T"], ["ะฃ", "Y"], ["ะฅ", "X"],
|
|
@@ -254,6 +254,11 @@ export function secretScanVariants(addedLines) {
|
|
|
254
254
|
// Collapse method concatenations like .concat("...") or .join("")
|
|
255
255
|
dejoined = dejoined.replace(/\.concat\(\s*["'`]/g, "").replace(/\.join\(\s*["'`]{2}\s*\)/g, "");
|
|
256
256
|
|
|
257
|
+
// Collapse array elements like ["A", "B"]
|
|
258
|
+
dejoined = dejoined.replace(/["'`]\s*,\s*["'`]/g, "");
|
|
259
|
+
// Collapse split variables across newlines (e.g. const a = "AKIA"; const b = "...")
|
|
260
|
+
dejoined = dejoined.replace(/["'`]\s*[,;+]*\s*[\r\n]+\s*(?:(?:export\s+)?(?:const|let|var)\s+[A-Za-z0-9_]+\s*=\s*)?["'`]/g, "");
|
|
261
|
+
|
|
257
262
|
// Collapse whitespace/newlines between adjacent base64 characters (including line-wrapped PEM/base64, template literals, and quoted string chunks)
|
|
258
263
|
const base64Dejoined = stripped
|
|
259
264
|
.replace(/([A-Za-z0-9+/=_-])\s*[\r\n]+\s*(?=[A-Za-z0-9+/=_-])/g, "$1")
|
|
@@ -266,9 +271,16 @@ export function secretScanVariants(addedLines) {
|
|
|
266
271
|
// source-level join AND spelled with homoglyphs still surfaces.
|
|
267
272
|
const confusable = normalizeSecretText(stripped);
|
|
268
273
|
const confusableDejoined = normalizeSecretText(dejoined);
|
|
274
|
+
const uniDecoded = dejoined
|
|
275
|
+
.replace(/\\u([0-9a-fA-F]{4})/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)))
|
|
276
|
+
.replace(/\\x([0-9a-fA-F]{2})/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)));
|
|
277
|
+
|
|
278
|
+
const stringLiteralsCollapsed = (stripped.match(/(["'`])(?:(?!\1)[^\n])*\1/g) || [])
|
|
279
|
+
.map(s => s.slice(1, -1))
|
|
280
|
+
.join('');
|
|
269
281
|
|
|
270
282
|
return {
|
|
271
|
-
all: [...new Set([addedLines, stripped, dejoined, base64Dejoined, hexDecoded, pctDecoded, confusable, confusableDejoined])],
|
|
283
|
+
all: [...new Set([addedLines, stripped, dejoined, base64Dejoined, hexDecoded, pctDecoded, confusable, confusableDejoined, uniDecoded, stringLiteralsCollapsed])],
|
|
272
284
|
normalized: dejoined,
|
|
273
285
|
base64Normalized: base64Dejoined,
|
|
274
286
|
};
|