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/README.md +6 -3
- package/bin/agentctl.mjs +166 -25
- package/package.json +1 -1
- package/src/config-edit.mjs +138 -0
- package/src/config.mjs +5 -0
- package/src/engine.mjs +52 -7
- package/src/git.mjs +176 -2
- package/src/ops/cli-intent.mjs +29 -0
- package/src/ops/command-registry.mjs +19 -10
- package/src/ops/doctor-registry.mjs +36 -14
- package/src/ops/ide-scaffold.mjs +22 -16
- package/src/ops/verify-output.mjs +38 -0
- package/src/provider-readiness.mjs +69 -0
- package/src/security.mjs +97 -1
- package/src/wizard-init.mjs +58 -10
- package/src/wizard-task.mjs +22 -5
- package/src/profiles-io.mjs +0 -77
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.
|
package/src/wizard-init.mjs
CHANGED
|
@@ -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 {
|
|
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: ${
|
|
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
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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,
|
package/src/wizard-task.mjs
CHANGED
|
@@ -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
|
-
* @
|
|
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
|
}
|
package/src/profiles-io.mjs
DELETED
|
@@ -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
|
-
}
|