geneseed 4.9.2 → 4.9.4
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 +21 -0
- package/README.md +1 -1
- package/SHIPPED.md +1 -1
- package/adapters/claude-code/README.md +1 -1
- package/adapters/opencode/plugins/geneseed-guard.js +125 -3
- package/docs/concepts/enforcement-table.md +1 -1
- package/docs/concepts/exclusions.md +1 -2
- package/docs/concepts/hooks.md +4 -2
- package/docs/guides/upgrade.md +1 -1
- package/docs/reference/plugin-guard.md +1 -1
- package/docs/understand/enforced-vs-asked.md +1 -1
- package/docs/understand/machine-claude.md +1 -1
- package/harness.config.json +1 -1
- package/js/hosts/hooks.mjs +15 -4
- package/js/hosts/loss.mjs +121 -2
- package/js/hosts/settings.mjs +1 -1
- package/js/inspect/checks-probe.mjs +1 -1
- package/js/inspect/cost.mjs +1 -1
- package/js/inspect/diff.mjs +28 -9
- package/js/web/api.mjs +5 -1
- package/package.json +1 -1
- package/src/laws/universal.md +13 -13
- package/web/dist/assets/{Activity-D7Igwuoo.js → Activity-C8sZVIUU.js} +1 -1
- package/web/dist/assets/{ActivityDetail-53rp05hx.js → ActivityDetail-Cc6vWUS4.js} +1 -1
- package/web/dist/assets/{Dashboard-BoEH5qyY.js → Dashboard-C5ATa3ow.js} +2 -2
- package/web/dist/assets/{Docs-BM56RCT8.js → Docs-tDW7i7mo.js} +1 -1
- package/web/dist/assets/{GreenhouseView-BTMvGVco.js → GreenhouseView-zk2Uzc3A.js} +1 -1
- package/web/dist/assets/{Installs-Dh1tB1NK.js → Installs-CB_rvbBP.js} +3 -2
- package/web/dist/assets/{JournalView-F4hlWTVB.js → JournalView-Bfj27XHC.js} +1 -1
- package/web/dist/assets/{Laws-iEheocNI.js → Laws-BA3CzUf5.js} +1 -1
- package/web/dist/assets/{Library-d4_uOGmC.js → Library-BWwiy9Vb.js} +1 -1
- package/web/dist/assets/{Loops-BbYeWl70.js → Loops-DaPk-ckL.js} +1 -1
- package/web/dist/assets/{OperatorHudView-Dki9Q6W3.js → OperatorHudView-CyiGhiHk.js} +1 -1
- package/web/dist/assets/{Personal-B_YQJjA_.js → Personal-Bwd27CmT.js} +1 -1
- package/web/dist/assets/{Tabs-CAl6oFwG.js → Tabs-BOD7Kaz1.js} +1 -1
- package/web/dist/assets/{drawable-BdTDG5oI.js → drawable-CyLe1gtW.js} +1 -1
- package/web/dist/assets/{index-BZgEGRbL.js → index-DQXG_mqo.js} +2 -2
- package/web/dist/index.html +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,27 @@ label. For the capability ↔ spec map, see [SHIPPED.md](SHIPPED.md).
|
|
|
8
8
|
|
|
9
9
|
## [Unreleased]
|
|
10
10
|
|
|
11
|
+
## [4.9.4] — 2026-10-18
|
|
12
|
+
|
|
13
|
+
Patch: `git reset --hard` asks only when it would abandon existing commits, not over uncommitted work.
|
|
14
|
+
|
|
15
|
+
### Upgrade notes
|
|
16
|
+
- Run `geneseed rebuild-all` once after updating so installed hooks, the OpenCode guard and Law IV text pick up the change.
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
- **`git reset --hard` asks only when it would abandon commits, not over uncommitted work** (user decision: what is in progress may break, what exists must be protected). A dirty tree reset with `git reset --hard` or `git reset --hard HEAD` now passes on every host. `git reset --hard <target>` asks (exit 2 on Bob, a block on OpenCode) when HEAD reaches a commit that neither `<target>` nor any remote branch holds — `HEAD~1` over an unpushed commit, `origin/main` behind local work — on any branch, and passes when those commits are pushed. A target the gate cannot resolve (`nosuchref`, `"$REF"`, `@{u}`) asks, saying it could not verify. Every other discard (`checkout -f`, `checkout -- <paths>`, `switch --discard-changes`, `restore`, `clean -f`, `stash drop`/`clear`) is unchanged, as are `reset --soft`/`--mixed`/`--keep`/`--merge`. Law IV (*Deletion Is Deliberate*) names the exception in both halves so the agent does not ask out of respect for the law. Run `geneseed rebuild-all` to pick it up.
|
|
20
|
+
|
|
21
|
+
## [4.9.3] — 2026-10-18
|
|
22
|
+
|
|
23
|
+
Patch: rm -rf / and other catastrophic commands are caught on every host even with the deletion gate off; a skill you replaced with a link reads as yours, not as an edit.
|
|
24
|
+
|
|
25
|
+
### Upgrade notes
|
|
26
|
+
- Run `geneseed rebuild-all` once after updating so installed hooks and OpenCode plugins pick up the changes.
|
|
27
|
+
|
|
28
|
+
### Fixed
|
|
29
|
+
- **`rm -rf /` asks on Claude Code, OpenClaude and Bob even with the deletion gate off.** These hosts had no catastrophic-command check of their own: a recursive removal of `/`, a drive root or the home directory was caught only by Law IV's real-loss check, so `--no-deletion-gate` let it through unasked. `git-gate` (and Bob's `tool-gate`) now run the catastrophic class before that switch and whatever it says: a recursive removal of `/`, `C:\`, `/c` or `~`/`$HOME` (`rm`, `rd`/`del /s`, `Remove-Item -Recurse`), `mkfs` on a device, `dd` over a raw disk, `format C:` and a fork bomb ask on Claude Code and OpenClaude and exit 2 on Bob, as `law-4`. It judges the program after the shell structure is stripped, so `sudo rm -rf /`, `true & rm -rf /`, `(rm -rf /)`, `rm -rf /;`, `bash -c 'rm -rf /'` and a heredoc or here-string fed to a shell (`bash <<'EOF'`) are caught, and a commit message or a heredoc fed to `cat` or `git commit -F -` that only names `rm -rf /` is not. The check is one definition shared with the OpenCode guard (the loss twin block, pinned byte for byte), which now also blocks `rm -rf C:\`, `rm -rf /;`, `rd /s/q C:\` and `bash -c 'rm -rf /'`; its own stricter text match stays on top (it still blocks a command that only quotes one, and any `Remove-Item -Recurse -Force`). Ordinary commands are unaffected.
|
|
30
|
+
- **A skill you replaced with a link to your own copy no longer reads as a hand edit forever.** The emit rightly refuses to write through a link, but `geneseed diff` then compared the rendered file with your linked one and reported it as "edited — review to back-port", which no rebuild could clear. A deployed path that is, or sits under, a link the emit refuses (a junction on Windows, a symlink on POSIX — the same test the emit uses) is now `linked`: `geneseed diff` lists it in its own group ("linked (yours, left alone)"), it is not drift, and it never goes into an improvements file. The console keeps it out of every drift figure: the Overview's "Hand edits" cell shows it as a muted "N linked" when it is all there is, and the Local edits page lists it apart, not selectable for Restore. A regular file you edited is still `edited`.
|
|
31
|
+
|
|
11
32
|
## [4.9.2] — 2026-10-18
|
|
12
33
|
|
|
13
34
|
Patch: the review pass — faster emits, console and Stop; Build and setup keep your gate switches; protected checks follow links both ways.
|
package/README.md
CHANGED
|
@@ -37,7 +37,7 @@ Want to read what it leaves behind before installing anything? [**geneseed-demo*
|
|
|
37
37
|
|
|
38
38
|
A hand-written instructions file is prose the model may or may not honour, copied into every repo and drifting in each one. Geneseed is a build, and that changes four things:
|
|
39
39
|
|
|
40
|
-
- **Laws are enforced, not suggested.** A force-push to `main`, a `reset --hard`
|
|
40
|
+
- **Laws are enforced, not suggested.** A force-push to `main`, a `reset --hard` that abandons unpushed commits, or a credential written into a tracked file is caught by a hook *before* the tool runs, in the host's own dialect — a prompt on Claude Code and OpenClaude, a hard block on OpenCode, exit 2 on Bob. The gates fail closed, and every catch is one line in a ledger that `geneseed status` counts.
|
|
41
41
|
- **One source, five targets.** Skills are byte-identical on every host; only the wiring differs. Every commit renders all 263 emit configurations, and a second emit into the same tree must change nothing.
|
|
42
42
|
- **Costs are measured, not guessed.** Zero runtime dependencies. The hook path loads in about 14 ms per tool call. The default *lean* footprint keeps the always-on context small and puts the full rationale one read away — the numbers are in [docs/token-footprint.md](docs/token-footprint.md).
|
|
43
43
|
- **It follows you.** Install once, globally; every repo inherits it. One `git pull` or `npm install -g geneseed@latest` rebuilds every active install.
|
package/SHIPPED.md
CHANGED
|
@@ -20,7 +20,7 @@ historical references, not files distributed with the repo.
|
|
|
20
20
|
> **On version numbers.** [`harness.config.json`](harness.config.json)'s `version` is a
|
|
21
21
|
> human-readable label only. The canonical identity of an installed harness is the
|
|
22
22
|
> **source fingerprint** stamped in `.geneseed-version` (see `geneseed version`), not the
|
|
23
|
-
> string. Current label: **4.9.
|
|
23
|
+
> string. Current label: **4.9.4**.
|
|
24
24
|
|
|
25
25
|
## Capabilities
|
|
26
26
|
|
|
@@ -19,7 +19,7 @@ absolute-path hooks instead — `geneseed setup` does that for you.) It:
|
|
|
19
19
|
destructive-git Rule. Drop the `process` pack (or exclude process 5) and the hook stays,
|
|
20
20
|
wired with `--no-consent`: commits and pushes pass without asking, while the
|
|
21
21
|
Law IV refusals (asked only on real data loss: a force-push to `main`, a `reset --hard`
|
|
22
|
-
|
|
22
|
+
that abandons unpushed commits, an `rm` git cannot undo) stay in every build unless the install is
|
|
23
23
|
built `--no-deletion-gate`, which appends that flag and stands the real-loss check down
|
|
24
24
|
(`--no-secrets-gate` does the same for `rule-gate`'s credential scan, `--no-done-gate` for
|
|
25
25
|
`learn`'s done check). The hook inspects
|
|
@@ -218,6 +218,11 @@ const SECRET_CONTENT_RE =
|
|
|
218
218
|
|
|
219
219
|
// Catastrophic, effectively irreversible shell → BLOCK. Both Unix and Windows shells,
|
|
220
220
|
// since OpenCode runs natively on Windows and the agent may emit cmd / PowerShell.
|
|
221
|
+
// The catastrophic class itself is `catastrophic()` in the loss twin block below — one definition,
|
|
222
|
+
// shared with the hook (js/hosts/loss.mjs), judged in program position and run whatever the
|
|
223
|
+
// deletion gate's switch says. This raw text match is OpenCode's own stricter layer on top of it:
|
|
224
|
+
// it also blocks a command that only quotes one (a heredoc fed to `cat` included) and any
|
|
225
|
+
// `Remove-Item -Recurse -Force`, whatever its target.
|
|
221
226
|
const SHELL_BLOCK_RE = [
|
|
222
227
|
// rm -rf /, ~, /*, ~/* — flags may be split (-r -f), long (--recursive), or end with
|
|
223
228
|
// `--`; the lookahead insists one of them recurses. The target must be the root or
|
|
@@ -572,6 +577,25 @@ function branchAct(rest, dir) {
|
|
|
572
577
|
return null;
|
|
573
578
|
}
|
|
574
579
|
|
|
580
|
+
/**
|
|
581
|
+
* `git reset --hard [<target>]` — asks only when it would abandon COMMITS: one HEAD reaches that
|
|
582
|
+
* neither `<target>` nor any remote ref does. The uncommitted work it discards passes: what is in
|
|
583
|
+
* progress may break, what exists must be protected (user decision, 2026-10-18). No target is
|
|
584
|
+
* HEAD itself, which abandons nothing.
|
|
585
|
+
*/
|
|
586
|
+
function resetHardAct(rest, dir) {
|
|
587
|
+
const dd = rest.indexOf('--');
|
|
588
|
+
const target = (dd < 0 ? rest : rest.slice(0, dd)).find((a) => !a.startsWith('-'));
|
|
589
|
+
if (target === undefined) return null;
|
|
590
|
+
if (target.includes(OPAQUE)) unverified(`the target \`${target.replace(OPAQUE, '')}\` expands in the shell`);
|
|
591
|
+
repoRoot(dir);
|
|
592
|
+
const t = git(dir, ['rev-parse', '-q', '--verify', `${target}^{commit}`]);
|
|
593
|
+
if (t.status !== 0) unverified(`cannot resolve ${target} to a commit`);
|
|
594
|
+
const r = git(dir, ['rev-list', '-n1', 'HEAD', '--not', t.stdout.trim(), '--remotes']);
|
|
595
|
+
if (r.status !== 0) unverified(`cannot compare HEAD with ${target}: ${firstLine(r.stderr)}`);
|
|
596
|
+
return r.stdout.trim() ? `\`git reset --hard ${target}\` would abandon commits no remote and not ${target} holds` : null;
|
|
597
|
+
}
|
|
598
|
+
|
|
575
599
|
/** `git clean` — forced, it asks when `git clean -n` with the same selection lists anything. */
|
|
576
600
|
function cleanAct(rest, dir) {
|
|
577
601
|
const forced = rest.some((a) => a === '--force' || hasShort(a, 'f'));
|
|
@@ -613,7 +637,7 @@ function gitAct(args, dir, always) {
|
|
|
613
637
|
const act = () => {
|
|
614
638
|
switch (verb) {
|
|
615
639
|
case 'reset':
|
|
616
|
-
return rest.includes('--hard') ?
|
|
640
|
+
return rest.includes('--hard') ? resetHardAct(rest, d) : null;
|
|
617
641
|
case 'checkout': {
|
|
618
642
|
const forced = rest.some((a) => a === '--force' || hasShort(a, 'f')) && dirty(d, [], '`git checkout --force`');
|
|
619
643
|
if (forced) return forced;
|
|
@@ -865,6 +889,104 @@ function lossReason(segments, cwd, { always = true, split } = {}) {
|
|
|
865
889
|
return `the gate could not verify what this command would lose (${e.message})`;
|
|
866
890
|
}
|
|
867
891
|
}
|
|
892
|
+
|
|
893
|
+
/**
|
|
894
|
+
* THE CATASTROPHIC CLASS — what no state can make safe: a recursive removal of a filesystem root,
|
|
895
|
+
* a drive root or the home directory, a device format or raw-disk overwrite, a fork bomb. Unlike
|
|
896
|
+
* `lossReason` it reads no state, and it stands whatever the deletion gate's switch says (user
|
|
897
|
+
* decision 2026-10-18): `--no-deletion-gate` silences the real-loss check, never this one. Judged
|
|
898
|
+
* in PROGRAM position, segment by segment after `stripStructure` and the wrappers, so `true & rm
|
|
899
|
+
* -rf /`, `sudo rm -rf /`, `(rm -rf /)` and `rm -rf /;` are caught and a commit message that only
|
|
900
|
+
* names `rm -rf /` is not. A shell or `eval` (`bash -c '…'`, `pwsh -Command …`) and a `$(…)` body
|
|
901
|
+
* are judged as commands of their own, and so is what a shell reads on stdin: a heredoc or
|
|
902
|
+
* here-string fed to `bash`/`sh`/`pwsh`/`cmd`… (`bash <<'EOF'` over `rm -rf /`). One fed to any
|
|
903
|
+
* other program (`cat <<EOF`, `git commit -F - <<EOF`) is data. The fork bomb spans `|`/`&`/`;`, so
|
|
904
|
+
* it is tested on the whole command. `split` cuts a command into raw-token segments, as for
|
|
905
|
+
* `lossReason`. Returns the offending text, or null.
|
|
906
|
+
*/
|
|
907
|
+
const CATASTROPHIC_CANDIDATE_RE = /(?:^|[^\w-])(?:rm|rmdir|rd|del|erase|remove-item|ri|mkfs(?:\.\w+)?|dd|format)(?:\.exe)?(?![\w-])|:\(\)/i;
|
|
908
|
+
const FORK_BOMB_RE = /:\(\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;\s*:/;
|
|
909
|
+
// `/`, a Git Bash or WSL drive (`/c`, `/mnt/c`), a Windows drive (`C:`, `C:\`), the home dir — globbed or not.
|
|
910
|
+
const ROOT_TARGET_RE = /^(?:\/|~[\\/]?|\$\{?HOME\}?[\\/]?|\/[a-z]\/?|\/mnt\/[a-z]\/?|[a-z]:[\\/]?)\*?$/i;
|
|
911
|
+
const RM_RECURSE_RE = /^(?:-[a-zA-Z]*[rR][a-zA-Z]*|--recursive)$/;
|
|
912
|
+
// PowerShell's `-Recurse` (or any prefix of it) and cmd.exe's `/s`, alone or glued (`/s/q`).
|
|
913
|
+
const PS_CMD_RECURSE_RE = /^(?:-r(?:e(?:c(?:u(?:r(?:s(?:e)?)?)?)?)?)?|(?:\/[a-z]+)*\/s(?:\/[a-z]+)*)$/i;
|
|
914
|
+
const leadingFlags = (w) => { let i = 0; while (i < w.length && /^[-/][\w-]+$/.test(w[i])) i += 1; return w.slice(i); };
|
|
915
|
+
// The programs that run what they read on stdin as commands.
|
|
916
|
+
const STDIN_SHELLS = new Set(['bash', 'sh', 'zsh', 'dash', 'ksh', 'fish', 'pwsh', 'powershell', 'cmd']);
|
|
917
|
+
// The same lexer as the splitter's (a comment, a quoted run, a heredoc opener, a newline), so the
|
|
918
|
+
// openers it finds are the ones left in the segments' tokens, in the same order.
|
|
919
|
+
const HEREDOC_SCAN_RE = /(?<![^\s;|&(])#[^\n]*|"[^"]*"?|'[^']*'?|(?<!<)<<(?!<)(-?)[ \t]*(["']?)([A-Za-z_]\w*)\2|\n/g;
|
|
920
|
+
/** Every heredoc body in `command`, in opener order; an unterminated one runs to the end, as in bash. */
|
|
921
|
+
function heredocBodies(command) {
|
|
922
|
+
const out = [];
|
|
923
|
+
const tags = [];
|
|
924
|
+
const re = new RegExp(HEREDOC_SCAN_RE);
|
|
925
|
+
let m;
|
|
926
|
+
while ((m = re.exec(command))) {
|
|
927
|
+
if (m[3]) { tags.push([m[1], m[3]]); continue; }
|
|
928
|
+
if (m[0] !== '\n' || !tags.length) continue;
|
|
929
|
+
let start = re.lastIndex;
|
|
930
|
+
while (tags.length) {
|
|
931
|
+
const [dash, tag] = tags.shift();
|
|
932
|
+
const body = [];
|
|
933
|
+
while (start < command.length) {
|
|
934
|
+
const end = command.indexOf('\n', start);
|
|
935
|
+
const line = command.slice(start, end < 0 ? command.length : end).replace(/\r$/, '');
|
|
936
|
+
start = end < 0 ? command.length : end + 1;
|
|
937
|
+
if ((dash ? line.replace(/^\t+/, '') : line) === tag) break;
|
|
938
|
+
body.push(line);
|
|
939
|
+
}
|
|
940
|
+
out.push(body.join('\n'));
|
|
941
|
+
}
|
|
942
|
+
re.lastIndex = start;
|
|
943
|
+
}
|
|
944
|
+
return out;
|
|
945
|
+
}
|
|
946
|
+
function catastrophic(command, split, depth = 0) {
|
|
947
|
+
if (!CATASTROPHIC_CANDIDATE_RE.test(command)) return null;
|
|
948
|
+
if (FORK_BOMB_RE.test(command)) return command.trim();
|
|
949
|
+
const bodies = command.includes('<<') ? heredocBodies(command) : [];
|
|
950
|
+
let next = 0;
|
|
951
|
+
for (const raw of split(command).map(stripStructure)) {
|
|
952
|
+
const text = raw.join(' ');
|
|
953
|
+
// What this segment feeds on stdin: each heredoc opener takes the next body, a here-string its word.
|
|
954
|
+
const fed = [];
|
|
955
|
+
raw.forEach((t, i) => {
|
|
956
|
+
if (t.startsWith('<<<')) fed.push(word(t.length > 3 ? t.slice(3) : raw[i + 1] || '').replace(OPAQUE, ''));
|
|
957
|
+
else if (t.startsWith('<<')) fed.push(bodies[next++] ?? '');
|
|
958
|
+
});
|
|
959
|
+
for (const body of depth < 4 ? substitutions(text) : []) {
|
|
960
|
+
const v = catastrophic(body, split, depth + 1);
|
|
961
|
+
if (v) return v;
|
|
962
|
+
}
|
|
963
|
+
let w = words(raw).map((t) => t.replace(OPAQUE, ''));
|
|
964
|
+
while (w.length && (/^\w+=/.test(w[0]) || WRAPPERS.has(progOf(w[0])))) {
|
|
965
|
+
w = /^\w+=/.test(w[0]) ? w.slice(1) : pastWrapper(progOf(w[0]), w.slice(1)) || leadingFlags(w.slice(1));
|
|
966
|
+
}
|
|
967
|
+
if (!w.length) continue;
|
|
968
|
+
const prog = progOf(w[0]);
|
|
969
|
+
for (const body of STDIN_SHELLS.has(prog) && depth < 4 ? fed : []) {
|
|
970
|
+
const v = catastrophic(body, split, depth + 1);
|
|
971
|
+
if (v) return v;
|
|
972
|
+
}
|
|
973
|
+
const args = w.slice(1).map((a) => a.replace(/^-(?:literal)?path[=:]/i, ''));
|
|
974
|
+
// cmd.exe's `/s` `/q` are switches, not the Git Bash drives `/s` `/q`.
|
|
975
|
+
const root = () => args.some((a) => ROOT_TARGET_RE.test(a) && !(CMD_SWITCH_PROGS.has(prog) && /^\/[a-z]$/i.test(a)));
|
|
976
|
+
let hit = false;
|
|
977
|
+
if (prog === 'rm') hit = args.some((a) => RM_RECURSE_RE.test(a)) && root();
|
|
978
|
+
else if (RM_PROGS.has(prog)) hit = args.some((a) => PS_CMD_RECURSE_RE.test(a)) && root();
|
|
979
|
+
else if (/^mkfs(?:\.\w+)?$/.test(prog)) hit = args.some((a) => a.startsWith('/dev/'));
|
|
980
|
+
else if (prog === 'dd') hit = args.some((a) => /^of=\/dev\/(?:sd|nvme|hd|disk)/.test(a));
|
|
981
|
+
else if (prog === 'format') hit = args.some((a) => /^[a-z]:/i.test(a));
|
|
982
|
+
else if (INDIRECT.has(prog) && depth < 4) {
|
|
983
|
+
const v = catastrophic(leadingFlags(w.slice(1)).join(' '), split, depth + 1);
|
|
984
|
+
if (v) return v;
|
|
985
|
+
}
|
|
986
|
+
if (hit) return text.trim();
|
|
987
|
+
}
|
|
988
|
+
return null;
|
|
989
|
+
}
|
|
868
990
|
// ---- loss twin: END
|
|
869
991
|
|
|
870
992
|
// A recursive scan rooted at a whole filesystem (Commands Must Return) → BLOCK. Twin of
|
|
@@ -1319,7 +1441,8 @@ export const GeneseedGuard = async (ctx) => {
|
|
|
1319
1441
|
// classes, and the shell check must still run after the write check.
|
|
1320
1442
|
if (hasAny(tool, SHELL_TOOLS)) {
|
|
1321
1443
|
const c = pickCommand(args)
|
|
1322
|
-
|
|
1444
|
+
const cut = (t) => scanSegments(t).map((seg) => seg.match(SCAN_TOKEN_RE) || [])
|
|
1445
|
+
if (c && (SHELL_BLOCK_RE.some((re) => re.test(c)) || catastrophic(c, cut))) { await deny(`catastrophic command (Deletion Is Deliberate): ${c.slice(0, 80)}`, "law-4"); return }
|
|
1323
1446
|
const scan = c && rootScan(c)
|
|
1324
1447
|
if (scan) {
|
|
1325
1448
|
await deny(`\`${scan.slice(0, 80)}\` recursively scans a whole filesystem root (Commands Must Return) — ` +
|
|
@@ -1329,7 +1452,6 @@ export const GeneseedGuard = async (ctx) => {
|
|
|
1329
1452
|
if (c && !DELETION_GATE_OFF && LOSS_CANDIDATE_RE.test(c)) {
|
|
1330
1453
|
// OpenCode's bash tool may run in `workdir`, resolved against the session directory.
|
|
1331
1454
|
const wd = typeof args.workdir === "string" && args.workdir ? path.resolve(cwd, args.workdir) : cwd
|
|
1332
|
-
const cut = (t) => scanSegments(t).map((seg) => seg.match(SCAN_TOKEN_RE) || [])
|
|
1333
1455
|
const loss = lossReason(cut(c), wd, { always: false, split: cut })
|
|
1334
1456
|
if (loss) { await deny(`${loss} (Deletion Is Deliberate) — confirm with the user first`, "law-4"); return }
|
|
1335
1457
|
}
|
|
@@ -22,7 +22,7 @@ silently guarding the wrong rule after a renumber. See [ADR 0005](../adr/0005-st
|
|
|
22
22
|
| id | Pack | Claude Code | OpenClaude | OpenCode | Bob |
|
|
23
23
|
|---|---|---|---|---|---|
|
|
24
24
|
| `sealed-secrets` | law | ask: rule-gate (credential-shaped content outside `.env*`). Switchable per install with `--no-secrets-gate` (see [Exclusions](exclusions.md)): this scan stands down on every host, the guard plugin's path block included | same as Claude Code | block: guard plugin (key/credential file path, or vendor-prefixed secret content); warns only on `.env*` | block (exit 2) |
|
|
25
|
-
| `deletion-is-deliberate` | law | ask: git-gate, only on real data loss judged against the repository's state (the list in [Hooks](hooks.md): a protected branch deleted or force-pushed, an unpushed unmerged branch deleted, uncommitted work discarded
|
|
25
|
+
| `deletion-is-deliberate` | law | ask: git-gate, only on real data loss judged against the repository's state (the list in [Hooks](hooks.md): a protected branch deleted or force-pushed, an unpushed unmerged branch deleted, unpushed commits abandoned by `reset --hard`, uncommitted work discarded other than by `reset --hard`, a stash dropped, the reflog pruned, files git cannot restore removed); asks when it cannot verify. Switchable per install with `--no-deletion-gate` (see [Exclusions](exclusions.md)): the real-loss check and the Law IV `permission.bash` keys stand down, everything else in this table stays **+** ask: git-gate, catastrophic shell (`rm -rf` of `/`, a drive root or `~`, `mkfs`, `dd` over a raw disk, `format`, fork bomb), whatever the state and even with the switch off | same as Claude Code | ask: permission prompt (`git reflog expire*`, `git gc --prune*`, a force push or delete of `main`/`master` by name) **+** block: guard plugin (catastrophic shell, the hook's check plus its own stricter text match: `rm -rf` of `/`, a drive root or `~`, fork bomb, `format`, `dd` over a raw disk) **+** block: guard plugin (every other real-loss case, the same state-aware decisions as the hook) | block (exit 2) |
|
|
26
26
|
| `persist-insight` | process | ask: rule-gate (a write to `user-rules.md` or the memory store) | same as Claude Code | ask once: guard plugin (refuses the *first* write to that path; a re-issued write goes through) | log only (warning, call goes through) |
|
|
27
27
|
| `consent-before-push` | process | ask: git-gate (every `git commit`/`git push`). Togglable: build without the process pack, or drop this rule alone with `--exclude-rules` (see [Exclusions](exclusions.md)). Only this question drops; the `deletion-is-deliberate` gate stays. | same as Claude Code | ask: permission prompt (`git commit*`/`git push*`), wired only when the process pack is on; a `loop/*` push is statically allowed | log only |
|
|
28
28
|
| `external-gate` | rigor | ask: rule-gate (a write under a project's `.geneseed/protected-checks.txt` list) | same as Claude Code | block: guard plugin (same list) | block (exit 2) |
|
|
@@ -80,10 +80,9 @@ geneseed build --emit claude-global --no-deletion-gate
|
|
|
80
80
|
|
|
81
81
|
The web console has the same switch on Rule IV's row of the Constitution page; the row's *Enforced by* reads **Gate off** while it is off. The install records it (`Deletion gate: off` under its pack list, written only when off) and every rebuild and upgrade keeps it; rebuild without the flag, or flip the switch back, to re-arm it. `geneseed status` shows `deletion gate: off` on the install's row.
|
|
82
82
|
|
|
83
|
-
What stops asking, per host: the real-loss check of `git-gate` (Claude Code, OpenClaude) and `tool-gate` (Bob, which then no longer exits 2 for it), the OpenCode guard plugin's real-loss block, and the Law IV keys of OpenCode's `permission.bash` (`git reflog expire*`, `git gc --prune*`, the `main`/`master` force-push and delete forms). What stays: the Rule's text in the carrier, *Sealed Secrets*, the root-wide scan check (*Commands Must Return*), *Consent Before Push* (a forced push still asks as a push while that rule is on), *External Gate*, and the
|
|
83
|
+
What stops asking, per host: the real-loss check of `git-gate` (Claude Code, OpenClaude) and `tool-gate` (Bob, which then no longer exits 2 for it), the OpenCode guard plugin's real-loss block, and the Law IV keys of OpenCode's `permission.bash` (`git reflog expire*`, `git gc --prune*`, the `main`/`master` force-push and delete forms). What stays: the Rule's text in the carrier, *Sealed Secrets*, the root-wide scan check (*Commands Must Return*), *Consent Before Push* (a forced push still asks as a push while that rule is on), *External Gate*, and the catastrophic-command check on every host (`rm -rf` of `/`, a drive root or the home directory, `format`, `dd` over a disk): a prompt on Claude Code and OpenClaude, a block on Bob and OpenCode.
|
|
84
84
|
|
|
85
85
|
Limits:
|
|
86
|
-
- **Claude Code, OpenClaude and Bob have no separate catastrophic-command check.** There, a removal outside a repository — `rm -rf /` included — is caught only by the real-loss check, so with the gate off it no longer asks.
|
|
87
86
|
- **OpenCode reads the switch at start.** The guard plugin reads `Deletion gate:` from the install's `AGENT.md` once, when OpenCode loads it: restart OpenCode after flipping it.
|
|
88
87
|
- **A global install beside a project install.** On Claude Code and OpenClaude the global `git-gate` stands down for a project install, so the project's switch decides. Bob's `tool-gate` and the OpenCode guard do not stand down: an armed global install still refuses real loss inside a project whose own install is off.
|
|
89
88
|
- **An existing `opencode.json` keeps its Law IV keys.** Switching off does not remove `permission.bash` entries already written — Geneseed cannot tell its own from yours — so the build warns that it keeps them and they keep asking. Delete them by hand.
|
package/docs/concepts/hooks.md
CHANGED
|
@@ -28,15 +28,17 @@ Geneseed writes its hooks into the host's settings file. Every hook calls one pr
|
|
|
28
28
|
**context.** Before your first message, the agent receives your own harness files — `user-rules.md`, `PROFILE.md`, the memory index, the notebook index, your wiki declaration — skipping any still untouched since install, then the repo's docs, discovered by convention or listed in a `context.json`. Each file is capped at 16 KB and the whole injection at 48 KB; what does not fit is listed for the agent to read on demand. You see nothing, except that the agent already knows your project. See [Project context](../guides/project-context.md).
|
|
29
29
|
|
|
30
30
|
**git-gate.** Runs before every shell command and looks for `git` in it, including chained one-liners and `git -C <path>` forms, and for a recursive scan of a whole filesystem.
|
|
31
|
+
- **A catastrophic command** gets a confirmation prompt citing *Deletion Is Deliberate* (on Bob, it is refused with exit 2) whatever the repository holds, even with the deletion gate switched off: a recursive removal of `/`, a drive root (`C:\`, `/c`) or the home directory (`~`, `$HOME`), `mkfs` on a device, `dd` over a raw disk, `format C:`, a fork bomb. It judges the program, not the text: a `sudo`, a background `&`, a subshell, `bash -c '…'` or a heredoc fed to a shell (`bash <<'EOF'`) hides nothing, and a heredoc fed to anything else (`cat`, `git commit -F -`) or a commit message that only names `rm -rf /` passes. The OpenCode guard runs the same code, plus its own stricter text match.
|
|
31
32
|
- **Real data loss** gets a confirmation prompt citing *Deletion Is Deliberate* — and only real loss, judged against the repository's actual state:
|
|
32
33
|
- deleting or force-pushing a protected branch (`main`, `master`, or a remote's default branch), locally or on the remote;
|
|
33
34
|
- `branch -D` of a branch whose commits are on no remote branch and not merged into the current one;
|
|
34
|
-
-
|
|
35
|
+
- `reset --hard <target>` abandoning commits that neither the target nor any remote branch holds (an unresolvable target asks too);
|
|
36
|
+
- discarding uncommitted work (`checkout -f`, `checkout -- <paths>`, `switch --discard-changes`, `restore`, `clean -f`) when the tree actually holds some;
|
|
35
37
|
- on a protected branch only: `checkout <paths>` without `--` and `git rm -f` over changed files while `main`, `master` or the default branch is checked out, and `branch -f`/`checkout -B`/`switch -C` moving a protected branch off commits no remote and not the new target holds;
|
|
36
38
|
- `stash drop`/`clear` while a stash exists; `reflog expire` and `gc --prune`, always;
|
|
37
39
|
- removing files (`rm`, `rmdir`, `del`, `erase`, `Remove-Item`) that git cannot restore: untracked, ignored or modified files, a repository's `.git`, or anything outside a repository.
|
|
38
40
|
|
|
39
|
-
A force push to a feature branch, the protected-branch-only forms above on a feature branch, a new branch (`checkout -b`, `switch -c`), `reset --hard`
|
|
41
|
+
A force push to a feature branch, the protected-branch-only forms above on a feature branch, a new branch (`checkout -b`, `switch -c`), `reset --hard` over uncommitted work or to a commit that keeps every unpushed one, `branch -D` of a pushed branch, or `rm` of a tracked, unmodified file passes. When the gate cannot tell — a glob or `$variable` it would have to expand, a `cd` it cannot follow, `sh -c`/`xargs`, git failing or missing — it asks, and says it could not verify. To read the state it runs `git` itself, only once a command already looked destructive; every other command pays nothing. That is a Rule, so it holds in every build — unless you switch its gate off for one install with `--no-deletion-gate` (or the switch on Rule IV in the console's Constitution page). The hook is then wired with `--no-deletion-gate` and this check stands down; the Rule's text still binds the agent, and the catastrophic-command check above and the root-scan and consent checks below still run ([Exclusions](exclusions.md)).
|
|
40
42
|
- **Every `git commit` and `git push`** gets a prompt citing *Consent Before Push*. This part belongs to the **process** doctrine pack: build without it, or exclude that rule, and the hook stays wired with `--no-consent` — commits and pushes go through without asking, real data loss is still stopped ([Rules](rules.md)).
|
|
41
43
|
- **A recursive scan of a whole filesystem** — `find /`, `du -sh /`, `grep -r foo /`, `rg x C:\`, `Get-ChildItem C:\ -Recurse` — gets a prompt citing *Commands Must Return* (on Bob, it is blocked). In Git Bash `/` mounts every drive and mounted share, so such a scan runs for hours, and `| head` does not stop it. The prompt tells the agent to search a specific directory instead. A deeper path (`find /c/Users/me/project`), `ls /` without `-R` (`ls -r` is reverse order, not recursion), a quoted argument (a search pattern such as `grep -rn "/" src`), a `#` comment, and the remote half of an `ssh` command pass. This check is wired in every build, whatever packs or rules you excluded: it rides inside `git-gate` with no flag of its own, because a new flag or verb would reach machines still running an older hook shim, which would refuse it — and Claude Code blocks a tool call whose gate fails.
|
|
42
44
|
- Any other command passes untouched.
|
package/docs/guides/upgrade.md
CHANGED
|
@@ -58,7 +58,7 @@ geneseed diff --full # with line-level diffs
|
|
|
58
58
|
geneseed diff --out improvements.md # write the improvements file
|
|
59
59
|
```
|
|
60
60
|
|
|
61
|
-
`diff` compares the global OpenCode install by default. Pass `--target <config dir>` to compare another install. The improvements file is self-contained. To make the changes permanent, hand it to an agent in a Geneseed clone and ask it to fold them back into `src/`. Then they survive every future build. The web console's **Changes** page exports the same file.
|
|
61
|
+
`diff` compares the global OpenCode install by default. Pass `--target <config dir>` to compare another install. A file you replaced with a link to your own copy (a skill folder junctioned or symlinked elsewhere, say) is listed as `linked`: Geneseed never writes through a link, so it is yours and not drift, and it stays out of the improvements file. The improvements file is self-contained. To make the changes permanent, hand it to an agent in a Geneseed clone and ask it to fold them back into `src/`. Then they survive every future build. The web console's **Changes** page exports the same file.
|
|
62
62
|
|
|
63
63
|
## Moving from a clone to npm
|
|
64
64
|
|
|
@@ -10,7 +10,7 @@ One of the [OpenCode plugins](opencode-plugins.md). [Environment variables](envi
|
|
|
10
10
|
|
|
11
11
|
**What it does.** Enforces the safety rules at the tool boundary — before a tool call runs, not after. High-confidence patterns only, so legitimate work is not caught.
|
|
12
12
|
|
|
13
|
-
- **Blocks** writes to private-key and credential files and writes whose content carries a credential (*Sealed Secrets*; an install built `--no-secrets-gate` carries `Secrets gate: off` in its `AGENT.md`, read once at load, and both stop blocking), catastrophic shell commands such as deleting the filesystem root and every command that would really lose data — a protected branch deleted or force-pushed, an unpushed unmerged branch deleted, a protected branch force-moved off commits nothing else holds, uncommitted work discarded
|
|
13
|
+
- **Blocks** writes to private-key and credential files and writes whose content carries a credential (*Sealed Secrets*; an install built `--no-secrets-gate` carries `Secrets gate: off` in its `AGENT.md`, read once at load, and both stop blocking), catastrophic shell commands such as deleting the filesystem root and every command that would really lose data — a protected branch deleted or force-pushed, an unpushed unmerged branch deleted, a protected branch force-moved off commits nothing else holds, unpushed commits abandoned by `reset --hard`, uncommitted work discarded other than by `reset --hard`, a stash dropped, files git cannot restore removed — judged against the repository's state, the same decisions the Claude/Bob hook makes (*Deletion Is Deliberate*; `reflog expire` and `gc --prune` are left to the `permission.bash` ask; an install built `--no-deletion-gate` carries `Deletion gate: off` in its `AGENT.md`, which the plugin reads once at load and then stops blocking real loss — catastrophic commands still block), a recursive scan rooted at a whole filesystem such as `find /` (*Commands Must Return* — search a specific directory instead), and any change — write, move, rename, delete — under a wiki folder declared `protected` in `geneseed-wiki.jsonc`, and writes to a check listed in the project's `.geneseed/protected-checks.txt` (*External Gate* — see [Hooks](../concepts/hooks.md)).
|
|
14
14
|
- **Warns** (logged, allowed) on `.env` writes.
|
|
15
15
|
- **Speed-bumps** the *first* write to `user-rules.md` or a memory file: it is refused once, naming *Persist Insight* — whether something is a standing rule or a fact to remember is your call, settled through the `rule` skill. The re-issued write goes through.
|
|
16
16
|
|
|
@@ -22,7 +22,7 @@ Most of the harness is *asked*. A few rules, where one mistake is expensive and
|
|
|
22
22
|
|
|
23
23
|
On Claude Code and OpenClaude the hooks never block silently. They make the host **ask you**, with the reason shown:
|
|
24
24
|
|
|
25
|
-
- **Real data loss**: a force-push or delete of `main`/`master`/the remote's default branch, force-moving one off commits nothing else holds, `branch -D` of an unpushed unmerged branch, `reset --hard
|
|
25
|
+
- **Real data loss**: a force-push or delete of `main`/`master`/the remote's default branch, force-moving one off commits nothing else holds, `branch -D` of an unpushed unmerged branch, a `reset --hard` abandoning unpushed commits, `checkout --`/`clean -f` and the like over uncommitted work (`reset --hard` over it passes), a stash dropped, the reflog pruned, an `rm` of files git cannot restore. Judged against the repository's real state, so the same command on a clean tree passes; when the gate cannot tell, it asks. Wired in every build.
|
|
26
26
|
- **Commit and push**: every `git commit` and `git push`, including chained one-liners and `git -C <path>`. This question belongs to the **process** pack. Build without it and the git gate stays wired. Only this question goes away, and destructive git is still caught.
|
|
27
27
|
- **A scan of a whole filesystem**: a recursive `find`, `du`, `grep -r`, `rg` or `Get-ChildItem -Recurse` rooted at `/`, `/c` or `C:\`, which in Git Bash walks every drive for hours. Wired in every build.
|
|
28
28
|
- **Credentials**: a write or edit whose content looks like a real key (AWS, GitHub, Anthropic, Slack, or a private-key block), anywhere except a `.env` file.
|
|
@@ -15,7 +15,7 @@ What a Claude Code or OpenClaude install writes, piece by piece. The **Kind** wo
|
|
|
15
15
|
| `user-rules.md` | Your own standing rules, read every session. Seeded empty | asked | 0 until you write in it | Leave it empty |
|
|
16
16
|
| `PROFILE.md` | Who you are and how you like answers pitched. Fill it with the `profile` skill | asked | 0 until you fill it | Leave the seeded text untouched |
|
|
17
17
|
| context hook | At session start, and again after a resume or compaction, puts your repo's README, CONTRIBUTING and a listing of `docs/` in front of the agent, with your rules, profile, memory index and wiki | automatic | Your docs, capped at 16 KB a file and 48 KB a session | `geneseed exclude add <folder>` silences every hook in that folder |
|
|
18
|
-
| git-gate hook | Before each shell command: asks you before any `git commit` or `git push`, and before a command that would really lose data — a force-push to `main`, a `reset --hard`
|
|
18
|
+
| git-gate hook | Before each shell command: asks you before any `git commit` or `git push`, and before a command that would really lose data — a force-push to `main`, a `reset --hard` that abandons unpushed commits, `branch -D` of an unpushed branch, an `rm` git cannot undo | enforced | ~14 ms per shell call | The commit/push question goes away if you build without the **process** pack; the destructive-git question has no switch except `geneseed exclude` |
|
|
19
19
|
| rule-gate hook | Before each write or edit: asks you when the file would carry a credential-shaped string (outside `.env`), or when the agent writes to `user-rules.md` or memory | enforced | ~14 ms per write | `geneseed exclude add <folder>` |
|
|
20
20
|
| learn hook | When the agent finishes a reply, and before compaction, distils durable facts from the recent conversation into `memory/`. Off until you set `GENESEED_LLM` to a model command such as `claude -p` | automatic | One model call each time it runs, once enabled | Leave `GENESEED_LLM` unset (the default) |
|
|
21
21
|
| `skills/` | Written procedures you call by name, such as `brainstorm` or `plan` | on demand | ~4.1k tokens for names and descriptions; a skill's body loads only when called | No per-skill switch; `geneseed uninstall` removes them all |
|
package/harness.config.json
CHANGED
|
@@ -2,5 +2,5 @@
|
|
|
2
2
|
"_comment": "version is a human-readable release label only; the canonical identity of an installed harness is the source fingerprint stamped in .geneseed-version (see `harness version`), not this string.",
|
|
3
3
|
"theme": "neutral",
|
|
4
4
|
"product": "Geneseed",
|
|
5
|
-
"version": "4.9.
|
|
5
|
+
"version": "4.9.4"
|
|
6
6
|
}
|
package/js/hosts/hooks.mjs
CHANGED
|
@@ -58,7 +58,7 @@ import {
|
|
|
58
58
|
GATE_LEDGER, globalHookStandingDown, hookProjectDir, resolvePath, sovereignBypass,
|
|
59
59
|
} from './hosts.mjs';
|
|
60
60
|
import { currentBranch, gitRootOf, loopLaunched } from './gitref.mjs';
|
|
61
|
-
import { LOSS_CANDIDATE_RE, lossReason, stripStructure } from './loss.mjs';
|
|
61
|
+
import { LOSS_CANDIDATE_RE, catastrophic, lossReason, stripStructure } from './loss.mjs';
|
|
62
62
|
import { selfAndParents, readStdin } from './hooks-prims.mjs';
|
|
63
63
|
import { recordUse } from './usage.mjs';
|
|
64
64
|
|
|
@@ -74,7 +74,8 @@ const GIT_GATE_RE = /\bgit\b[^\n]*\b(?:commit|push)\b/;
|
|
|
74
74
|
|
|
75
75
|
// Law IV's boundary half lives in `./loss.mjs`: it asks only when a command would really lose
|
|
76
76
|
// data — a protected branch deleted or force-pushed, an unpushed unmerged branch deleted,
|
|
77
|
-
//
|
|
77
|
+
// unpushed commits abandoned by `reset --hard`, uncommitted work discarded (other than by
|
|
78
|
+
// `reset --hard`, user decision 2026-10-18) on a tree that holds some, a stash dropped while one exists, the
|
|
78
79
|
// reflog or unreachable commits pruned, a removal of what git cannot restore — and fails closed
|
|
79
80
|
// when it cannot tell (user decision, 2026-10-17, replacing the form-only `DESTRUCTIVE_GIT_RE`).
|
|
80
81
|
// `LOSS_CANDIDATE_RE` is the cheap prefilter: an ordinary command fails it and never reaches the
|
|
@@ -438,8 +439,18 @@ const rootArg = (t) => !/^["']/.test(t) && FS_ROOT_RE.test(t);
|
|
|
438
439
|
function gitDecide(args, payload) {
|
|
439
440
|
const command = ((payload && payload.tool_input) || {}).command;
|
|
440
441
|
if (typeof command !== 'string') return 0;
|
|
441
|
-
//
|
|
442
|
-
//
|
|
442
|
+
// Law IV's catastrophic class (a root, a drive, the home dir, a disk): no state makes it safe,
|
|
443
|
+
// so it runs BEFORE the switch below and whatever it says (user decision, 2026-10-18). On
|
|
444
|
+
// Claude an ask is the strongest verdict the hook has; on Bob, `law-4` exits 2.
|
|
445
|
+
const doomed = catastrophic(command, lossSegments);
|
|
446
|
+
if (doomed) {
|
|
447
|
+
return ask(args, 'git-gate', 'law-4', `Geneseed (Deletion Is Deliberate) \u2014 \`${doomed.slice(0, 80)}\` `
|
|
448
|
+
+ 'would destroy a filesystem root, a drive, the home directory or a whole disk, which nothing '
|
|
449
|
+
+ 'could restore. This check stands even with the deletion gate off.');
|
|
450
|
+
}
|
|
451
|
+
// `--no-deletion-gate`: the install switched Law IV's real-loss boundary off (user decision,
|
|
452
|
+
// 2026-10-17). Only this check stands down — the catastrophic class above, ops 2 and process 5
|
|
453
|
+
// below still run.
|
|
443
454
|
if (!args.noDeletionGate && LOSS_CANDIDATE_RE.test(command)) {
|
|
444
455
|
// A pass here FALLS THROUGH: a force push to a feature branch loses nothing, but it is still
|
|
445
456
|
// a push, and process 5 below still asks for it.
|
package/js/hosts/loss.mjs
CHANGED
|
@@ -10,7 +10,9 @@
|
|
|
10
10
|
* - a protected branch (`main`, `master`, a remote's default) deleted or force-pushed, local
|
|
11
11
|
* or remote;
|
|
12
12
|
* - `branch -D x` while x carries a commit no remote ref and no HEAD reaches;
|
|
13
|
-
* -
|
|
13
|
+
* - `reset --hard <target>` abandoning commits HEAD reaches and neither <target> nor a remote
|
|
14
|
+
* does — the uncommitted work it discards passes (user decision, 2026-10-18);
|
|
15
|
+
* - uncommitted work discarded (`checkout -f`/`-- <paths>`, `switch -f`/
|
|
14
16
|
* `--discard-changes`, `restore` beyond `--staged` alone, `clean -f`) — and only when the
|
|
15
17
|
* tree actually holds such work (for `clean`, what `git clean -n` says it would remove);
|
|
16
18
|
* - only where a protected branch is concerned (user ruling, 2026-10-17): `checkout <paths>`
|
|
@@ -332,6 +334,25 @@ function branchAct(rest, dir) {
|
|
|
332
334
|
return null;
|
|
333
335
|
}
|
|
334
336
|
|
|
337
|
+
/**
|
|
338
|
+
* `git reset --hard [<target>]` — asks only when it would abandon COMMITS: one HEAD reaches that
|
|
339
|
+
* neither `<target>` nor any remote ref does. The uncommitted work it discards passes: what is in
|
|
340
|
+
* progress may break, what exists must be protected (user decision, 2026-10-18). No target is
|
|
341
|
+
* HEAD itself, which abandons nothing.
|
|
342
|
+
*/
|
|
343
|
+
function resetHardAct(rest, dir) {
|
|
344
|
+
const dd = rest.indexOf('--');
|
|
345
|
+
const target = (dd < 0 ? rest : rest.slice(0, dd)).find((a) => !a.startsWith('-'));
|
|
346
|
+
if (target === undefined) return null;
|
|
347
|
+
if (target.includes(OPAQUE)) unverified(`the target \`${target.replace(OPAQUE, '')}\` expands in the shell`);
|
|
348
|
+
repoRoot(dir);
|
|
349
|
+
const t = git(dir, ['rev-parse', '-q', '--verify', `${target}^{commit}`]);
|
|
350
|
+
if (t.status !== 0) unverified(`cannot resolve ${target} to a commit`);
|
|
351
|
+
const r = git(dir, ['rev-list', '-n1', 'HEAD', '--not', t.stdout.trim(), '--remotes']);
|
|
352
|
+
if (r.status !== 0) unverified(`cannot compare HEAD with ${target}: ${firstLine(r.stderr)}`);
|
|
353
|
+
return r.stdout.trim() ? `\`git reset --hard ${target}\` would abandon commits no remote and not ${target} holds` : null;
|
|
354
|
+
}
|
|
355
|
+
|
|
335
356
|
/** `git clean` — forced, it asks when `git clean -n` with the same selection lists anything. */
|
|
336
357
|
function cleanAct(rest, dir) {
|
|
337
358
|
const forced = rest.some((a) => a === '--force' || hasShort(a, 'f'));
|
|
@@ -373,7 +394,7 @@ function gitAct(args, dir, always) {
|
|
|
373
394
|
const act = () => {
|
|
374
395
|
switch (verb) {
|
|
375
396
|
case 'reset':
|
|
376
|
-
return rest.includes('--hard') ?
|
|
397
|
+
return rest.includes('--hard') ? resetHardAct(rest, d) : null;
|
|
377
398
|
case 'checkout': {
|
|
378
399
|
const forced = rest.some((a) => a === '--force' || hasShort(a, 'f')) && dirty(d, [], '`git checkout --force`');
|
|
379
400
|
if (forced) return forced;
|
|
@@ -625,4 +646,102 @@ export function lossReason(segments, cwd, { always = true, split } = {}) {
|
|
|
625
646
|
return `the gate could not verify what this command would lose (${e.message})`;
|
|
626
647
|
}
|
|
627
648
|
}
|
|
649
|
+
|
|
650
|
+
/**
|
|
651
|
+
* THE CATASTROPHIC CLASS — what no state can make safe: a recursive removal of a filesystem root,
|
|
652
|
+
* a drive root or the home directory, a device format or raw-disk overwrite, a fork bomb. Unlike
|
|
653
|
+
* `lossReason` it reads no state, and it stands whatever the deletion gate's switch says (user
|
|
654
|
+
* decision 2026-10-18): `--no-deletion-gate` silences the real-loss check, never this one. Judged
|
|
655
|
+
* in PROGRAM position, segment by segment after `stripStructure` and the wrappers, so `true & rm
|
|
656
|
+
* -rf /`, `sudo rm -rf /`, `(rm -rf /)` and `rm -rf /;` are caught and a commit message that only
|
|
657
|
+
* names `rm -rf /` is not. A shell or `eval` (`bash -c '…'`, `pwsh -Command …`) and a `$(…)` body
|
|
658
|
+
* are judged as commands of their own, and so is what a shell reads on stdin: a heredoc or
|
|
659
|
+
* here-string fed to `bash`/`sh`/`pwsh`/`cmd`… (`bash <<'EOF'` over `rm -rf /`). One fed to any
|
|
660
|
+
* other program (`cat <<EOF`, `git commit -F - <<EOF`) is data. The fork bomb spans `|`/`&`/`;`, so
|
|
661
|
+
* it is tested on the whole command. `split` cuts a command into raw-token segments, as for
|
|
662
|
+
* `lossReason`. Returns the offending text, or null.
|
|
663
|
+
*/
|
|
664
|
+
const CATASTROPHIC_CANDIDATE_RE = /(?:^|[^\w-])(?:rm|rmdir|rd|del|erase|remove-item|ri|mkfs(?:\.\w+)?|dd|format)(?:\.exe)?(?![\w-])|:\(\)/i;
|
|
665
|
+
const FORK_BOMB_RE = /:\(\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;\s*:/;
|
|
666
|
+
// `/`, a Git Bash or WSL drive (`/c`, `/mnt/c`), a Windows drive (`C:`, `C:\`), the home dir — globbed or not.
|
|
667
|
+
const ROOT_TARGET_RE = /^(?:\/|~[\\/]?|\$\{?HOME\}?[\\/]?|\/[a-z]\/?|\/mnt\/[a-z]\/?|[a-z]:[\\/]?)\*?$/i;
|
|
668
|
+
const RM_RECURSE_RE = /^(?:-[a-zA-Z]*[rR][a-zA-Z]*|--recursive)$/;
|
|
669
|
+
// PowerShell's `-Recurse` (or any prefix of it) and cmd.exe's `/s`, alone or glued (`/s/q`).
|
|
670
|
+
const PS_CMD_RECURSE_RE = /^(?:-r(?:e(?:c(?:u(?:r(?:s(?:e)?)?)?)?)?)?|(?:\/[a-z]+)*\/s(?:\/[a-z]+)*)$/i;
|
|
671
|
+
const leadingFlags = (w) => { let i = 0; while (i < w.length && /^[-/][\w-]+$/.test(w[i])) i += 1; return w.slice(i); };
|
|
672
|
+
// The programs that run what they read on stdin as commands.
|
|
673
|
+
const STDIN_SHELLS = new Set(['bash', 'sh', 'zsh', 'dash', 'ksh', 'fish', 'pwsh', 'powershell', 'cmd']);
|
|
674
|
+
// The same lexer as the splitter's (a comment, a quoted run, a heredoc opener, a newline), so the
|
|
675
|
+
// openers it finds are the ones left in the segments' tokens, in the same order.
|
|
676
|
+
const HEREDOC_SCAN_RE = /(?<![^\s;|&(])#[^\n]*|"[^"]*"?|'[^']*'?|(?<!<)<<(?!<)(-?)[ \t]*(["']?)([A-Za-z_]\w*)\2|\n/g;
|
|
677
|
+
/** Every heredoc body in `command`, in opener order; an unterminated one runs to the end, as in bash. */
|
|
678
|
+
function heredocBodies(command) {
|
|
679
|
+
const out = [];
|
|
680
|
+
const tags = [];
|
|
681
|
+
const re = new RegExp(HEREDOC_SCAN_RE);
|
|
682
|
+
let m;
|
|
683
|
+
while ((m = re.exec(command))) {
|
|
684
|
+
if (m[3]) { tags.push([m[1], m[3]]); continue; }
|
|
685
|
+
if (m[0] !== '\n' || !tags.length) continue;
|
|
686
|
+
let start = re.lastIndex;
|
|
687
|
+
while (tags.length) {
|
|
688
|
+
const [dash, tag] = tags.shift();
|
|
689
|
+
const body = [];
|
|
690
|
+
while (start < command.length) {
|
|
691
|
+
const end = command.indexOf('\n', start);
|
|
692
|
+
const line = command.slice(start, end < 0 ? command.length : end).replace(/\r$/, '');
|
|
693
|
+
start = end < 0 ? command.length : end + 1;
|
|
694
|
+
if ((dash ? line.replace(/^\t+/, '') : line) === tag) break;
|
|
695
|
+
body.push(line);
|
|
696
|
+
}
|
|
697
|
+
out.push(body.join('\n'));
|
|
698
|
+
}
|
|
699
|
+
re.lastIndex = start;
|
|
700
|
+
}
|
|
701
|
+
return out;
|
|
702
|
+
}
|
|
703
|
+
export function catastrophic(command, split, depth = 0) {
|
|
704
|
+
if (!CATASTROPHIC_CANDIDATE_RE.test(command)) return null;
|
|
705
|
+
if (FORK_BOMB_RE.test(command)) return command.trim();
|
|
706
|
+
const bodies = command.includes('<<') ? heredocBodies(command) : [];
|
|
707
|
+
let next = 0;
|
|
708
|
+
for (const raw of split(command).map(stripStructure)) {
|
|
709
|
+
const text = raw.join(' ');
|
|
710
|
+
// What this segment feeds on stdin: each heredoc opener takes the next body, a here-string its word.
|
|
711
|
+
const fed = [];
|
|
712
|
+
raw.forEach((t, i) => {
|
|
713
|
+
if (t.startsWith('<<<')) fed.push(word(t.length > 3 ? t.slice(3) : raw[i + 1] || '').replace(OPAQUE, ''));
|
|
714
|
+
else if (t.startsWith('<<')) fed.push(bodies[next++] ?? '');
|
|
715
|
+
});
|
|
716
|
+
for (const body of depth < 4 ? substitutions(text) : []) {
|
|
717
|
+
const v = catastrophic(body, split, depth + 1);
|
|
718
|
+
if (v) return v;
|
|
719
|
+
}
|
|
720
|
+
let w = words(raw).map((t) => t.replace(OPAQUE, ''));
|
|
721
|
+
while (w.length && (/^\w+=/.test(w[0]) || WRAPPERS.has(progOf(w[0])))) {
|
|
722
|
+
w = /^\w+=/.test(w[0]) ? w.slice(1) : pastWrapper(progOf(w[0]), w.slice(1)) || leadingFlags(w.slice(1));
|
|
723
|
+
}
|
|
724
|
+
if (!w.length) continue;
|
|
725
|
+
const prog = progOf(w[0]);
|
|
726
|
+
for (const body of STDIN_SHELLS.has(prog) && depth < 4 ? fed : []) {
|
|
727
|
+
const v = catastrophic(body, split, depth + 1);
|
|
728
|
+
if (v) return v;
|
|
729
|
+
}
|
|
730
|
+
const args = w.slice(1).map((a) => a.replace(/^-(?:literal)?path[=:]/i, ''));
|
|
731
|
+
// cmd.exe's `/s` `/q` are switches, not the Git Bash drives `/s` `/q`.
|
|
732
|
+
const root = () => args.some((a) => ROOT_TARGET_RE.test(a) && !(CMD_SWITCH_PROGS.has(prog) && /^\/[a-z]$/i.test(a)));
|
|
733
|
+
let hit = false;
|
|
734
|
+
if (prog === 'rm') hit = args.some((a) => RM_RECURSE_RE.test(a)) && root();
|
|
735
|
+
else if (RM_PROGS.has(prog)) hit = args.some((a) => PS_CMD_RECURSE_RE.test(a)) && root();
|
|
736
|
+
else if (/^mkfs(?:\.\w+)?$/.test(prog)) hit = args.some((a) => a.startsWith('/dev/'));
|
|
737
|
+
else if (prog === 'dd') hit = args.some((a) => /^of=\/dev\/(?:sd|nvme|hd|disk)/.test(a));
|
|
738
|
+
else if (prog === 'format') hit = args.some((a) => /^[a-z]:/i.test(a));
|
|
739
|
+
else if (INDIRECT.has(prog) && depth < 4) {
|
|
740
|
+
const v = catastrophic(leadingFlags(w.slice(1)).join(' '), split, depth + 1);
|
|
741
|
+
if (v) return v;
|
|
742
|
+
}
|
|
743
|
+
if (hit) return text.trim();
|
|
744
|
+
}
|
|
745
|
+
return null;
|
|
746
|
+
}
|
|
628
747
|
// ---- loss twin: END
|
package/js/hosts/settings.mjs
CHANGED
|
@@ -104,7 +104,7 @@ const consentRuleOn = (d, excluded = []) =>
|
|
|
104
104
|
*
|
|
105
105
|
* `rm -rf *` WENT TOO (user ruling, 2026-10-17): a blanket ask on every recursive removal is the
|
|
106
106
|
* glob-cannot-read-state problem again — the guard's `lossReason` lets an `rm` of tracked,
|
|
107
|
-
* unmodified files through and blocks one git cannot restore, and its `
|
|
107
|
+
* unmodified files through and blocks one git cannot restore, and its `catastrophic()` still
|
|
108
108
|
* blocks `rm -rf /`, `~` and the drive-root forms outright. Same stranded-key handling as above:
|
|
109
109
|
* an existing `opencode.json` keeps its `rm -rf *` key, asking, until the user deletes it.
|
|
110
110
|
*
|
|
@@ -185,7 +185,7 @@ export function gateProbe(targets = installTargets(), { platform = process.platf
|
|
|
185
185
|
// ---- OpenCode: the guard plugin, probed in-process ------------------------------------------
|
|
186
186
|
|
|
187
187
|
const GUARD_PLUGIN = 'geneseed-guard.js';
|
|
188
|
-
/** The guard blocks catastrophic shell (`
|
|
188
|
+
/** The guard blocks catastrophic shell (`catastrophic()`); destructive git is the permission
|
|
189
189
|
* prompt's job there, and only WARNS in the guard — so its must-refuse payload is this one. */
|
|
190
190
|
const GUARD_BAD_COMMAND = 'rm -rf /';
|
|
191
191
|
|
package/js/inspect/cost.mjs
CHANGED
|
@@ -35,7 +35,7 @@ import { isFile, readText } from '../lib/fs.mjs';
|
|
|
35
35
|
* console draws it on the cost chart, so it lives here, once, where both read it. Raise it by
|
|
36
36
|
* exactly the measured overrun and say why in that test's docblock, which keeps its history.
|
|
37
37
|
*/
|
|
38
|
-
export const CARRIER_CEILING = { full: 62_219, lean:
|
|
38
|
+
export const CARRIER_CEILING = { full: 62_219, lean: 39_111 };
|
|
39
39
|
|
|
40
40
|
/**
|
|
41
41
|
* A frontmatter value as the host shows it. The emit writes descriptions JSON-escaped
|