leopold-driver 0.6.0 → 0.7.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/assets/VERSION CHANGED
@@ -1 +1 @@
1
- 0.6.0
1
+ 0.7.1
@@ -4,5 +4,6 @@
4
4
  "summary": "Garry Tan's planning + QA skill suite that Leopold conducts (/spec, /autoplan, /plan-*-review).",
5
5
  "homepage": "https://github.com/garrytan/gstack",
6
6
  "license": "MIT",
7
- "order": 20
7
+ "order": 20,
8
+ "capabilities": ["filesystem.home", "network"]
8
9
  }
@@ -4,5 +4,6 @@
4
4
  "summary": "The autonomous orchestration harness itself (skills + Stop/PreToolUse hooks).",
5
5
  "homepage": "https://github.com/Jonhvmp/leopold",
6
6
  "license": "MIT",
7
- "order": 10
7
+ "order": 10,
8
+ "capabilities": ["settings.write", "filesystem.home"]
8
9
  }
@@ -5,6 +5,7 @@
5
5
  "homepage": "https://github.com/Jonhvmp/leopold",
6
6
  "license": "MIT",
7
7
  "order": 30,
8
+ "capabilities": ["network", "settings.write", "filesystem.home", "package.install", "process.spawn"],
8
9
  "dashboard": {
9
10
  "label": "Memory",
10
11
  "module": "~/.claude/ovmem/dashboard.py",
@@ -4,5 +4,6 @@
4
4
  "summary": "LSP-backed code intelligence (MCP). Symbol-level retrieval + editing instead of grep/whole-file reads — sharper edits, far fewer tokens. Mandatory for quality.",
5
5
  "homepage": "https://github.com/oraios/serena",
6
6
  "license": "MIT",
7
- "order": 15
7
+ "order": 15,
8
+ "capabilities": ["mcp.register", "settings.write", "package.install", "network"]
8
9
  }
@@ -112,6 +112,10 @@ case "$tool" in
112
112
  # normalize: newlines/tabs -> space, collapse runs (defeats whitespace/tab evasion).
113
113
  norm="$(printf '%s' "$cmd" | tr '\n\t' ' ' | tr -s ' ')"
114
114
 
115
+ # secret vault / master key are off-limits to the worker (secrets arrive as env vars)
116
+ matches "$norm" 'secrets\.(key|env)' && \
117
+ deny "Leopold guard: touching the secret vault/key via shell is forbidden. Secrets are pre-loaded as environment variables."
118
+
115
119
  # Opt-in deny-by-default (LEOPOLD_PARANOID=1): only a small allowlist of
116
120
  # read/build/test/lint commands passes; everything else is denied. Best-effort
117
121
  # (it keys off the first command word), kept off by default in favor of the
@@ -184,6 +188,7 @@ case "$tool" in
184
188
  */GUARDRAILS.md) deny "Leopold guard: GUARDRAILS.md is immutable during an autonomous run." ;;
185
189
  */settings.json|*/settings.local.json) deny "Leopold guard: editing Claude Code settings is forbidden in autonomous mode." ;;
186
190
  */leopold/hooks/*|*/.leopold/state.json) deny "Leopold guard: the guardrail hooks and run state are immutable during an autonomous run." ;;
191
+ */secrets.key|*/.leopold/secrets.env) deny "Leopold guard: the secret vault and master key are off-limits. Secrets are pre-loaded as env vars." ;;
187
192
  esac
188
193
  ;;
189
194
  esac
@@ -47,6 +47,25 @@ ext_installed() { bash "$1/manage.sh" detect >/dev/null 2>&1; }
47
47
  ext_status() { bash "$1/manage.sh" status 2>/dev/null || true; }
48
48
  ext_run() { bash "$1/manage.sh" "$2"; }
49
49
 
