@spinabot/brigade 0.1.0 → 0.1.2

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,94 @@
1
+ /**
2
+ * Translate Node-style error codes (ECONNREFUSED, ENOTFOUND, EACCES, …) into
3
+ * single-line plain-English descriptions suitable for surface in CLI error
4
+ * output. Without this, raw `getaddrinfo ENOTFOUND foo.local` strings reach
5
+ * paying users — same B2B-jargon class as Pi's `/login` leak that triggered
6
+ * this audit pass.
7
+ *
8
+ * Pure function. Returns `null` for unrecognised errors so the caller can
9
+ * fall through to its own fallback (often `cleanProviderError` for the
10
+ * Pi/provider error envelope).
11
+ */
12
+ const NODE_ERRNO_MESSAGES = {
13
+ ECONNREFUSED: "couldn't connect — nothing is listening at that address",
14
+ ETIMEDOUT: "connection timed out — the host took too long to answer",
15
+ ENOTFOUND: "couldn't resolve that host — check the address and your DNS",
16
+ EHOSTUNREACH: "couldn't reach that host — check your network",
17
+ ENETUNREACH: "no network route to that host",
18
+ EAI_AGAIN: "DNS lookup failed temporarily — try again in a moment",
19
+ ECONNRESET: "connection dropped mid-request",
20
+ EPIPE: "the connection was closed before we could finish writing",
21
+ EACCES: "permission denied",
22
+ EPERM: "permission denied",
23
+ ENOENT: "file or directory not found",
24
+ EEXIST: "already exists",
25
+ EISDIR: "expected a file but got a directory",
26
+ ENOTDIR: "expected a directory but got a file",
27
+ ENOSPC: "no space left on this device",
28
+ EMFILE: "too many open files — close some apps and retry",
29
+ EBUSY: "file is locked by another process — close it and retry",
30
+ EROFS: "read-only filesystem",
31
+ ENOMEM: "out of memory",
32
+ };
33
+ /**
34
+ * Detect a Node errno in the input and replace with the plain-English line.
35
+ * Strategy:
36
+ * 1. Walk the well-known errno tokens; if one appears at the start (`^X:`)
37
+ * or as the bare token (`getaddrinfo ENOTFOUND foo`), return the mapped
38
+ * message with the offending host/path appended when we can extract it.
39
+ * 2. Strip absolute paths from the appended detail (no `C:\Users\...` leaks).
40
+ */
41
+ export function translateNodeErrno(raw) {
42
+ if (!raw)
43
+ return null;
44
+ for (const [code, plain] of Object.entries(NODE_ERRNO_MESSAGES)) {
45
+ // Match the token surrounded by start/end/whitespace/colon/quote — so
46
+ // "ECONNREFUSED 127.0.0.1:7777" matches but "RECONNECTING" doesn't.
47
+ const re = new RegExp(`(?:^|[\\s:"'])${code}(?:$|[\\s:"',])`);
48
+ if (!re.test(raw))
49
+ continue;
50
+ const detail = extractDetailAfter(raw, code);
51
+ return detail ? `${plain} (${detail})` : plain;
52
+ }
53
+ return null;
54
+ }
55
+ /**
56
+ * Universal CLI-error helper: try Node errno first, then fall through to a
57
+ * caller-supplied fallback. Mirrors the `friendlyError` shape used in the
58
+ * TUI surface.
59
+ */
60
+ export function friendlyCliError(raw, fallback = (s) => s) {
61
+ const errno = translateNodeErrno(raw);
62
+ if (errno)
63
+ return errno;
64
+ return fallback(raw);
65
+ }
66
+ /**
67
+ * Pull a useful detail off a Node errno string, stripping absolute paths so
68
+ * we don't leak `C:\Users\Bob\.brigade\…` to a B2B user.
69
+ *
70
+ * Examples:
71
+ * `getaddrinfo ENOTFOUND foo.local` → "foo.local"
72
+ * `EACCES: permission denied, open 'C:\\Users\\Bob\\.brigade\\auth.json'`
73
+ * → "auth.json"
74
+ * `connect ECONNREFUSED 127.0.0.1:7777` → "127.0.0.1:7777"
75
+ */
76
+ function extractDetailAfter(raw, code) {
77
+ const idx = raw.indexOf(code);
78
+ if (idx < 0)
79
+ return "";
80
+ const tail = raw.slice(idx + code.length).trim();
81
+ if (!tail)
82
+ return "";
83
+ // Quoted path: keep only the basename to drop directory leakage.
84
+ const pathMatch = tail.match(/['"]([^'"]+)['"]/);
85
+ if (pathMatch?.[1]) {
86
+ const segments = pathMatch[1].split(/[/\\]/);
87
+ const last = segments[segments.length - 1] ?? "";
88
+ return last;
89
+ }
90
+ // Bare host:port or hostname after the code.
91
+ const hostMatch = tail.match(/^[:,\s]*([a-zA-Z0-9._:-]+(?::\d+)?)/);
92
+ return hostMatch?.[1] ?? "";
93
+ }
94
+ //# sourceMappingURL=cli-error.js.map
@@ -12,15 +12,35 @@ import * as fs from "node:fs/promises";
12
12
  import * as os from "node:os";
