geneseed 4.9.1 → 4.9.3
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 +30 -0
- package/SHIPPED.md +1 -1
- package/adapters/opencode/plugins/geneseed-context.js +7 -1
- package/adapters/opencode/plugins/geneseed-guard.js +123 -12
- package/adapters/opencode/plugins/geneseed-learn.js +7 -1
- package/docs/concepts/enforcement-table.md +1 -1
- package/docs/concepts/exclusions.md +1 -2
- package/docs/concepts/hooks.md +3 -2
- package/docs/guides/upgrade.md +1 -1
- package/harness.config.json +1 -1
- package/js/README.md +30 -9
- package/js/build/args.mjs +9 -8
- package/js/build/driver.mjs +21 -21
- package/js/build/emit-claude.mjs +3 -3
- package/js/build/generate.mjs +4 -5
- package/js/build/source.mjs +18 -22
- package/js/hosts/gitref.mjs +5 -3
- package/js/hosts/hooks-done.mjs +21 -9
- package/js/hosts/hooks-learn.mjs +23 -10
- package/js/hosts/hooks-prims.mjs +25 -0
- package/js/hosts/hooks.mjs +19 -4
- package/js/hosts/installs.mjs +5 -4
- package/js/hosts/loss.mjs +98 -0
- package/js/hosts/native.mjs +3 -1
- package/js/hosts/owned.mjs +34 -6
- package/js/hosts/settings.mjs +6 -5
- package/js/inspect/checks-probe.mjs +1 -1
- package/js/inspect/cost.mjs +37 -5
- package/js/inspect/diff.mjs +154 -64
- package/js/inspect/validate.mjs +2 -2
- package/js/lib/fs.mjs +73 -2
- package/js/maintain/setup.mjs +31 -7
- package/js/maintain/uninstall.mjs +1 -1
- package/js/maintain/update.mjs +1 -1
- package/js/web/actions.mjs +36 -3
- package/js/web/api.mjs +17 -6
- package/js/web/handler.mjs +4 -16
- package/js/web/jobs.mjs +5 -3
- package/js/web/state.mjs +3 -0
- package/package.json +1 -1
- package/web/dist/assets/{Activity-GQGAZHIE.js → Activity-C8sZVIUU.js} +1 -1
- package/web/dist/assets/{ActivityDetail-DktWyxim.js → ActivityDetail-Cc6vWUS4.js} +1 -1
- package/web/dist/assets/Dashboard-C5ATa3ow.js +2 -0
- package/web/dist/assets/{Docs-CsWYLnN1.js → Docs-tDW7i7mo.js} +3 -3
- package/web/dist/assets/GreenhouseView-zk2Uzc3A.js +2 -0
- package/web/dist/assets/Installs-CB_rvbBP.js +3 -0
- package/web/dist/assets/JournalView-Bfj27XHC.js +1 -0
- package/web/dist/assets/Laws-BA3CzUf5.js +3 -0
- package/web/dist/assets/Library-BWwiy9Vb.js +1 -0
- package/web/dist/assets/{Loops-BUBOeM0C.js → Loops-DaPk-ckL.js} +2 -2
- package/web/dist/assets/{OperatorHudView-BJuCYsjf.js → OperatorHudView-CyiGhiHk.js} +1 -1
- package/web/dist/assets/Personal-Bwd27CmT.js +1 -0
- package/web/dist/assets/{Tabs-GPQlocTw.js → Tabs-BOD7Kaz1.js} +1 -1
- package/web/dist/assets/drawable-CyLe1gtW.js +1 -0
- package/web/dist/assets/index-DQXG_mqo.js +2 -0
- package/web/dist/index.html +1 -1
- package/web/src/pages/Laws.jsx +60 -353
- package/web/dist/assets/Dashboard-CmSn6JfK.js +0 -2
- package/web/dist/assets/GreenhouseView-Dt_cIxAY.js +0 -2
- package/web/dist/assets/Installs-CWuUU4I3.js +0 -2
- package/web/dist/assets/JournalView-ClbZj_YF.js +0 -1
- package/web/dist/assets/Laws-R5zZCmya.js +0 -3
- package/web/dist/assets/Library-u36V7oSD.js +0 -1
- package/web/dist/assets/Personal-DWQuSAUz.js +0 -1
- package/web/dist/assets/drawable-CE-arUnF.js +0 -1
- package/web/dist/assets/index-3lnPT_1J.js +0 -2
package/js/build/driver.mjs
CHANGED
|
@@ -40,7 +40,7 @@ import { emitClaudeRender } from './emit-claude.mjs';
|
|
|
40
40
|
import { emitOpencodeRender, emitOpencodeGlobalRender } from './emit-opencode.mjs';
|
|
41
41
|
import { settingsIntegrityCheck } from '../hosts/settings.mjs';
|
|
42
42
|
import { hookRunnerEntry } from '../hosts/shim.mjs';
|
|
43
|
-
import { writeText, withPlatformNewlines, isFile } from '../lib/fs.mjs';
|
|
43
|
+
import { writeText, withPlatformNewlines, isFile, withWriteRecord } from '../lib/fs.mjs';
|
|
44
44
|
import { parseJson, jsonDumpsIndent } from '../lib/json.mjs';
|
|
45
45
|
import { relPosix } from '../lib/text.mjs';
|
|
46
46
|
// P5c moved these out of this file: `bin/geneseed-cli.mjs` needs the same four resolvers to
|
|
@@ -58,7 +58,7 @@ import {
|
|
|
58
58
|
// status` renders to count, so `bin/geneseed-cli.mjs` needs the same checkout paths and the
|
|
59
59
|
// same cfg. golden.py's 259 cells all build one, which is what made the move safe.
|
|
60
60
|
import {
|
|
61
|
-
ROOT, makeCfg,
|
|
61
|
+
GATE_SWITCHES, ROOT, makeCfg,
|
|
62
62
|
} from './source.mjs';
|
|
63
63
|
|
|
64
64
|
// P5f moved these, for the same arithmetic a third time: `harness rebuild-all` reads the
|
|
@@ -75,8 +75,11 @@ import { configDefaults, die, parseArgs } from './args.mjs';
|
|
|
75
75
|
export { parseDriverArgs } from './args.mjs';
|
|
76
76
|
|
|
77
77
|
|
|
78
|
-
/**
|
|
79
|
-
|
|
78
|
+
/**
|
|
79
|
+
* `_build_emit.PRIMARY_AGENT_SRC`. Exported for `diffCollect`'s render memo: a render input
|
|
80
|
+
* `sourceFingerprint` does not hash, so the memo keys on its bytes itself.
|
|
81
|
+
*/
|
|
82
|
+
export const PRIMARY_AGENT_SRC = path.join(ROOT, 'adapters', 'opencode', 'agents', 'orchestrator.md');
|
|
80
83
|
|
|
81
84
|
/** `_build_render.resolve_out` — absolute, or relative to the CURRENT WORKING DIRECTORY
|
|
82
85
|
* (not to ROOT), so the harness renders straight into any repository.
|
|
@@ -555,9 +558,9 @@ const claudeShaped = (name) => (cfg, args, out) => {
|
|
|
555
558
|
const claudeMd = path.join(configDir || carrierInLayer ? cfgDir : root, carrier);
|
|
556
559
|
const hookOpts = hookRunnerEntry();
|
|
557
560
|
before?.();
|
|
558
|
-
const r = emitClaudeCore(cfg, args, {
|
|
561
|
+
const r = withWriteRecord(() => emitClaudeCore(cfg, args, {
|
|
559
562
|
cfgDir, claudeMd, scope: configDir ? 'global' : 'project', host, out, hookOpts,
|
|
560
|
-
});
|
|
563
|
+
}));
|
|
561
564
|
process.stdout.write(summary(root ?? cfgDir, r));
|
|
562
565
|
return configDir ? cfgDir : undefined;
|
|
563
566
|
};
|
|
@@ -604,7 +607,7 @@ function writeMarkers(markerDir, emit, footprint) {
|
|
|
604
607
|
* but this argument would overwrite the live install `diff` is only reading.
|
|
605
608
|
*/
|
|
606
609
|
const GLOBAL_EMITS = {
|
|
607
|
-
opencode: emitOpencodeGlobal,
|
|
610
|
+
opencode: (...a) => withWriteRecord(() => emitOpencodeGlobal(...a)),
|
|
608
611
|
claude: claudeShaped('claude-global'),
|
|
609
612
|
bob: claudeShaped('bob-global'),
|
|
610
613
|
openclaude: claudeShaped('openclaude-global'),
|
|
@@ -633,7 +636,7 @@ const PROJECT_EMITS = {
|
|
|
633
636
|
// P2. `opencode` joins the three for `cmdValidate`, which has to be able to render EVERY
|
|
634
637
|
// `--emit` choice into its sandbox and not only the three doctor already scans. Its call
|
|
635
638
|
// shape is the Claude-shaped emits' exactly — `(cfg, args, out)` — so the row is the whole change.
|
|
636
|
-
opencode: emitOpencode,
|
|
639
|
+
opencode: (...a) => withWriteRecord(() => emitOpencode(...a)),
|
|
637
640
|
};
|
|
638
641
|
|
|
639
642
|
/**
|
|
@@ -648,24 +651,21 @@ const PROJECT_EMITS = {
|
|
|
648
651
|
* carry straight past the parameter default.
|
|
649
652
|
*/
|
|
650
653
|
function axesCfg({
|
|
651
|
-
posture, mode, trust, doctrines, excludeRules, excludeSkills,
|
|
652
|
-
deletionGate = null, secretsGate = null, doneGate = null,
|
|
654
|
+
posture, mode, trust, doctrines, excludeRules, excludeSkills, ...gates
|
|
653
655
|
}) {
|
|
654
656
|
return makeCfg({
|
|
655
657
|
...(posture ? { posture } : {}), ...(mode ? { mode } : {}), ...(trust ? { trust } : {}),
|
|
656
658
|
...(doctrines ? { doctrines } : {}), ...(excludeRules ? { excludeRules } : {}),
|
|
657
659
|
...(excludeSkills ? { excludeSkills } : {}),
|
|
658
|
-
//
|
|
659
|
-
|
|
660
|
-
...(
|
|
661
|
-
...(doneGate === false ? { noDoneGate: true } : {}),
|
|
660
|
+
// The gate switches (`GATE_SWITCHES` keys): only an explicit `false` disarms; `null` (no
|
|
661
|
+
// opinion) keeps the gate.
|
|
662
|
+
...Object.fromEntries(GATE_SWITCHES.filter((g) => gates[g.key] === false).map((g) => [g.arg, true])),
|
|
662
663
|
});
|
|
663
664
|
}
|
|
664
665
|
|
|
665
666
|
export function emitProjectInto(host, {
|
|
666
667
|
theme, out, root, footprint = 'full', posture = null, mode = null, trust = null,
|
|
667
|
-
doctrines = null, excludeRules = null, excludeSkills = null,
|
|
668
|
-
secretsGate = null, doneGate = null,
|
|
668
|
+
doctrines = null, excludeRules = null, excludeSkills = null, ...gates
|
|
669
669
|
}) {
|
|
670
670
|
const emit = PROJECT_EMITS[host];
|
|
671
671
|
// The axes ride the same rail into every `*Into`, and for the same reason: a drift reader
|
|
@@ -674,7 +674,7 @@ export function emitProjectInto(host, {
|
|
|
674
674
|
// checked an all-packs render nobody asked for.
|
|
675
675
|
return withPlatformNewlines(() => emit(
|
|
676
676
|
axesCfg({
|
|
677
|
-
posture, mode, trust, doctrines, excludeRules, excludeSkills,
|
|
677
|
+
posture, mode, trust, doctrines, excludeRules, excludeSkills, ...gates }),
|
|
678
678
|
{ theme, footprint, root }, out,
|
|
679
679
|
));
|
|
680
680
|
}
|
|
@@ -689,16 +689,16 @@ export function emitProjectInto(host, {
|
|
|
689
689
|
*/
|
|
690
690
|
export function buildInto({
|
|
691
691
|
theme, out, footprint = 'lean', posture = null, mode = null, trust = null, doctrines = null,
|
|
692
|
-
excludeRules = null, excludeSkills = null,
|
|
692
|
+
excludeRules = null, excludeSkills = null, ...gates
|
|
693
693
|
}) {
|
|
694
694
|
return withPlatformNewlines(() => build(axesCfg({
|
|
695
|
-
posture, mode, trust, doctrines, excludeRules, excludeSkills,
|
|
695
|
+
posture, mode, trust, doctrines, excludeRules, excludeSkills, ...gates }),
|
|
696
696
|
theme, out, { footprint, nativeCatalog: false }));
|
|
697
697
|
}
|
|
698
698
|
|
|
699
699
|
export function emitGlobalInto(host, {
|
|
700
700
|
theme, out, cfgDir, footprint, posture = null, mode = null, doctrines = null, trust = null,
|
|
701
|
-
excludeRules = null, excludeSkills = null,
|
|
701
|
+
excludeRules = null, excludeSkills = null, ...gates
|
|
702
702
|
}) {
|
|
703
703
|
// `build.HOSTS.get(host, build.HOSTS["opencode"])` — an unknown host falls back rather than
|
|
704
704
|
// raising, because the host comes from a marker file a user can edit.
|
|
@@ -715,7 +715,7 @@ export function emitGlobalInto(host, {
|
|
|
715
715
|
// the same scar the footprint left in `diffCollect`, one axis over each time.
|
|
716
716
|
return withPlatformNewlines(() => emit(
|
|
717
717
|
axesCfg({
|
|
718
|
-
posture, mode, trust, doctrines, excludeRules, excludeSkills,
|
|
718
|
+
posture, mode, trust, doctrines, excludeRules, excludeSkills, ...gates }),
|
|
719
719
|
{ theme, footprint, root: null, cfgDir }, out,
|
|
720
720
|
));
|
|
721
721
|
}
|
package/js/build/emit-claude.mjs
CHANGED
|
@@ -182,7 +182,7 @@ export function emitClaudeRender(cfg, job) {
|
|
|
182
182
|
// WIRE — see `claudeWire` below. One child per emit, so the two halves are two
|
|
183
183
|
// functions rather than two spawns; the seam between them is a call, not a process.
|
|
184
184
|
const managed = claudeWire(job, claudeMdText, agentText !== null, cfg.doctrines,
|
|
185
|
-
cfg.excludeRules, cfg
|
|
185
|
+
cfg.excludeRules, cfg);
|
|
186
186
|
|
|
187
187
|
return {
|
|
188
188
|
owned,
|
|
@@ -216,7 +216,7 @@ export function emitClaudeRender(cfg, job) {
|
|
|
216
216
|
* always a string, so `''` is the only falsy value either language sees.
|
|
217
217
|
*/
|
|
218
218
|
function claudeWire(job, claudeMdText, hasAgentText, doctrines = null, excludeRules = [],
|
|
219
|
-
|
|
219
|
+
gates = {}) {
|
|
220
220
|
phaseLog('WIRE');
|
|
221
221
|
const { cfgDir, claudeMd, scope, host, oldManaged, preambleExclude, hookOpts } = job;
|
|
222
222
|
const old = oldManaged && typeof oldManaged === 'object' && !Array.isArray(oldManaged)
|
|
@@ -280,7 +280,7 @@ function claudeWire(job, claudeMdText, hasAgentText, doctrines = null, excludeRu
|
|
|
280
280
|
// DECIDES the install's packs, so the marker on disk is still the previous build's.
|
|
281
281
|
const [, managedHooks] = mergeClaudeSettings(
|
|
282
282
|
settingsPath, oldSf === settingsName ? get(old, 'settings_hooks') : null, hookOpts,
|
|
283
|
-
doctrines, excludeRules, host, cfgDir,
|
|
283
|
+
doctrines, excludeRules, host, cfgDir, gates,
|
|
284
284
|
);
|
|
285
285
|
managed.settings_hooks = managedHooks;
|
|
286
286
|
|
package/js/build/generate.mjs
CHANGED
|
@@ -141,8 +141,7 @@ export function cmdBuild(args) {
|
|
|
141
141
|
*/
|
|
142
142
|
export function setupBuildArgs(theme, emit, out = null, root = null, footprint = 'lean',
|
|
143
143
|
posture = 'peer', mode = 'direct', doctrines = null, allPacks = PACK_ORDER,
|
|
144
|
-
excludeRules = null, trust = DEFAULT_PRESET, excludeSkills = null,
|
|
145
|
-
{ secretsGate = true, doneGate = true } = {}) {
|
|
144
|
+
excludeRules = null, trust = DEFAULT_PRESET, excludeSkills = null, gates = {}) {
|
|
146
145
|
const argv = ['--theme', theme, '--emit', emit];
|
|
147
146
|
if (!emit.endsWith('-global')) {
|
|
148
147
|
if (out) argv.push('--out', out);
|
|
@@ -172,9 +171,9 @@ export function setupBuildArgs(theme, emit, out = null, root = null, footprint =
|
|
|
172
171
|
if (excludeSkills?.length) argv.push('--exclude-skills', excludeSkills.join(','));
|
|
173
172
|
// The gate switches elide when armed: the driver's default IS armed (no config key feeds
|
|
174
173
|
// them), so only an explicit `false` speaks, and every armed argv stays the bytes it was.
|
|
174
|
+
// `gates` is one object keyed as `GATE_SWITCHES` keys it (`gatesOfDir`, a profile, a body).
|
|
175
175
|
// Law order, so a Law IV-only install still ends on `--no-deletion-gate` as it did.
|
|
176
|
-
const
|
|
177
|
-
for (const g of GATE_SWITCHES) if (gates[g.key] === false) argv.push(g.flag);
|
|
176
|
+
for (const g of GATE_SWITCHES) if (gates?.[g.key] === false) argv.push(g.flag);
|
|
178
177
|
return argv;
|
|
179
178
|
}
|
|
180
179
|
|
|
@@ -238,7 +237,7 @@ export function installProfile(host, scope, root, notify = null) {
|
|
|
238
237
|
const gates = gatesOfDir(root, host);
|
|
239
238
|
const out = scope === 'global' ? null : root;
|
|
240
239
|
const argv = setupBuildArgs(theme, emit, out, out, footprint, posture, mode, doctrines,
|
|
241
|
-
PACK_ORDER, excludeRules, trust, excludeSkills, gates
|
|
240
|
+
PACK_ORDER, excludeRules, trust, excludeSkills, gates);
|
|
242
241
|
// A GLOBAL ROW NAMES ITS OWN DIR. `root` came from the host's config-dir resolver, which
|
|
243
242
|
// honours env overrides (`OPENCODE_CONFIG_DIR`, `CLAUDE_CONFIG_DIR`…); without the flag the
|
|
244
243
|
// same argv run from a shell lacking that variable re-emits into the DEFAULT dir — a second
|
package/js/build/source.mjs
CHANGED
|
@@ -385,6 +385,19 @@ export function resolveSkillNames(names, {
|
|
|
385
385
|
return { ids: [...new Set(ids)].sort(), notices, unknown };
|
|
386
386
|
}
|
|
387
387
|
|
|
388
|
+
/**
|
|
389
|
+
* The laws whose hook gate an install can switch off, in law order: the cfg/profile key, the
|
|
390
|
+
* build flag, the carrier marker (`<marker>: off`, written only when off) and the law. ONE
|
|
391
|
+
* table, so the flag, the marker, the rebuild argv, the read-back and `status` cannot drift
|
|
392
|
+
* apart. Every gate is ARMED by default and the law's text binds either way. The deletion row
|
|
393
|
+
* is #319's, unchanged: existing installs carry its flag and marker.
|
|
394
|
+
*/
|
|
395
|
+
export const GATE_SWITCHES = [
|
|
396
|
+
{ key: 'secretsGate', flag: '--no-secrets-gate', arg: 'noSecretsGate', marker: 'Secrets gate', law: 'I' },
|
|
397
|
+
{ key: 'doneGate', flag: '--no-done-gate', arg: 'noDoneGate', marker: 'Done gate', law: 'III' },
|
|
398
|
+
{ key: 'deletionGate', flag: '--no-deletion-gate', arg: 'noDeletionGate', marker: 'Deletion gate', law: 'IV' },
|
|
399
|
+
];
|
|
400
|
+
|
|
388
401
|
/**
|
|
389
402
|
* `_build_core.js_cfg()`, originated rather than received.
|
|
390
403
|
*
|
|
@@ -399,8 +412,7 @@ export function resolveSkillNames(names, {
|
|
|
399
412
|
*/
|
|
400
413
|
export function makeCfg({
|
|
401
414
|
posture = 'peer', mode = 'direct', trust = DEFAULT_PRESET, doctrines = PACK_ORDER,
|
|
402
|
-
excludeRules = [], excludeSkills = [],
|
|
403
|
-
noDoneGate = false,
|
|
415
|
+
excludeRules = [], excludeSkills = [], ...switches
|
|
404
416
|
} = {}) {
|
|
405
417
|
return {
|
|
406
418
|
root: ROOT,
|
|
@@ -428,25 +440,9 @@ export function makeCfg({
|
|
|
428
440
|
excludeRules: Array.isArray(excludeRules) ? [...excludeRules] : [],
|
|
429
441
|
// Skill names left out of this install — same "unspecified takes nothing away" default.
|
|
430
442
|
excludeSkills: Array.isArray(excludeSkills) ? [...excludeSkills] : [],
|
|
431
|
-
//
|
|
432
|
-
// only a literal `true` disarms, so every
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
// same contract (user decision 2026-10-18; see GATE_SWITCHES).
|
|
436
|
-
secretsGate: noSecretsGate !== true,
|
|
437
|
-
doneGate: noDoneGate !== true,
|
|
443
|
+
// The gate switches (`GATE_SWITCHES`: `secretsGate`, `doneGate`, `deletionGate`), each armed
|
|
444
|
+
// unless its `no*` arg says so in so many words: only a literal `true` disarms, so every
|
|
445
|
+
// caller that never heard of a switch keeps the gate.
|
|
446
|
+
...Object.fromEntries(GATE_SWITCHES.map((g) => [g.key, switches[g.arg] !== true])),
|
|
438
447
|
};
|
|
439
448
|
}
|
|
440
|
-
|
|
441
|
-
/**
|
|
442
|
-
* The laws whose hook gate an install can switch off, in law order: the cfg/profile key, the
|
|
443
|
-
* build flag, the carrier marker (`<marker>: off`, written only when off) and the law. ONE
|
|
444
|
-
* table, so the flag, the marker, the rebuild argv, the read-back and `status` cannot drift
|
|
445
|
-
* apart. Every gate is ARMED by default and the law's text binds either way. The deletion row
|
|
446
|
-
* is #319's, unchanged: existing installs carry its flag and marker.
|
|
447
|
-
*/
|
|
448
|
-
export const GATE_SWITCHES = [
|
|
449
|
-
{ key: 'secretsGate', flag: '--no-secrets-gate', arg: 'noSecretsGate', marker: 'Secrets gate', law: 'I' },
|
|
450
|
-
{ key: 'doneGate', flag: '--no-done-gate', arg: 'noDoneGate', marker: 'Done gate', law: 'III' },
|
|
451
|
-
{ key: 'deletionGate', flag: '--no-deletion-gate', arg: 'noDeletionGate', marker: 'Deletion gate', law: 'IV' },
|
|
452
|
-
];
|
package/js/hosts/gitref.mjs
CHANGED
|
@@ -12,9 +12,11 @@
|
|
|
12
12
|
* `child_process` ban like every other closure that feeds `bin/geneseed-cli.mjs` — may ever
|
|
13
13
|
* import. A module that is fs/path only is importable from anywhere.
|
|
14
14
|
*
|
|
15
|
-
* The OpenCode guard plugin
|
|
16
|
-
*
|
|
17
|
-
*
|
|
15
|
+
* The OpenCode guard plugin (`adapters/opencode/plugins/geneseed-guard.js`) is copied whole
|
|
16
|
+
* into an install and cannot import this, so it carries twins of `gitRootOf`, `gitDirOf`,
|
|
17
|
+
* `gitCommonDirOf`, `currentBranch`, `protectedBranches` and `stashExists` for its Law IV and
|
|
18
|
+
* protected-checks gates. `tests/plugins/guard.test.mjs` compares each with the original here;
|
|
19
|
+
* change one, change both.
|
|
18
20
|
*/
|
|
19
21
|
import { existsSync, statSync, lstatSync, readFileSync, readdirSync, openSync, readSync, closeSync } from 'node:fs';
|
|
20
22
|
import path from 'node:path';
|
package/js/hosts/hooks-done.mjs
CHANGED
|
@@ -28,6 +28,11 @@
|
|
|
28
28
|
* result is not `is_error` — every non-zero exit, denial and timeout carries it (checked on
|
|
29
29
|
* 14 000+ real Claude Code results, 2026-10-17).
|
|
30
30
|
*
|
|
31
|
+
* READ FROM THE END (constant-cost Stop). Only the current turn matters, so the transcript is read
|
|
32
|
+
* backward in growing windows (`tailReader`) until the turn's opening prompt is inside one; a
|
|
33
|
+
* turn longer than every window falls back to the whole file. Never judged on a truncated turn:
|
|
34
|
+
* a window without a prompt answers "read more", not "no edit".
|
|
35
|
+
*
|
|
31
36
|
* At most one block per turn: `stop_hook_active` is true when this Stop follows a block, and the
|
|
32
37
|
* gate then lets the turn end and leaves a ledger line, so a gate that cries wolf gets found.
|
|
33
38
|
*/
|
|
@@ -37,7 +42,7 @@ import { readText, printOut as out, printErr as err } from '../lib/fs.mjs';
|
|
|
37
42
|
import { within } from '../lib/paths.mjs';
|
|
38
43
|
import { gitRootOf } from './gitref.mjs';
|
|
39
44
|
import { DONE_LIST, recordGate } from './hooks.mjs';
|
|
40
|
-
import { splitLines } from './hooks-prims.mjs';
|
|
45
|
+
import { splitLines, tailReader } from './hooks-prims.mjs';
|
|
41
46
|
|
|
42
47
|
const EDIT_TOOLS = new Set(['Write', 'Edit', 'NotebookEdit', 'MultiEdit']);
|
|
43
48
|
const SHELL_TOOLS = new Set(['Bash', 'PowerShell']);
|
|
@@ -102,18 +107,24 @@ function isPrompt(obj) {
|
|
|
102
107
|
/**
|
|
103
108
|
* The decision, over transcript TEXT: the checks that have not passed since the turn's last
|
|
104
109
|
* edit, or [] when the turn may end (no edit, or every check passed after it). Exported for the
|
|
105
|
-
* unit tier — a transcript is text no CLI cell can vary cheaply.
|
|
110
|
+
* unit tier — a transcript is text no CLI cell can vary cheaply. `whole` false means the text
|
|
111
|
+
* is only the file's tail: with no prompt in it the turn may start earlier, so the answer is
|
|
112
|
+
* null ("read more") — a turn judged from its middle could miss its edit and wrongly pass.
|
|
106
113
|
*/
|
|
107
|
-
export function doneDecide(transcript, checks, root, cwd = root) {
|
|
114
|
+
export function doneDecide(transcript, checks, root, cwd = root, whole = true) {
|
|
108
115
|
const entries = [];
|
|
109
116
|
for (const line of splitLines(transcript)) {
|
|
110
117
|
if (!line.trim()) continue;
|
|
111
118
|
try { entries.push(JSON.parse(line)); } catch { /* a torn last line is not evidence */ }
|
|
112
119
|
}
|
|
113
|
-
let start =
|
|
120
|
+
let start = -1;
|
|
114
121
|
for (let i = entries.length - 1; i >= 0; i -= 1) {
|
|
115
122
|
if (isPrompt(entries[i])) { start = i + 1; break; }
|
|
116
123
|
}
|
|
124
|
+
if (start === -1) {
|
|
125
|
+
if (!whole) return null;
|
|
126
|
+
start = 0;
|
|
127
|
+
}
|
|
117
128
|
const calls = []; // tool_use blocks, in order
|
|
118
129
|
const results = new Map(); // tool_use_id -> is_error
|
|
119
130
|
for (const obj of entries.slice(start)) {
|
|
@@ -147,9 +158,10 @@ export function doneDecide(transcript, checks, root, cwd = root) {
|
|
|
147
158
|
* The Stop-side wrapper `cmdLearn` calls first. Returns true when it printed a block (the turn
|
|
148
159
|
* goes on, so `learn` must not distil yet); false in every other case, with nothing on stdout.
|
|
149
160
|
* `install` is the root `cmdLearn` already derived from `--memory` (null without one), so the
|
|
150
|
-
* ledger lands in the same root its sovereignty check read.
|
|
161
|
+
* ledger lands in the same root its sovereignty check read. `read` is `cmdLearn`'s per-Stop
|
|
162
|
+
* transcript reader, so the distillation after an allow reuses the windows read here.
|
|
151
163
|
*/
|
|
152
|
-
export function doneGate(meta, args, install) {
|
|
164
|
+
export function doneGate(meta, args, install, read = tailReader()) {
|
|
153
165
|
try {
|
|
154
166
|
const cwd = typeof meta.cwd === 'string' && meta.cwd ? meta.cwd : process.cwd();
|
|
155
167
|
const root = gitRootOf(cwd);
|
|
@@ -160,15 +172,15 @@ export function doneGate(meta, args, install) {
|
|
|
160
172
|
err(`[done-gate] no transcript${args.host === 'bob' ? ' (bob)' : ''} — turn not judged.\n`);
|
|
161
173
|
return false;
|
|
162
174
|
}
|
|
163
|
-
let
|
|
175
|
+
let missing;
|
|
164
176
|
try {
|
|
165
|
-
|
|
177
|
+
missing = read(meta.transcript_path,
|
|
178
|
+
(text, whole) => doneDecide(text, checks, root, cwd, whole));
|
|
166
179
|
} catch {
|
|
167
180
|
// No ask tier on Stop: blocking on an unreadable transcript would trap the user.
|
|
168
181
|
err('[done-gate] transcript unreadable — turn not judged.\n');
|
|
169
182
|
return false;
|
|
170
183
|
}
|
|
171
|
-
const missing = doneDecide(transcript, checks, root, cwd);
|
|
172
184
|
if (!missing.length) return false;
|
|
173
185
|
if (meta.stop_hook_active === true) {
|
|
174
186
|
recordGate(install, 'done-gate-cap', 'law-3');
|
package/js/hosts/hooks-learn.mjs
CHANGED
|
@@ -12,7 +12,7 @@ import { NO_WINDOW } from '../lib/proc.mjs';
|
|
|
12
12
|
import {
|
|
13
13
|
globalHookStandingDown, hookProjectDir, resolveMemoryDir, sovereignBypass,
|
|
14
14
|
} from './hosts.mjs';
|
|
15
|
-
import { readStdin, splitLines, splitWords } from './hooks-prims.mjs';
|
|
15
|
+
import { readStdin, splitLines, splitWords, tailReader } from './hooks-prims.mjs';
|
|
16
16
|
import { doneGate } from './hooks-done.mjs';
|
|
17
17
|
import {
|
|
18
18
|
existingSlugs, readFacts, writeMemories, consolidateMemory, appendAgentLesson,
|
|
@@ -94,15 +94,26 @@ function contentText(content) {
|
|
|
94
94
|
return '';
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
-
/**
|
|
98
|
-
|
|
99
|
-
|
|
97
|
+
/**
|
|
98
|
+
* Render a Claude-Code-style JSONL transcript into `role: text` notes, read from the END: the
|
|
99
|
+
* window grows until its notes hold twice MAX_NOTES_CHARS UTF-16 units (so at least
|
|
100
|
+
* MAX_NOTES_CHARS code points) or it is the whole file. The window's notes are a suffix of the
|
|
101
|
+
* whole file's, so the tail `cmdLearn` keeps is the one a full read would give — and a tail of
|
|
102
|
+
* tool output, which flattens to nothing, just reads further back.
|
|
103
|
+
*/
|
|
104
|
+
function flattenTranscript(p, read) {
|
|
100
105
|
try {
|
|
101
106
|
// `errors="replace"`: Node's utf8 decoder already substitutes U+FFFD.
|
|
102
|
-
|
|
107
|
+
return read(p, (raw, whole) => {
|
|
108
|
+
const flat = flattenText(raw);
|
|
109
|
+
return whole || flat.length >= 2 * MAX_NOTES_CHARS ? flat : null;
|
|
110
|
+
});
|
|
103
111
|
} catch {
|
|
104
112
|
return '';
|
|
105
113
|
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function flattenText(raw) {
|
|
106
117
|
const acc = [];
|
|
107
118
|
for (let line of splitLines(raw)) {
|
|
108
119
|
line = line.trim();
|
|
@@ -152,11 +163,11 @@ function flattenTranscript(p) {
|
|
|
152
163
|
* store, a transcript payload that is not JSON. Those are decisions over text, and the corpus
|
|
153
164
|
* reaches none of them.
|
|
154
165
|
*/
|
|
155
|
-
export function readNotes(raw, meta = hookMeta(raw)) {
|
|
166
|
+
export function readNotes(raw, meta = hookMeta(raw), read = tailReader()) {
|
|
156
167
|
if (!raw.trim()) return '';
|
|
157
168
|
const tp = meta.agent_transcript_path || meta.transcript_path;
|
|
158
169
|
if (!tp) return raw;
|
|
159
|
-
const flat = flattenTranscript(tp);
|
|
170
|
+
const flat = flattenTranscript(tp, read);
|
|
160
171
|
const lastMsg = typeof meta.last_assistant_message === 'string'
|
|
161
172
|
? meta.last_assistant_message.trim() : '';
|
|
162
173
|
if (!flat) return lastMsg || flat;
|
|
@@ -314,7 +325,9 @@ export function cmdLearn(args) {
|
|
|
314
325
|
// means the turn goes on, so there is nothing to distil yet: the next Stop will.
|
|
315
326
|
// `--no-done-gate` (the install switched Law III's gate off) skips the check, never the
|
|
316
327
|
// distillation below.
|
|
317
|
-
|
|
328
|
+
// One reader for this Stop: an allowed turn's distillation reuses the windows the gate read.
|
|
329
|
+
const read = tailReader();
|
|
330
|
+
if (hookEvent === 'Stop' && !args.file && !args.noDoneGate && doneGate(meta, args, install, read)) return 0;
|
|
318
331
|
|
|
319
332
|
// I2 (Bob): its Stop payload never carries `transcript_path`, so there is never a transcript
|
|
320
333
|
// to distil — and the bare envelope itself is not notes text (that would waste a model call
|
|
@@ -334,7 +347,7 @@ export function cmdLearn(args) {
|
|
|
334
347
|
|
|
335
348
|
// B3: a real hook call with `$GENESEED_LLM` unset has nothing to send a prompt to — stdout on
|
|
336
349
|
// Stop/SubagentStop/PreCompact reaches only the debug log, never the user (docs `hooks.md`) —
|
|
337
|
-
// so reading and flattening the transcript (
|
|
350
|
+
// so reading and flattening the transcript (a 2 MB tail or more, every turn) just to print it nowhere
|
|
338
351
|
// is pure waste. Checked BEFORE `readNotes`, which is what does that read. A manual
|
|
339
352
|
// invocation keeps printing the prompt on an unset `$GENESEED_LLM` — that is the documented,
|
|
340
353
|
// deliberate behaviour (docs/reference/env-context.md) for someone running `learn` by hand to
|
|
@@ -345,7 +358,7 @@ export function cmdLearn(args) {
|
|
|
345
358
|
return 0;
|
|
346
359
|
}
|
|
347
360
|
|
|
348
|
-
let notes = readNotes(raw, meta);
|
|
361
|
+
let notes = readNotes(raw, meta, read);
|
|
349
362
|
if (!notes.trim()) {
|
|
350
363
|
err('[learn] no notes or transcript content \u2014 nothing to distil.\n');
|
|
351
364
|
return 0;
|
package/js/hosts/hooks-prims.mjs
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
import { statSync, readFileSync, readdirSync } from 'node:fs';
|
|
11
11
|
import path from 'node:path';
|
|
12
12
|
import { comparePaths } from '../lib/paths.mjs';
|
|
13
|
+
import { readTail } from '../lib/fs.mjs';
|
|
13
14
|
|
|
14
15
|
// ---- Python path and text primitives -------------------------------------------------
|
|
15
16
|
// Small, and each one is here because a JS-idiomatic equivalent differs observably.
|
|
@@ -75,6 +76,30 @@ export function splitLines(text) {
|
|
|
75
76
|
return parts;
|
|
76
77
|
}
|
|
77
78
|
|
|
79
|
+
/** The first window a Stop reads from a transcript's end; real transcripts reach 50 MB. */
|
|
80
|
+
export const TAIL_BYTES = 2 * 1024 * 1024;
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* A transcript reader for ONE hook invocation: `read(file, judge)` hands `judge(text, whole)`
|
|
84
|
+
* the file's last `bytes`, then a window four times larger, until `judge` answers (anything
|
|
85
|
+
* but null) or the window holds the whole file — where it must answer. Each window is read once
|
|
86
|
+
* per reader, so the done gate and `learn` share one read when both run on the same Stop.
|
|
87
|
+
* A read error (missing, unreadable) throws to the caller, uncached. Never a module-level cache:
|
|
88
|
+
* the host appends between Stops, and a test rewrites the same path.
|
|
89
|
+
*/
|
|
90
|
+
export function tailReader(bytes = TAIL_BYTES) {
|
|
91
|
+
const seen = new Map();
|
|
92
|
+
return (file, judge) => {
|
|
93
|
+
for (let n = bytes; ; n *= 4) {
|
|
94
|
+
const key = `${n}:${file}`;
|
|
95
|
+
if (!seen.has(key)) seen.set(key, readTail(file, n));
|
|
96
|
+
const { text, start } = seen.get(key);
|
|
97
|
+
const answer = judge(text, start === 0);
|
|
98
|
+
if (answer !== null || start === 0) return answer;
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
78
103
|
/** `str.split()` with no argument — runs of whitespace, ends stripped, no empties. */
|
|
79
104
|
export function splitWords(s) {
|
|
80
105
|
return s.split(/\s+/).filter(Boolean);
|
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
|
|
|
@@ -438,8 +438,18 @@ const rootArg = (t) => !/^["']/.test(t) && FS_ROOT_RE.test(t);
|
|
|
438
438
|
function gitDecide(args, payload) {
|
|
439
439
|
const command = ((payload && payload.tool_input) || {}).command;
|
|
440
440
|
if (typeof command !== 'string') return 0;
|
|
441
|
-
//
|
|
442
|
-
//
|
|
441
|
+
// Law IV's catastrophic class (a root, a drive, the home dir, a disk): no state makes it safe,
|
|
442
|
+
// so it runs BEFORE the switch below and whatever it says (user decision, 2026-10-18). On
|
|
443
|
+
// Claude an ask is the strongest verdict the hook has; on Bob, `law-4` exits 2.
|
|
444
|
+
const doomed = catastrophic(command, lossSegments);
|
|
445
|
+
if (doomed) {
|
|
446
|
+
return ask(args, 'git-gate', 'law-4', `Geneseed (Deletion Is Deliberate) \u2014 \`${doomed.slice(0, 80)}\` `
|
|
447
|
+
+ 'would destroy a filesystem root, a drive, the home directory or a whole disk, which nothing '
|
|
448
|
+
+ 'could restore. This check stands even with the deletion gate off.');
|
|
449
|
+
}
|
|
450
|
+
// `--no-deletion-gate`: the install switched Law IV's real-loss boundary off (user decision,
|
|
451
|
+
// 2026-10-17). Only this check stands down — the catastrophic class above, ops 2 and process 5
|
|
452
|
+
// below still run.
|
|
443
453
|
if (!args.noDeletionGate && LOSS_CANDIDATE_RE.test(command)) {
|
|
444
454
|
// A pass here FALLS THROUGH: a force push to a feature branch loses nothing, but it is still
|
|
445
455
|
// a push, and process 5 below still asks for it.
|
|
@@ -525,8 +535,13 @@ export const DONE_LIST = '.geneseed/done-checks.txt';
|
|
|
525
535
|
* not seen here; CI running the checks is the boundary that holds against that.
|
|
526
536
|
*/
|
|
527
537
|
function protectedCheck(p) {
|
|
538
|
+
return protectedIn(path.resolve(p)) ?? protectedIn(resolvePath(p));
|
|
539
|
+
}
|
|
540
|
+
// One reading of the target. `protectedCheck` takes two — the path as written and the path with
|
|
541
|
+
// links followed — so a link from outside into a protected folder and a protected folder that
|
|
542
|
+
// links out of the repo are both caught: following links never loosens the gate.
|
|
543
|
+
function protectedIn(target) {
|
|
528
544
|
try {
|
|
529
|
-
const target = resolvePath(p);
|
|
530
545
|
const root = gitRootOf(path.dirname(target));
|
|
531
546
|
if (!root) return null;
|
|
532
547
|
if (normcase(path.relative(root, target)) === normcase(DONE_LIST)) return DONE_LIST;
|
package/js/hosts/installs.mjs
CHANGED
|
@@ -609,9 +609,6 @@ export function gatesOfDir(d, host = null) {
|
|
|
609
609
|
return Object.fromEntries(GATE_SWITCHES.map((g) => [g.key, gateOfDir(d, g.marker, host)]));
|
|
610
610
|
}
|
|
611
611
|
|
|
612
|
-
/** Law IV's switch alone (#319's reader, kept for its callers). */
|
|
613
|
-
export const deletionGateOfDir = (d, host = null) => gateOfDir(d, 'Deletion gate', host);
|
|
614
|
-
|
|
615
612
|
// ---- what host a deployed dir belongs to (`_harness_mcp`) ---------------------------------
|
|
616
613
|
|
|
617
614
|
/** `_harness_mcp._claude_read_manifest`. */
|
|
@@ -838,7 +835,11 @@ export function installsReferencingShim(shimPath = hookShimPath(), targets = ins
|
|
|
838
835
|
// ---- what this machine installed (`_harness_setup._installed_defaults`) --------------------
|
|
839
836
|
|
|
840
837
|
/**
|
|
841
|
-
* `_harness_setup._installed_defaults` —
|
|
838
|
+
* `_harness_setup._installed_defaults` — eight keys: `theme`, `posture`, `mode`, `emit`,
|
|
839
|
+
* `footprint`, `doctrines`, `excludeRules`, `excludeSkills`. NOT the gate switches: a walk
|
|
840
|
+
* across every install on the machine would carry one host's `off` into another's re-run, and
|
|
841
|
+
* for a gate that disarms a boundary its owner never switched off — `setup` reads them off the
|
|
842
|
+
* one install it is re-running (`targetGates` in js/maintain/setup.mjs).
|
|
842
843
|
*
|
|
843
844
|
* It carried only `theme` and `emit` for four phases because those are the two `status`
|
|
844
845
|
* prints and the two `doctor` reads, and the port's keep-vs-delete rule is "part of an
|
package/js/hosts/loss.mjs
CHANGED
|
@@ -625,4 +625,102 @@ export function lossReason(segments, cwd, { always = true, split } = {}) {
|
|
|
625
625
|
return `the gate could not verify what this command would lose (${e.message})`;
|
|
626
626
|
}
|
|
627
627
|
}
|
|
628
|
+
|
|
629
|
+
/**
|
|
630
|
+
* THE CATASTROPHIC CLASS — what no state can make safe: a recursive removal of a filesystem root,
|
|
631
|
+
* a drive root or the home directory, a device format or raw-disk overwrite, a fork bomb. Unlike
|
|
632
|
+
* `lossReason` it reads no state, and it stands whatever the deletion gate's switch says (user
|
|
633
|
+
* decision 2026-10-18): `--no-deletion-gate` silences the real-loss check, never this one. Judged
|
|
634
|
+
* in PROGRAM position, segment by segment after `stripStructure` and the wrappers, so `true & rm
|
|
635
|
+
* -rf /`, `sudo rm -rf /`, `(rm -rf /)` and `rm -rf /;` are caught and a commit message that only
|
|
636
|
+
* names `rm -rf /` is not. A shell or `eval` (`bash -c '…'`, `pwsh -Command …`) and a `$(…)` body
|
|
637
|
+
* are judged as commands of their own, and so is what a shell reads on stdin: a heredoc or
|
|
638
|
+
* here-string fed to `bash`/`sh`/`pwsh`/`cmd`… (`bash <<'EOF'` over `rm -rf /`). One fed to any
|
|
639
|
+
* other program (`cat <<EOF`, `git commit -F - <<EOF`) is data. The fork bomb spans `|`/`&`/`;`, so
|
|
640
|
+
* it is tested on the whole command. `split` cuts a command into raw-token segments, as for
|
|
641
|
+
* `lossReason`. Returns the offending text, or null.
|
|
642
|
+
*/
|
|
643
|
+
const CATASTROPHIC_CANDIDATE_RE = /(?:^|[^\w-])(?:rm|rmdir|rd|del|erase|remove-item|ri|mkfs(?:\.\w+)?|dd|format)(?:\.exe)?(?![\w-])|:\(\)/i;
|
|
644
|
+
const FORK_BOMB_RE = /:\(\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;\s*:/;
|
|
645
|
+
// `/`, a Git Bash or WSL drive (`/c`, `/mnt/c`), a Windows drive (`C:`, `C:\`), the home dir — globbed or not.
|
|
646
|
+
const ROOT_TARGET_RE = /^(?:\/|~[\\/]?|\$\{?HOME\}?[\\/]?|\/[a-z]\/?|\/mnt\/[a-z]\/?|[a-z]:[\\/]?)\*?$/i;
|
|
647
|
+
const RM_RECURSE_RE = /^(?:-[a-zA-Z]*[rR][a-zA-Z]*|--recursive)$/;
|
|
648
|
+
// PowerShell's `-Recurse` (or any prefix of it) and cmd.exe's `/s`, alone or glued (`/s/q`).
|
|
649
|
+
const PS_CMD_RECURSE_RE = /^(?:-r(?:e(?:c(?:u(?:r(?:s(?:e)?)?)?)?)?)?|(?:\/[a-z]+)*\/s(?:\/[a-z]+)*)$/i;
|
|
650
|
+
const leadingFlags = (w) => { let i = 0; while (i < w.length && /^[-/][\w-]+$/.test(w[i])) i += 1; return w.slice(i); };
|
|
651
|
+
// The programs that run what they read on stdin as commands.
|
|
652
|
+
const STDIN_SHELLS = new Set(['bash', 'sh', 'zsh', 'dash', 'ksh', 'fish', 'pwsh', 'powershell', 'cmd']);
|
|
653
|
+
// The same lexer as the splitter's (a comment, a quoted run, a heredoc opener, a newline), so the
|
|
654
|
+
// openers it finds are the ones left in the segments' tokens, in the same order.
|
|
655
|
+
const HEREDOC_SCAN_RE = /(?<![^\s;|&(])#[^\n]*|"[^"]*"?|'[^']*'?|(?<!<)<<(?!<)(-?)[ \t]*(["']?)([A-Za-z_]\w*)\2|\n/g;
|
|
656
|
+
/** Every heredoc body in `command`, in opener order; an unterminated one runs to the end, as in bash. */
|
|
657
|
+
function heredocBodies(command) {
|
|
658
|
+
const out = [];
|
|
659
|
+
const tags = [];
|
|
660
|
+
const re = new RegExp(HEREDOC_SCAN_RE);
|
|
661
|
+
let m;
|
|
662
|
+
while ((m = re.exec(command))) {
|
|
663
|
+
if (m[3]) { tags.push([m[1], m[3]]); continue; }
|
|
664
|
+
if (m[0] !== '\n' || !tags.length) continue;
|
|
665
|
+
let start = re.lastIndex;
|
|
666
|
+
while (tags.length) {
|
|
667
|
+
const [dash, tag] = tags.shift();
|
|
668
|
+
const body = [];
|
|
669
|
+
while (start < command.length) {
|
|
670
|
+
const end = command.indexOf('\n', start);
|
|
671
|
+
const line = command.slice(start, end < 0 ? command.length : end).replace(/\r$/, '');
|
|
672
|
+
start = end < 0 ? command.length : end + 1;
|
|
673
|
+
if ((dash ? line.replace(/^\t+/, '') : line) === tag) break;
|
|
674
|
+
body.push(line);
|
|
675
|
+
}
|
|
676
|
+
out.push(body.join('\n'));
|
|
677
|
+
}
|
|
678
|
+
re.lastIndex = start;
|
|
679
|
+
}
|
|
680
|
+
return out;
|
|
681
|
+
}
|
|
682
|
+
export function catastrophic(command, split, depth = 0) {
|
|
683
|
+
if (!CATASTROPHIC_CANDIDATE_RE.test(command)) return null;
|
|
684
|
+
if (FORK_BOMB_RE.test(command)) return command.trim();
|
|
685
|
+
const bodies = command.includes('<<') ? heredocBodies(command) : [];
|
|
686
|
+
let next = 0;
|
|
687
|
+
for (const raw of split(command).map(stripStructure)) {
|
|
688
|
+
const text = raw.join(' ');
|
|
689
|
+
// What this segment feeds on stdin: each heredoc opener takes the next body, a here-string its word.
|
|
690
|
+
const fed = [];
|
|
691
|
+
raw.forEach((t, i) => {
|
|
692
|
+
if (t.startsWith('<<<')) fed.push(word(t.length > 3 ? t.slice(3) : raw[i + 1] || '').replace(OPAQUE, ''));
|
|
693
|
+
else if (t.startsWith('<<')) fed.push(bodies[next++] ?? '');
|
|
694
|
+
});
|
|
695
|
+
for (const body of depth < 4 ? substitutions(text) : []) {
|
|
696
|
+
const v = catastrophic(body, split, depth + 1);
|
|
697
|
+
if (v) return v;
|
|
698
|
+
}
|
|
699
|
+
let w = words(raw).map((t) => t.replace(OPAQUE, ''));
|
|
700
|
+
while (w.length && (/^\w+=/.test(w[0]) || WRAPPERS.has(progOf(w[0])))) {
|
|
701
|
+
w = /^\w+=/.test(w[0]) ? w.slice(1) : pastWrapper(progOf(w[0]), w.slice(1)) || leadingFlags(w.slice(1));
|
|
702
|
+
}
|
|
703
|
+
if (!w.length) continue;
|
|
704
|
+
const prog = progOf(w[0]);
|
|
705
|
+
for (const body of STDIN_SHELLS.has(prog) && depth < 4 ? fed : []) {
|
|
706
|
+
const v = catastrophic(body, split, depth + 1);
|
|
707
|
+
if (v) return v;
|
|
708
|
+
}
|
|
709
|
+
const args = w.slice(1).map((a) => a.replace(/^-(?:literal)?path[=:]/i, ''));
|
|
710
|
+
// cmd.exe's `/s` `/q` are switches, not the Git Bash drives `/s` `/q`.
|
|
711
|
+
const root = () => args.some((a) => ROOT_TARGET_RE.test(a) && !(CMD_SWITCH_PROGS.has(prog) && /^\/[a-z]$/i.test(a)));
|
|
712
|
+
let hit = false;
|
|
713
|
+
if (prog === 'rm') hit = args.some((a) => RM_RECURSE_RE.test(a)) && root();
|
|
714
|
+
else if (RM_PROGS.has(prog)) hit = args.some((a) => PS_CMD_RECURSE_RE.test(a)) && root();
|
|
715
|
+
else if (/^mkfs(?:\.\w+)?$/.test(prog)) hit = args.some((a) => a.startsWith('/dev/'));
|
|
716
|
+
else if (prog === 'dd') hit = args.some((a) => /^of=\/dev\/(?:sd|nvme|hd|disk)/.test(a));
|
|
717
|
+
else if (prog === 'format') hit = args.some((a) => /^[a-z]:/i.test(a));
|
|
718
|
+
else if (INDIRECT.has(prog) && depth < 4) {
|
|
719
|
+
const v = catastrophic(leadingFlags(w.slice(1)).join(' '), split, depth + 1);
|
|
720
|
+
if (v) return v;
|
|
721
|
+
}
|
|
722
|
+
if (hit) return text.trim();
|
|
723
|
+
}
|
|
724
|
+
return null;
|
|
725
|
+
}
|
|
628
726
|
// ---- loss twin: END
|