50
+ ext_caps() { # extension.json -> space-separated capabilities (empty if none)
51
+ if command -v jq >/dev/null 2>&1; then
52
+ jq -r '(.capabilities // []) | join(" ")' "$1" 2>/dev/null
53
+ elif command -v python3 >/dev/null 2>&1; then
54
+ python3 -c "import json,sys;print(' '.join(json.load(open(sys.argv[1])).get('capabilities',[])))" "$1" 2>/dev/null
55
+ fi
56
+ }
57
+
58
+ # Show an extension's declared capabilities and require explicit consent before
59
+ # install/update grants them. No declaration -> nothing to gate, proceed.
60
+ ext_consent() { # dir -> 0 if the user consents
61
+ local caps; caps="$(ext_caps "$1/extension.json")"
62
+ [ -n "$caps" ] || return 0
63
+ printf "\n %sThis extension requests:%s %s%s%s\n" "$C_BOLD" "$C_RESET" "$C_YELLOW" "$caps" "$C_RESET"
64
+ printf " Install/update grants these. Proceed? [y/N] "
65
+ local a; read -r a || a=""
66
+ case "$a" in [yY]*) return 0 ;; *) echo " cancelled."; return 1 ;; esac
67
+ }
68
+
50
69
  pause() { printf "\n%spress Enter to continue%s " "$C_DIM" "$C_RESET"; read -r _ || true; }
51
70
 
52
71
  # ---- screens ----------------------------------------------------------------
@@ -90,6 +109,8 @@ component_menu() {
90
109
  ext_installed "$d" && st="installed${C_RESET} ${C_DIM}($(ext_status "$d"))"
91
110
  printf " %s%s%s\n status: %s%s\n\n" "$C_BOLD" "$title" "$C_RESET" "$C_GREEN" "$st"
92
111
  printf " %s%s%s\n\n" "$C_DIM" "$(_jget "$d/extension.json" summary)" "$C_RESET"
112
+ local caps; caps="$(ext_caps "$d/extension.json")"
113
+ [ -n "$caps" ] && printf " %scapabilities:%s %s\n\n" "$C_DIM" "$C_RESET" "$caps"
93
114
  local has_dash=""; [ -n "$(_jget "$d/extension.json" dashboard)" ] && has_dash=1
94
115
  if [ -n "$has_dash" ]; then
95
116
  printf " 1) Install 2) Update 3) Remove 4) Doctor w) Watch b) Back\n\n"
@@ -98,8 +119,8 @@ component_menu() {
98
119
  fi
99
120
  printf "select: "; read -r a || a="b"
100
121
  case "$a" in
101
- 1) ext_run "$d" install || echo "${C_YELLOW}install returned non-zero${C_RESET}"; pause ;;
102
- 2) ext_run "$d" update || echo "${C_YELLOW}update returned non-zero${C_RESET}"; pause ;;
122
+ 1) ext_consent "$d" && { ext_run "$d" install || echo "${C_YELLOW}install returned non-zero${C_RESET}"; }; pause ;;
123
+ 2) ext_consent "$d" && { ext_run "$d" update || echo "${C_YELLOW}update returned non-zero${C_RESET}"; }; pause ;;
103
124
  3) ext_run "$d" remove || echo "${C_YELLOW}remove returned non-zero${C_RESET}"; pause ;;
104
125
  4) ext_run "$d" doctor || true; pause ;;
105
126
  w|W) [ -n "$has_dash" ] && { ext_run "$d" watch || true; }; pause ;;