13
13
  import * as path from "node:path";
14
14
  /**
15
- * `~/.brigade` — Pi's agentDir for our app. Env override `BRIGADE_DIR` takes
16
- * precedence so tests, dotfile-managers, and CI runners can redirect Brigade's
15
+ * `~/.brigade` — Pi's agentDir for our app. Two env-var overrides supported,
16
+ * priority order:
17
+ * 1. `BRIGADE_STATE_DIR` — preferred name, matches `XDG_STATE_HOME` style
18
+ * 2. `BRIGADE_DIR` — original Brigade name; kept as a backwards-compat alias
19
+ *
20
+ * Either lets tests, dotfile-managers, and CI runners redirect Brigade's
17
21
  * state to a sandbox dir without symlinking the home directory.
18
22
  */
19
- export const BRIGADE_DIR = process.env.BRIGADE_DIR && process.env.BRIGADE_DIR.trim().length > 0
20
- ? path.resolve(process.env.BRIGADE_DIR)
21
- : path.join(os.homedir(), ".brigade");
23
+ export const BRIGADE_DIR = (() => {
24
+ const stateDir = process.env.BRIGADE_STATE_DIR;
25
+ if (stateDir && stateDir.trim().length > 0)
26
+ return path.resolve(stateDir);
27
+ const legacy = process.env.BRIGADE_DIR;
28
+ if (legacy && legacy.trim().length > 0)
29
+ return path.resolve(legacy);
30
+ return path.join(os.homedir(), ".brigade");
31
+ })();
22
32
  /** ~/.brigade/config.json — our user preferences. */
23
33
  const CONFIG_FILE = path.join(BRIGADE_DIR, "config.json");
34
+ /**
35
+ * `~/.brigade/workspace/` — the canonical home for the persona layer files
36
+ * (SOUL.md, IDENTITY.md, AGENTS.md, TOOLS.md, USER.md, BOOTSTRAP.md,
37
+ * HEARTBEAT.md). Files live at the workspace root with UPPERCASE names so the
38
+ * same conventions apply whether they're read by the system-prompt assembler
39
+ * or cloned to another machine alongside the rest of the workspace.
40
+ */
41
+ export function getBrigadeWorkspaceDir() {
42
+ return path.join(BRIGADE_DIR, "workspace");
43
+ }
24
44
  /**
25
45
  * Compute the session directory for a given working directory, rooted at
26
46
  * Brigade's own agentDir.
@@ -44,6 +64,21 @@ export function getBrigadeSessionDir(cwd, agentDir = BRIGADE_DIR) {
44
64
  * Read user preferences from disk. Returns `{}` if missing or unparseable
45
65
  * (a corrupt config should never block boot — the user just sees onboarding).
46
66
  *
67
+ * Two-tier read for backwards compatibility with the v0.1 split-file layout:
68
+ * 1. Legacy `config.json` first (still authoritative if it exists — the
69
+ * migration tool only renames it AFTER copying values into brigade.json,
70
+ * so a present `config.json` means "no migration has happened yet").
71
+ * 2. Fall back to the unified `brigade.json` (post-migration home for these
72
+ * values). Reads them from the v2 reference-aligned blocks:
73
+ * - default provider+model → agents.defaults.model.primary
74
+ * - fallback provider+model → agents.defaults.model.fallbacks[0]
75
+ * - installedAt → meta.installedAt
76
+ * - thinkingLevel/compaction → settings.* (Brigade-private namespace)
77
+ * Plus an in-flight upgrade fallback to the v1 settings.defaultProvider/
78
+ * defaultModelId/fallbackProvider/fallbackModelId/installedAt fields, in
79
+ * case a write landed during a partially-completed migration. Drop in v3
80
+ * once everyone's been on v2 for a release.
81
+ *
47
82
  * `configPath` overrides the default location — used by tests to redirect
48
83
  * to a tempdir without mutating $HOME.
49
84
  */
