@cohortapp/agent-sdk 2.5.1 → 2.6.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.
Files changed (106) hide show
  1. package/bin/maestro.mjs +185 -88
  2. package/bin/maestro.test.mjs +175 -48
  3. package/docs/runbooks/backup-restore.md +65 -33
  4. package/framework-features.json +4 -4
  5. package/lib/backup/policy.mjs +710 -0
  6. package/lib/backup/policy.test.mjs +305 -0
  7. package/lib/budget-escalate.mjs +133 -0
  8. package/lib/budget-escalate.test.mjs +232 -0
  9. package/lib/budget-guard.envelope.test.mjs +476 -0
  10. package/lib/budget-guard.mjs +853 -75
  11. package/lib/budget-guard.test.mjs +91 -42
  12. package/lib/cadences.mjs +33 -0
  13. package/lib/channels/orgmail/adapter.mjs +88 -3
  14. package/lib/channels/orgmail/adapter.test.mjs +137 -0
  15. package/lib/channels/repeat-suppressor.mjs +198 -0
  16. package/lib/channels/repeat-suppressor.test.mjs +134 -0
  17. package/lib/comms/receipts.mjs +297 -0
  18. package/lib/cost/ledger-row.mjs +333 -0
  19. package/lib/cost/ledger-row.test.mjs +183 -0
  20. package/lib/execution/drive.mjs +28 -1
  21. package/lib/execution/effects.mjs +191 -12
  22. package/lib/execution/effects.test.mjs +50 -11
  23. package/lib/goals/admission.mjs +13 -1
  24. package/lib/goals/admission.test.mjs +26 -1
  25. package/lib/goals/loop.mjs +13 -0
  26. package/lib/kpi-sensors.test.mjs +3 -0
  27. package/lib/mandate/cache.mjs +13 -5
  28. package/lib/mandate/derive.mjs +146 -21
  29. package/lib/mandate/derive.test.mjs +50 -6
  30. package/lib/mandate/model.mjs +32 -4
  31. package/lib/mandate/refresh.test.mjs +16 -2
  32. package/lib/mcp/server.test.mjs +12 -3
  33. package/lib/model-router/economics.mjs +107 -76
  34. package/lib/model-router/economics.test.mjs +64 -46
  35. package/lib/model-router/integration-coverage.test.mjs +39 -37
  36. package/lib/model-router/ledger.mjs +75 -22
  37. package/lib/model-router/ledger.test.mjs +35 -2
  38. package/lib/org/client.mjs +14 -0
  39. package/lib/org/cost-sync.mjs +16 -2
  40. package/lib/org/doctor.mjs +62 -1
  41. package/lib/org/doctor.test.mjs +36 -3
  42. package/lib/org/email-remedy.mjs +49 -0
  43. package/lib/org/engagement-ledger.mjs +376 -0
  44. package/lib/org/engagement-ledger.test.mjs +112 -0
  45. package/lib/org/engagement.mjs +1056 -0
  46. package/lib/org/engagement.test.mjs +739 -0
  47. package/lib/org/messaging.mjs +230 -3
  48. package/lib/org/messaging.test.mjs +110 -1
  49. package/lib/org/param-contract.mjs +56 -2
  50. package/lib/org/param-contract.test.mjs +26 -0
  51. package/lib/org/protocol.checksum +1 -1
  52. package/lib/org/protocol.mjs +5 -0
  53. package/lib/org/protocol.test.mjs +7 -1
  54. package/lib/org/tool-surface.mjs +506 -10
  55. package/lib/org/tool-surface.test.mjs +191 -7
  56. package/lib/org/ui-parity.mjs +333 -6
  57. package/lib/org/ui-parity.test.mjs +96 -3
  58. package/lib/org/work-ledger.mjs +241 -0
  59. package/lib/org/work-ledger.test.mjs +237 -0
  60. package/lib/plan/adoption-e2e.test.mjs +366 -0
  61. package/lib/plan/budget-enforcement.test.mjs +400 -0
  62. package/lib/plan/budget-runtime.mjs +215 -0
  63. package/lib/plan/compile.mjs +201 -5
  64. package/lib/plan/compile.test.mjs +19 -5
  65. package/lib/plan/emit.mjs +8 -0
  66. package/lib/plan/emit.test.mjs +18 -0
  67. package/lib/resource-governor.mjs +58 -12
  68. package/lib/resource-governor.test.mjs +41 -1
  69. package/lib/security/audit-engine.mjs +45 -8
  70. package/lib/security/audit-engine.test.mjs +35 -0
  71. package/lib/setup/enroll-from-cohort.mjs +14 -1
  72. package/lib/setup/sections/mandate.mjs +48 -7
  73. package/lib/setup/sections/mandate.test.mjs +17 -2
  74. package/lib/setup/sections/orgmail.mjs +10 -2
  75. package/lib/setup/state.mjs +83 -2
  76. package/lib/telemetry/collect.mjs +360 -20
  77. package/lib/telemetry/collect.test.mjs +266 -0
  78. package/package.json +1 -1
  79. package/scripts/cost/track-claude-usage.mjs +207 -48
  80. package/scripts/cost/track-claude-usage.test.mjs +148 -0
  81. package/scripts/daemon/agent-daemon.mjs +315 -17
  82. package/scripts/daemon/assurance-e2e.test.mjs +421 -0
  83. package/scripts/daemon/assurance.mjs +944 -0
  84. package/scripts/daemon/assurance.test.mjs +668 -0
  85. package/scripts/daemon/cadence-consumer-governance.test.mjs +56 -0
  86. package/scripts/daemon/cadence-consumer.mjs +147 -9
  87. package/scripts/daemon/cadence-consumer.test.mjs +6 -0
  88. package/scripts/daemon/cadence-handlers.mjs +158 -0
  89. package/scripts/daemon/cadence-handlers.test.mjs +64 -0
  90. package/scripts/daemon/deliver.mjs +314 -0
  91. package/scripts/daemon/dispatcher-governance.test.mjs +10 -0
  92. package/scripts/daemon/dispatcher.mjs +64 -6
  93. package/scripts/daemon/responder-cost.test.mjs +68 -0
  94. package/scripts/daemon/responder.mjs +351 -298
  95. package/scripts/local-triggers/generate-plists.test.mjs +7 -4
  96. package/scripts/maintenance/backup-run.mjs +415 -0
  97. package/scripts/maintenance/backup-to-cloud.sh +16 -116
  98. package/scripts/org/send-orgmail.mjs +16 -0
  99. package/scripts/record-receipt.sh +63 -0
  100. package/scripts/restore-from-backup.sh +14 -3
  101. package/scripts/restore-from-backup.test.mjs +8 -5
  102. package/scripts/send-email-threaded.py +47 -0
  103. package/scripts/send-sms.sh +4 -0
  104. package/scripts/send-whatsapp.sh +4 -0
  105. package/scripts/setup/init-backup.mjs +93 -38
  106. package/scripts/slack-send.sh +12 -0
