@spinabot/brigade 0.1.0 → 0.1.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/CHANGELOG.md +41 -10
- package/LICENSE +21 -21
- package/README.md +11 -2
- package/dist/cli/chat-cmd.js +74 -3
- package/dist/cli/config-cmd.js +40 -1
- package/dist/cli/connect-cmd.js +42 -2
- package/dist/cli/doctor-cmd.js +85 -15
- package/dist/cli/gateway-cmd.js +69 -6
- package/dist/cli.js +53 -12
- package/dist/core/agent.js +3 -3
- package/dist/core/auth-error.js +111 -0
- package/dist/core/auth-label.js +147 -0
- package/dist/core/brigade-config.js +730 -0
- package/dist/core/cli-error.js +94 -0
- package/dist/core/config.js +182 -7
- package/dist/core/exec-approvals.js +337 -0
- package/dist/core/server.js +36 -0
- package/dist/core/system-prompt-defaults.js +221 -45
- package/dist/core/system-prompt-guidance.js +2 -0
- package/dist/core/system-prompt.js +842 -102
- package/dist/core/version.js +14 -0
- package/dist/index.js +8 -6
- package/dist/protocol.js +15 -0
- package/dist/ui/brand.js +31 -15
- package/dist/ui/chat.js +90 -11
- package/dist/ui/onboarding.js +218 -15
- package/dist/ui/terminal-cleanup.js +132 -0
- package/package.json +2 -3
- package/assets/brigade-wordmark-on-black.png +0 -0
|
@@ -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
|
package/dist/core/config.js
CHANGED
|
@@ -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.
|
|
16
|
-
*
|
|
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 =
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|