@cohortapp/agent-sdk 2.5.0 → 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.
- package/bin/maestro.mjs +185 -88
- package/bin/maestro.test.mjs +175 -48
- package/docs/runbooks/backup-restore.md +65 -33
- package/framework-features.json +4 -4
- package/lib/backup/policy.mjs +710 -0
- package/lib/backup/policy.test.mjs +305 -0
- package/lib/budget-escalate.mjs +133 -0
- package/lib/budget-escalate.test.mjs +232 -0
- package/lib/budget-guard.envelope.test.mjs +476 -0
- package/lib/budget-guard.mjs +853 -75
- package/lib/budget-guard.test.mjs +91 -42
- package/lib/cadences.mjs +33 -0
- package/lib/channels/orgmail/adapter.mjs +88 -3
- package/lib/channels/orgmail/adapter.test.mjs +137 -0
- package/lib/channels/repeat-suppressor.mjs +198 -0
- package/lib/channels/repeat-suppressor.test.mjs +134 -0
- package/lib/comms/receipts.mjs +297 -0
- package/lib/cost/ledger-row.mjs +333 -0
- package/lib/cost/ledger-row.test.mjs +183 -0
- package/lib/execution/drive.mjs +28 -1
- package/lib/execution/effects.mjs +191 -12
- package/lib/execution/effects.test.mjs +50 -11
- package/lib/goals/admission.mjs +13 -1
- package/lib/goals/admission.test.mjs +26 -1
- package/lib/goals/loop.mjs +13 -0
- package/lib/kpi-sensors.test.mjs +3 -0
- package/lib/mandate/cache.mjs +13 -5
- package/lib/mandate/derive.mjs +146 -21
- package/lib/mandate/derive.test.mjs +50 -6
- package/lib/mandate/model.mjs +32 -4
- package/lib/mandate/refresh.test.mjs +16 -2
- package/lib/mcp/server.test.mjs +12 -3
- package/lib/model-router/economics.mjs +107 -76
- package/lib/model-router/economics.test.mjs +64 -46
- package/lib/model-router/integration-coverage.test.mjs +39 -37
- package/lib/model-router/ledger.mjs +75 -22
- package/lib/model-router/ledger.test.mjs +35 -2
- package/lib/org/client.mjs +14 -0
- package/lib/org/cost-sync.mjs +16 -2
- package/lib/org/doctor.mjs +62 -1
- package/lib/org/doctor.test.mjs +36 -3
- package/lib/org/email-remedy.mjs +49 -0
- package/lib/org/engagement-ledger.mjs +376 -0
- package/lib/org/engagement-ledger.test.mjs +112 -0
- package/lib/org/engagement.mjs +1056 -0
- package/lib/org/engagement.test.mjs +739 -0
- package/lib/org/inbound/hydrate.mjs +107 -15
- package/lib/org/inbound/hydrate.test.mjs +127 -0
- package/lib/org/messaging.mjs +230 -3
- package/lib/org/messaging.test.mjs +110 -1
- package/lib/org/param-contract.mjs +56 -2
- package/lib/org/param-contract.test.mjs +26 -0
- package/lib/org/protocol.checksum +1 -1
- package/lib/org/protocol.mjs +5 -0
- package/lib/org/protocol.test.mjs +7 -1
- package/lib/org/tool-surface.mjs +506 -10
- package/lib/org/tool-surface.test.mjs +191 -7
- package/lib/org/ui-parity.mjs +333 -6
- package/lib/org/ui-parity.test.mjs +96 -3
- package/lib/org/work-ledger.mjs +241 -0
- package/lib/org/work-ledger.test.mjs +237 -0
- package/lib/plan/adoption-e2e.test.mjs +366 -0
- package/lib/plan/budget-enforcement.test.mjs +400 -0
- package/lib/plan/budget-runtime.mjs +215 -0
- package/lib/plan/compile.mjs +201 -5
- package/lib/plan/compile.test.mjs +19 -5
- package/lib/plan/emit.mjs +8 -0
- package/lib/plan/emit.test.mjs +18 -0
- package/lib/resource-governor.mjs +58 -12
- package/lib/resource-governor.test.mjs +41 -1
- package/lib/security/audit-engine.mjs +45 -8
- package/lib/security/audit-engine.test.mjs +35 -0
- package/lib/setup/enroll-from-cohort.mjs +14 -1
- package/lib/setup/sections/mandate.mjs +48 -7
- package/lib/setup/sections/mandate.test.mjs +17 -2
- package/lib/setup/sections/orgmail.mjs +10 -2
- package/lib/setup/state.mjs +83 -2
- package/lib/telemetry/collect.mjs +360 -20
- package/lib/telemetry/collect.test.mjs +266 -0
- package/package.json +1 -1
- package/scripts/cost/track-claude-usage.mjs +207 -48
- package/scripts/cost/track-claude-usage.test.mjs +148 -0
- package/scripts/daemon/agent-daemon.mjs +315 -17
- package/scripts/daemon/assurance-e2e.test.mjs +421 -0
- package/scripts/daemon/assurance.mjs +944 -0
- package/scripts/daemon/assurance.test.mjs +668 -0
- package/scripts/daemon/cadence-consumer-governance.test.mjs +56 -0
- package/scripts/daemon/cadence-consumer.mjs +147 -9
- package/scripts/daemon/cadence-consumer.test.mjs +6 -0
- package/scripts/daemon/cadence-handlers.mjs +158 -0
- package/scripts/daemon/cadence-handlers.test.mjs +64 -0
- package/scripts/daemon/deliver.mjs +314 -0
- package/scripts/daemon/dispatcher-governance.test.mjs +10 -0
- package/scripts/daemon/dispatcher.mjs +64 -6
- package/scripts/daemon/responder-cost.test.mjs +68 -0
- package/scripts/daemon/responder.mjs +351 -298
- package/scripts/local-triggers/generate-plists.test.mjs +7 -4
- package/scripts/maintenance/backup-run.mjs +415 -0
- package/scripts/maintenance/backup-to-cloud.sh +16 -116
- package/scripts/org/send-orgmail.mjs +16 -0
- package/scripts/record-receipt.sh +63 -0
- package/scripts/restore-from-backup.sh +14 -3
- package/scripts/restore-from-backup.test.mjs +8 -5
- package/scripts/send-email-threaded.py +47 -0
- package/scripts/send-sms.sh +4 -0
- package/scripts/send-whatsapp.sh +4 -0
- package/scripts/setup/init-backup.mjs +93 -38
- 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
|
+
};
|