@@ -53,7 +88,54 @@ export async function loadConfig(configPath = CONFIG_FILE) {
53
88
  return JSON.parse(data);
54
89
  }
55
90
  catch {
56
- return {};
91
+ // Legacy file missing. Only fall back to the unified brigade.json when
92
+ // we're reading the DEFAULT config path — a custom configPath (test
93
+ // tempdir, integration probes) wants strict isolation: empty means
94
+ // empty, no leakage from the user's real `~/.brigade/brigade.json`.
95
+ if (configPath !== CONFIG_FILE)
96
+ return {};
97
+ try {
98
+ const { loadBrigadeConfig, parseModelRef } = await import("./brigade-config.js");
99
+ const cfg = await loadBrigadeConfig(BRIGADE_DIR);
100
+ // In-flight upgrade fallback: if v2 lift hasn't happened yet, the
101
+ // legacy v1 `settings.defaultProvider`/etc. may still be present in
102
+ // memory (loadBrigadeConfig only validates the current schema, but
103
+ // the backup-recovery path could legitimately hand back stale data
104
+ // during a partial migration). Read those as a last resort.
105
+ const legacySettings = cfg.settings;
106
+ const primaryRef = cfg.agents?.defaults?.model?.primary;
107
+ const primary = parseModelRef(primaryRef);
108
+ const fallbackRef = cfg.agents?.defaults?.model?.fallbacks?.[0];
109
+ const fallback = parseModelRef(fallbackRef);
110
+ // Emit only fields that are actually present so callers can keep
111
+ // using `Object.keys(cfg).length === 0` as their "is this an empty
112
+ // config" check (the `brigade config list` "(empty)" message
113
+ // depends on this — listing a config that's all-undefined would
114
+ // show as not-empty otherwise).
115
+ const out = {};
116
+ const defaultProvider = primary?.provider ?? legacySettings?.defaultProvider;
117
+ const defaultModelId = primary?.modelId ?? legacySettings?.defaultModelId;
118
+ const thinkingLevel = legacySettings?.thinkingLevel;
119
+ const fallbackProvider = fallback?.provider ?? legacySettings?.fallbackProvider;
120
+ const fallbackModelId = fallback?.modelId ?? legacySettings?.fallbackModelId;
121
+ const installedAt = cfg.meta?.installedAt ?? legacySettings?.installedAt;
122
+ if (defaultProvider !== undefined)
123
+ out.defaultProvider = defaultProvider;
124
+ if (defaultModelId !== undefined)
125
+ out.defaultModelId = defaultModelId;
126
+ if (thinkingLevel !== undefined)
127
+ out.thinkingLevel = thinkingLevel;
128
+ if (fallbackProvider !== undefined)
129
+ out.fallbackProvider = fallbackProvider;
130
+ if (fallbackModelId !== undefined)
131
+ out.fallbackModelId = fallbackModelId;
132
+ if (installedAt !== undefined)
133
+ out.installedAt = installedAt;
134
+ return out;
135
+ }
136
+ catch {
137
+ return {};
138
+ }
57
139
  }
58
140
  }
