jules-orchestrator-kit 0.53.0 → 0.54.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/src/security.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { openSync, writeSync, fsyncSync, closeSync, renameSync, realpathSync, existsSync, lstatSync, unlinkSync } from "node:fs";
1
+ import { openSync, readFileSync, writeSync, fsyncSync, closeSync, renameSync, realpathSync, existsSync, lstatSync, unlinkSync } from "node:fs";
2
2
  import { dirname, join, basename } from "node:path";
3
3
  import { randomBytes } from "node:crypto";
4
4
  import { canonicalizePath, isWindowsAbsolutePath } from "./config.mjs";
@@ -911,6 +911,102 @@ export function hasHighEntropyToken(text = "", file = null) {
911
911
  return false;
912
912
  }
913
913
 
914
+ /** Bytes of any one binary file the scanner will read. */
915
+ const BINARY_SCAN_CAP_BYTES = 8 * 1024 * 1024;
916
+
917
+ /** Runs of printable ASCII at least this long are worth classifying. */
918
+ const BINARY_STRING_MIN_RUN = 8;
919
+
920
+ /**
921
+ * Scan the contents of files git summarised as "Binary files ... differ".
922
+ *
923
+ * Everything else in this module reads the diff *text*, and git renders a
924
+ * binary file as one 43-byte summary line — so a credential became invisible to
925
+ * the entire scanner by prefixing the file with a single NUL byte. That is not
926
+ * a theoretical bypass: `printf '\0ghp_...' > secret.dat` walked a live GitHub
927
+ * token straight through a green gate.
928
+ *
929
+ * Only the *structured* high-confidence patterns are applied here, never
930
+ * entropy. A real PNG is full of high-entropy bytes and would fail every gate
931
+ * it touched; a string matching `ghp_[A-Za-z0-9]{36}` inside a file claiming to
932
+ * be an image is not a coincidence.
933
+ *
934
+ * @param {Array<{ file: string, bytes: number }>} entries
935
+ * @param {string} root
936
+ * @param {object} [opts]
937
+ * @param {number} [opts.capBytes] - per-file read ceiling
938
+ * @returns {Array<{ severity: string, type: string, file: string, line: null, description: string }>}
939
+ */
940
+ export function scanBinaryPayloads(entries = [], root = process.cwd(), opts = {}) {
941
+ const cap = Number.isFinite(opts.capBytes) ? opts.capBytes : BINARY_SCAN_CAP_BYTES;
942
+ const findings = [];
943
+ // `entries` comes from a git call that returns null on failure, and the
944
+ // default parameter only covers `undefined`.
945
+ const list = Array.isArray(entries) ? entries : [];
946
+
947
+ for (const entry of list) {
948
+ if (!entry || !entry.file) continue;
949
+ // A file too large to read is reported rather than skipped: silence here is
950
+ // exactly the hole being closed.
951
+ if (entry.bytes > cap) {
952
+ findings.push({
953
+ severity: "HIGH",
954
+ type: "BINARY_PAYLOAD_UNSCANNED",
955
+ file: entry.file,
956
+ line: null,
957
+ description: `Binary file ${entry.file} is ${Math.round(entry.bytes / 1024)} KB, above the ${Math.round(cap / 1024)} KB scan ceiling, and was not inspected for credentials`,
958
+ });
959
+ continue;
960
+ }
961
+
962
+ let buf;
963
+ try {
964
+ buf = readFileSync(join(root, entry.file));
965
+ } catch (_) {
966
+ continue;
967
+ }
968
+
969
+ // Extract printable runs the way `strings(1)` does: a credential inside a
970
+ // binary is still ASCII, and decoding the whole buffer as UTF-8 would let
971
+ // replacement characters split the token apart.
972
+ const runs = [];
973
+ let current = "";
974
+ for (const byte of buf) {
975
+ if (byte >= 0x20 && byte <= 0x7e) {
976
+ current += String.fromCharCode(byte);
977
+ } else {
978
+ if (current.length >= BINARY_STRING_MIN_RUN) runs.push(current);
979
+ current = "";
980
+ }
981
+ }
982
+ if (current.length >= BINARY_STRING_MIN_RUN) runs.push(current);
983
+ if (runs.length === 0) continue;
984
+
985
+ const text = runs.join("\n");
986
+ if (hasHighConfidenceSecret(text)) {
987
+ findings.push({
988
+ severity: "CRITICAL",
989
+ type: "HIGH_CONFIDENCE_SECRET",
990
+ file: entry.file,
991
+ line: null,
992
+ description: `High-confidence secret pattern found inside binary file ${entry.file}, which the diff renders only as "Binary files ... differ"`,
993
+ });
994
+ continue;
995
+ }
996
+ if (hasEncodedSecret(text)) {
997
+ findings.push({
998
+ severity: "CRITICAL",
999
+ type: "HIGH_CONFIDENCE_SECRET",
1000
+ file: entry.file,
1001
+ line: null,
1002
+ description: `Base64-encoded secret found inside binary file ${entry.file}`,
1003
+ });
1004
+ }
1005
+ }
1006
+
1007
+ return findings;
1008
+ }
1009
+
914
1010
  /**
915
1011
  * Classify a block of added lines. Returns the single most severe finding, or
916
1012
  * null when the block is clean.
@@ -1,8 +1,9 @@
1
1
  import { existsSync, readFileSync, writeFileSync, openSync, fsyncSync, closeSync, renameSync, mkdirSync, readdirSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { parseYaml, TIER_PRESETS, VENDOR_TIERS, FALLBACK_TIER } from "./config.mjs";
4
- import { suggestProvider } from "./provider-readiness.mjs";
5
- import { PROFILE_NAMES } from "./profiles.mjs";
4
+ import { suggestProvider, detectAvailableProviders } from "./provider-readiness.mjs";
5
+ import { detectDefaultBranch } from "./git.mjs";
6
+ import { PROFILE_NAMES, PROFILE_DESCRIPTIONS } from "./profiles.mjs";
6
7
  import { detectStackOracles, runVerificationProbe } from "./wizard-oracle.mjs";
7
8
  import { select, multiSelect, input, confirm, spinner, isTTY } from "./tui.mjs";
8
9
  import { KIT_VERSION } from "./version.mjs";
@@ -174,6 +175,11 @@ export function planInit(root = process.cwd(), options = {}) {
174
175
  const requestedProfile = String(options.profile || existingConfig.verify?.profile || "standard").toLowerCase();
175
176
  const profile = PROFILE_NAMES.includes(requestedProfile) ? requestedProfile : "standard";
176
177
 
178
+ // Detected, not assumed. A hardcoded `main` made the very first
179
+ // `agentctl check` fail with an unresolvable base ref in every repository
180
+ // whose git chose `master`, or whose team standardised on `develop`.
181
+ const baseBranch = options.baseBranch || existingConfig.base_branch || detectDefaultBranch(root);
182
+
177
183
  const limitsBlock = isCustomLimits
178
184
  ? `\nlimits:\n concurrency: ${limits.concurrency}\n daily_tasks: ${limits.daily_tasks}\n stagger_ms: ${limits.stagger_ms}\n diff_kb: ${limits.diff_kb}\n`
179
185
  : "";
@@ -183,7 +189,7 @@ export function planInit(root = process.cwd(), options = {}) {
183
189
  version: 1
184
190
  provider: ${provider}
185
191
  tier: ${tierName}
186
- base_branch: ${options.baseBranch || existingConfig.base_branch || "main"}
192
+ base_branch: ${baseBranch}
187
193
  branch_prefix: ${existingConfig.branch_prefix || "agent/"}
188
194
  ${limitsBlock}
189
195
  verify:
@@ -229,6 +235,7 @@ allow_paths: ${allowPaths.length > 0 ? "\n" + allowPaths.map((p) => ` - "${p}"`
229
235
  tier: tierName,
230
236
  provider,
231
237
  profile,
238
+ baseBranch,
232
239
  verify,
233
240
  limits,
234
241
  presets: selectedPresets,
@@ -292,6 +299,8 @@ export async function runInitWizard(root = process.cwd(), options = {}) {
292
299
  }
293
300
 
294
301
  let selectedTier = options.tier || existingConfig.tier || FALLBACK_TIER;
302
+ let selectedProvider = options.provider || existingConfig.provider;
303
+ let selectedProfile = options.profile || existingConfig.verify?.profile;
295
304
  let testCmd = options.testCmd;
296
305
  let buildCmd = options.buildCmd;
297
306
  let selectedPresets = options.presets;
@@ -302,14 +311,51 @@ export async function runInitWizard(root = process.cwd(), options = {}) {
302
311
  await new Promise((resolve) => setTimeout(resolve, 300));
303
312
  sp.stop(`Detected Stack: ${oracle.stack}`);
304
313
 
305
- const optionsList = tierOptions();
306
- const defaultTierIdx = Math.max(0, optionsList.findIndex((t) => t.value === selectedTier));
307
-
308
- selectedTier = await select(
309
- optionsList,
310
- "Which plan does your Jules account use? (limits are adjustable later)",
311
- { ...options, defaultIdx: defaultTierIdx }
314
+ // Which agent, before anything about one vendor's plans.
315
+ //
316
+ // The wizard's first question used to be "Which plan does your Jules
317
+ // account use?", asked of everyone — including people who came to drive
318
+ // Claude Code or Codex and were now left guessing whether a Jules
319
+ // subscription was a prerequisite. Ask what the repository is for first,
320
+ // and ask the plan question only of the provider it belongs to.
321
+ const probes = detectAvailableProviders({ env: options.env || process.env });
322
+ const providerOptions = probes.map((pr) => ({
323
+ label: `${pr.name}${pr.ready ? "" : " (not available here)"}`,
324
+ value: pr.name,
325
+ hint: pr.ready ? pr.label : `${pr.label} — ${pr.remedy}`,
326
+ }));
327
+ const defaultProviderIdx = Math.max(
328
+ 0,
329
+ providerOptions.findIndex((o) => o.value === (selectedProvider || probes.find((pr) => pr.ready)?.name))
312
330
  );
331
+ selectedProvider = await select(providerOptions, "Which agent should run the tasks?", {
332
+ ...options,
333
+ defaultIdx: defaultProviderIdx,
334
+ });
335
+
336
+ // Only the hosted provider meters work against an account plan; asking a
337
+ // local-CLI user about tiers is asking about something that does not exist
338
+ // for them.
339
+ if (selectedProvider === "jules") {
340
+ const optionsList = tierOptions();
341
+ const defaultTierIdx = Math.max(0, optionsList.findIndex((t) => t.value === selectedTier));
342
+ selectedTier = await select(
343
+ optionsList,
344
+ "Which plan does your Jules account use? (limits are adjustable later)",
345
+ { ...options, defaultIdx: defaultTierIdx }
346
+ );
347
+ }
348
+
349
+ const profileOptions = PROFILE_NAMES.map((n) => ({
350
+ label: n,
351
+ value: n,
352
+ hint: PROFILE_DESCRIPTIONS[n],
353
+ }));
354
+ const defaultProfileIdx = Math.max(0, profileOptions.findIndex((o) => o.value === (selectedProfile || "standard")));
355
+ selectedProfile = await select(profileOptions, "How hard should the gate verify agent work?", {
356
+ ...options,
357
+ defaultIdx: defaultProfileIdx,
358
+ });
313
359
 
314
360
  testCmd = await input("Verification Test Command", {
315
361
  defaultValue: testCmd || existingConfig.verify?.test || oracle.candidates.testCmd || "npm test",
@@ -357,6 +403,8 @@ export async function runInitWizard(root = process.cwd(), options = {}) {
357
403
  const plan = planInit(root, {
358
404
  ...options,
359
405
  tier: selectedTier,
406
+ provider: selectedProvider,
407
+ profile: selectedProfile,
360
408
  testCmd,
361
409
  buildCmd,
362
410
  presets: selectedPresets,
@@ -257,7 +257,8 @@ ${fullPrompt}
257
257
  * Run interactive or headless task creation wizard.
258
258
  * @param {string} [root=process.cwd()]
259
259
  * @param {object} [options]
260
- * @returns {Promise<{ ok: boolean, taskFile: string, plan: object }>}
260
+ * @param {boolean} [options.dryRun] - synthesize and validate the envelope without writing it
261
+ * @returns {Promise<{ ok: boolean, dryRun: boolean, taskFile: string, written: boolean, plan: object }>}
261
262
  */
