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.
@@ -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# Jules Orchestrator runtime state & credentials\n${missing.join("\n")}\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
  *
@@ -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
- violations.push({ file, reason: `Forbidden path restriction matched pattern "${matchedDeny}"`, rule: "deny", pattern: matchedDeny });
224
- continue;
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
@@ -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 { checkTrojanSource } from "./bidi-guard.mjs";
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
- // Check Trojan Source
335
- if (!segment.file || !segment.file.endsWith('.md')) {
336
- const tsRes = checkTrojanSource(addedText, options);
337
- if (!tsRes.ok) {
338
- // Find line number where it happened if possible
339
- const bidiRegex = /[\u202A\u202B\u202C\u202D\u202E\u2066\u2067\u2068\u2069\u061C\u200E\u200F]/;
340
- let lineNo = null;
341
- for (const l of segment.lines) {
342
- if (bidiRegex.test(l.text)) {
343
- lineNo = l.no;
344
- break;
345
- }
346
- }
347
- for (const v of tsRes.violations) {
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: "TROJAN_SOURCE_DETECTED",
358
+ type: v.type,
351
359
  file: segment.file,
352
- line: lineNo,
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 { checkTrojanSource } from "./bidi-guard.mjs";
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";