geneseed 4.7.0 → 4.9.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/CHANGELOG.md +38 -0
- package/README.md +1 -1
- package/SHIPPED.md +1 -1
- package/adapters/claude-code/README.md +5 -1
- package/adapters/opencode/HOW-OPENCODE-LOADS.md +1 -1
- package/adapters/opencode/README.md +5 -4
- package/adapters/opencode/plugins/geneseed-context.js +17 -1
- package/adapters/opencode/plugins/geneseed-guard.js +569 -38
- package/bin/geneseed-hook.mjs +16 -5
- package/docs/concepts/enforcement-table.md +3 -3
- package/docs/concepts/exclusions.md +37 -1
- package/docs/concepts/hooks.md +13 -5
- package/docs/concepts/host-matrix.md +3 -3
- package/docs/reference/plugin-context.md +1 -1
- package/docs/reference/plugin-guard.md +3 -3
- package/docs/reference/troubleshoot-doctor.md +5 -2
- package/docs/understand/a-day.md +6 -6
- package/docs/understand/design-choices-gates.md +2 -2
- package/docs/understand/enforced-vs-asked.md +4 -4
- package/docs/understand/harness.md +1 -1
- package/docs/understand/machine-claude.md +1 -1
- package/docs/understand/machine-opencode.md +2 -2
- package/docs/web-ui.md +7 -4
- package/harness.config.json +1 -1
- package/js/README.md +2 -1
- package/js/build/args.mjs +7 -1
- package/js/build/driver.mjs +19 -7
- package/js/build/emit-claude.mjs +4 -3
- package/js/build/emit-opencode.mjs +2 -1
- package/js/build/generate.mjs +18 -5
- package/js/build/render.mjs +7 -1
- package/js/build/source.mjs +22 -1
- package/js/cli-table.json +120 -0
- package/js/hosts/gitref.mjs +47 -1
- package/js/hosts/hooks-done.mjs +4 -0
- package/js/hosts/hooks-learn.mjs +3 -1
- package/js/hosts/hooks.mjs +22 -43
- package/js/hosts/installs.mjs +30 -1
- package/js/hosts/loss.mjs +522 -0
- package/js/hosts/settings.mjs +90 -80
- package/js/inspect/checks-probe.mjs +18 -6
- package/js/inspect/diff.mjs +4 -1
- package/js/inspect/status.mjs +5 -2
- package/js/inspect/validate.mjs +11 -4
- package/js/maintain/uninstall.mjs +7 -2
- package/js/maintain/update.mjs +1 -1
- package/js/web/actions.mjs +24 -3
- package/js/web/api.mjs +5 -2
- package/package.json +1 -1
- package/src/AGENT.md.tmpl +1 -1
- package/src/laws/universal.md +17 -17
- package/src/skills/worktree.md +1 -1
- package/web/dist/assets/{Activity-Dj0izJwj.js → Activity-DZ3O_DsJ.js} +1 -1
- package/web/dist/assets/{ActivityDetail-wjq5W02K.js → ActivityDetail-BQSWwoJ-.js} +1 -1
- package/web/dist/assets/Dashboard-COLskkOJ.js +2 -0
- package/web/dist/assets/Docs-DUxizutI.js +5 -0
- package/web/dist/assets/FilterInput-zLFXoSt6.js +1 -0
- package/web/dist/assets/GreenhouseView-BSzVX_l7.js +2 -0
- package/web/dist/assets/{Installs-XSm0a_zQ.js → Installs-Ck4YyEzs.js} +2 -2
- package/web/dist/assets/JournalView-BbbACsYK.js +1 -0
- package/web/dist/assets/Laws-BZ_M86r3.js +3 -0
- package/web/dist/assets/{Library-CkaPVUYU.js → Library-D_4LbUg2.js} +1 -1
- package/web/dist/assets/{Loops-CexwOARr.js → Loops-Bzb2Y96W.js} +2 -2
- package/web/dist/assets/{Markdown-DrXLM6TA.js → Markdown-iHvBrpRN.js} +1 -1
- package/web/dist/assets/{OperatorHudView-D5qTQat0.js → OperatorHudView-D-nzB6_r.js} +1 -1
- package/web/dist/assets/{Personal-B_Vx9a7a.js → Personal-C66284wd.js} +1 -1
- package/web/dist/assets/Tabs-DkPfjG3H.js +1 -0
- package/web/dist/assets/{activity-dcg5vF9j.js → activity-DvPxoS4Y.js} +1 -1
- package/web/dist/assets/drawable-DlrXDOJ7.js +1 -0
- package/web/dist/assets/{index-BoG1M8Xy.css → index-BCJz4_8C.css} +1 -1
- package/web/dist/assets/{index-BmbWeoTM.js → index-DJorAjeH.js} +2 -2
- package/web/dist/assets/{react-DRhoX0kS.js → react-BOF6yL40.js} +2 -2
- package/web/dist/assets/{sections-GalMS0Zv.js → sections-BET3c1WT.js} +1 -1
- package/web/dist/assets/{usePoll-BmR631BB.js → usePoll-NSTzLOF4.js} +1 -1
- package/web/dist/index.html +4 -4
- package/web/src/pages/Laws.jsx +84 -9
- package/web/dist/assets/Dashboard-UMbgHbzF.js +0 -2
- package/web/dist/assets/Docs-D3ABE_ds.js +0 -5
- package/web/dist/assets/FilterInput-BSfpStOZ.js +0 -1
- package/web/dist/assets/GreenhouseView-C238LWBD.js +0 -2
- package/web/dist/assets/JournalView-BUj4Ff-6.js +0 -1
- package/web/dist/assets/Laws-yGBJFKgY.js +0 -3
- package/web/dist/assets/Tabs-YZbGqWPW.js +0 -1
- package/web/dist/assets/drawable-BoQnwVpr.js +0 -1
|
@@ -6,14 +6,15 @@
|
|
|
6
6
|
// added content carries a credential-shaped string (`.env`-shaped targets exempt).
|
|
7
7
|
// - External Gate: block writes to a check listed in `.geneseed/protected-checks.txt`,
|
|
8
8
|
// and to the done gate's own list `.geneseed/done-checks.txt`.
|
|
9
|
-
// - Deletion Is Deliberate: block catastrophic shell commands
|
|
9
|
+
// - Deletion Is Deliberate: block catastrophic shell commands, and every command that would
|
|
10
|
+
// really lose data, judged against the repository's state (`lossReason`, below).
|
|
10
11
|
// - Commands Must Return: block a recursive scan rooted at a whole filesystem (find /).
|
|
11
12
|
// - Persist Insight (rule vs memory): speed-bump the first write to user-rules.md
|
|
12
13
|
// memory file — that choice belongs to the user, via the rule skill.
|
|
13
14
|
// - Wiki (AGENT.md §8): block mutations under a declared wiki's `protected`
|
|
14
15
|
// folders — the user's knowledge base sets its own no-go zones in geneseed-wiki.jsonc.
|
|
15
16
|
// High-confidence patterns only, so legitimate work is never caught. Borderline cases
|
|
16
|
-
// (.env edits
|
|
17
|
+
// (.env edits) are WARNED, not blocked. Every block is recorded in the gate
|
|
17
18
|
// ledger (`<cfg>/notebook/gates.jsonl`) under the hook's rule key (`law-1`, `rigor-5`,
|
|
18
19
|
// `law-4`, `ops-2`, `process-1`, `wiki`); every skill or subagent call, blocked or not, is
|
|
19
20
|
// counted in `<cfg>/notebook/usage.jsonl` when that `notebook/` exists and `.usage-off` does not.
|
|
@@ -47,7 +48,8 @@
|
|
|
47
48
|
// upgrade path there is still a session-level `permission` ruleset set by the loop launcher
|
|
48
49
|
// when it creates the loop session (`session.create` accepts one).
|
|
49
50
|
|
|
50
|
-
import { promises as fs, realpathSync, existsSync, readFileSync } from "node:fs"
|
|
51
|
+
import { promises as fs, realpathSync, existsSync, readFileSync, readdirSync, statSync, lstatSync } from "node:fs"
|
|
52
|
+
import { spawnSync } from "node:child_process"
|
|
51
53
|
import * as path from "node:path"
|
|
52
54
|
import { fileURLToPath } from "node:url"
|
|
53
55
|
import { homedir } from "node:os"
|
|
@@ -122,6 +124,29 @@ const MODE = (process.env.GENESEED_GUARD || "on").toLowerCase()
|
|
|
122
124
|
const OFF = ["off", "0", "false", "no"].includes(MODE)
|
|
123
125
|
const WARN_ONLY = MODE === "warn"
|
|
124
126
|
|
|
127
|
+
// The gate switches (`--no-deletion-gate` 2026-10-17, `--no-secrets-gate` 2026-10-18, user
|
|
128
|
+
// decisions). The build writes `Deletion gate: off` / `Secrets gate: off` into the install's
|
|
129
|
+
// AGENT.md only when that gate is off; this reads the carrier ONCE at load, from the same
|
|
130
|
+
// harness dir the context plugin resolves ($GENESEED_HARNESS, else <plugin dir>/.. for a global
|
|
131
|
+
// install, else <plugin dir>/../.. for a project one), so flipping a switch takes an OpenCode
|
|
132
|
+
// restart. Anything unreadable or unexpected reads as ARMED. Each stands down only its own check
|
|
133
|
+
// below (the real-loss check; the Sealed Secrets path block and content scan) — catastrophic
|
|
134
|
+
// commands, root scans and protected checks still block.
|
|
135
|
+
function carrierText() {
|
|
136
|
+
try {
|
|
137
|
+
const dirs = [path.resolve(PLUGIN_DIR, ".."), path.resolve(PLUGIN_DIR, "..", "..")]
|
|
138
|
+
if (process.env.GENESEED_HARNESS) dirs.unshift(process.env.GENESEED_HARNESS)
|
|
139
|
+
for (const d of dirs) {
|
|
140
|
+
try { return readFileSync(path.join(d, "AGENT.md"), "utf8") } catch { continue }
|
|
141
|
+
}
|
|
142
|
+
} catch { /* fall through: armed */ }
|
|
143
|
+
return ""
|
|
144
|
+
}
|
|
145
|
+
const CARRIER_TEXT = carrierText()
|
|
146
|
+
const gateOff = (marker) => new RegExp(`^${marker}:[ \\t]*off[ \\t]*\\r?$`, "m").test(CARRIER_TEXT)
|
|
147
|
+
const DELETION_GATE_OFF = gateOff("Deletion gate")
|
|
148
|
+
const SECRETS_GATE_OFF = gateOff("Secrets gate")
|
|
149
|
+
|
|
125
150
|
function log(msg) { console.error(`[geneseed-guard] ${msg}`) }
|
|
126
151
|
|
|
127
152
|
// ---- the gate ledger ------------------------------------------------------------
|
|
@@ -179,8 +204,8 @@ const SECRET_WARN_RE = [/(^|\/)\.env(\.[\w.-]+)?$/i]
|
|
|
179
204
|
// credential-shaped string, not the target path (SECRET_RE above is the path twin, file names
|
|
180
205
|
// only). High-precision vendor prefixes only, same rationale as the Node side: a generic
|
|
181
206
|
// "looks like entropy" scan fires on every hash and lockfile. Kept byte-identical by the parity
|
|
182
|
-
// test in tests/plugins/guard.test.mjs — simple enough to literally diff,
|
|
183
|
-
//
|
|
207
|
+
// test in tests/plugins/guard.test.mjs — simple enough to literally diff, like the loss twin
|
|
208
|
+
// block below. `.env*` is exempt (SECRET_WARN_RE above already names it as where a
|
|
184
209
|
// secret may legitimately live) via the same path check used for the WARN tier.
|
|
185
210
|
const SECRET_CONTENT_RE =
|
|
186
211
|
/\b(?:AKIA[0-9A-Z]{16}|ghp_[A-Za-z0-9]{30,}|github_pat_[A-Za-z0-9_]{30,}|sk-ant-[A-Za-z0-9_-]{20,}|xox[abprs]-[0-9A-Za-z-]{10,})\b|-----BEGIN [A-Z ]*PRIVATE KEY-----/
|
|
@@ -201,36 +226,537 @@ const SHELL_BLOCK_RE = [
|
|
|
201
226
|
/\bremove-item\b[^\n]*-recurse\b[^\n]*-force\b/i, // PowerShell rm -rf
|
|
202
227
|
/\bremove-item\b[^\n]*-force\b[^\n]*-recurse\b/i, // (either flag order)
|
|
203
228
|
]
|
|
204
|
-
//
|
|
205
|
-
//
|
|
206
|
-
//
|
|
207
|
-
//
|
|
208
|
-
//
|
|
209
|
-
//
|
|
210
|
-
//
|
|
211
|
-
//
|
|
212
|
-
//
|
|
213
|
-
//
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
229
|
+
// Deletion Is Deliberate, calibrated to REAL loss — the twin of js/hosts/loss.mjs (the plugin is
|
|
230
|
+
// copied whole into an OpenCode install, so it cannot import it). The static `permission.bash`
|
|
231
|
+
// globs (js/hosts/settings.mjs `LAW_IV_BASH`) keep only what loses data in EVERY state
|
|
232
|
+
// (`reflog expire`, `gc --prune`); a glob cannot read a work tree, so everything that depends
|
|
233
|
+
// on state is decided here. `tool.execute.before` has no ask tier, so a real loss is BLOCKED —
|
|
234
|
+
// the Bob behaviour — and `GENESEED_GUARD=warn` downgrades it to a log line.
|
|
235
|
+
//
|
|
236
|
+
// The fs-only readers below are the twins of js/hosts/gitref.mjs (gitRootOf, gitDirOf,
|
|
237
|
+
// gitCommonDirOf, currentBranch, protectedBranches, stashExists); the block between the twin
|
|
238
|
+
// markers is js/hosts/loss.mjs verbatim, and tests/plugins/guard.test.mjs diffs it.
|
|
239
|
+
function gitRootOf(cwd) {
|
|
240
|
+
try {
|
|
241
|
+
let dir = path.resolve(cwd)
|
|
242
|
+
for (;;) {
|
|
243
|
+
if (existsSync(path.join(dir, ".git"))) return dir
|
|
244
|
+
const up = path.dirname(dir)
|
|
245
|
+
if (up === dir) return null
|
|
246
|
+
dir = up
|
|
247
|
+
}
|
|
248
|
+
} catch { return null }
|
|
249
|
+
}
|
|
250
|
+
function gitDirOf(root) {
|
|
251
|
+
const dotgit = path.join(root, ".git")
|
|
252
|
+
if (statSync(dotgit).isFile()) {
|
|
253
|
+
const m = /^gitdir:\s*(.+?)\s*$/m.exec(readFileSync(dotgit, "utf8"))
|
|
254
|
+
if (!m) throw new Error(`${dotgit} does not name a gitdir`)
|
|
255
|
+
return path.resolve(root, m[1])
|
|
256
|
+
}
|
|
257
|
+
return dotgit
|
|
258
|
+
}
|
|
259
|
+
function gitCommonDirOf(root) {
|
|
260
|
+
const gitdir = gitDirOf(root)
|
|
261
|
+
try { return path.resolve(gitdir, readFileSync(path.join(gitdir, "commondir"), "utf8").trim()) } catch { return gitdir }
|
|
262
|
+
}
|
|
263
|
+
function currentBranch(cwd) {
|
|
264
|
+
try {
|
|
265
|
+
const root = gitRootOf(cwd)
|
|
266
|
+
if (!root) return null
|
|
267
|
+
const ref = /^ref:\s*refs\/heads\/(.+?)\s*$/m.exec(readFileSync(path.join(gitDirOf(root), "HEAD"), "utf8"))
|
|
268
|
+
return ref ? ref[1] : null
|
|
269
|
+
} catch { return null }
|
|
270
|
+
}
|
|
271
|
+
function protectedBranches(root) {
|
|
272
|
+
const names = new Set(["main", "master"])
|
|
273
|
+
const remotes = path.join(gitCommonDirOf(root), "refs", "remotes")
|
|
274
|
+
let dirs = []
|
|
275
|
+
try { dirs = readdirSync(remotes) } catch { /* no remotes */ }
|
|
276
|
+
for (const r of dirs) {
|
|
277
|
+
try {
|
|
278
|
+
const m = /^ref:\s*refs\/remotes\/[^/]+\/(.+?)\s*$/m.exec(readFileSync(path.join(remotes, r, "HEAD"), "utf8"))
|
|
279
|
+
if (m) names.add(m[1])
|
|
280
|
+
} catch { /* this remote has no HEAD symref */ }
|
|
281
|
+
}
|
|
282
|
+
return names
|
|
283
|
+
}
|
|
284
|
+
function stashExists(root) {
|
|
285
|
+
const common = gitCommonDirOf(root)
|
|
286
|
+
if (existsSync(path.join(common, "refs", "stash"))) return true
|
|
287
|
+
try { return /^[0-9a-f]+ refs\/stash$/m.test(readFileSync(path.join(common, "packed-refs"), "utf8")) } catch { return false }
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// ---- loss twin: BEGIN — adapters/opencode/plugins/geneseed-guard.js carries everything down to
|
|
291
|
+
// the END marker verbatim, with `export ` dropped (tests/plugins/guard.test.mjs diffs the two).
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* The cheap prefilter: a command that matches neither alternative never reaches the parser or
|
|
295
|
+
* a spawn. A git destructive VERB anywhere after `git` on the same line, or a removal program
|
|
296
|
+
* as a word at a command boundary. Deliberately loose — the parse decides; this only has to be
|
|
297
|
+
* a superset of what the parse acts on.
|
|
298
|
+
*/
|
|
299
|
+
const LOSS_CANDIDATE_RE =
|
|
300
|
+
/\bgit\b[^\n]*\b(?:reset|clean|branch|checkout|switch|restore|stash|reflog|gc|push)\b|(?:^|[\s;&|("'])(?:rm|rmdir|del|erase|remove-item|ri|rd)(?:\.exe)?(?=[\s;&|)"']|$)/im;
|
|
301
|
+
|
|
302
|
+
class Unverified extends Error {}
|
|
303
|
+
const unverified = (why) => { throw new Unverified(why); };
|
|
304
|
+
|
|
305
|
+
const RM_PROGS = new Set(['rm', 'rmdir', 'del', 'erase', 'remove-item', 'ri', 'rd']);
|
|
306
|
+
// cmd.exe's own removal verbs take `/s` `/q` switches, which are not paths.
|
|
307
|
+
const CMD_SWITCH_PROGS = new Set(['rmdir', 'del', 'erase', 'rd']);
|
|
308
|
+
const CD_PROGS = new Set(['cd', 'pushd', 'chdir', 'set-location', 'sl']);
|
|
309
|
+
const WRAPPERS = new Set(['sudo', 'doas', 'command', 'builtin', '\\builtin', 'time', 'nice', 'nohup', 'exec', 'env']);
|
|
310
|
+
// Programs that run ANOTHER command from their arguments or stdin: what they would remove is
|
|
311
|
+
// not on this command line in a form the parse can read, so a removal word inside one asks.
|
|
312
|
+
const INDIRECT = new Set(['bash', 'sh', 'zsh', 'dash', 'ksh', 'fish', 'pwsh', 'powershell', 'cmd',
|
|
313
|
+
'eval', 'xargs', 'find', 'parallel', 'watch', 'invoke-expression', 'iex']);
|
|
314
|
+
|
|
315
|
+
const OPAQUE = '\u0000';
|
|
316
|
+
const GIT_TIMEOUT_MS = 5000;
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* `git -C <dir> …` — THE spawn. Returns `{ status, stdout, stderr }`; throws `Unverified` when
|
|
320
|
+
* git could not run to completion (missing, timed out, output too large), so the caller asks.
|
|
321
|
+
*/
|
|
322
|
+
function git(dir, args) {
|
|
323
|
+
/** @type {Record<string, string | undefined>} */
|
|
324
|
+
const env = { ...process.env, GIT_OPTIONAL_LOCKS: '0', GIT_TERMINAL_PROMPT: '0' };
|
|
325
|
+
delete env.GIT_DIR; delete env.GIT_WORK_TREE; delete env.GIT_INDEX_FILE;
|
|
326
|
+
const r = spawnSync('git', ['-C', dir, '-c', 'core.fsmonitor=false', ...args], {
|
|
327
|
+
encoding: 'utf8', shell: false, windowsHide: true, timeout: GIT_TIMEOUT_MS,
|
|
328
|
+
maxBuffer: 8 * 1024 * 1024, stdio: ['ignore', 'pipe', 'pipe'], env,
|
|
329
|
+
});
|
|
330
|
+
if (r.error || r.status === null) {
|
|
331
|
+
unverified(`git ${args[0]} did not finish (${r.error ? /** @type {any} */ (r.error).code || r.error.message : 'killed'})`);
|
|
332
|
+
}
|
|
333
|
+
return r;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const firstLine = (s) => String(s || '').trim().split(/\r?\n/)[0] || 'no output';
|
|
337
|
+
|
|
338
|
+
/** A raw token with its quotes removed, or `OPAQUE`-marked when the shell would rewrite it. */
|
|
339
|
+
function word(raw) {
|
|
340
|
+
const opaque = /[`$]|^~|[{}]/.test(raw);
|
|
341
|
+
const s = raw.replace(/"([^"]*)"?|'([^']*)'?/g, (_, a, b) => a ?? b ?? '');
|
|
342
|
+
return opaque ? `${OPAQUE}${s}` : s;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** The segment's words, redirections dropped (`2>/dev/null`, `> out.log`) — they are not operands. */
|
|
346
|
+
function words(rawTokens) {
|
|
347
|
+
const out = [];
|
|
348
|
+
for (let i = 0; i < rawTokens.length; i++) {
|
|
349
|
+
const t = rawTokens[i];
|
|
350
|
+
if (/^(?:\d*|&)(?:>>?|<)&?$/.test(t)) { i += 1; continue; } // a bare operator: drop its target too
|
|
351
|
+
if (/^(?:\d*|&)(?:>>?|<)/.test(t)) continue;
|
|
352
|
+
out.push(word(t));
|
|
353
|
+
}
|
|
354
|
+
return out;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** `p` resolved against `dir`. On Windows a Git Bash `/c/…` path maps to `C:/…`; any other POSIX root cannot. */
|
|
358
|
+
function resolveIn(dir, p) {
|
|
359
|
+
if (p.includes(OPAQUE)) unverified(`\`${p.replace(OPAQUE, '')}\` expands in the shell`);
|
|
360
|
+
let q = p;
|
|
361
|
+
if (process.platform === 'win32') {
|
|
362
|
+
const m = /^\/([a-zA-Z])(\/.*)?$/.exec(q);
|
|
363
|
+
if (m) q = `${m[1]}:${m[2] || '/'}`;
|
|
364
|
+
else if (/^\/(?!\/)/.test(q)) unverified(`${p} is a POSIX path this host cannot map`);
|
|
365
|
+
}
|
|
366
|
+
if (!dir && !path.isAbsolute(q)) unverified('the working directory is unknown after a `cd` the gate cannot follow');
|
|
367
|
+
return path.resolve(dir || '', q);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function repoRoot(dir) {
|
|
371
|
+
if (!dir) unverified('the working directory is unknown after a `cd` the gate cannot follow');
|
|
372
|
+
return gitRootOf(dir) || unverified(`${dir} is not inside a git repository`);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** Ask if `git status` shows a tracked change (staged or not) under `paths` (all when empty). */
|
|
376
|
+
function dirty(dir, paths, act) {
|
|
377
|
+
for (const p of paths) if (p.includes(OPAQUE)) unverified(`\`${p.replace(OPAQUE, '')}\` expands in the shell`);
|
|
378
|
+
repoRoot(dir);
|
|
379
|
+
const r = git(dir, ['status', '--porcelain', '-z', '--untracked-files=no', '--', ...paths]);
|
|
380
|
+
if (r.status !== 0) unverified(`git status failed: ${firstLine(r.stderr)}`);
|
|
381
|
+
const first = r.stdout.split('\0').find(Boolean);
|
|
382
|
+
return first ? `${act} would discard uncommitted changes (${first.slice(3)})` : null;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const isCluster = (a) => /^-[a-zA-Z]+$/.test(a);
|
|
386
|
+
const hasShort = (a, c) => isCluster(a) && a.includes(c);
|
|
387
|
+
|
|
388
|
+
/** `git push` — asks only when a protected branch would be force-pushed or deleted. */
|
|
389
|
+
function pushAct(rest, dir) {
|
|
390
|
+
let force = false; let del = false; let all = false; let mirror = false; let prune = false;
|
|
391
|
+
const pos = [];
|
|
392
|
+
for (let i = 0; i < rest.length; i++) {
|
|
393
|
+
const a = rest[i];
|
|
394
|
+
if (a === '--') { pos.push(...rest.slice(i + 1)); break; }
|
|
395
|
+
if (['-o', '--push-option', '--repo', '--receive-pack', '--exec'].includes(a)) { i += 1; continue; }
|
|
396
|
+
if (a.startsWith('--')) {
|
|
397
|
+
if (/^--force(?:-with-lease)?(?:=|$)/.test(a)) force = true;
|
|
398
|
+
else if (a === '--delete') del = true;
|
|
399
|
+
else if (a === '--all' || a === '--branches') all = true;
|
|
400
|
+
else if (a === '--mirror') mirror = true;
|
|
401
|
+
else if (a === '--prune') prune = true;
|
|
402
|
+
continue;
|
|
403
|
+
}
|
|
404
|
+
if (isCluster(a)) { if (a.includes('f')) force = true; if (a.includes('d')) del = true; continue; }
|
|
405
|
+
pos.push(a);
|
|
406
|
+
}
|
|
407
|
+
const specs = pos.slice(1);
|
|
408
|
+
const forcedSpec = (s) => force || del || s.startsWith('+') || s.startsWith(':');
|
|
409
|
+
if (!force && !del && !mirror && !prune && !specs.some(forcedSpec)) return null;
|
|
410
|
+
if (mirror) return '`git push --mirror` force-pushes and deletes every remote branch to match this repository';
|
|
411
|
+
if (prune) return '`git push --prune` deletes every remote branch with no local counterpart';
|
|
412
|
+
if (all && force) return 'a forced `git push --all` force-pushes every branch, protected ones included';
|
|
413
|
+
const root = repoRoot(dir);
|
|
414
|
+
const protectedSet = protectedBranches(root);
|
|
415
|
+
const verb = del ? 'delete' : 'force-push';
|
|
416
|
+
let targets;
|
|
417
|
+
if (specs.length) {
|
|
418
|
+
targets = [];
|
|
419
|
+
for (const spec of specs) {
|
|
420
|
+
if (spec.includes(OPAQUE)) unverified(`the refspec \`${spec.replace(OPAQUE, '')}\` expands in the shell`);
|
|
421
|
+
if (!forcedSpec(spec)) continue;
|
|
422
|
+
const s = spec.replace(/^\+/, '');
|
|
423
|
+
const colon = s.indexOf(':');
|
|
424
|
+
if (!del && colon === 0) {
|
|
425
|
+
const dst = s.slice(1).replace(/^refs\/heads\//, '');
|
|
426
|
+
if (protectedSet.has(dst)) return `\`git push ${spec}\` would delete the protected branch ${dst}`;
|
|
427
|
+
continue;
|
|
428
|
+
}
|
|
429
|
+
targets.push(del || colon < 0 ? s : s.slice(colon + 1));
|
|
430
|
+
}
|
|
431
|
+
} else {
|
|
432
|
+
targets = defaultPushTargets(dir);
|
|
433
|
+
}
|
|
434
|
+
for (let t of targets) {
|
|
435
|
+
if (t === 'HEAD' || t === '@') t = currentBranch(dir) || unverified('HEAD is detached, so the pushed branch is unknown');
|
|
436
|
+
t = t.replace(/^refs\/heads\//, '');
|
|
437
|
+
if (t.startsWith('refs/')) continue; // a tag or another namespace, not a branch
|
|
438
|
+
if (/[*?]/.test(t)) unverified(`the refspec target \`${t}\` is a pattern`);
|
|
439
|
+
if (protectedSet.has(t)) return `\`git push\` would ${verb} the protected branch ${t}`;
|
|
440
|
+
}
|
|
441
|
+
return null;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* What a refspec-less forced push targets: the current branch (`simple`/`current`) and its
|
|
446
|
+
* upstream (`upstream`). `matching` pushes every branch and a `remote.*.push` rewrites the
|
|
447
|
+
* target — neither is something this read can bound, so both ask.
|
|
448
|
+
*/
|
|
449
|
+
function defaultPushTargets(dir) {
|
|
450
|
+
const cur = currentBranch(dir) || unverified('HEAD is detached, so the pushed branch is unknown');
|
|
451
|
+
const esc = cur.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
452
|
+
const r = git(dir, ['config', '--get-regexp', `^(push\\.default|remote\\..*\\.push|branch\\.${esc}\\.merge)$`]);
|
|
453
|
+
if (r.status !== 0 && r.status !== 1) unverified(`git config failed: ${firstLine(r.stderr)}`);
|
|
454
|
+
const targets = [cur];
|
|
455
|
+
for (const line of r.stdout.split(/\r?\n/).filter(Boolean)) {
|
|
456
|
+
const [key, value = ''] = line.split(/\s+/, 2);
|
|
457
|
+
if (/^remote\..*\.push$/i.test(key)) unverified(`${key} rewrites what a bare push targets`);
|
|
458
|
+
if (/^push\.default$/i.test(key) && value === 'matching') unverified('push.default=matching pushes every branch');
|
|
459
|
+
if (/\.merge$/i.test(key)) targets.push(value);
|
|
460
|
+
}
|
|
461
|
+
return targets;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* The checked-out branch when it is protected, null when HEAD is detached or unreadable, and
|
|
466
|
+
* undefined when it is a known unprotected branch — read off disk, so a feature branch passes the
|
|
467
|
+
* protected-branch-only forms below without running git (user ruling, 2026-10-17).
|
|
468
|
+
*/
|
|
469
|
+
function protectedHead(dir) {
|
|
470
|
+
const cur = currentBranch(dir);
|
|
471
|
+
return cur && !protectedBranches(repoRoot(dir)).has(cur) ? undefined : cur;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/** `lost` on the protected branch `cur`; an unknown HEAD cannot be cleared as unprotected, so it asks. */
|
|
475
|
+
function onProtected(cur, lost) {
|
|
476
|
+
if (!lost) return null;
|
|
477
|
+
if (!cur) unverified('HEAD is detached or unreadable, so the gate cannot tell whether a protected branch is checked out');
|
|
478
|
+
return `${lost} on the protected branch ${cur}`;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* `branch -f`, `checkout -B`, `switch -C` moving `name` to `start` (HEAD when absent): stops when
|
|
483
|
+
* `name` is protected and its old tip holds a commit neither `start` nor any remote ref reaches.
|
|
484
|
+
* A branch that does not exist yet is created, which loses nothing.
|
|
485
|
+
*/
|
|
486
|
+
function forceMove(dir, name, start, act) {
|
|
487
|
+
if (name === undefined) return null; // git refuses the command on its own
|
|
488
|
+
if (name.includes(OPAQUE)) unverified(`the branch name \`${name.replace(OPAQUE, '')}\` expands in the shell`);
|
|
489
|
+
if (!protectedBranches(repoRoot(dir)).has(name)) return null;
|
|
490
|
+
const tip = git(dir, ['rev-parse', '-q', '--verify', `refs/heads/${name}`]);
|
|
491
|
+
if (tip.status === 1) return null;
|
|
492
|
+
if (tip.status !== 0) unverified(`cannot read the branch ${name}: ${firstLine(tip.stderr)}`);
|
|
493
|
+
const to = start ?? 'HEAD';
|
|
494
|
+
if (to.includes(OPAQUE)) unverified(`the start point \`${to.replace(OPAQUE, '')}\` expands in the shell`);
|
|
495
|
+
const r = git(dir, ['rev-list', '-n1', `refs/heads/${name}`, '--not', to, '--remotes']);
|
|
496
|
+
if (r.status !== 0) unverified(`cannot compare ${name} with ${to}: ${firstLine(r.stderr)}`);
|
|
497
|
+
return r.stdout.trim() ? `${act} ${name} would strand commits no remote and not ${to} holds` : null;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* `git checkout` with no `--` and no force: `-B <protected>` force-moves it; otherwise, on a
|
|
502
|
+
* protected branch, positionals naming files (the first one is not a commit, or follows one)
|
|
503
|
+
* discard their changes. A lone commit-ish is a branch switch, which refuses to overwrite work.
|
|
504
|
+
*/
|
|
505
|
+
function checkoutAct(rest, dir) {
|
|
506
|
+
const pos = [];
|
|
507
|
+
let reset;
|
|
508
|
+
for (let i = 0; i < rest.length; i++) {
|
|
509
|
+
const a = rest[i];
|
|
510
|
+
if (a === '-b' || a === '--orphan') return null; // a new branch: additive
|
|
511
|
+
if (a === '-B') { reset = rest[i + 1]; i += 1; continue; }
|
|
512
|
+
if (a.startsWith('--pathspec-from-file')) unverified('checkout reads its paths from a file');
|
|
513
|
+
if (!a.startsWith('-')) pos.push(a);
|
|
514
|
+
}
|
|
515
|
+
if (reset !== undefined) return forceMove(dir, reset, pos[0], '`git checkout -B`');
|
|
516
|
+
if (!pos.length) return null;
|
|
517
|
+
const cur = protectedHead(dir);
|
|
518
|
+
if (cur === undefined) return null;
|
|
519
|
+
let paths = pos;
|
|
520
|
+
if (!pos[0].includes(OPAQUE) && git(dir, ['rev-parse', '-q', '--verify', `${pos[0]}^{commit}`]).status === 0) {
|
|
521
|
+
if (pos.length === 1) return null;
|
|
522
|
+
paths = pos.slice(1);
|
|
523
|
+
}
|
|
524
|
+
return onProtected(cur, dirty(dir, paths, '`git checkout <paths>`'));
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/** `git branch` — a forced delete asks for a protected branch or one whose commits live nowhere else. */
|
|
528
|
+
function branchAct(rest, dir) {
|
|
529
|
+
const opts = rest.filter((a) => a.startsWith('-'));
|
|
530
|
+
const del = opts.some((a) => a === '--delete' || a === '-D' || hasShort(a, 'd') || hasShort(a, 'D'));
|
|
531
|
+
const force = opts.some((a) => a === '--force' || a === '-D' || hasShort(a, 'f') || hasShort(a, 'D'));
|
|
532
|
+
if (!force) return null; // an un-forced delete refuses an unmerged branch on its own
|
|
533
|
+
const names = rest.filter((a) => !a.startsWith('-'));
|
|
534
|
+
if (!del) {
|
|
535
|
+
// `branch -f <name> [<start>]`; a forced rename or copy (-M, -C) is another form.
|
|
536
|
+
if (opts.some((a) => a === '--move' || a === '--copy' || [...'mMcC'].some((c) => hasShort(a, c)))) return null;
|
|
537
|
+
return forceMove(dir, names[0], names[1], '`git branch -f`');
|
|
538
|
+
}
|
|
539
|
+
if (opts.some((a) => a === '-r' || a === '--remotes')) return null; // a remote-tracking ref; `fetch` restores it
|
|
540
|
+
const root = repoRoot(dir);
|
|
541
|
+
const protectedSet = protectedBranches(root);
|
|
542
|
+
for (const n of names) {
|
|
543
|
+
if (n.includes(OPAQUE)) unverified(`the branch name \`${n.replace(OPAQUE, '')}\` expands in the shell`);
|
|
544
|
+
if (protectedSet.has(n)) return `\`git branch -D ${n}\` deletes the protected branch ${n}`;
|
|
545
|
+
// Empty output: every commit of `n` is reachable from a remote ref or from HEAD.
|
|
546
|
+
const r = git(dir, ['rev-list', '-n1', `refs/heads/${n}`, '--not', '--remotes', 'HEAD']);
|
|
547
|
+
if (r.status !== 0) unverified(`cannot read the branch ${n}: ${firstLine(r.stderr)}`);
|
|
548
|
+
if (r.stdout.trim()) return `\`git branch -D ${n}\` deletes commits no remote and no HEAD holds`;
|
|
549
|
+
}
|
|
550
|
+
return null;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/** `git clean` — forced, it asks when `git clean -n` with the same selection lists anything. */
|
|
554
|
+
function cleanAct(rest, dir) {
|
|
555
|
+
const forced = rest.some((a) => a === '--force' || hasShort(a, 'f'));
|
|
556
|
+
if (!forced) return null;
|
|
557
|
+
const dry = [];
|
|
558
|
+
for (const a of rest) {
|
|
559
|
+
if (a.includes(OPAQUE)) unverified(`\`${a.replace(OPAQUE, '')}\` expands in the shell`);
|
|
560
|
+
if (a === '--force' || a === '--interactive') continue;
|
|
561
|
+
if (isCluster(a)) {
|
|
562
|
+
const kept = a.replace(/[fin]/g, '');
|
|
563
|
+
if (kept !== '-') dry.push(kept);
|
|
564
|
+
continue;
|
|
565
|
+
}
|
|
566
|
+
dry.push(a);
|
|
567
|
+
}
|
|
568
|
+
repoRoot(dir);
|
|
569
|
+
const r = git(dir, ['clean', '-n', ...dry]);
|
|
570
|
+
if (r.status !== 0) unverified(`git clean -n failed: ${firstLine(r.stderr)}`);
|
|
571
|
+
const first = firstLine(r.stdout);
|
|
572
|
+
return r.stdout.trim() ? `\`git clean\` would delete untracked files (${first.replace(/^Would remove /, '')})` : null;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/** One `git …` segment: the verdict for its verb, or null. */
|
|
576
|
+
function gitAct(args, dir, always) {
|
|
577
|
+
let d = dir;
|
|
578
|
+
let i = 0;
|
|
579
|
+
let redirected = false;
|
|
580
|
+
for (; i < args.length; i++) {
|
|
581
|
+
const a = args[i];
|
|
582
|
+
// A `-C` the gate cannot resolve leaves the directory unknown; only a destructive verb asks for it.
|
|
583
|
+
if (a === '-C') { try { d = resolveIn(d, args[i + 1] ?? OPAQUE); } catch { d = null; } i += 1; continue; }
|
|
584
|
+
if (a === '-c') { i += 1; continue; }
|
|
585
|
+
if (/^--(?:git-dir|work-tree)(?:=|$)/.test(a)) { redirected = true; if (!a.includes('=')) i += 1; continue; }
|
|
586
|
+
if (a.startsWith('-')) continue;
|
|
587
|
+
break;
|
|
588
|
+
}
|
|
589
|
+
const verb = args[i];
|
|
590
|
+
const rest = args.slice(i + 1);
|
|
591
|
+
const act = () => {
|
|
592
|
+
switch (verb) {
|
|
593
|
+
case 'reset':
|
|
594
|
+
return rest.includes('--hard') ? dirty(d, [], '`git reset --hard`') : null;
|
|
595
|
+
case 'checkout': {
|
|
596
|
+
const forced = rest.some((a) => a === '--force' || hasShort(a, 'f')) && dirty(d, [], '`git checkout --force`');
|
|
597
|
+
if (forced) return forced;
|
|
598
|
+
const dd = rest.indexOf('--');
|
|
599
|
+
if (dd < 0) return checkoutAct(rest, d);
|
|
600
|
+
const paths = rest.slice(dd + 1);
|
|
601
|
+
return paths.length ? dirty(d, paths, '`git checkout --`') : null;
|
|
602
|
+
}
|
|
603
|
+
case 'switch': {
|
|
604
|
+
const c = rest.findIndex((a) => a === '-C' || a === '--force-create');
|
|
605
|
+
const pos = c < 0 ? [] : rest.slice(c + 1).filter((a) => !a.startsWith('-'));
|
|
606
|
+
return (c >= 0 && forceMove(d, pos[0], pos[1], '`git switch -C`'))
|
|
607
|
+
|| (rest.some((a) => a === '--force' || a === '--discard-changes' || hasShort(a, 'f'))
|
|
608
|
+
? dirty(d, [], '`git switch --discard-changes`') : null);
|
|
609
|
+
}
|
|
610
|
+
case 'rm': {
|
|
611
|
+
// Only a forced removal discards changes: git refuses a modified file without -f, and
|
|
612
|
+
// `--cached` or `-n` keep the file. Protected branch only (user ruling, 2026-10-17).
|
|
613
|
+
if (!rest.some((a) => a === '--force' || hasShort(a, 'f'))) return null;
|
|
614
|
+
if (rest.some((a) => a === '--cached' || a === '--dry-run' || hasShort(a, 'n'))) return null;
|
|
615
|
+
if (rest.some((a) => a.startsWith('--pathspec-from-file'))) unverified('git rm reads its paths from a file');
|
|
616
|
+
const paths = rest.filter((a) => !a.startsWith('-'));
|
|
617
|
+
const cur = paths.length ? protectedHead(d) : undefined;
|
|
618
|
+
return cur === undefined ? null : onProtected(cur, dirty(d, paths, '`git rm -f`'));
|
|
619
|
+
}
|
|
620
|
+
case 'restore': {
|
|
621
|
+
const staged = rest.some((a) => a === '--staged' || hasShort(a, 'S'));
|
|
622
|
+
const worktree = rest.some((a) => a === '--worktree' || hasShort(a, 'W'));
|
|
623
|
+
if (staged && !worktree) return null; // unstaging only: the change stays in the tree
|
|
624
|
+
if (rest.some((a) => a.startsWith('--pathspec-from-file'))) unverified('restore reads its paths from a file');
|
|
625
|
+
const paths = [];
|
|
626
|
+
for (let j = 0; j < rest.length; j++) {
|
|
627
|
+
const a = rest[j];
|
|
628
|
+
if (a === '-s' || a === '--source') { j += 1; continue; }
|
|
629
|
+
if (a === '--') continue;
|
|
630
|
+
if (!a.startsWith('-')) paths.push(a);
|
|
631
|
+
}
|
|
632
|
+
return paths.length ? dirty(d, paths, '`git restore`') : null;
|
|
633
|
+
}
|
|
634
|
+
case 'clean':
|
|
635
|
+
return cleanAct(rest, d);
|
|
636
|
+
case 'stash': {
|
|
637
|
+
const sub = rest.find((a) => !a.startsWith('-'));
|
|
638
|
+
if (sub !== 'drop' && sub !== 'clear') return null;
|
|
639
|
+
return stashExists(repoRoot(d)) ? `\`git stash ${sub}\` deletes stashed work` : null;
|
|
640
|
+
}
|
|
641
|
+
case 'reflog':
|
|
642
|
+
return always && rest.includes('expire') ? '`git reflog expire` deletes the reflog recovery depends on' : null;
|
|
643
|
+
case 'gc':
|
|
644
|
+
return always && rest.some((a) => a.startsWith('--prune'))
|
|
645
|
+
? '`git gc --prune` deletes the unreachable commits recovery depends on' : null;
|
|
646
|
+
case 'branch':
|
|
647
|
+
return branchAct(rest, d);
|
|
648
|
+
case 'push':
|
|
649
|
+
return pushAct(rest, d);
|
|
650
|
+
default:
|
|
651
|
+
return null;
|
|
652
|
+
}
|
|
653
|
+
};
|
|
654
|
+
const verdict = act();
|
|
655
|
+
if (verdict && redirected) return `${verdict} (and --git-dir/--work-tree points it at a repository the gate did not read)`;
|
|
656
|
+
return verdict;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
/** One removal segment: asks unless every operand is tracked AND unmodified in a git work tree. */
|
|
660
|
+
function rmAct(prog, args, dir) {
|
|
661
|
+
const targets = [];
|
|
662
|
+
let opts = true;
|
|
663
|
+
for (let i = 0; i < args.length; i++) {
|
|
664
|
+
const a = args[i];
|
|
665
|
+
if (opts && a === '--') { opts = false; continue; }
|
|
666
|
+
if (opts && /^-(?:path|literalpath)$/i.test(a)) { targets.push(args[i + 1] ?? OPAQUE); i += 1; continue; }
|
|
667
|
+
const bound = opts && /^-(?:path|literalpath)[:=](.+)$/i.exec(a);
|
|
668
|
+
if (bound) { targets.push(bound[1]); continue; }
|
|
669
|
+
if (opts && /^-(?:filter|include|exclude)(?:[:=]|$)/i.test(a)) unverified(`\`${prog} ${a}\` selects files the gate cannot list`);
|
|
670
|
+
if (opts && a.length > 1 && a.startsWith('-')) continue;
|
|
671
|
+
if (opts && CMD_SWITCH_PROGS.has(prog) && /^\/[a-zA-Z?]$/.test(a)) continue;
|
|
672
|
+
targets.push(a);
|
|
673
|
+
}
|
|
674
|
+
const groups = new Map();
|
|
675
|
+
for (const t of targets) {
|
|
676
|
+
const shown = t.replace(OPAQUE, '');
|
|
677
|
+
if (t.includes(OPAQUE) || /[*?[\],]/.test(t)) unverified(`\`${shown}\` expands in the shell, so the gate cannot list what it names`);
|
|
678
|
+
const abs = resolveIn(dir, t);
|
|
679
|
+
if (abs.split(/[\\/]+/).includes('.git')) return `\`${prog} ${shown}\` deletes git's own data`;
|
|
680
|
+
// Nothing there, nothing lost: `rm -f build.log` on a clean tree is routine cleanup.
|
|
681
|
+
try { lstatSync(abs); } catch (e) {
|
|
682
|
+
if (e && e.code === 'ENOENT') continue;
|
|
683
|
+
unverified(`cannot read ${shown}: ${e && e.code}`);
|
|
684
|
+
}
|
|
685
|
+
const root = gitRootOf(abs);
|
|
686
|
+
if (!root) return `\`${prog} ${shown}\` deletes a path outside any git repository, which nothing could restore`;
|
|
687
|
+
if (path.resolve(root) === path.resolve(abs)) return `\`${prog} ${shown}\` deletes a whole repository or worktree`;
|
|
688
|
+
const rel = path.relative(root, abs).split(path.sep).join('/');
|
|
689
|
+
if (!groups.has(root)) groups.set(root, []);
|
|
690
|
+
groups.get(root).push({ shown, rel });
|
|
691
|
+
}
|
|
692
|
+
const fold = (s) => (process.platform === 'win32' ? s.toLowerCase() : s);
|
|
693
|
+
for (const [root, list] of groups) {
|
|
694
|
+
const r = git(root, ['ls-files', '-z', '-t', '--cached', '--others', '--modified', '--directory',
|
|
695
|
+
'--', ...list.map((x) => `:(literal)${x.rel}`)]);
|
|
696
|
+
if (r.status !== 0) unverified(`git ls-files failed: ${firstLine(r.stderr)}`);
|
|
697
|
+
const entries = r.stdout.split('\0').filter(Boolean).map((e) => ({ tag: e[0], p: fold(e.slice(2)) }));
|
|
698
|
+
for (const { shown, rel } of list) {
|
|
699
|
+
const key = fold(rel);
|
|
700
|
+
const mine = entries.filter((e) => e.p === key || e.p.startsWith(`${key}/`) || e.p === `${key}/`);
|
|
701
|
+
if (!mine.length) return `\`${prog} ${shown}\` deletes something git does not track, which nothing could restore`;
|
|
702
|
+
const bad = mine.find((e) => e.tag !== 'H');
|
|
703
|
+
if (bad) {
|
|
704
|
+
const what = bad.tag === '?' ? 'untracked or ignored' : 'modified or otherwise not restorable';
|
|
705
|
+
return `\`${prog} ${shown}\` deletes ${what} files (${bad.p}), which git cannot restore`;
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
return null;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
/** The program word, stripped of its path and `.exe`, lowercased (PowerShell and cmd are case-blind). */
|
|
713
|
+
const progOf = (w) => w.split(/[\\/]/).pop().toLowerCase().replace(/\.exe$/, '');
|
|
714
|
+
|
|
715
|
+
/**
|
|
716
|
+
* The verdict for a whole command, given its segments as RAW token arrays (quotes still on —
|
|
717
|
+
* `js/hosts/hooks.mjs` cuts and tokenises with the same lexer `rootScan` uses, so a quoted
|
|
718
|
+
* `;` or a heredoc body is never a segment). Returns the reason it would lose data, or null
|
|
719
|
+
* when it provably would not. What it cannot settle — a glob, a git that fails — is a "could not
|
|
720
|
+
* verify" reason, never a throw. A BUG still throws, on purpose: `guardGate` turns it into a
|
|
721
|
+
* `gate-error` (an ask on Claude, a warning on Bob) and the OpenCode guard logs and allows it —
|
|
722
|
+
* each host's ceiling for a crashed gate (docs/adr/0007-gates-fail-closed-per-host-ceiling.md).
|
|
723
|
+
* `always: false` skips the two acts that lose data in every state (`reflog expire`, `gc --prune`)
|
|
724
|
+
* — the OpenCode guard passes it, because a static `permission.bash` glob already asks for those.
|
|
725
|
+
*/
|
|
726
|
+
function lossReason(segments, cwd, { always = true } = {}) {
|
|
727
|
+
let dir = cwd;
|
|
728
|
+
try {
|
|
729
|
+
for (const raw of segments) {
|
|
730
|
+
let w = words(raw);
|
|
731
|
+
while (w.length && (/^\w+=/.test(w[0]) || WRAPPERS.has(progOf(w[0])))) {
|
|
732
|
+
const wrapper = progOf(w[0]);
|
|
733
|
+
w = w.slice(1);
|
|
734
|
+
// A wrapper's own flags (`sudo -u x`, `env -i`) are not the program.
|
|
735
|
+
if (wrapper !== 'command' && wrapper !== 'exec') while (w.length && w[0].startsWith('-')) w = w.slice(1);
|
|
736
|
+
}
|
|
737
|
+
if (!w.length) continue;
|
|
738
|
+
const prog = progOf(w[0]);
|
|
739
|
+
const args = w.slice(1);
|
|
740
|
+
if (CD_PROGS.has(prog)) {
|
|
741
|
+
const target = args.find((a) => !a.startsWith('-') && !/^\/d$/i.test(a));
|
|
742
|
+
try { dir = target && target !== '-' ? resolveIn(dir, target) : null; } catch { dir = null; }
|
|
743
|
+
continue;
|
|
744
|
+
}
|
|
745
|
+
let verdict = null;
|
|
746
|
+
if (prog === 'git') verdict = gitAct(args, dir, always);
|
|
747
|
+
else if (RM_PROGS.has(prog)) verdict = rmAct(prog, args, dir);
|
|
748
|
+
else if (INDIRECT.has(prog) && LOSS_CANDIDATE_RE.test(args.join(' '))) {
|
|
749
|
+
unverified(`\`${prog}\` runs a command the gate cannot read`);
|
|
750
|
+
}
|
|
751
|
+
if (verdict) return verdict;
|
|
752
|
+
}
|
|
753
|
+
return null;
|
|
754
|
+
} catch (e) {
|
|
755
|
+
if (!(e instanceof Unverified)) throw e;
|
|
756
|
+
return `the gate could not verify what this command would lose (${e.message})`;
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
// ---- loss twin: END
|
|
234
760
|
|
|
235
761
|
// A recursive scan rooted at a whole filesystem (Commands Must Return) → BLOCK. Twin of
|
|
236
762
|
// js/hosts/hooks.mjs's `rootScan` — keep in step; tests/plugins/guard.test.mjs diffs the
|
|
@@ -644,7 +1170,7 @@ export const GeneseedGuard = async (ctx) => {
|
|
|
644
1170
|
// blocks the whole call (see `pickPath` for why this is not a single path).
|
|
645
1171
|
const paths = pickPath(args, cwd)
|
|
646
1172
|
for (const p of paths) {
|
|
647
|
-
if (SECRET_RE.some((re) => re.test(p))) { await deny(`write to secret/key file ${p} (Sealed Secrets)`, "law-1"); return }
|
|
1173
|
+
if (!SECRETS_GATE_OFF && SECRET_RE.some((re) => re.test(p))) { await deny(`write to secret/key file ${p} (Sealed Secrets)`, "law-1"); return }
|
|
648
1174
|
if (SECRET_WARN_RE.some((re) => re.test(p))) log(`WARN: writing ${p} — keep secrets out of tracked files (Sealed Secrets)`)
|
|
649
1175
|
const sensor = protectedCheck(p)
|
|
650
1176
|
if (sensor) { await deny(`${p} is a protected check (${sensor === DONE_LIST ? "the done gate list itself" : `${sensor}, listed in ${SENSOR_LIST}`}) — a check the agent can edit is not a check (External Gate)`, "rigor-5"); return }
|
|
@@ -661,7 +1187,7 @@ export const GeneseedGuard = async (ctx) => {
|
|
|
661
1187
|
// FILE, not pooled — a patch touching `.env` AND an ordinary file must still catch a
|
|
662
1188
|
// secret in the ordinary file; only the hunk whose OWN path is `.env`-shaped is
|
|
663
1189
|
// exempt, same as the path WARN tier above.
|
|
664
|
-
for (const { path: cp, content } of pickAddedContentByPath(args, cwd)) {
|
|
1190
|
+
for (const { path: cp, content } of SECRETS_GATE_OFF ? [] : pickAddedContentByPath(args, cwd)) {
|
|
665
1191
|
if (SECRET_WARN_RE.some((re) => re.test(cp))) continue
|
|
666
1192
|
if (SECRET_CONTENT_RE.test(content)) {
|
|
667
1193
|
await deny(`${cp} would carry a credential-shaped string (Sealed Secrets) — secrets `
|
|
@@ -689,7 +1215,12 @@ export const GeneseedGuard = async (ctx) => {
|
|
|
689
1215
|
"search a specific directory instead: the project, node_modules, ~/.npm, %APPDATA%", "ops-2")
|
|
690
1216
|
return
|
|
691
1217
|
}
|
|
692
|
-
if (c &&
|
|
1218
|
+
if (c && !DELETION_GATE_OFF && LOSS_CANDIDATE_RE.test(c)) {
|
|
1219
|
+
// OpenCode's bash tool may run in `workdir`, resolved against the session directory.
|
|
1220
|
+
const wd = typeof args.workdir === "string" && args.workdir ? path.resolve(cwd, args.workdir) : cwd
|
|
1221
|
+
const loss = lossReason(scanSegments(c).map((seg) => seg.match(SCAN_TOKEN_RE) || []), wd, { always: false })
|
|
1222
|
+
if (loss) { await deny(`${loss} (Deletion Is Deliberate) — confirm with the user first`, "law-4"); return }
|
|
1223
|
+
}
|
|
693
1224
|
}
|
|
694
1225
|
} catch (err) {
|
|
695
1226
|
// Our own deny must propagate; any inspection error must never break a tool call.
|