262
263
  export async function runTaskCreateWizard(root = process.cwd(), options = {}) {
263
264
  const interactive = options.interactive !== false && isTTY(options.stdin || process.stdin);
@@ -338,20 +339,36 @@ export async function runTaskCreateWizard(root = process.cwd(), options = {}) {
338
339
 
339
340
  // Write Task File to canonical queue directory (getQueueDir)
340
341
  const queueDir = getQueueDir(root);
341
- if (!existsSync(queueDir)) {
342
- mkdirSync(queueDir, { recursive: true });
343
- }
344
-
345
342
  const taskFile = resolve(queueDir, `${plan.taskId}.md`);
346
343
  if (!taskFile.startsWith(resolve(queueDir))) {
347
344
  throw new Error("Task ID path traversal blocked.");
348
345
  }
349
346
 
347
+ // `--dry-run` was accepted by the CLI parser and then dropped on the floor:
348
+ // the envelope was written to the queue either way, so a rehearsal queued
349
+ // real work. A rehearsal returns the same plan and touches nothing — not even
350
+ // the queue directory, which would otherwise be created as a side effect.
351
+ if (options.dryRun) {
352
+ return {
353
+ ok: true,
354
+ dryRun: true,
355
+ taskFile,
356
+ written: false,
357
+ plan,
358
+ };
359
+ }
360
+
361
+ if (!existsSync(queueDir)) {
362
+ mkdirSync(queueDir, { recursive: true });
363
+ }
364
+
350
365
  writeFileSync(taskFile, plan.taskFileContent, "utf-8");
351
366
 
352
367
  return {
353
368
  ok: true,
369
+ dryRun: false,
354
370
  taskFile,
371
+ written: true,
355
372
  plan,
356
373
  };
357
374
  }
@@ -1,77 +0,0 @@
1
- import { existsSync, readFileSync, writeFileSync, renameSync } from "node:fs";
2
- import { join } from "node:path";
3
- import { PROFILE_NAMES } from "./profiles.mjs";
4
-
5
- /**
6
- * Set `verify.profile` in the repository's manifest, in place.
7
- *
8
- * A surgical text edit rather than a parse-and-reserialise: the kit's YAML
9
- * reader is a subset parser, so round-tripping the file through it would drop
10
- * every comment the wizard wrote and any key the subset does not model. The
11
- * manifest is a file a human maintains; a tool that rewrites it must leave the
12
- * rest of it alone.
13
- *
14
- * @param {string} root
15
- * @param {string} profile - one of {@link PROFILE_NAMES}
16
- * @returns {{ ok: boolean, file?: string, error?: string }}
17
- */
18
- export function setVerificationProfile(root, profile) {
19
- const name = String(profile || "").toLowerCase();
20
- if (!PROFILE_NAMES.includes(name)) {
21
- return { ok: false, error: `Unknown profile '${profile}'. Choose one of: ${PROFILE_NAMES.join(", ")}` };
22
- }
23
-
24
- const candidates = [join(root, ".agent", "config.yml"), join(root, ".agent", "jules.yml")];
25
- const file = candidates.find((f) => existsSync(f));
26
- if (!file) {
27
- return { ok: false, error: "No .agent/config.yml found. Run `agentctl init` first." };
28
- }
29
-
30
- let text;
31
- try {
32
- text = readFileSync(file, "utf-8");
33
- } catch (err) {
34
- return { ok: false, error: `Could not read ${file}: ${err.message}` };
35
- }
36
-
37
- const eol = text.includes("\r\n") ? "\r\n" : "\n";
38
- const lines = text.split(/\r?\n/);
39
-
40
- // Find the `verify:` mapping and the `profile:` key nested directly under it.
41
- let verifyIdx = -1;
42
- let profileIdx = -1;
43
- for (let i = 0; i < lines.length; i++) {
44
- if (/^verify:\s*$/.test(lines[i])) {
45
- verifyIdx = i;
46
- for (let j = i + 1; j < lines.length; j++) {
47
- // A non-indented, non-blank line ends the block.
48
- if (lines[j].trim() !== "" && !/^\s/.test(lines[j])) break;
49
- if (/^\s+profile:\s*/.test(lines[j])) {
50
- profileIdx = j;
51
- break;
52
- }
53
- }
54
- break;
55
- }
56
- }
57
-
58
- if (profileIdx >= 0) {
59
- const indent = lines[profileIdx].match(/^(\s*)/)[1];
60
- lines[profileIdx] = `${indent}profile: ${name}`;
61
- } else if (verifyIdx >= 0) {
62
- lines.splice(verifyIdx + 1, 0, ` profile: ${name}`);
63
- } else {
64
- if (lines.length && lines[lines.length - 1] !== "") lines.push("");
65
- lines.push("verify:", ` profile: ${name}`, "");
66
- }
67
-
68
- const tmp = `${file}.tmp-${process.pid}`;
69
- try {
70
- writeFileSync(tmp, lines.join(eol), "utf-8");
71
- renameSync(tmp, file);
72
- } catch (err) {
73
- return { ok: false, error: `Could not write ${file}: ${err.message}` };
74
- }
75
-
76
- return { ok: true, file };
77
- }