59
141
  /**
@@ -75,6 +157,99 @@ export async function saveConfig(partial, configPath = CONFIG_FILE) {
75
157
  ...partial,
76
158
  installedAt: existing.installedAt ?? partial.installedAt ?? new Date().toISOString(),
77
159
  };
78
- await fs.writeFile(configPath, JSON.stringify(merged, null, 2), "utf8");
160
+ // Two-tier write: prefer brigade.json (the unified config) when no legacy
161
+ // config.json is present at the tested path. This keeps tests with custom
162
+ // configPath working (they'll create config.json in their tempdir) AND
163
+ // makes prod writes flow into brigade.json after migration. We never WRITE
164
+ // to a legacy config.json once migration has happened — that would
165
+ // re-create the split-file layout we just consolidated.
166
+ let legacyExists = false;
167
+ try {
168
+ await fs.stat(configPath);
169
+ legacyExists = true;
170
+ }
171
+ catch {
172
+ /* missing — fall through */
173
+ }
174
+ if (legacyExists || configPath !== CONFIG_FILE) {
175
+ await fs.writeFile(configPath, JSON.stringify(merged, null, 2), "utf8");
176
+ return;
177
+ }
178
+ // Default path + no legacy file → write into brigade.json under the v2
179
+ // reference-aligned blocks. Mapping:
180
+ // defaultProvider + defaultModelId → agents.defaults.model.primary (when BOTH set)
181
+ // defaultProvider OR defaultModelId → settings.defaultProvider/Id (transient,
182
+ // in-flight scratch until both halves arrive)
183
+ // fallbackProvider + fallbackModelId → agents.defaults.model.fallbacks[0]
184
+ // installedAt → meta.installedAt
185
+ // thinkingLevel/compaction → settings.* (Brigade-private)
186
+ const { loadBrigadeConfig, writeBrigadeConfig, composeModelRefExternal } = await import("./brigade-config.js");
187
+ const brigadeCfg = await loadBrigadeConfig(BRIGADE_DIR);
188
+ // Resolve the effective provider+modelId pair from the merged values PLUS
189
+ // any in-flight scratch already in settings. This is what makes the
190
+ // two-step CLI ritual (`set defaultProvider X` then `set defaultModelId Y`)
191
+ // land both halves into agents.defaults.model.primary on the second call,
192
+ // instead of leaving the scratch in settings indefinitely.
193
+ const settingsAsRecord = (brigadeCfg.settings ?? {});
194
+ const effectiveProvider = merged.defaultProvider ??
195
+ settingsAsRecord.defaultProvider;
196
+ const effectiveModelId = merged.defaultModelId ??
197
+ settingsAsRecord.defaultModelId;
198
+ const primaryRef = composeModelRefExternal(effectiveProvider, effectiveModelId);
199
+ const fallbackRef = composeModelRefExternal(merged.fallbackProvider, merged.fallbackModelId);
200
+ const existingAgents = brigadeCfg.agents ?? {};
201
+ const existingDefaults = existingAgents.defaults ?? {};
202
+ const existingModel = existingDefaults.model ?? {};
203
+ brigadeCfg.agents = {
204
+ ...existingAgents,
205
+ defaults: {
206
+ ...existingDefaults,
207
+ model: {
208
+ ...existingModel,
209
+ primary: primaryRef ?? existingModel.primary,
210
+ fallbacks: fallbackRef ? [fallbackRef] : existingModel.fallbacks,
211
+ },
212
+ },
213
+ // Preserve any user-populated `agents.list` verbatim. Do NOT seed a
214
+ // stub entry when the list is empty/absent — the runtime synthesizes
215
+ // a virtual default agent keyed by "main" in that case (see
216
+ // brigade-config.ts migrations for the same rationale). Seeding a
217
+ // `{ id: "main", name: "<product>" }` stub would leak the product
218
+ // brand into the persona slot; the `name` field is reserved for the
219
+ // user's choice via BOOTSTRAP / identity command.
220
+ ...(existingAgents.list && existingAgents.list.length > 0
221
+ ? { list: existingAgents.list }
222
+ : {}),
223
+ };
224
+ if (merged.installedAt) {
225
+ brigadeCfg.meta = {
226
+ ...(brigadeCfg.meta ?? {}),
227
+ installedAt: brigadeCfg.meta?.installedAt ?? merged.installedAt,
228
+ };
229
+ }
230
+ const newSettings = { ...(brigadeCfg.settings ?? {}) };
231
+ if (merged.thinkingLevel !== undefined) {
232
+ newSettings.thinkingLevel = merged.thinkingLevel;
233
+ }
234
+ // Partial defaults: when only ONE half of provider+modelId is known,
235
+ // stash it in settings as in-flight scratch so the next `set` call can
236
+ // combine the two into a complete agents.defaults.model.primary ref.
237
+ // Once primaryRef is composed, clear the scratch — it'd otherwise lie
238
+ // stale on disk and shadow the canonical agents.defaults.model.primary
239
+ // on the next read.
240
+ if (primaryRef) {
241
+ delete newSettings.defaultProvider;
242
+ delete newSettings.defaultModelId;
243
+ }
244
+ else {
245
+ if (effectiveProvider !== undefined)
246
+ newSettings.defaultProvider = effectiveProvider;
247
+ if (effectiveModelId !== undefined)
248
+ newSettings.defaultModelId = effectiveModelId;
249
+ }
250
+ // compaction is set by other code paths directly through writeBrigadeConfig
251
+ // and is preserved by the spread above.
252
+ brigadeCfg.settings = newSettings;
253
+ await writeBrigadeConfig(BRIGADE_DIR, brigadeCfg);
79
254
  }