@@ -449,9 +449,9 @@ html,body{margin:0;background:var(--bg);color:var(--fg);font-family:var(--sans);
449
449
  .mrow{display:flex;gap:8px;flex-wrap:wrap;margin-top:14px}
450
450
  .mchip{font-family:var(--mono);font-size:10px;letter-spacing:.04em;border:1px solid var(--border);border-radius:9999px;padding:3px 10px;color:var(--muted-fg)}
451
451
  .meters{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:14px}
452
- .meter .top{display:flex;justify-content:space-between;align-items:baseline}
453
- .meter .lbl{font-family:var(--mono);font-size:10px;letter-spacing:.08em;text-transform:uppercase;color:var(--muted-fg)}
454
- .meter .val{font-family:var(--mono);font-size:12px;font-variant-numeric:tabular-nums}
452
+ .meter .top{display:flex;justify-content:space-between;align-items:baseline;gap:8px}
453
+ .meter .lbl{font-family:var(--mono);font-size:10px;letter-spacing:.08em;text-transform:uppercase;color:var(--muted-fg);flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
454
+ .meter .val{font-family:var(--mono);font-size:12px;font-variant-numeric:tabular-nums;flex-shrink:0}
455
455
  .bar{height:5px;background:var(--secondary);border-radius:9999px;margin-top:7px;overflow:hidden}
456
456
  .bar>i{display:block;height:100%;background:var(--success);transition:width .3s}
457
457
  .bar.warn>i{background:var(--warnbar)}.bar.full>i{background:var(--destructive)}
@@ -632,7 +632,8 @@ function widgetEl(name,w){
632
632
  if(w.kind==="bars"){
633
633
  const wrap=el("div","meters"),items=w.items||[],mx=Math.max(1,...items.map(x=>x.max||x.value||0));
634
634
  items.forEach(it=>{const m=el("div","meter"),top=el("div","top");
635
- top.append(el("span","lbl",it.label),el("span","val tnum",""+it.value));
635
+ const lbl=el("span","lbl",it.label);lbl.title=it.label;
636
+ top.append(lbl,el("span","val tnum",""+it.value));
636
637
  const bar=el("div","bar"),i=el("i");i.style.width=Math.round(100*(it.value||0)/(it.max||mx))+"%";bar.append(i);
637
638
  m.append(top,bar);wrap.append(m);});
638
639
  return wrap;
package/dist/budget.js ADDED
@@ -0,0 +1,16 @@
1
+ // USD budget hard-stop for the SDK driver. The Claude Code CLI already reports
2
+ // `total_cost_usd` per session, so we never need a model price map: accumulate the
3
+ // real cost per item and stop the run when it crosses the cap. Two checks, like
4
+ // paperclip's evaluateCostEvent + getInvocationBlock: preventive (before an item)
5
+ // and reactive (after each item's cost lands).
6
+ /** Parse a budget value (CLI flag / env) into a positive USD number, or undefined. */
7
+ export function parseBudgetUsd(raw) {
8
+ if (raw === undefined || raw === "")
9
+ return undefined;
10
+ const n = Number(raw);
11
+ return Number.isFinite(n) && n > 0 ? n : undefined;
12
+ }
13
+ /** True when spend has reached or passed the cap. A missing cap never trips. */
14
+ export function overBudget(spentUsd, capUsd) {
15
+ return capUsd !== undefined && spentUsd >= capUsd;
16
+ }
package/dist/config.js CHANGED
@@ -1,6 +1,7 @@
1
1
  // Load the brief and run state from .leopold/, and the driver config from env.
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
+ import { parseBudgetUsd } from "./budget.js";
4
5
  export function findLeoDir(cwd) {
5
6
  let dir = path.resolve(cwd);
6
7
  for (;;) {
@@ -75,6 +76,11 @@ export function clearRunTokens(leoDir) {
75
76
  catch { /* ignore */ }
76
77
  }
77
78
  }
79
+ /** Read `--flag value` from argv (the value is the next token). */
80
+ function flagValue(argv, name) {
81
+ const i = argv.indexOf(name);
82
+ return i >= 0 && i + 1 < argv.length ? argv[i + 1] : undefined;
83
+ }
78
84
  export function loadConfig(argv) {
79
85
  return {
80
86
  conductorModel: process.env.LEOPOLD_CONDUCTOR_MODEL || undefined,
@@ -83,5 +89,6 @@ export function loadConfig(argv) {
83
89
  webhookUrl: process.env.LEOPOLD_WEBHOOK || undefined,
84
90
  dryRun: argv.includes("--dry-run"),
85
91
  worktree: argv.includes("--worktree") || process.env.LEOPOLD_WORKTREE === "1",
92
+ budgetUsd: parseBudgetUsd(flagValue(argv, "--budget-usd") ?? process.env.LEOPOLD_BUDGET_USD),
86
93
  };
87
94
  }
package/dist/guard.js CHANGED
@@ -6,7 +6,10 @@ import fs from "node:fs";
6
6
  import path from "node:path";
7
7
  const GH_PR = /(^|[^\w-])gh(\s.*)?\s(pr\s+(create|merge)|release\s+create)/i;
8
8
  const PUBLISH = /(npm|pnpm|yarn)\s+publish|cargo\s+publish|twine\s+upload|pip\s+.*upload/i;
9
- const PROTECTED_PATH = /(GUARDRAILS\.md|settings\.json|settings\.local\.json|leopold\/hooks\/|\.leopold\/state\.json)/;
9
+ const PROTECTED_PATH = /(GUARDRAILS\.md|settings\.json|settings\.local\.json|leopold\/hooks\/|\.leopold\/state\.json|secrets\.key|secrets\.env)/;
10
+ // The secret vault (.leopold/secrets.env) and master key (~/.claude/leopold/secrets.key)
11
+ // are off-limits to the worker — secrets reach it only as $NAME env vars.
12
+ const SECRET_FILE = /secrets\.key|secrets\.env/;
10
13
  // git global options that consume the following token as their value.
11
14
  const GIT_VALUE_OPTS = new Set([
12
15
  "-c", "-C", "--git-dir", "--work-tree", "--namespace", "--exec-path", "--config-env",
@@ -61,8 +64,15 @@ export function makeGuard(leoDir, onBlock) {
61
64
  onBlock(toolName, message);
62
65
  return { behavior: "deny", message };
63
66
  };
67
+ if (toolName === "Read") {
68
+ const p = String(input.file_path ?? "");
69
+ if (SECRET_FILE.test(p))
70
+ return deny("Leopold guard: reading the secret vault or master key is forbidden. Secrets are pre-loaded as $NAME env vars.");
71
+ }
64
72
  if (toolName === "Bash") {
65
73
  const c = norm(String(input.command ?? ""));
74
+ if (SECRET_FILE.test(c))
75
+ return deny("Leopold guard: touching the secret vault/key via shell is forbidden. Secrets are pre-loaded as $NAME env vars.");
66
76
  if (isRecursiveForceRm(c))
67
77
  return deny("Leopold guard: recursive+forced rm is forbidden in autonomous mode.");
68
78
  if (isFindDelete(c))
package/dist/index.js CHANGED
@@ -4,6 +4,7 @@
4
4
  // into the package at build time; subcommands run them.
5
5
  import { runDriver } from "./loop.js";
6
6
  import { runInstall, runMenu, runWatch, runExt, runDoctor } from "./harness.js";
7
+ import { runSecrets } from "./secrets.js";
7
8
  const sub = process.argv[2];
8
9
  const rest = process.argv.slice(3);
9
10
  function help() {
@@ -16,13 +17,17 @@ Usage:
16
17
  leopold-driver serena [install|doctor] manage an extension (also: gstack, ovmem)
17
18
  leopold-driver doctor run every extension's doctor
18
19
  leopold-driver update reinstall from this package
19
- leopold-driver run [--dry-run] conduct the .leopold run (the SDK driver)
20
+ leopold-driver run [--worktree] [--budget-usd N] [--dry-run]
21
+ conduct the .leopold run (the SDK driver)
22
+ leopold-driver secrets set|list [NAME] manage the run's encrypted secret vault
20
23
 
21
24
  Most commands run the bundled harness — no repo clone, no make. 'watch' needs Python 3.
22
25
  Newer version: npm i -g leopold-driver@latest.
23
26
 
24
27
  Conducting a run uses your existing Claude Code login (ANTHROPIC_API_KEY only in headless).
25
- Env: LEOPOLD_CONDUCTOR_MODEL, LEOPOLD_WORKER_MODEL, LEOPOLD_MAX_TURNS_PER_ITEM, LEOPOLD_WEBHOOK
28
+ --worktree isolates the run in a git worktree; --budget-usd stops it at a USD cap.
29
+ Env: LEOPOLD_CONDUCTOR_MODEL, LEOPOLD_WORKER_MODEL, LEOPOLD_MAX_TURNS_PER_ITEM, LEOPOLD_WEBHOOK,
30
+ LEOPOLD_WORKTREE, LEOPOLD_BUDGET_USD
26
31
  `);
27
32
  }
28
33
  function conduct() {
@@ -47,6 +52,8 @@ switch (sub) {
47
52
  process.exit(runExt(sub, rest));
48
53
  case "doctor":
49
54
  process.exit(runDoctor());
55
+ case "secrets":
56
+ process.exit(runSecrets(rest));
50
57
  case "--help":
51
58
  case "-h":
52
59
  case "help":
package/dist/loop.js CHANGED
@@ -9,6 +9,7 @@ import { logEvent, logDecision, markItemDone, openItems, nextOpenItem } from "./
9
9
  import { notify } from "./notify.js";
10
10
  import { createWorktree, cleanupWorktree } from "./worktree.js";
11
11
  import { reapOrphan } from "./reaper.js";
12
+ import { overBudget } from "./budget.js";
12
13
  export async function runDriver(cwd, argv) {
13
14
  const cfg = loadConfig(argv);
14
15
  const brief = loadBrief(cwd);
@@ -31,13 +32,18 @@ export async function runDriver(cwd, argv) {
31
32
  }
32
33
  }
33
34
  const state = initState(brief);
35
+ state.budget_usd = cfg.budgetUsd;
36
+ state.spent_usd = 0;
34
37
  if (worktree) {
35
38
  state.worktree_path = worktree.path;
36
39
  state.worktree_branch = worktree.branch;
37
- writeState(brief.leoDir, state);
38
40
  }
41
+ writeState(brief.leoDir, state);
39
42
  const recent = [];
40
- logEvent(brief.leoDir, { event: "run_start", conductor: cfg.conductorModel, worktree: worktree?.path ?? null });
43
+ logEvent(brief.leoDir, {
44
+ event: "run_start", conductor: cfg.conductorModel,
45
+ worktree: worktree?.path ?? null, budget_usd: cfg.budgetUsd ?? null,
46
+ });
41
47
  console.log(`Leopold is conducting "${brief.root}". Git is locked. touch .leopold/STOP to halt.\n`);
42
48
  const stop = (reason) => {
43
49
  state.active = false;
@@ -54,6 +60,11 @@ export async function runDriver(cwd, argv) {
54
60
  await notify(brief.leoDir, cfg.webhookUrl, "Leopold stopped", "Kill switch hit.");
55
61
  return;
56
62
  }
63
+ if (overBudget(state.spent_usd ?? 0, cfg.budgetUsd)) {
64
+ stop("budget_exceeded");
65
+ await notify(brief.leoDir, cfg.webhookUrl, "Leopold stopped", `Budget reached: $${(state.spent_usd ?? 0).toFixed(2)} of $${cfg.budgetUsd?.toFixed(2)}. Work so far is staged for your review.`);
66
+ return;
67
+ }
57
68
  if (state.iteration >= state.max_iterations) {
58
69
  stop("iteration_budget");
59
70
  await notify(brief.leoDir, cfg.webhookUrl, "Leopold stopped", "Iteration budget reached.");
@@ -84,6 +95,10 @@ export async function runDriver(cwd, argv) {
84
95
  item,
85
96
  workerPrompt,
86
97
  onBlock: (tool, reason) => logEvent(brief.leoDir, { event: "guard_block", tool, reason }),
98
+ onCost: (usd) => {
99
+ state.spent_usd = (state.spent_usd ?? 0) + usd;
100
+ logEvent(brief.leoDir, { event: "cost", item, usd, spent_usd: state.spent_usd });
101
+ },
87
102
  onTurn: async (status) => {
88
103
  logEvent(brief.leoDir, { event: "worker_turn", kind: status.kind, item: status.item || item });
89
104
  const verdict = await decide(cfg, brief, status, recent.slice(-5).join("\n"));
@@ -0,0 +1,142 @@
1
+ // Encrypted secret vault for a run. Secrets the work needs are injected into the
2
+ // worker as environment variables (resolved at run time), so they reach the worker's
3
+ // Bash tool as $NAME but never land in the prompt/transcript the model sees.
4
+ //
5
+ // At rest: AES-256-GCM. The 32-byte master key lives at ~/.claude/leopold/secrets.key
6
+ // (mode 0600, generated on demand); the vault is .leopold/secrets.env (the encrypted
7
+ // blob). Mirrors paperclip's local-encrypted-provider, file edition — no DB.
8
+ import crypto from "node:crypto";
9
+ import fs from "node:fs";
10
+ import os from "node:os";
11
+ import path from "node:path";
12
+ import { findLeoDir } from "./config.js";
13
+ const ALGO = "aes-256-gcm";
14
+ const NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
15
+ export function keyPath() {
16
+ const base = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude");
17
+ return path.join(base, "leopold", "secrets.key");
18
+ }
19
+ export function vaultPath(leoDir) {
20
+ return path.join(leoDir, "secrets.env");
21
+ }
22
+ /** Paths the guard must protect from the worker (the vault and the master key). */
23
+ export function secretFilePaths(leoDir) {
24
+ return [vaultPath(leoDir), keyPath()];
25
+ }
26
+ function loadOrCreateKey() {
27
+ const kp = keyPath();
28
+ if (fs.existsSync(kp))
29
+ return Buffer.from(fs.readFileSync(kp, "utf8").trim(), "base64");
30
+ const key = crypto.randomBytes(32);
31
+ fs.mkdirSync(path.dirname(kp), { recursive: true });
32
+ fs.writeFileSync(kp, key.toString("base64"), { mode: 0o600 });
33
+ try {
34
+ fs.chmodSync(kp, 0o600);
35
+ }
36
+ catch { /* best effort on platforms without chmod */ }
37
+ return key;
38
+ }
39
+ function encrypt(key, plaintext) {
40
+ const iv = crypto.randomBytes(12);
41
+ const cipher = crypto.createCipheriv(ALGO, key, iv);
42
+ const data = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
43
+ return JSON.stringify({
44
+ v: 1, iv: iv.toString("base64"),
45
+ tag: cipher.getAuthTag().toString("base64"), data: data.toString("base64"),
46
+ });
47
+ }
48
+ function decrypt(key, blob) {
49
+ const o = JSON.parse(blob);
50
+ const decipher = crypto.createDecipheriv(ALGO, key, Buffer.from(o.iv, "base64"));
51
+ decipher.setAuthTag(Buffer.from(o.tag, "base64"));
52
+ return Buffer.concat([decipher.update(Buffer.from(o.data, "base64")), decipher.final()]).toString("utf8");
53
+ }
54
+ function readVault(leoDir) {
55
+ const vp = vaultPath(leoDir);
56
+ if (!fs.existsSync(vp))
57
+ return {};
58
+ try {
59
+ return JSON.parse(decrypt(loadOrCreateKey(), fs.readFileSync(vp, "utf8")));
60
+ }
61
+ catch {
62
+ return {}; // wrong/rotated key or corrupt vault — fail closed (no secrets)
63
+ }
64
+ }
65
+ function writeVault(leoDir, secrets) {
66
+ fs.writeFileSync(vaultPath(leoDir), encrypt(loadOrCreateKey(), JSON.stringify(secrets)), { mode: 0o600 });
67
+ }
68
+ export function isValidName(name) {
69
+ return NAME_RE.test(name);
70
+ }
71
+ export function setSecret(leoDir, name, value) {
72
+ if (!isValidName(name))
73
+ throw new Error(`invalid secret name: ${name}`);
74
+ const s = readVault(leoDir);
75
+ s[name] = value;
76
+ writeVault(leoDir, s);
77
+ }
78
+ /** Decrypt the vault to {NAME: value} for env injection. */
79
+ export function loadSecrets(leoDir) {
80
+ return readVault(leoDir);
81
+ }
82
+ /** Set the run's secrets into process.env for the duration of one item; returns a
83
+ * restore() that puts the previous environment back (so secrets don't outlive the item). */
84
+ export function applySecretsEnv(leoDir) {
85
+ const secrets = loadSecrets(leoDir);
86
+ const prev = [];
87
+ for (const [k, v] of Object.entries(secrets)) {
88
+ prev.push([k, process.env[k]]);
89
+ process.env[k] = v;
90
+ }
91
+ return {
92
+ restore() {
93
+ for (const [k, p] of prev) {
94
+ if (p === undefined)
95
+ delete process.env[k];
96
+ else
97
+ process.env[k] = p;
98
+ }
99
+ },
100
+ };
101
+ }
102
+ export function listSecretNames(leoDir) {
103
+ return Object.keys(readVault(leoDir)).sort();
104
+ }
105
+ /** `leopold-driver secrets set NAME | list` — the value for `set` is read from
106
+ * stdin so it never appears in shell history. */
107
+ export function runSecrets(argv) {
108
+ const sub = argv[0];
109
+ let leoDir;
110
+ try {
111
+ leoDir = findLeoDir(process.cwd());
112
+ }
113
+ catch {
114
+ console.error("leopold-driver secrets: no .leopold/ here. Run /leopold-brief first.");
115
+ return 1;
116
+ }
117
+ if (sub === "list") {
118
+ const names = listSecretNames(leoDir);
119
+ process.stdout.write(names.length ? names.join("\n") + "\n" : "(no secrets)\n");
120
+ return 0;
121
+ }
122
+ if (sub === "set") {
123
+ const name = argv[1];
124
+ if (!name || !isValidName(name)) {
125
+ console.error("usage: leopold-driver secrets set NAME (NAME = a valid env var name)");
126
+ return 2;
127
+ }
128
+ let value;
129
+ try {
130
+ value = fs.readFileSync(0, "utf8").replace(/\r?\n$/, ""); // stdin, strip one trailing newline
131
+ }
132
+ catch {
133
+ console.error("could not read the secret value from stdin");
134
+ return 1;
135
+ }
136
+ setSecret(leoDir, name, value);
137
+ console.log(`secret '${name}' stored (encrypted) in .leopold/secrets.env`);
138
+ return 0;
139
+ }
140
+ console.error("usage: leopold-driver secrets set NAME | list");
141
+ return 2;
142
+ }
package/dist/worker.js CHANGED
@@ -8,10 +8,12 @@ import { query } from "@anthropic-ai/claude-agent-sdk";
8
8
  import { InputChannel } from "./channel.js";
9
9
  import { parseStatus, isTurnComplete } from "./protocol.js";
10
10
  import { makeGuard } from "./guard.js";
11
+ import { applySecretsEnv } from "./secrets.js";
11
12
  const WORKER_APPEND = `You are a Leopold worker, conducted by an autonomous orchestrator. No human is watching live. Rules for this session:
12
13
  - Do NOT ask the human anything. Decide reversible or charter-clear calls yourself and keep going.
13
14
  - Spawned mode: if you invoke gstack skills, auto-pick the recommended option; never prompt.
14
15
  - git commit/push/publish are LOCKED by a guard. Never attempt them. Stage with "git add" and report instead.
16
+ - Secrets you may need are pre-loaded as environment variables; use them as $NAME, and never ask for, echo, or print their values.
15
17
  - Close EVERY turn with a fenced status block, then stop and wait for the conductor's reply:
16
18
 
17
19
  \`\`\`leopold-status
@@ -29,10 +31,15 @@ export async function runItem(opts) {
29
31
  const channel = new InputChannel();
30
32
  channel.push(workerPrompt);
31
33
  const guard = makeGuard(brief.leoDir, onBlock);
34
+ // Inject the run's secrets as env vars for this item: they reach the worker's Bash
35
+ // tool as $NAME but never enter the prompt. Restored after the loop (runs are
36
+ // sequential, so there is no env overlap between items).
37
+ const { restore: restoreSecrets } = applySecretsEnv(brief.leoDir);
32
38
  const q = query({
33
39
  prompt: channel,
34
40
  options: {
35
41
  cwd: brief.worktreeRoot ?? brief.root,
42
+ env: { ...process.env },
36
43
  maxTurns: cfg.maxTurnsPerItem,
37
44
  permissionMode: "default",
38
45
  canUseTool: guard,
@@ -61,6 +68,10 @@ export async function runItem(opts) {
61
68
  }
62
69
  }
63
70
  else if (msg.type === "result") {
71
+ // The CLI reports the item's real cost here — accumulate it for the budget.
72
+ const cost = msg.total_cost_usd;
73
+ if (typeof cost === "number" && Number.isFinite(cost))
74
+ opts.onCost?.(cost);
64
75
  // Session ended (channel closed, or the worker stopped on its own). Flush
65
76
  // whatever we have so the conductor can make a final call.
66
77
  if (turnText.trim()) {
@@ -72,4 +83,5 @@ export async function runItem(opts) {
72
83
  break;
73
84
  }
74
85
  }
86
+ restoreSecrets();
75
87
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leopold-driver",
3
- "version": "0.6.0",
3
+ "version": "0.7.1",
4
4
  "description": "Leopold SDK driver: a persistent conductor that orchestrates fresh Claude Code workers per task, decides from your charter, and notifies you. Uses your Claude Code auth. Git stays locked.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -37,7 +37,7 @@
37
37
  "scripts": {
38
38
  "build": "tsc -p tsconfig.json && node scripts/copy-runtime.mjs",
39
39
  "typecheck": "tsc -p tsconfig.json --noEmit",
40
- "test": "node --import tsx --test test/protocol.test.ts test/guard.test.ts test/worktree.test.ts test/reaper.test.ts",
40
+ "test": "node --import tsx --test test/protocol.test.ts test/guard.test.ts test/worktree.test.ts test/reaper.test.ts test/budget.test.ts test/secrets.test.ts",
41
41
  "dev": "tsx src/index.ts",
42
42
  "start": "node dist/index.js",
43
43
  "prepublishOnly": "npm run build"