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.
@@ -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
+ }
@@ -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# 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"],
@@ -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
  };