jules-orchestrator-kit 0.74.0 โ 0.75.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/CHANGELOG.md +13 -0
- package/README.md +2 -2
- package/bin/agentctl.mjs +372 -20
- package/package.json +1 -1
- package/scripts/ci-scope-guard.mjs +30 -7
- package/src/bidi-guard.mjs +190 -15
- package/src/config.mjs +40 -1
- package/src/engine.mjs +184 -47
- package/src/git.mjs +15 -0
- package/src/migrate.mjs +493 -0
- package/src/ops/command-registry.mjs +91 -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/scaffold.mjs +49 -1
- package/src/scope-guard.mjs +73 -5
- package/src/secret-scanner.mjs +1 -1
- package/src/security.mjs +37 -30
- package/src/self-audit.mjs +237 -3
- package/src/state.mjs +100 -4
- package/src/uninstall.mjs +227 -0
- package/src/wizard-init.mjs +11 -0
- package/src/wizard-task.mjs +8 -2
|
@@ -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/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"],
|
package/src/security.mjs
CHANGED
|
@@ -19,7 +19,10 @@
|
|
|
19
19
|
import { readFileSync } from "node:fs";
|
|
20
20
|
import { join } from "node:path";
|
|
21
21
|
import { detectCrossPackageBoundaryViolations, detectEdgeRuntime } from "./stack-detector.mjs";
|
|
22
|
-
import {
|
|
22
|
+
import {
|
|
23
|
+
checkUnicodeSecurity,
|
|
24
|
+
locateUnicodeFindingLine,
|
|
25
|
+
} from "./bidi-guard.mjs";
|
|
23
26
|
import {
|
|
24
27
|
hasHighConfidenceSecret,
|
|
25
28
|
hasLowConfidenceSecret,
|
|
@@ -331,42 +334,35 @@ export function scanDiff(diffTextStr = "", options = {}) {
|
|
|
331
334
|
for (const segment of segments) {
|
|
332
335
|
const addedText = segment.lines.map((l) => l.text).join("\n");
|
|
333
336
|
|
|
334
|
-
|
|
335
|
-
if (
|
|
336
|
-
const
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
337
|
+
const hit = classifyAddedLines(addedText, segment.file);
|
|
338
|
+
if (hit) {
|
|
339
|
+
const line = segment.file ? locateFindingLine(segment.lines, hit.type, segment.file) : null;
|
|
340
|
+
const at = segment.file ? ` (${segment.file}${line ? `:${line}` : ""})` : "";
|
|
341
|
+
findings.push({
|
|
342
|
+
severity: hit.severity,
|
|
343
|
+
type: hit.type,
|
|
344
|
+
file: segment.file,
|
|
345
|
+
line,
|
|
346
|
+
description: `${hit.description}${at}`,
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// Unicode security: BiDi Trojan Source, invisible obfuscation, mixed-script
|
|
351
|
+
// confusables. Markdown remains exempt (documentation may cite the controls).
|
|
352
|
+
if (!segment.file || !segment.file.endsWith(".md")) {
|
|
353
|
+
const uniRes = checkUnicodeSecurity(addedText, options);
|
|
354
|
+
if (!uniRes.ok) {
|
|
355
|
+
for (const v of uniRes.violations) {
|
|
348
356
|
findings.push({
|
|
349
357
|
severity: "CRITICAL",
|
|
350
|
-
type:
|
|
358
|
+
type: v.type,
|
|
351
359
|
file: segment.file,
|
|
352
|
-
line:
|
|
360
|
+
line: locateUnicodeFindingLine(segment.lines, v.type),
|
|
353
361
|
description: v.reason,
|
|
354
362
|
});
|
|
355
363
|
}
|
|
356
364
|
}
|
|
357
365
|
}
|
|
358
|
-
|
|
359
|
-
const hit = classifyAddedLines(addedText, segment.file);
|
|
360
|
-
if (!hit) continue;
|
|
361
|
-
const line = segment.file ? locateFindingLine(segment.lines, hit.type, segment.file) : null;
|
|
362
|
-
const at = segment.file ? ` (${segment.file}${line ? `:${line}` : ""})` : "";
|
|
363
|
-
findings.push({
|
|
364
|
-
severity: hit.severity,
|
|
365
|
-
type: hit.type,
|
|
366
|
-
file: segment.file,
|
|
367
|
-
line,
|
|
368
|
-
description: `${hit.description}${at}`,
|
|
369
|
-
});
|
|
370
366
|
}
|
|
371
367
|
|
|
372
368
|
// Scanning per file loses anything that only matches across a file boundary,
|
|
@@ -462,6 +458,9 @@ export {
|
|
|
462
458
|
ENV_TEMPLATE_BASENAMES,
|
|
463
459
|
isEnvTemplateException,
|
|
464
460
|
checkScope,
|
|
461
|
+
isProtectedWorkflowException,
|
|
462
|
+
UNWAIVABLE_DENY_PATTERNS,
|
|
463
|
+
isUnwaivableForbiddenPath,
|
|
465
464
|
} from "./scope-guard.mjs";
|
|
466
465
|
export {
|
|
467
466
|
HIGH_CONFIDENCE_PATTERNS,
|
|
@@ -475,4 +474,12 @@ export {
|
|
|
475
474
|
hasHighEntropyToken,
|
|
476
475
|
} from "./secret-scanner.mjs";
|
|
477
476
|
export { TAMPER_KINDS, TAMPER_KIND_NAMES, resolveAllowedTamperKinds, checkTestTampering } from "./test-tamper-guard.mjs";
|
|
478
|
-
export {
|
|
477
|
+
export {
|
|
478
|
+
checkTrojanSource,
|
|
479
|
+
checkUnicodeSecurity,
|
|
480
|
+
BIDI_CONTROL_REGEX,
|
|
481
|
+
INVISIBLE_OBFUSCATION_REGEX,
|
|
482
|
+
PLANE14_TAG_REGEX,
|
|
483
|
+
hasMixedScriptConfusable,
|
|
484
|
+
locateUnicodeFindingLine,
|
|
485
|
+
} from "./bidi-guard.mjs";
|