leopold-driver 0.6.0 → 0.7.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.
- package/assets/VERSION +1 -1
- package/assets/extensions/gstack/extension.json +2 -1
- package/assets/extensions/leopold/extension.json +2 -1
- package/assets/extensions/ovmem/extension.json +1 -0
- package/assets/extensions/serena/extension.json +2 -1
- package/assets/hooks/guard-irreversible.sh +5 -0
- package/assets/scripts/leopold-menu.sh +23 -2
- package/dist/budget.js +16 -0
- package/dist/config.js +7 -0
- package/dist/guard.js +11 -1
- package/dist/index.js +9 -2
- package/dist/loop.js +17 -2
- package/dist/secrets.js +142 -0
- package/dist/worker.js +12 -0
- package/package.json +2 -2
package/assets/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.
|
|
1
|
+
0.7.0
|
|
@@ -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}";
|
|
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 ;;
|
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 [--
|
|
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
|
-
|
|
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, {
|
|
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"));
|
package/dist/secrets.js
ADDED
|
@@ -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.
|
|
3
|
+
"version": "0.7.0",
|
|
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"
|