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