@@ -0,0 +1,710 @@
1
+ /**
2
+ * lib/backup/policy.mjs — what a fleet machine backs up, where, and what it
3
+ * must NEVER touch.
4
+ *
5
+ * ── The decision this module encodes ────────────────────────────────────────
6
+ *
7
+ * `maestro doctor` used to FAIL every agent on earth with "No backup target
8
+ * configured", and the only remedy on offer was an interactive wizard that
9
+ * wrote `enabled: false` and demanded a GCS/S3 bucket plus cloud credentials
10
+ * the machine did not have. So every agent was born red, the check was learned
11
+ * as noise, and the actual DR posture stayed exactly where it was: nothing.
12
+ *
13
+ * The posture implemented here is **restore points first, offsite second**:
14
+ *
15
+ * Tier 1 — LOCAL RESTORE POINTS (on by default, zero configuration).
16
+ * A nightly tarball written OUTSIDE the agent repo, to
17
+ * `~/Library/Application Support/Maestro/backups/<prefix>/`. It costs
18
+ * nothing, needs no credentials, and covers the failure that actually
19
+ * happens week to week: a corrupted queue, a bad state write, a botched
20
+ * checkout, an `rm -rf` of the repo. Living outside the repo is the whole
21
+ * point — a backup inside the tree it protects is not a backup.
22
+ *
23
+ * Tier 2 — OFFSITE COPY (opt-in, one config block).
24
+ * The same tarball pushed to gcs/s3/rsync. This is the only tier that
25
+ * survives machine loss, so an agent WITHOUT it is not silently green:
26
+ * doctor WARNs, naming the residual risk in words ("local restore points
27
+ * only — a dead machine loses everything since enrolment").
28
+ *
29
+ * Why not just demand offsite and stay red until someone configures it? Because
30
+ * a check that is red on every machine for months is a check nobody reads, and
31
+ * because "no restore point at all" and "restore points that would not survive
32
+ * a house fire" are genuinely different severities. Doctor now says which one
33
+ * you have. FAIL-OPEN IS FINE; SILENT IS NOT.
34
+ *
35
+ * ── What is NEVER backed up ─────────────────────────────────────────────────
36
+ *
37
+ * `DENY_PATTERNS` is a HARD deny-list enforced in code, not a YAML default a
38
+ * config edit can undo. `.env` and `.cohort-key.json` are LIVE credentials:
39
+ * `.env` carries COHORT_API_KEY (authenticates as this member) and
40
+ * `.cohort-key.json` is the device identity. Shipping either to a bucket turns
41
+ * a backup into a credential-exfiltration channel, and the failure would be
42
+ * invisible — the tarball uploads fine. So the deny-list is applied twice:
43
+ * as tar `--exclude` patterns AND as a pre-flight filter that DROPS any
44
+ * configured include path that resolves to a denied file, reporting the drop
45
+ * as a violation rather than quietly obeying it.
46
+ *
47
+ * A restore never wants these anyway: `scripts/restore-from-backup.sh`
48
+ * deliberately re-pairs for a FRESH device token instead of restoring the dead
49
+ * machine's identity, and secrets come back via `maestro secrets sync`.
50
+ *
51
+ * Pure + injectable (fs, env, homedir). Never throws — a DR helper that can
52
+ * crash the thing observing it is worse than one that says "assume bad".
53
+ *
54
+ * @module lib/backup/policy
55
+ */
56
+
57
+ "use strict";
58
+
59
+ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
60
+ import { homedir } from "node:os";
61
+ import { join, resolve, isAbsolute, normalize } from "node:path";
62
+ import yaml from "js-yaml";
63
+
64
+ /** Config file, relative to the agent root. */
65
+ export const CONFIG_REL = ".maestro/backup-config.yaml";
66
+ /** Success marker the runner stamps, relative to the agent root. */
67
+ export const MARKER_REL = ".maestro/last-backup.json";
68
+
69
+ /**
70
+ * Paths that must NEVER enter a backup archive, whatever the config says.
71
+ *
72
+ * Each entry is matched against a repo-relative POSIX path, as a path prefix
73
+ * (`state/tmp` matches `state/tmp/anything`) or, when it contains `*`, as a
74
+ * glob over a single path segment. Order is irrelevant; any match denies.
75
+ *
76
+ * Rationale per group:
77
+ * - live credentials — `.env*`, `.cohort-key.json`, keychains, private keys.
78
+ * A restore re-pairs; it never restores an identity.
79
+ * - Claude Code auth — `.claude/.credentials.json` is an OAuth token.
80
+ * - reconstructables — node_modules, .git, RAG index binaries: huge, and a
81
+ * restore rebuilds them faster than it downloads them.
82
+ * - hot files — sqlite WAL/SHM and state/tmp are mid-write by
83
+ * definition; archiving them yields torn data.
84
+ */
85
+ export const DENY_PATTERNS = Object.freeze([
86
+ // ── Live credentials: the whole reason this list is code, not config ──
87
+ ".env",
88
+ ".env.*",
89
+ "*.env",
90
+ ".cohort-key.json",
91
+ ".neolith-key.json",
92
+ ".claude/.credentials.json",
93
+ "config/secrets.local.yaml",
94
+ "*.pem",
95
+ "*.key",
96
+ "*.p12",
97
+ "*.pfx",
98
+ "id_rsa*",
99
+ "id_ed25519*",
100
+ ".ssh",
101
+ ".npmrc",
102
+ ".gnupg",
103
+ // ── Reconstructable bulk ──
104
+ "node_modules",
105
+ ".git",
106
+ "state/rag/index",
107
+ // ── Hot / torn-by-definition ──
108
+ "state/tmp",
109
+ "*.sqlite-wal",
110
+ "*.sqlite-shm",
111
+ "*.db-wal",
112
+ "*.db-shm",
113
+ ".DS_Store",
114
+ ]);
115
+
116
+ /**
117
+ * Credential shapes that must never be INSIDE an archived file.
118
+ *
119
+ * ── why a path deny-list is not enough ──────────────────────────────────────
120
+ * Every enforcement point in this module used to match path NAMES only, so
121
+ * `.env` was correctly excluded while `state/setup/progress.json` — a 0644 file
122
+ * inside the default `state` include — carried `"orgToken":"nlk_…"`, the exact
123
+ * bytes of that agent's `COHORT_API_KEY`. The runner then listed the archive,
124
+ * found no denied paths, and logged "archive verified clean". Set
125
+ * `offsite.provider` and it uploads nightly. That is the credential-exfiltration
126
+ * channel the module doc warns about, arriving through the one door nobody was
127
+ * watching.
128
+ *
129
+ * A file whose CONTENT matches is dropped from the archive and reported as a
130
+ * violation. Dropped, not fatal: these patterns will occasionally hit a test
131
+ * fixture, and the cost of a false positive must be one missing file in a
132
+ * restore point — never "this machine has no DR posture".
133
+ *
134
+ * Deliberately high-signal only. This is not a general secret scanner; it is the
135
+ * set of things that authenticate AS this agent or its operator.
136
+ */
137
+ export const SECRET_CONTENT_PATTERNS = Object.freeze([
138
+ { id: "cohort-api-key", re: /\bnlk_[A-Za-z0-9]{16,}/ },
139
+ { id: "anthropic-key", re: /\bsk-ant-[A-Za-z0-9_-]{20,}/ },
140
+ { id: "openai-key", re: /\bsk-(?:proj-)?[A-Za-z0-9]{32,}/ },
141
+ { id: "github-token", re: /\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{30,}/ },
142
+ { id: "slack-token", re: /\bxox[abprs]-[A-Za-z0-9-]{10,}/ },
143
+ { id: "aws-access-key", re: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/ },
144
+ { id: "google-api-key", re: /\bAIza[0-9A-Za-z_-]{35}\b/ },
145
+ { id: "private-key-block", re: /-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP )?PRIVATE KEY-----/ },
146
+ ]);
147
+
148
+ /** Files larger than this are not content-scanned (and not skipped — see scanFileForSecrets). */
149
+ export const CONTENT_SCAN_MAX_BYTES = 2 * 1024 * 1024;
150
+
151
+ /**
152
+ * Which credential shapes appear in this text? Pure; never throws.
153
+ * @param {string} text
154
+ * @returns {string[]} matching pattern ids
155
+ */
156
+ export function scanTextForSecrets(text) {
157
+ if (typeof text !== "string" || !text) return [];
158
+ const hits = [];
159
+ for (const p of SECRET_CONTENT_PATTERNS) {
160
+ if (p.re.test(text)) hits.push(p.id);
161
+ }
162
+ return hits;
163
+ }
164
+
165
+ /**
166
+ * Content-scan one file. Binary files (a NUL byte in the first 8 KiB) are
167
+ * skipped — a credential is text by construction and scanning compiled blobs
168
+ * would only manufacture false positives.
169
+ *
170
+ * A file too large to scan is reported as `{ oversize:true }` rather than
171
+ * silently passed: the caller decides, and it says so either way.
172
+ *
173
+ * @param {string} absPath
174
+ * @param {object} [deps] injectable {statSync, readFileSync}
175
+ * @returns {{hits:string[], scanned:boolean, oversize:boolean, binary:boolean, error:string|null}}
176
+ */
177
+ export function scanFileForSecrets(absPath, deps = {}) {
178
+ const _stat = deps.statSync || statSync;
179
+ const _read = deps.readFileSync || readFileSync;
180
+ const out = { hits: [], scanned: false, oversize: false, binary: false, error: null };
181
+ let size = 0;
182
+ try {
183
+ size = _stat(absPath).size;
184
+ } catch (err) {
185
+ out.error = err && err.message ? err.message : String(err);
186
+ return out;
187
+ }
188
+ if (size > CONTENT_SCAN_MAX_BYTES) {
189
+ out.oversize = true;
190
+ return out;
191
+ }
192
+ let buf;
193
+ try {
194
+ buf = _read(absPath);
195
+ } catch (err) {
196
+ out.error = err && err.message ? err.message : String(err);
197
+ return out;
198
+ }
199
+ const probe = buf.subarray ? buf.subarray(0, 8192) : Buffer.from(String(buf)).subarray(0, 8192);
200
+ if (probe.includes ? probe.includes(0) : false) {
201
+ out.binary = true;
202
+ return out;
203
+ }
204
+ out.scanned = true;
205
+ out.hits = scanTextForSecrets(buf.toString("utf-8"));
206
+ return out;
207
+ }
208
+
209
+ /**
210
+ * Default include set. Durable, hand-authored, or expensive-to-recompute state
211
+ * only. `logs/` is deliberately ABSENT from the default: it is the largest and
212
+ * least valuable tree, and the audit trail that matters is already mirrored to
213
+ * the org event ledger. An operator who wants logs adds them explicitly.
214
+ */
215
+ export const DEFAULT_INCLUDE = Object.freeze([
216
+ "state",
217
+ "knowledge",
218
+ "memory",
219
+ "outputs",
220
+ "config",
221
+ ".maestro",
222
+ ]);
223
+
224
+ /** Nightly, 03:10 local — quiet hours, and ahead of nightly-cost-reconcile. */
225
+ export const DEFAULT_SCHEDULE = Object.freeze({ hour: 3, minute: 10 });
226
+
227
+ /** Two weeks of local restore points: long enough to notice a slow corruption. */
228
+ export const DEFAULT_RETENTION_DAYS = 14;
229
+
230
+ /** Providers the runner knows how to push to. `local` needs no credentials. */
231
+ export const OFFSITE_PROVIDERS = Object.freeze(["gcs", "s3", "rsync"]);
232
+
233
+ /**
234
+ * Where Tier-1 snapshots live. Outside the agent repo on purpose (see module
235
+ * doc) and inside the user's own Library so no sudo, no shared paths, and
236
+ * per-user permissions apply.
237
+ *
238
+ * @param {object} [deps]
239
+ * @param {string} [deps.home] injectable home dir (tests)
240
+ * @param {object} [deps.env] injectable env (MAESTRO_BACKUP_DIR override)
241
+ * @returns {string}
242
+ */
243
+ export function defaultLocalRoot(deps = {}) {
244
+ const env = deps.env || process.env;
245
+ if (env.MAESTRO_BACKUP_DIR) return resolve(String(env.MAESTRO_BACKUP_DIR));
246
+ const home = deps.home || env.HOME || homedir();
247
+ return join(home, "Library", "Application Support", "Maestro", "backups");
248
+ }
249
+
250
+ /**
251
+ * The config a brand-new agent is born with: Tier 1 on, Tier 2 declared but
252
+ * empty, exclusions documented so the operator can see what is protected.
253
+ *
254
+ * Emitted verbatim by scripts/setup/init-backup.mjs. Kept as a template string
255
+ * (not a `yaml.dump`) so the COMMENTS ship — they are the only place an
256
+ * operator reads why `.env` is not in the archive.
257
+ *
258
+ * @param {object} [o]
259
+ * @param {string} [o.prefix] archive prefix (typically the repo slug)
260
+ * @returns {string} YAML document
261
+ */
262
+ export function defaultConfigYaml(o = {}) {
263
+ const prefix = sanitisePrefix(o.prefix || "agent");
264
+ return `# .maestro/backup-config.yaml — this agent's disaster-recovery posture.
265
+ #
266
+ # TIER 1 (below, on by default): nightly local restore points written OUTSIDE
267
+ # this repo, to ~/Library/Application Support/Maestro/backups/${prefix}/.
268
+ # Zero credentials, survives a bad checkout / corrupted queue / rm -rf of the
269
+ # repo. Does NOT survive machine loss.
270
+ #
271
+ # TIER 2 (offsite, below): the same archive pushed off the machine. This is the
272
+ # only tier that survives a dead Mac mini, so \`maestro doctor\` WARNS until it
273
+ # is set. Fill in provider + bucket and the nightly-backup cadence picks it up
274
+ # on the next run — no restart needed.
275
+ #
276
+ # NEVER ARCHIVED, and not negotiable from this file: .env, .cohort-key.json,
277
+ # private keys, .claude/.credentials.json, node_modules, .git, state/tmp and
278
+ # the RAG index. Those are live credentials or reconstructable bulk; the
279
+ # deny-list lives in lib/backup/policy.mjs (DENY_PATTERNS) and is enforced in
280
+ # code precisely so a config edit cannot ship a credential to a bucket.
281
+ # A restore re-pairs for a FRESH token — it never restores a dead identity.
282
+
283
+ enabled: true
284
+ prefix: ${prefix}
285
+
286
+ # Tier 1 — local restore points.
287
+ local:
288
+ enabled: true
289
+ # dir: ~/Library/Application Support/Maestro/backups # override if you must
290
+ retention_days: ${DEFAULT_RETENTION_DAYS}
291
+
292
+ # Tier 2 — off-machine copy. THE one that survives losing the box.
293
+ # retention_days is enforced for gcs and s3 (the runner prunes its own
294
+ # <bucket>/<prefix>/ key space after each successful upload). For rsync it is
295
+ # NOT enforced — maestro will not run a remote delete over ssh against a path
296
+ # from a config file — and the runner says so in the log every night.
297
+ offsite:
298
+ provider: "" # gcs | s3 | rsync (empty = not configured)
299
+ bucket: "" # gs://bucket, s3 bucket name, or user@host:/path for rsync
300
+ retention_days: 30
301
+
302
+ # Nightly at 03:10 local, via the \`nightly-backup\` cadence. Change the cadence
303
+ # in lib/cadences.mjs, not here — this value is documentation.
304
+ schedule: "${DEFAULT_SCHEDULE.minute} ${DEFAULT_SCHEDULE.hour} * * *"
305
+
306
+ include:
307
+ ${DEFAULT_INCLUDE.map((p) => ` - ${p}`).join("\n")}
308
+
309
+ # Extra excludes on top of the hard-coded deny-list (repo-relative).
310
+ exclude: []
311
+ `;
312
+ }
313
+
314
+ /**
315
+ * Archive prefixes end up in file names AND bucket keys, so they get the
316
+ * boring treatment: lowercase, `[a-z0-9._-]` only, no leading dots or dashes
317
+ * (a `..`-prefixed key is a path-traversal invitation on the restore side).
318
+ */
319
+ export function sanitisePrefix(raw) {
320
+ const s = String(raw || "")
321
+ .toLowerCase()
322
+ .replace(/[^a-z0-9._-]+/g, "-")
323
+ .replace(/-{2,}/g, "-")
324
+ .replace(/^[.\-]+/, "")
325
+ .replace(/[.\-]+$/, "");
326
+ return s || "agent";
327
+ }
328
+
329
+ /**
330
+ * Parse the config YAML. Never throws: a malformed document degrades to
331
+ * `null`, which callers report as "unreadable" rather than as "absent" (those
332
+ * are different problems and the operator needs to know which one they have).
333
+ *
334
+ * @param {string} text
335
+ * @returns {object|null}
336
+ */
337
+ export function parseBackupConfig(text) {
338
+ if (typeof text !== "string" || !text.trim()) return null;
339
+ try {
340
+ const doc = yaml.load(text);
341
+ if (!doc || typeof doc !== "object" || Array.isArray(doc)) return null;
342
+ return doc;
343
+ } catch {
344
+ return null;
345
+ }
346
+ }
347
+
348
+ /** Normalise a repo-relative path for matching: POSIX separators, no `./`. */
349
+ function relKey(p) {
350
+ return String(p)
351
+ .replace(/\\/g, "/")
352
+ .replace(/^\.\//, "")
353
+ .replace(/\/+$/, "");
354
+ }
355
+
356
+ /**
357
+ * Does `rel` (a repo-relative path) hit the hard deny-list?
358
+ *
359
+ * Matches on any path SEGMENT so a deny of `node_modules` also denies
360
+ * `state/x/node_modules`, and on the full path so `config/secrets.local.yaml`
361
+ * denies exactly that file. Glob support is deliberately one segment wide
362
+ * (`*.pem`, `.env.*`) — enough for the credential shapes we care about, small
363
+ * enough to reason about.
364
+ *
365
+ * @param {string} rel
366
+ * @param {string[]} [patterns]
367
+ * @returns {string|null} the pattern that denied it, or null
368
+ */
369
+ export function denyMatch(rel, patterns = DENY_PATTERNS) {
370
+ const key = relKey(rel);
371
+ if (!key) return null;
372
+ const segments = key.split("/");
373
+ for (const pattern of patterns) {
374
+ const pat = relKey(pattern);
375
+ if (!pat) continue;
376
+ if (pat.includes("*")) {
377
+ const re = new RegExp(
378
+ `^${pat.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, "[^/]*")}$`
379
+ );
380
+ if (segments.some((seg) => re.test(seg))) return pattern;
381
+ if (re.test(key)) return pattern;
382
+ continue;
383
+ }
384
+ if (key === pat) return pattern;
385
+ if (key.startsWith(`${pat}/`)) return pattern;
386
+ // Bare segment denies (node_modules, .git) apply at any depth.
387
+ if (!pat.includes("/") && segments.includes(pat)) return pattern;
388
+ }
389
+ return null;
390
+ }
391
+
392
+ /**
393
+ * Resolve the effective backup plan for an agent root.
394
+ *
395
+ * The single source of truth for the runner, the doctor check and the cadence
396
+ * handler — three call sites that used to each re-derive their own idea of
397
+ * what "configured" meant (and disagreed).
398
+ *
399
+ * @param {object} o
400
+ * @param {string} o.agentRoot
401
+ * @param {object} [o.fs] injectable {existsSync, readFileSync} (tests)
402
+ * @param {object} [o.env] injectable env
403
+ * @param {string} [o.home] injectable home dir
404
+ * @returns {{
405
+ * present: boolean,
406
+ * readable: boolean,
407
+ * enabled: boolean,
408
+ * prefix: string,
409
+ * local: {enabled: boolean, dir: string, retentionDays: number},
410
+ * offsite: {configured: boolean, provider: string, bucket: string, retentionDays: number},
411
+ * include: string[],
412
+ * exclude: string[],
413
+ * violations: Array<{path: string, pattern: string}>,
414
+ * tier: "none"|"unreadable"|"disabled"|"local"|"offsite",
415
+ * }}
416
+ */
417
+ export function resolveBackupPlan(o = {}) {
418
+ const _fs = o.fs || { existsSync, readFileSync };
419
+ const agentRoot = resolve(o.agentRoot || process.cwd());
420
+ const path = join(agentRoot, CONFIG_REL);
421
+
422
+ const base = {
423
+ present: false,
424
+ readable: false,
425
+ enabled: false,
426
+ prefix: sanitisePrefix(agentRoot.split("/").pop()),
427
+ local: { enabled: false, dir: "", retentionDays: DEFAULT_RETENTION_DAYS },
428
+ offsite: { configured: false, provider: "", bucket: "", retentionDays: 30 },
429
+ include: [...DEFAULT_INCLUDE],
430
+ exclude: [],
431
+ violations: [],
432
+ tier: "none",
433
+ };
434
+
435
+ let raw;
436
+ try {
437
+ if (!_fs.existsSync(path)) return base;
438
+ raw = _fs.readFileSync(path, "utf-8");
439
+ } catch {
440
+ return { ...base, present: true, tier: "unreadable" };
441
+ }
442
+
443
+ const doc = parseBackupConfig(raw);
444
+ if (!doc) return { ...base, present: true, tier: "unreadable" };
445
+
446
+ const enabled = doc.enabled === true;
447
+ const prefix = sanitisePrefix(doc.prefix || base.prefix);
448
+
449
+ const localCfg = doc.local && typeof doc.local === "object" ? doc.local : {};
450
+ // Tier 1 is on unless explicitly switched off — the default posture.
451
+ const localEnabled = localCfg.enabled !== false;
452
+ const localDir = localCfg.dir
453
+ ? resolve(expandHome(String(localCfg.dir), o))
454
+ : join(defaultLocalRoot(o), prefix);
455
+
456
+ const offCfg = doc.offsite && typeof doc.offsite === "object" ? doc.offsite : {};
457
+ const provider = String(offCfg.provider || "").trim().toLowerCase();
458
+ const bucket = String(offCfg.bucket || "").trim();
459
+ const offsiteConfigured = OFFSITE_PROVIDERS.includes(provider) && bucket !== "";
460
+
461
+ // Include list: config wins, defaults otherwise; deny-list violations are
462
+ // DROPPED and reported. A typo'd `- .env` never reaches tar.
463
+ const rawInclude = Array.isArray(doc.include) && doc.include.length ? doc.include : [...DEFAULT_INCLUDE];
464
+ const include = [];
465
+ const violations = [];
466
+ for (const entry of rawInclude) {
467
+ const rel = relKey(entry);
468
+ if (!rel) continue;
469
+ if (isAbsolute(rel) || normalize(rel).startsWith("..")) {
470
+ violations.push({ path: rel, pattern: "outside-agent-root" });
471
+ continue;
472
+ }
473
+ const hit = denyMatch(rel);
474
+ if (hit) {
475
+ violations.push({ path: rel, pattern: hit });
476
+ continue;
477
+ }
478
+ if (!include.includes(rel)) include.push(rel);
479
+ }
480
+
481
+ const exclude = (Array.isArray(doc.exclude) ? doc.exclude : [])
482
+ .map(relKey)
483
+ .filter(Boolean);
484
+
485
+ let tier = "disabled";
486
+ if (enabled && offsiteConfigured) tier = "offsite";
487
+ else if (enabled && localEnabled) tier = "local";
488
+
489
+ return {
490
+ present: true,
491
+ readable: true,
492
+ enabled,
493
+ prefix,
494
+ local: {
495
+ enabled: localEnabled,
496
+ dir: localDir,
497
+ retentionDays: posInt(localCfg.retention_days, DEFAULT_RETENTION_DAYS),
498
+ },
499
+ offsite: {
500
+ configured: offsiteConfigured,
501
+ provider,
502
+ bucket,
503
+ retentionDays: posInt(offCfg.retention_days, 30),
504
+ },
505
+ include,
506
+ exclude,
507
+ violations,
508
+ tier,
509
+ };
510
+ }
511
+
512
+ /** `~`-relative paths in config resolve against the real home dir. */
513
+ function expandHome(p, deps = {}) {
514
+ if (!p.startsWith("~")) return p;
515
+ const env = deps.env || process.env;
516
+ const home = deps.home || env.HOME || homedir();
517
+ return join(home, p.slice(1));
518
+ }
519
+
520
+ function posInt(v, fallback) {
521
+ const n = Number(v);
522
+ return Number.isFinite(n) && n > 0 ? Math.floor(n) : fallback;
523
+ }
524
+
525
+ /**
526
+ * tar `--exclude` arguments for a plan: the hard deny-list first (so it cannot
527
+ * be reordered away), then the operator's extras.
528
+ *
529
+ * @param {object} plan
530
+ * @returns {string[]}
531
+ */
532
+ export function tarExcludeArgs(plan) {
533
+ const pats = [...DENY_PATTERNS, ...((plan && plan.exclude) || [])];
534
+ const args = [];
535
+ for (const p of pats) {
536
+ args.push(`--exclude=${p}`);
537
+ // bsdtar/gnutar match --exclude against the stored path; a bare segment
538
+ // deny needs the `*/seg` form to hit nested copies too.
539
+ if (!p.includes("/") && !p.includes("*")) args.push(`--exclude=*/${p}`);
540
+ }
541
+ return args;
542
+ }
543
+
544
+ /**
545
+ * Walk a plan's include trees and find every file whose CONTENT carries a
546
+ * credential. The pre-flight half of the content deny-list: its output becomes
547
+ * `--exclude` arguments, so the bytes never enter the archive at all.
548
+ *
549
+ * Path-denied entries are skipped as we walk (no point scanning `node_modules`),
550
+ * which also keeps this cheap: the observed real agent is ~4,800 files / 4 MB.
551
+ *
552
+ * NEVER THROWS. An unreadable directory is reported in `errors[]` and the walk
553
+ * continues — a DR helper that can crash the backup is worse than one that
554
+ * reports partial coverage. `truncated` is set when the file budget is hit, so a
555
+ * caller can refuse rather than believe a partial scan.
556
+ *
557
+ * @param {object} plan from resolveBackupPlan
558
+ * @param {string} agentRoot
559
+ * @param {object} [deps] { readdirSync, statSync, readFileSync, maxFiles }
560
+ * @returns {{ leaks:Array<{path:string,patterns:string[]}>, scanned:number,
561
+ * skippedBinary:number, oversize:string[], errors:string[],
562
+ * truncated:boolean }}
563
+ */
564
+ export function scanIncludesForSecrets(plan, agentRoot, deps = {}) {
565
+ const _readdir = deps.readdirSync || readdirSync;
566
+ const _stat = deps.statSync || statSync;
567
+ const maxFiles = Number.isFinite(deps.maxFiles) ? deps.maxFiles : 50_000;
568
+ const root = resolve(agentRoot || process.cwd());
569
+
570
+ const leaks = [];
571
+ const oversize = [];
572
+ const errors = [];
573
+ let scanned = 0;
574
+ let skippedBinary = 0;
575
+ let seen = 0;
576
+ let truncated = false;
577
+
578
+ const walk = (rel) => {
579
+ if (truncated) return;
580
+ if (denyMatch(rel)) return;
581
+ const abs = join(root, rel);
582
+ let st;
583
+ try {
584
+ st = _stat(abs);
585
+ } catch (err) {
586
+ errors.push(`${rel}: ${err && err.message ? err.message : err}`);
587
+ return;
588
+ }
589
+ if (st.isDirectory && st.isDirectory()) {
590
+ let entries = [];
591
+ try {
592
+ entries = _readdir(abs);
593
+ } catch (err) {
594
+ errors.push(`${rel}: ${err && err.message ? err.message : err}`);
595
+ return;
596
+ }
597
+ for (const name of entries) walk(`${rel}/${name}`);
598
+ return;
599
+ }
600
+ if (++seen > maxFiles) {
601
+ truncated = true;
602
+ return;
603
+ }
604
+ const r = scanFileForSecrets(abs, deps);
605
+ if (r.error) errors.push(`${rel}: ${r.error}`);
606
+ if (r.oversize) oversize.push(rel);
607
+ if (r.binary) skippedBinary += 1;
608
+ if (r.scanned) scanned += 1;
609
+ if (r.hits.length) leaks.push({ path: rel, patterns: r.hits });
610
+ };
611
+
612
+ for (const inc of (plan && plan.include) || []) walk(relKey(inc));
613
+ return { leaks, scanned, skippedBinary, oversize, errors, truncated };
614
+ }
615
+
616
+ /**
617
+ * Human-readable one-liner for doctor/alerts, given a plan + freshness verdict.
618
+ * Returns the level AND the sentence so both consumers stay in lockstep.
619
+ *
620
+ * Severity ladder (the decision in one place):
621
+ * fail — no config, unreadable config, disabled, or every include denied.
622
+ * fail — Tier 1 on but no successful run has EVER happened.
623
+ * warn — restore points exist but are stale, or are local-only.
624
+ * ok — offsite tier, fresh.
625
+ *
626
+ * @param {object} plan from resolveBackupPlan
627
+ * @param {object} fresh {configured, lastBackupAt, ageHours, stale}
628
+ * @returns {{level:"ok"|"warn"|"fail", msg:string, fix?:string}}
629
+ */
630
+ export function backupVerdict(plan, fresh = {}) {
631
+ if (!plan || !plan.present) {
632
+ return {
633
+ level: "fail",
634
+ msg: "No DR posture at all — .maestro/backup-config.yaml is missing, so nothing on this machine is recoverable.",
635
+ fix: "maestro init backup-replication --apply (writes the default: nightly LOCAL restore points outside the repo, no credentials needed)",
636
+ };
637
+ }
638
+ if (!plan.readable) {
639
+ return {
640
+ level: "fail",
641
+ msg: ".maestro/backup-config.yaml is present but unparseable — the nightly backup cannot run.",
642
+ fix: "fix the YAML, or delete it and re-run: maestro init backup-replication --apply",
643
+ };
644
+ }
645
+ if (!plan.enabled) {
646
+ return {
647
+ level: "fail",
648
+ msg: "Backup is configured but switched OFF (.maestro/backup-config.yaml: enabled is not true) — this machine has no restore points.",
649
+ fix: "set enabled: true in .maestro/backup-config.yaml",
650
+ };
651
+ }
652
+ if (!plan.include.length) {
653
+ return {
654
+ level: "fail",
655
+ msg: `Backup enabled but every include path was rejected (${plan.violations.map((v) => `${v.path} → ${v.pattern}`).join(", ") || "empty include list"}) — the archive would be empty.`,
656
+ fix: "restore the include list in .maestro/backup-config.yaml (state, knowledge, memory, outputs, config, .maestro)",
657
+ };
658
+ }
659
+
660
+ const where = plan.tier === "offsite" ? `${plan.offsite.provider}://${plan.offsite.bucket}` : plan.local.dir;
661
+ const age = Number.isFinite(fresh.ageHours) ? `${Math.round(fresh.ageHours)}h ago` : "never";
662
+
663
+ if (!fresh.lastBackupAt) {
664
+ return {
665
+ level: "fail",
666
+ msg: `Backup enabled but has NEVER completed a run — there is no restore point (target ${where}).`,
667
+ fix: "run it once now: node scripts/maintenance/backup-run.mjs (then the nightly-backup cadence keeps it current)",
668
+ };
669
+ }
670
+ if (fresh.stale) {
671
+ return {
672
+ level: "warn",
673
+ msg: `Last backup ${age} — stale (target ${where}). Check logs/maintenance/backup.log.`,
674
+ fix: "node scripts/maintenance/backup-run.mjs",
675
+ };
676
+ }
677
+ if (plan.tier !== "offsite") {
678
+ return {
679
+ level: "warn",
680
+ msg: `Restore points are LOCAL-ONLY: fresh (${age}) at ${where}, but nothing is off this machine — losing the box loses everything since enrolment.`,
681
+ fix: "set offsite.provider (gcs|s3|rsync) + offsite.bucket in .maestro/backup-config.yaml",
682
+ };
683
+ }
684
+ return {
685
+ level: "ok",
686
+ msg: `Backup fresh + off-machine: last run ${age} → ${where} (local copy at ${plan.local.dir})`,
687
+ };
688
+ }
689
+
690
+ export default {
691
+ CONFIG_REL,
692
+ MARKER_REL,
693
+ DENY_PATTERNS,
694
+ DEFAULT_INCLUDE,
695
+ DEFAULT_RETENTION_DAYS,
696
+ OFFSITE_PROVIDERS,
697
+ defaultLocalRoot,
698
+ defaultConfigYaml,
699
+ sanitisePrefix,
700
+ parseBackupConfig,
701
+ denyMatch,
702
+ resolveBackupPlan,
703
+ tarExcludeArgs,
704
+ backupVerdict,
705
+ SECRET_CONTENT_PATTERNS,
706
+ CONTENT_SCAN_MAX_BYTES,
707
+ scanTextForSecrets,
708
+ scanFileForSecrets,
709
+ scanIncludesForSecrets,
710
+ };