80
255
  //# sourceMappingURL=config.js.map
@@ -0,0 +1,337 @@
1
+ /**
2
+ * Persistent tool-approval allowlist — storage + decision contract.
3
+ *
4
+ * Lives at `<brigadeDir>/exec-approvals.json`. The actual approval flow (TUI
5
+ * prompt with "approve once / always / never" choices) is wired separately;
6
+ * this module is the storage layer + the pure-function decider that the flow
7
+ * calls when a tool is about to run.
8
+ *
9
+ * Why "tool-approval" and not "command-approval": Brigade's risk surface is
10
+ * the set of registered tools (Bash, Edit, Write, …) plus arbitrary `args`
11
+ * strings. A `tool` scope blanket-approves every invocation of a single tool;
12
+ * a `tool+pattern` scope adds an args-substring guard so an operator can say
13
+ * "always allow `Bash` when its args contain `npm test`" without unlocking
14
+ * `Bash rm -rf /`. Substring (not regex) is intentional — regex in security-
15
+ * relevant code is a footgun (catastrophic backtracking, accidental over-
16
+ * matching from `.` in untrusted input). Regex matching is a v2 feature when
17
+ * we have a hardened pattern surface (anchors, no backrefs, length cap).
18
+ */
19
+ import { existsSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
20
+ import * as fs from "node:fs/promises";
21
+ import * as path from "node:path";
22
+ /* ─────────────────────────── constants ─────────────────────────── */
23
+ const APPROVALS_FILENAME = "exec-approvals.json";
24
+ /** Hard cap on entry count to keep the file small + decisions O(n) in something tiny. */
25
+ const MAX_ENTRIES = 1_000;
26
+ /* ─────────────────────────── path helpers ─────────────────────────── */
27
+ function approvalsFilePath(brigadeDir) {
28
+ return path.join(brigadeDir, APPROVALS_FILENAME);
29
+ }
30
+ function tmpFilePath(brigadeDir) {
31
+ // Same dir as the target so rename() is atomic on the same volume. Cross-
32
+ // volume renames silently degrade to copy+unlink, defeating the atomicity.
33
+ return path.join(brigadeDir, `${APPROVALS_FILENAME}.tmp`);
34
+ }
35
+ function clobberedPath(brigadeDir, ts) {
36
+ return path.join(brigadeDir, `${APPROVALS_FILENAME}.clobbered.${ts}`);
37
+ }
38
+ /* ─────────────────────────── schema validation ─────────────────────────── */
39
+ /**
40
+ * Hand-rolled validator. We deliberately don't pull in a runtime schema lib
41
+ * here — this file is on the hot path of every tool dispatch and a 200KB AJV
42
+ * compiled schema would be overkill for a 4-shape struct. Returns true iff
43
+ * the value is a structurally-valid ExecApprovalsFile; everything else falls
44
+ * back to "treat as corrupt, snapshot, start fresh".
45
+ */
46
+ function isValidFile(value) {
47
+ if (!value || typeof value !== "object")
48
+ return false;
49
+ const v = value;
50
+ if (v.version !== 1)
51
+ return false;
52
+ if (!Array.isArray(v.entries))
53
+ return false;
54
+ for (const e of v.entries) {
55
+ if (!isValidEntry(e))
56
+ return false;
57
+ }
58
+ return true;
59
+ }
60
+ function isValidEntry(value) {
61
+ if (!value || typeof value !== "object")
62
+ return false;
63
+ const e = value;
64
+ if (typeof e.decidedAt !== "string")
65
+ return false;
66
+ if (e.decidedBy !== undefined && typeof e.decidedBy !== "string")
67
+ return false;
68
+ if (e.expiresAt !== undefined && typeof e.expiresAt !== "string")
69
+ return false;
70
+ return isValidScope(e.scope);
71
+ }
72
+ function isValidScope(value) {
73
+ if (!value || typeof value !== "object")
74
+ return false;
75
+ const s = value;
76
+ if (s.kind === "tool") {
77
+ return typeof s.tool === "string" && s.tool.length > 0;
78
+ }
79
+ if (s.kind === "tool+pattern") {
80
+ return (typeof s.tool === "string" &&
81
+ s.tool.length > 0 &&
82
+ typeof s.argsPattern === "string" &&
83
+ s.argsPattern.length > 0);
84
+ }
85
+ return false;
86
+ }
87
+ /* ─────────────────────────── scope identity ─────────────────────────── */
88
+ /**
89
+ * Two scopes are "the same" when they would match the same set of (tool, args)
90
+ * pairs. Used by addApproval/removeApproval for dedup. Pattern matching is
91
+ * exact-string (case-sensitive) — we don't normalise whitespace so the user's
92
+ * intent is preserved verbatim.
93
+ */
94
+ function scopesEqual(a, b) {
95
+ if (a.kind !== b.kind)
96
+ return false;
97
+ if (a.kind === "tool" && b.kind === "tool") {
98
+ return a.tool === b.tool;
99
+ }
100
+ if (a.kind === "tool+pattern" && b.kind === "tool+pattern") {
101
+ return a.tool === b.tool && a.argsPattern === b.argsPattern;
102
+ }
103
+ return false;
104
+ }
105
+ /* ─────────────────────────── expiration ─────────────────────────── */
106
+ function isExpired(entry, now) {
107
+ if (!entry.expiresAt)
108
+ return false;
109
+ const t = Date.parse(entry.expiresAt);
110
+ if (!Number.isFinite(t))
111
+ return false; // unparseable expiry → treat as never
112
+ return t <= now.getTime();
113
+ }
114
+ /* ─────────────────────────── load ─────────────────────────── */
115
+ /**
116
+ * Read the approvals file. Missing file → fresh empty result. Corrupt /
117
+ * schema-invalid file → snapshot to `<filename>.clobbered.<ts>`, warn to
118
+ * stderr, return fresh empty result. We never throw from load — a single
119
+ * bad approval file should not crash boot.
120
+ */
121
+ export async function loadApprovals(brigadeDir) {
122
+ const filePath = approvalsFilePath(brigadeDir);
123
+ let raw;
124
+ try {
125
+ raw = await fs.readFile(filePath, "utf8");
126
+ }
127
+ catch {
128
+ return { version: 1, entries: [] };
129
+ }
130
+ let parsed;
131
+ try {
132
+ parsed = JSON.parse(raw);
133
+ }
134
+ catch {
135
+ await snapshotClobbered(brigadeDir, raw, "json parse");
136
+ return { version: 1, entries: [] };
137
+ }
138
+ if (!isValidFile(parsed)) {
139
+ await snapshotClobbered(brigadeDir, raw, "schema");
140
+ return { version: 1, entries: [] };
141
+ }
142
+ return parsed;
143
+ }
144
+ async function snapshotClobbered(brigadeDir, raw, reason) {
145
+ const ts = Date.now();
146
+ const dest = clobberedPath(brigadeDir, ts);
147
+ try {
148
+ await fs.writeFile(dest, raw, "utf8");
149
+ process.stderr.write(`[brigade] approvals file at ${approvalsFilePath(brigadeDir)} was corrupt (${reason}); snapshot saved to ${dest}\n`);
150
+ }
151
+ catch {
152
+ // Snapshot failed (disk full / permission). Fall back to a stderr-only
153
+ // warning — losing the snapshot is preferable to crashing the agent.
154
+ process.stderr.write(`[brigade] approvals file at ${approvalsFilePath(brigadeDir)} was corrupt (${reason}); could not write snapshot\n`);
155
+ }
156
+ }
157
+ /* ─────────────────────────── save ─────────────────────────── */
158
+ /**
159
+ * Atomically write the approvals file. Validates the schema before writing —
160
+ * a programming error elsewhere should not be allowed to corrupt the file on
161
+ * disk. Mode is 0o600 (POSIX); on Windows the chmod call is a no-op and any
162
+ * EPERM is swallowed.
163
+ */
164
+ export async function saveApprovals(brigadeDir, file) {
165
+ if (!isValidFile(file)) {
166
+ throw new Error("saveApprovals: refusing to write invalid approvals file");
167
+ }
168
+ if (file.entries.length > MAX_ENTRIES) {
169
+ throw new Error(`saveApprovals: too many entries (${file.entries.length} > ${MAX_ENTRIES})`);
170
+ }
171
+ await fs.mkdir(brigadeDir, { recursive: true });
172
+ const target = approvalsFilePath(brigadeDir);
173
+ const tmp = tmpFilePath(brigadeDir);
174
+ const json = `${JSON.stringify(file, null, 2)}\n`;
175
+ // Write to a sibling .tmp first so a crash mid-write leaves the existing
176
+ // file intact. fs.writeFile already overwrites the tmp file if a previous
177
+ // run left one behind.
178
+ await fs.writeFile(tmp, json, "utf8");
179
+ try {
180
+ await fs.chmod(tmp, 0o600);
181
+ }
182
+ catch (err) {
183
+ // Windows / non-POSIX filesystems: chmod is unsupported (EPERM /
184
+ // ENOSYS). Swallow — the rename below is what makes this atomic; the
185
+ // permissions matter for security, not correctness.
186
+ const code = err?.code;
187
+ if (code !== "EPERM" && code !== "ENOSYS")
188
+ throw err;
189
+ }
190
+ await fs.rename(tmp, target);
191
+ }
192
+ /* ─────────────────────────── add / remove / clear ─────────────────────────── */
193
+ /**
194
+ * Append (or refresh) an approval. Dedup rule:
195
+ * - If an entry with an equivalent scope exists AND has expired, replace it
196
+ * (so a fresh approval extends a stale one).
197
+ * - If an entry with an equivalent scope exists AND is still valid, refresh
198
+ * `decidedAt` (and `expiresAt` if a new ttlMs was passed) without dropping
199
+ * the matched scope.
200
+ * - Otherwise, append.
201
+ *
202
+ * Returns the entry that ended up in the file (the new or refreshed one).
203
+ */
204
+ export async function addApproval(brigadeDir, scope, opts = {}) {
205
+ if (!isValidScope(scope)) {
206
+ throw new Error("addApproval: invalid scope");
207
+ }
208
+ const file = await loadApprovals(brigadeDir);
209
+ const now = new Date();
210
+ const entry = {
211
+ scope,
212
+ decidedAt: now.toISOString(),
213
+ };
214
+ if (opts.decidedBy !== undefined)
215
+ entry.decidedBy = opts.decidedBy;
216
+ if (typeof opts.ttlMs === "number" && opts.ttlMs > 0) {
217
+ entry.expiresAt = new Date(now.getTime() + opts.ttlMs).toISOString();
218
+ }
219
+ const existingIdx = file.entries.findIndex((e) => scopesEqual(e.scope, scope));
220
+ if (existingIdx === -1) {
221
+ file.entries.push(entry);
222
+ }
223
+ else {
224
+ const existing = file.entries[existingIdx];
225
+ if (isExpired(existing, now)) {
226
+ // Replace expired stub wholesale — old metadata is no longer valid.
227
+ file.entries[existingIdx] = entry;
228
+ }
229
+ else {
230
+ // Refresh the live entry. Preserve decidedBy when the caller didn't
231
+ // override it (we don't want a "ttl bump" call to wipe the original
232
+ // approver), and preserve the existing expiresAt unless ttlMs was
233
+ // passed — bumping a permanent approval shouldn't silently demote
234
+ // it to a TTL'd one.
235
+ const refreshed = {
236
+ scope: existing.scope,
237
+ decidedAt: now.toISOString(),
238
+ decidedBy: opts.decidedBy ?? existing.decidedBy,
239
+ expiresAt: entry.expiresAt ?? existing.expiresAt,
240
+ };
241
+ file.entries[existingIdx] = refreshed;
242
+ }
243
+ }
244
+ await saveApprovals(brigadeDir, file);
245
+ return file.entries[existingIdx === -1 ? file.entries.length - 1 : existingIdx];
246
+ }
247
+ /** Remove a matching scope. Returns true if removed, false if not present. */
248
+ export async function removeApproval(brigadeDir, scope) {
249
+ if (!isValidScope(scope))
250
+ return false;
251
+ const file = await loadApprovals(brigadeDir);
252
+ const before = file.entries.length;
253
+ file.entries = file.entries.filter((e) => !scopesEqual(e.scope, scope));
254
+ if (file.entries.length === before)
255
+ return false;
256
+ await saveApprovals(brigadeDir, file);
257
+ return true;
258
+ }
259
+ /** Drop every entry. Returns the count that was removed. */
260
+ export async function clearApprovals(brigadeDir) {
261
+ const file = await loadApprovals(brigadeDir);
262
+ const count = file.entries.length;
263
+ if (count === 0) {
264
+ // Still write — keeps the file present + stat-able for tooling that
265
+ // checks for it (e.g. doctor's "approvals file: yes/no" line).
266
+ await saveApprovals(brigadeDir, { version: 1, entries: [] });
267
+ return 0;
268
+ }
269
+ await saveApprovals(brigadeDir, { version: 1, entries: [] });
270
+ return count;
271
+ }
272
+ /* ─────────────────────────── decide (pure) ─────────────────────────── */
273
+ /**
274
+ * Pure decider — does NOT read or write the file. Caller passes in the loaded
275
+ * file so this stays trivially testable and free of I/O. Returns the matching
276
+ * entry on hit; on miss the caller is expected to prompt the user.
277
+ *
278
+ * Match order: walk entries in declaration order, return on the first match.
279
+ * Specificity (`tool+pattern` vs plain `tool`) is NOT preferred — if a user
280
+ * has both, the older one wins. That's intentional: the prompt UI should
281
+ * surface duplicates rather than letting them quietly shadow each other.
282
+ */
283
+ export function decideApproval(file, params) {
284
+ const now = params.now ?? new Date();
285
+ for (const entry of file.entries) {
286
+ if (isExpired(entry, now))
287
+ continue;
288
+ if (entry.scope.kind === "tool" && entry.scope.tool === params.tool) {
289
+ return { allow: true, matched: entry };
290
+ }
291
+ if (entry.scope.kind === "tool+pattern" &&
292
+ entry.scope.tool === params.tool &&
293
+ params.args.includes(entry.scope.argsPattern)) {
294
+ return { allow: true, matched: entry };
295
+ }
296
+ }
297
+ return { allow: false, needsPrompt: true };
298
+ }
299
+ /* ─────────────────────────── pruneExpired ─────────────────────────── */
300
+ /**
301
+ * Remove every expired entry. Returns the count removed. Safe to call on any
302
+ * schedule — at boot, on each `brigade auth` command, or as a periodic timer.
303
+ * Writes the file only when something actually changed (avoids needlessly
304
+ * touching mtime on every boot).
305
+ */
306
+ export async function pruneExpired(brigadeDir, now = new Date()) {
307
+ const file = await loadApprovals(brigadeDir);
308
+ const before = file.entries.length;
309
+ file.entries = file.entries.filter((e) => !isExpired(e, now));
310
+ const removed = before - file.entries.length;
311
+ if (removed > 0)
312
+ await saveApprovals(brigadeDir, file);
313
+ return removed;
314
+ }
315
+ /* ─────────────────────────── sync escape hatch ─────────────────────────── */
316
+ /**
317
+ * Synchronous best-effort cleanup of a stale `.tmp` file. Not exported — used
318
+ * internally if a future code path needs to recover from a crashed write
319
+ * without awaiting. Kept here so the recovery story stays in one file.
320
+ */
321
+ function _syncCleanupTmp(brigadeDir) {
322
+ const tmp = tmpFilePath(brigadeDir);
323
+ if (!existsSync(tmp))
324
+ return;
325
+ try {
326
+ unlinkSync(tmp);
327
+ }
328
+ catch {
329
+ /* swallow — best effort */
330
+ }
331
+ }
332
+ // Touch unused refs so noUnusedLocals (off today, but might flip on) doesn't
333
+ // flag them. These imports remain available for future sync recovery paths.
334
+ void _syncCleanupTmp;
335
+ void renameSync;
336
+ void writeFileSync;
337
+ //# sourceMappingURL=exec-approvals.js.map