geneseed 3.20.0 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +35 -0
- package/README.md +9 -1
- package/SHIPPED.md +1 -1
- package/adapters/bob/README.md +31 -12
- package/adapters/claude-code/README.md +24 -17
- package/adapters/claude-code/settings.json +4 -6
- package/adapters/openclaude/README.md +13 -5
- package/adapters/opencode/plugins/geneseed-guard.js +291 -174
- package/bin/geneseed-hook.mjs +36 -5
- package/docs/README.md +3 -1
- package/docs/candidate-hosts.md +0 -8
- package/docs/concepts/enforcement-table.md +66 -0
- package/docs/concepts/hook-gates.md +2 -2
- package/docs/concepts/hooks.md +6 -6
- package/docs/concepts/host-matrix.md +4 -3
- package/docs/concepts/loop-branch.md +6 -1
- package/docs/concepts/rules.md +1 -1
- package/docs/design-history.md +59 -0
- package/docs/extending.md +11 -3
- package/docs/guides/install-opencode.md +1 -1
- package/docs/guides/loop-running.md +1 -1
- package/docs/reference/env-context.md +3 -2
- package/docs/reference/environment.md +2 -1
- package/docs/reference/plugin-guard.md +3 -3
- package/docs/reference/troubleshoot-hooks.md +34 -0
- package/docs/understand/design-choices-engineering.md +29 -0
- package/docs/understand/design-choices-gates.md +23 -0
- package/docs/understand/design-choices-hosts.md +21 -0
- package/docs/understand/design-choices-install.md +19 -0
- package/docs/understand/design-choices-shape.md +21 -0
- package/docs/understand/design-choices.md +34 -0
- package/docs/understand/enforced-vs-asked.md +5 -4
- package/docs/understand/machine-bob.md +1 -1
- package/docs/understand/machine-opencode.md +2 -2
- package/harness.config.json +1 -1
- package/js/build/driver.mjs +42 -16
- package/js/build/emit-claude.mjs +24 -8
- package/js/build/emit-opencode.mjs +33 -15
- package/js/build/generate.mjs +38 -9
- package/js/build/source.mjs +39 -0
- package/js/cli-table.json +6 -6
- package/js/hosts/gitref.mjs +3 -3
- package/js/hosts/hooks-context.mjs +11 -30
- package/js/hosts/hooks-learn.mjs +89 -8
- package/js/hosts/hooks.mjs +212 -18
- package/js/hosts/hosts.mjs +217 -13
- package/js/hosts/installs.mjs +259 -46
- package/js/hosts/native.mjs +48 -12
- package/js/hosts/opencode.mjs +47 -17
- package/js/hosts/settings.mjs +366 -42
- package/js/hosts/shim.mjs +37 -0
- package/js/inspect/checks-authoring.mjs +81 -1
- package/js/inspect/checks-constitution.mjs +2 -1
- package/js/inspect/checks-repo.mjs +68 -4
- package/js/inspect/doctor.mjs +40 -9
- package/js/inspect/scan.mjs +5 -4
- package/js/inspect/status.mjs +62 -12
- package/js/lib/text.mjs +23 -0
- package/js/loop/catalog.mjs +7 -2
- package/js/maintain/migrate.mjs +42 -5
- package/js/maintain/uninstall.mjs +150 -38
- package/js/maintain/update.mjs +22 -2
- package/js/web/actions.mjs +28 -10
- package/js/web/api.mjs +5 -3
- package/js/web/state.mjs +28 -15
- package/package.json +1 -1
- package/src/agents/_template.md +1 -1
- package/src/bricks/_template.md +1 -1
- package/src/doctrines/ops.md +9 -7
- package/src/laws/universal.md +1 -1
- package/src/skills/_template.md +3 -3
- package/src/skills/herdr.md +1 -1
- package/web/dist/assets/{Activity-IbwdHHyx.js → Activity-BCtqxjfy.js} +1 -1
- package/web/dist/assets/{ActivityDetail-B5uivdWI.js → ActivityDetail-Ba3Oo4dE.js} +1 -1
- package/web/dist/assets/{Dashboard-v6WQ37bD.js → Dashboard-DpHza68-.js} +2 -2
- package/web/dist/assets/{Docs-CypIfDBX.js → Docs-Bw_-UK0O.js} +1 -1
- package/web/dist/assets/{GreenhouseView-ClxP0hQG.js → GreenhouseView-DnJ1Ggp1.js} +1 -1
- package/web/dist/assets/{Installs-CPkQeFjO.js → Installs-CWoNKFBk.js} +1 -1
- package/web/dist/assets/{JournalView-BeEInhUf.js → JournalView-g6-bw4c8.js} +1 -1
- package/web/dist/assets/{Laws-Ds6U5xBQ.js → Laws-DkVWR1tb.js} +1 -1
- package/web/dist/assets/{Library-BraUKPyQ.js → Library-CK9HZzjV.js} +1 -1
- package/web/dist/assets/{Loops-DWcy3mwK.js → Loops-BP98SMOH.js} +1 -1
- package/web/dist/assets/{OperatorHudView-CQyrJQ9m.js → OperatorHudView-DI_n12M9.js} +1 -1
- package/web/dist/assets/{Personal-YWqs6qFL.js → Personal-Bk89f13a.js} +1 -1
- package/web/dist/assets/{Tabs-EhzEC-Xh.js → Tabs-3WuYIE1b.js} +1 -1
- package/web/dist/assets/{drawable-CEKPNV7X.js → drawable-oCUeq9e5.js} +1 -1
- package/web/dist/assets/{index-CPi22b7y.js → index-CiXwChvf.js} +2 -2
- package/web/dist/index.html +1 -1
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// "enforce by injection, don't just instruct" stance as the context plugin:
|
|
5
5
|
// - Sealed Secrets: block writes to private-key / credential files.
|
|
6
6
|
// - Deletion Is Deliberate: block catastrophic shell commands.
|
|
7
|
+
// - Commands Must Return: block a recursive scan rooted at a whole filesystem (find /).
|
|
7
8
|
// - Persist Insight (rule vs memory): speed-bump the first write to user-rules.md
|
|
8
9
|
// memory file — that choice belongs to the user, via the rule skill.
|
|
9
10
|
// - Wiki (AGENT.md §8): block mutations under a declared wiki's `protected`
|
|
@@ -17,21 +18,30 @@
|
|
|
17
18
|
// Install: dropped into the plugins dir by `build --emit opencode[-global]` (the *.js
|
|
18
19
|
// glob), exactly like the context and learn plugins. Errors never break a tool call.
|
|
19
20
|
//
|
|
20
|
-
// `permission.ask`
|
|
21
|
-
// `
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
//
|
|
21
|
+
// No `permission.ask` hook (OpenCode verdict I-2). Upstream declares one in its plugin
|
|
22
|
+
// types but never triggers it: `Permission.ask` evaluates the rules and publishes the
|
|
23
|
+
// `permission.asked` bus event with no plugin hook in between. The loop/* exemption this
|
|
24
|
+
// plugin once hung there (downgrade the static `git commit*`/`git push*` ask to allow on a
|
|
25
|
+
// launched loop branch, the twin of js/hosts/hooks.mjs's `loopExempt`) therefore never ran,
|
|
26
|
+
// and is gone. Lost behaviour, stated plainly: an OpenCode loop's COMMIT gets the static ask
|
|
27
|
+
// every time — that fails safe, one prompt, never a silent allow. The `permission.asked`
|
|
28
|
+
// event + SDK reply route was not taken: it races the TUI's own prompt and is unverified from
|
|
29
|
+
// inside a plugin.
|
|
30
|
+
//
|
|
31
|
+
// The PUSH half is recovered differently (user decision, 2026-10-10): pushing a `loop/*`
|
|
32
|
+
// branch is not pushing `main`/`master`, so it is allowed STATICALLY rather than dynamically —
|
|
33
|
+
// js/hosts/settings.mjs's `LOOP_PUSH_ALLOW` wires an unconditional `permission.bash` allow for
|
|
34
|
+
// the loop engine's own refspec shapes (`git push [-u] <remote> HEAD:loop/*` and its
|
|
35
|
+
// `refs/heads/` spelling), ordered so a force push, a `+refspec`, or a `--delete`/bare-`:`
|
|
36
|
+
// push to `loop/*` still asks (`LAW_IV_BASH` comes after it, and `findLast` wins on the last
|
|
37
|
+
// match). This plugin has no part in that — it is a `permission.bash` key, read before any
|
|
38
|
+
// `tool.execute.before` hook runs — so there is nothing to change here for the push half; this
|
|
39
|
+
// comment only keeps this file's account in step. The commit still has no static equivalent:
|
|
40
|
+
// a commit's content cannot be named by a glob the way a push's target branch can, so the
|
|
41
|
+
// upgrade path there is still a session-level `permission` ruleset set by the loop launcher
|
|
42
|
+
// when it creates the loop session (`session.create` accepts one).
|
|
33
43
|
|
|
34
|
-
import { promises as fs, realpathSync, existsSync,
|
|
44
|
+
import { promises as fs, realpathSync, existsSync, readFileSync } from "node:fs"
|
|
35
45
|
import * as path from "node:path"
|
|
36
46
|
import { fileURLToPath } from "node:url"
|
|
37
47
|
import { homedir } from "node:os"
|
|
@@ -123,9 +133,25 @@ const SECRET_RE = [
|
|
|
123
133
|
/(^|\/)\.npmrc$/i,
|
|
124
134
|
/(^|\/)\.pypirc$/i,
|
|
125
135
|
]
|
|
126
|
-
// .env files are often edited legitimately → WARN, don't hard-block.
|
|
136
|
+
// .env files are often edited legitimately → WARN, don't hard-block. Considered aligning this
|
|
137
|
+
// with js/hosts/hooks.mjs's DOTENV_RE (`/(^|[\\/])\.env(\.[^\\/]*)?$/`) the way SECRET_CONTENT_RE
|
|
138
|
+
// is aligned with SECRET_RE below — but the two diverge beyond case/slash-style: DOTENV_RE's
|
|
139
|
+
// suffix is `*` (zero-or-more, any non-slash char — matches `.env.` and `.env!`), this one's is
|
|
140
|
+
// `+` over `[\w.-]` (one-or-more, word/dot/hyphen only). Widening to DOTENV_RE's shape would
|
|
141
|
+
// change which filenames warn instead of block on THIS host, not just fold case, so it is left
|
|
142
|
+
// as-is rather than silently changing guard behaviour for a parity task.
|
|
127
143
|
const SECRET_WARN_RE = [/(^|\/)\.env(\.[\w.-]+)?$/i]
|
|
128
144
|
|
|
145
|
+
// Twin of js/hosts/hooks.mjs's SECRET_RE (Law I's boundary half) — scans WRITE CONTENT for a
|
|
146
|
+
// credential-shaped string, not the target path (SECRET_RE above is the path twin, file names
|
|
147
|
+
// only). High-precision vendor prefixes only, same rationale as the Node side: a generic
|
|
148
|
+
// "looks like entropy" scan fires on every hash and lockfile. Kept byte-identical by the parity
|
|
149
|
+
// test in tests/plugins/guard.test.mjs — simple enough to literally diff, unlike SHELL_WARN_RE's
|
|
150
|
+
// hand-mirrored git acts. `.env*` is exempt (SECRET_WARN_RE above already names it as where a
|
|
151
|
+
// secret may legitimately live) via the same path check used for the WARN tier.
|
|
152
|
+
const SECRET_CONTENT_RE =
|
|
153
|
+
/\b(?:AKIA[0-9A-Z]{16}|ghp_[A-Za-z0-9]{30,}|github_pat_[A-Za-z0-9_]{30,}|sk-ant-[A-Za-z0-9_-]{20,}|xox[abprs]-[0-9A-Za-z-]{10,})\b|-----BEGIN [A-Z ]*PRIVATE KEY-----/
|
|
154
|
+
|
|
129
155
|
// Catastrophic, effectively irreversible shell → BLOCK. Both Unix and Windows shells,
|
|
130
156
|
// since OpenCode runs natively on Windows and the agent may emit cmd / PowerShell.
|
|
131
157
|
const SHELL_BLOCK_RE = [
|
|
@@ -142,16 +168,217 @@ const SHELL_BLOCK_RE = [
|
|
|
142
168
|
/\bremove-item\b[^\n]*-recurse\b[^\n]*-force\b/i, // PowerShell rm -rf
|
|
143
169
|
/\bremove-item\b[^\n]*-force\b[^\n]*-recurse\b/i, // (either flag order)
|
|
144
170
|
]
|
|
145
|
-
// History-rewriting / irreversible git ops → WARN
|
|
146
|
-
|
|
171
|
+
// History-rewriting / irreversible git ops (Deletion Is Deliberate) → WARN, never BLOCK:
|
|
172
|
+
// `tool.execute.before` has no ask tier, so these are a speed bump rather than a wall. The
|
|
173
|
+
// same acts js/hosts/hooks.mjs's `DESTRUCTIVE_GIT_RE` asks about — mirrored by hand rather
|
|
174
|
+
// than shared, since this plugin is copied whole into an OpenCode install, outside this
|
|
175
|
+
// repo's module graph. `clean`
|
|
176
|
+
// and `--delete` need `--force` alongside them to count, EITHER order (`--force --delete` is
|
|
177
|
+
// the same act as `--delete --force`) — an un-forced `clean`/`branch --delete` already refuses
|
|
178
|
+
// or only removes what is reproducible. `-D --force`/`--delete -f`/`-d --force`/`-df` are the
|
|
179
|
+
// same act again and are a known ceiling this does not chase. `git switch -h` lists `-f,
|
|
180
|
+
// --force` and `--discard-changes` as two SEPARATE options, not one flag's long/short spelling,
|
|
181
|
+
// so both are listed. `restore` and `stash drop`/`clear` are anchored to the VERB position
|
|
182
|
+
// (`\bgit\s+restore\b`, not `\bgit\s+.*restore\b`) — every other row needs a flag that is
|
|
183
|
+
// vanishingly unlikely in a commit message or filename, but `restore` needs none and
|
|
184
|
+
// `drop`/`clear` are ordinary English, so a message like "restore working behavior" or "clear
|
|
185
|
+
// old state" must never warn. `restore` matches UNLESS `--staged` appears on the line, EXCEPT
|
|
186
|
+
// `--staged --worktree` together, which restores the working tree too and so discards exactly
|
|
187
|
+
// like plain `restore` (see the twin rationale and tests in js/hosts/hooks.mjs).
|
|
188
|
+
const SHELL_WARN_RE = [
|
|
189
|
+
/\bgit\s+push\b[^\n]*(--force\b|-f\b|\+\S|\s--delete\b|\s:\S)/, // forced, mirror, or delete push
|
|
190
|
+
/\bgit\s+reset\s+--hard\b/,
|
|
191
|
+
/\bgit\s+clean\b[^\n]*\s(-[a-zA-Z]*f|--force\b)/,
|
|
192
|
+
/\bgit\s+branch\b[^\n]*\s(-D\b|--delete\b[^\n]*--force\b|--force\b[^\n]*--delete\b)/,
|
|
193
|
+
/\bgit\s+checkout\s+--\s/,
|
|
194
|
+
/\bgit\s+checkout\b[^\n]*\s(-[a-zA-Z]*f\b|--force\b)/,
|
|
195
|
+
/\bgit\s+switch\b[^\n]*\s(-[a-zA-Z]*f\b|--force\b|--discard-changes\b)/,
|
|
196
|
+
/\bgit\s+restore\b(?:(?![^\n]*--staged\b)|(?=[^\n]*--staged\b)(?=[^\n]*--worktree\b))/,
|
|
197
|
+
/\bgit\s+stash\s+(drop|clear)\b/,
|
|
198
|
+
/\bgit\s+worktree\b[^\n]*\bremove\b[^\n]*(-[a-zA-Z]*f\b|--force\b)/,
|
|
199
|
+
/\bgit\s+reflog\b[^\n]*\bexpire\b/,
|
|
200
|
+
/\bgit\s+gc\b[^\n]*--prune\b/,
|
|
201
|
+
]
|
|
202
|
+
|
|
203
|
+
// A recursive scan rooted at a whole filesystem (Commands Must Return) → BLOCK. Twin of
|
|
204
|
+
// js/hosts/hooks.mjs's `rootScan` — keep in step; tests/plugins/guard.test.mjs diffs the
|
|
205
|
+
// constants below against it. In Git Bash `/` mounts every drive and share: on 2026-10-10 a
|
|
206
|
+
// `find / … | head -5` ran 3.5 h (`| head` stops nothing). Exempt: a segment whose program is ssh, a quoted argument, any path
|
|
207
|
+
// deeper than a root, a scanner word that is not the program. Backslash is never an escape in
|
|
208
|
+
// the tokeniser, so `C:\ -Recurse` stays two tokens.
|
|
209
|
+
const SCAN_ALWAYS = new Set(["find", "du", "tree", "rg", "fd", "where.exe"])
|
|
210
|
+
const SCAN_GREP_R = new Set(["grep", "egrep"])
|
|
211
|
+
const SCAN_LS_R = new Set(["ls", "dir"])
|
|
212
|
+
const SCAN_PS_R = new Set(["get-childitem", "gci"])
|
|
213
|
+
// `-r` recurses for grep only; for ls it is reverse, so ls/dir need an uppercase `R` cluster.
|
|
214
|
+
const GREP_RECURSE_RE = /^(?:-[a-zA-Z]*[rR][a-zA-Z]*|--recursive)$/
|
|
215
|
+
const LS_RECURSE_RE = /^(?:-[a-zA-Z]*R[a-zA-Z]*|--recursive)$/
|
|
216
|
+
const PS_RECURSE_RE = /^-r(?:e(?:c(?:u(?:r(?:s(?:e)?)?)?)?)?)?$/i
|
|
217
|
+
const FS_ROOT_RE = /^(?:\/|\/[a-z]\/?|\/mnt\/[a-z]\/?|[a-z]:[\\/]?)\*?$/i
|
|
218
|
+
const SCAN_WORD_RE = /\b(?:find|du|tree|rg|fd|where\.exe|grep|egrep|ls|dir|get-childitem|gci)\b/i
|
|
219
|
+
const SCAN_TOKEN_RE = /(?:[^\s"']+|"[^"]*"?|'[^']*'?)+/g
|
|
220
|
+
const SCAN_WRAPPERS = new Set(["sudo", "command", "\\builtin", "time", "nice"])
|
|
221
|
+
// A comment (`#` starting a word), a quoted run (its `;`/`|` is data), a separator, or a heredoc
|
|
222
|
+
// opener (never the `<<<` here-string).
|
|
223
|
+
const SCAN_SPLIT_RE = /(?<![^\s;|&(])#[^\n]*|"[^"]*"?|'[^']*'?|\|\||&&|[|;\n]|(?<!<)<<(?!<)(-?)[ \t]*(["']?)([A-Za-z_]\w*)\2/g
|
|
224
|
+
// Cut at separators OUTSIDE quotes and drop every heredoc body (to its TAG line; `<<-` allows
|
|
225
|
+
// tabs): a commit message that names a root scan is data, not a command to block.
|
|
226
|
+
function scanSegments(command) {
|
|
227
|
+
const segs = []
|
|
228
|
+
const tags = []
|
|
229
|
+
const re = new RegExp(SCAN_SPLIT_RE)
|
|
230
|
+
let start = 0
|
|
231
|
+
let m
|
|
232
|
+
while ((m = re.exec(command))) {
|
|
233
|
+
if (m[3]) { tags.push([m[1], m[3]]); continue }
|
|
234
|
+
if (m[0][0] === '"' || m[0][0] === "'") continue
|
|
235
|
+
segs.push(command.slice(start, m.index))
|
|
236
|
+
start = re.lastIndex
|
|
237
|
+
// A comment is cut OUT of its segment; the newline after it is the next separator.
|
|
238
|
+
if (m[0][0] === "#") continue
|
|
239
|
+
while (m[0] === "\n" && tags.length) {
|
|
240
|
+
const end = command.indexOf("\n", start)
|
|
241
|
+
const line = command.slice(start, end < 0 ? command.length : end).replace(/\r$/, "")
|
|
242
|
+
start = end < 0 ? command.length : end + 1
|
|
243
|
+
if ((tags[0][0] ? line.replace(/^\t+/, "") : line) === tags[0][1]) tags.shift()
|
|
244
|
+
if (end < 0) break
|
|
245
|
+
}
|
|
246
|
+
re.lastIndex = start
|
|
247
|
+
}
|
|
248
|
+
segs.push(command.slice(start))
|
|
249
|
+
return segs
|
|
250
|
+
}
|
|
251
|
+
function rootScan(command) {
|
|
252
|
+
// A segment whose program is ssh passes (its remote command is ssh's argument): ssh is in no
|
|
253
|
+
// scanner set below. Only that segment — `ssh h true; find /` still blocks.
|
|
254
|
+
if (!SCAN_WORD_RE.test(command)) return null
|
|
255
|
+
for (const seg of scanSegments(command)) {
|
|
256
|
+
// Quotes kept until the root test, so a quoted "a:" is told from a bare C:.
|
|
257
|
+
let toks = (seg.match(SCAN_TOKEN_RE) || []).filter((t) => !/^\w+=/.test(t))
|
|
258
|
+
while (toks.length && SCAN_WRAPPERS.has(unquote(toks[0]))) toks = toks.slice(1)
|
|
259
|
+
if (!toks.length) continue
|
|
260
|
+
// The program word without its path (either slash) or an `.exe` suffix.
|
|
261
|
+
const word = unquote(toks[0]).split(/[\\/]/).pop().toLowerCase()
|
|
262
|
+
const prog = SCAN_ALWAYS.has(word) ? word : word.replace(/\.exe$/, "")
|
|
263
|
+
const always = SCAN_ALWAYS.has(prog)
|
|
264
|
+
const grep = SCAN_GREP_R.has(prog)
|
|
265
|
+
const ls = SCAN_LS_R.has(prog)
|
|
266
|
+
const ps = SCAN_PS_R.has(prog)
|
|
267
|
+
if (!always && !grep && !ls && !ps) continue
|
|
268
|
+
// `-Path=x` and PowerShell's colon binding `-Path:C:\` read as the path itself.
|
|
269
|
+
const argv = toks.slice(1).map((t) => {
|
|
270
|
+
const m = /^-path[=:](.+)$/i.exec(t)
|
|
271
|
+
return m ? m[1] : t
|
|
272
|
+
})
|
|
273
|
+
if (!always && !argv.some((a) => (grep && GREP_RECURSE_RE.test(a))
|
|
274
|
+
|| (ls && (LS_RECURSE_RE.test(a) || (a.length >= 3 && PS_RECURSE_RE.test(a))))
|
|
275
|
+
|| (ps && PS_RECURSE_RE.test(a)))) continue
|
|
276
|
+
if (argv.some(rootArg)) return seg.trim()
|
|
277
|
+
}
|
|
278
|
+
return null
|
|
279
|
+
}
|
|
280
|
+
// For ls/dir a `-Recurse` abbreviation counts from `-re` up: bare `-r` there is POSIX reverse.
|
|
281
|
+
const unquote = (t) => t.replace(/^["']+|["']+$/g, "")
|
|
282
|
+
// A QUOTED token is data (a grep pattern, a path named in a search), never a root target.
|
|
283
|
+
const rootArg = (t) => !/^["']/.test(t) && FS_ROOT_RE.test(t)
|
|
284
|
+
|
|
285
|
+
// `apply_patch`'s only argument is `patchText` (OpenCode verdict I-1): every gpt-5*
|
|
286
|
+
// model gets ONLY this tool, never `edit`/`write`, so a path that lives inside the
|
|
287
|
+
// patch text — not in `filePath`/`path`/etc — must still hit every gate below, or
|
|
288
|
+
// Sealed Secrets, the protected-check gate, the rule store and the wiki gate all go
|
|
289
|
+
// dark for that model class. Markers match upstream's own parser
|
|
290
|
+
// (`packages/opencode/src/patch/index.ts` `parsePatchHeader`): `*** Add File:`,
|
|
291
|
+
// `*** Update File:` (optionally followed by `*** Move to:`, both the old and the new
|
|
292
|
+
// path count) and `*** Delete File:`. The `+`-prefixed body lines are the new file
|
|
293
|
+
// content, never a path, and are not scanned here — no gate in this file inspects
|
|
294
|
+
// write content, only the target path.
|
|
295
|
+
function parsePatchPaths(patchText) {
|
|
296
|
+
const out = []
|
|
297
|
+
const lines = String(patchText).split(/\r?\n/)
|
|
298
|
+
for (let i = 0; i < lines.length; i++) {
|
|
299
|
+
const line = lines[i]
|
|
300
|
+
let m = /^\*\*\* (?:Add|Delete) File:\s*(.+)$/.exec(line)
|
|
301
|
+
if (m) { out.push(m[1].trim()); continue }
|
|
302
|
+
m = /^\*\*\* Update File:\s*(.+)$/.exec(line)
|
|
303
|
+
if (m) {
|
|
304
|
+
out.push(m[1].trim())
|
|
305
|
+
const mv = /^\*\*\* Move to:\s*(.+)$/.exec(lines[i + 1] || "")
|
|
306
|
+
if (mv) { out.push(mv[1].trim()); i++ }
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
return out.filter(Boolean)
|
|
310
|
+
}
|
|
147
311
|
|
|
148
|
-
|
|
312
|
+
// Returns every candidate path for this call, normalized (backslashes -> `/`) and,
|
|
313
|
+
// for an `apply_patch` call, resolved against `cwd` (the session's `ctx.directory` —
|
|
314
|
+
// the same root `apply_patch.ts:72` resolves against upstream) since a patch marker
|
|
315
|
+
// is worktree-relative. Non-patch tools keep the single-path shape every other gate
|
|
316
|
+
// already expects, just wrapped in an array.
|
|
317
|
+
function pickPath(args, cwd) {
|
|
318
|
+
if (args && typeof args.patchText === "string") {
|
|
319
|
+
return parsePatchPaths(args.patchText)
|
|
320
|
+
.map((p) => path.resolve(cwd || process.cwd(), p).replace(/\\/g, "/"))
|
|
321
|
+
}
|
|
149
322
|
for (const k of ["filePath", "path", "file", "target", "filename"]) {
|
|
150
323
|
// Normalize Windows backslashes to `/` so the secret-path patterns (which use `/`
|
|
151
324
|
// as the segment separator) match `C:\Users\me\.ssh\id_rsa` the same as a POSIX path.
|
|
152
|
-
if (args && typeof args[k] === "string") return args[k].replace(/\\/g, "/")
|
|
325
|
+
if (args && typeof args[k] === "string") return [args[k].replace(/\\/g, "/")]
|
|
153
326
|
}
|
|
154
|
-
return
|
|
327
|
+
return []
|
|
328
|
+
}
|
|
329
|
+
// Per-FILE added content for an `apply_patch` call: `parsePatchPaths` flattens every marker
|
|
330
|
+
// path into one list (right for the path-only gates, which must catch a secret/protected name
|
|
331
|
+
// on either side of a `Move to`), but the CONTENT scan must not pool every file's `+` lines
|
|
332
|
+
// into one string — a patch that touches a `.env` AND an ordinary file would then exempt the
|
|
333
|
+
// ordinary file's own secret too, because the exemption was computed over the pool, not the
|
|
334
|
+
// line's own file. One section per marker; a `Move to` target is where the `+` lines actually
|
|
335
|
+
// land, so content is attributed to it, not the pre-move path. `*** Delete File:` sections
|
|
336
|
+
// collect no `+` lines (nothing is being written).
|
|
337
|
+
function parsePatchSections(patchText) {
|
|
338
|
+
const out = []
|
|
339
|
+
let current = null
|
|
340
|
+
const flush = () => { if (current) out.push(current) }
|
|
341
|
+
const lines = String(patchText).split(/\r?\n/)
|
|
342
|
+
for (let i = 0; i < lines.length; i++) {
|
|
343
|
+
const line = lines[i]
|
|
344
|
+
let m = /^\*\*\* (?:Add|Delete) File:\s*(.+)$/.exec(line)
|
|
345
|
+
if (m) { flush(); current = { path: m[1].trim(), added: [] }; continue }
|
|
346
|
+
m = /^\*\*\* Update File:\s*(.+)$/.exec(line)
|
|
347
|
+
if (m) {
|
|
348
|
+
flush()
|
|
349
|
+
let p = m[1].trim()
|
|
350
|
+
const mv = /^\*\*\* Move to:\s*(.+)$/.exec(lines[i + 1] || "")
|
|
351
|
+
if (mv) { p = mv[1].trim(); i++ }
|
|
352
|
+
current = { path: p, added: [] }
|
|
353
|
+
continue
|
|
354
|
+
}
|
|
355
|
+
if (current && line.startsWith("+") && !line.startsWith("+++")) current.added.push(line.slice(1))
|
|
356
|
+
}
|
|
357
|
+
flush()
|
|
358
|
+
return out.filter((s) => s.path)
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// The content a write/edit/apply_patch call would actually land on disk, per FILE — what the
|
|
362
|
+
// content secret scan runs against, paired with the path it belongs to so the `.env` exemption
|
|
363
|
+
// can be checked against that file alone. `write` carries `content`, `edit` carries `newString`
|
|
364
|
+
// (OpenCode tool Parameters, packages/opencode/src/tool/{write,edit}.ts) — both single-path, so
|
|
365
|
+
// one pair. For `apply_patch`, one pair per marker section; only the `+`-prefixed hunk lines are
|
|
366
|
+
// NEW content — a `-` line is text being REMOVED, so a secret there is leaving the file, not
|
|
367
|
+
// landing in it, and must not block the call.
|
|
368
|
+
function pickAddedContentByPath(args, cwd) {
|
|
369
|
+
if (args && typeof args.patchText === "string") {
|
|
370
|
+
return parsePatchSections(args.patchText)
|
|
371
|
+
.filter((s) => s.added.length)
|
|
372
|
+
.map((s) => ({
|
|
373
|
+
path: path.resolve(cwd || process.cwd(), s.path).replace(/\\/g, "/"),
|
|
374
|
+
content: s.added.join("\n"),
|
|
375
|
+
}))
|
|
376
|
+
}
|
|
377
|
+
const [p] = pickPath(args, cwd)
|
|
378
|
+
if (!p) return []
|
|
379
|
+
if (typeof args.content === "string") return [{ path: p, content: args.content }]
|
|
380
|
+
if (typeof args.newString === "string") return [{ path: p, content: args.newString }]
|
|
381
|
+
return []
|
|
155
382
|
}
|
|
156
383
|
function pickCommand(args) {
|
|
157
384
|
for (const k of ["command", "cmd", "script"]) {
|
|
@@ -319,130 +546,8 @@ function protectedCheck(p) {
|
|
|
319
546
|
// substring stance: over-matching the class is harmless (see WRITE_TOOLS note).
|
|
320
547
|
const WIKI_MUTATE_TOOLS = [...WRITE_TOOLS, "delete", "remove", "rename", "move", "trash"]
|
|
321
548
|
|
|
322
|
-
// ---- the loop/* exemption (Consent Before Push) --------------------------------
|
|
323
|
-
// Twin of js/hosts/gitref.mjs (currentBranch/loopLaunched) and js/hosts/hooks.mjs's
|
|
324
|
-
// git-gate (the rest) — keep in step. Node's native sync `fs` (not the `fs.promises`
|
|
325
|
-
// import above): a worktree's `.git` is read the same way the Claude/Bob hook reads it, so
|
|
326
|
-
// the two decide the exemption identically. This plugin cannot import from `js/` at all
|
|
327
|
-
// (it is copied whole into an OpenCode install, outside this repo's module graph), so it
|
|
328
|
-
// keeps its own standalone copy rather than the single shared owner the Node side has.
|
|
329
|
-
|
|
330
|
-
const GIT_GATE_RE = /\bgit\b[^\n]*\b(?:commit|push)\b/
|
|
331
|
-
|
|
332
|
-
// No newline, backtick, or `$(` substitution anywhere, no `<`/`>` redirect, and no single
|
|
333
|
-
// `&` (a shell runs `a & b` as two commands exactly like `a && b`) — these can smuggle a
|
|
334
|
-
// second command (or a shared-branch target) past every check below. `(?<!&)&(?!&)` matches
|
|
335
|
-
// a lone `&` without matching either half of a doubled `&&`.
|
|
336
|
-
const UNSAFE_CHARS_RE = /[\n`<>]|\$\(|(?<!&)&(?!&)/
|
|
337
|
-
|
|
338
|
-
// The only three git shapes a loop's own automation ever needs — see js/hosts/hooks.mjs's
|
|
339
|
-
// identically-named constants for the full rationale. The token class `[\w./:@^~=+,-]`
|
|
340
|
-
// excludes quotes, braces, `!`, `*`, `$` and `&` — a brace/glob expansion is exactly how a
|
|
341
|
-
// shell turns one whitelisted-looking token into several unknown ones, which is also why the
|
|
342
|
-
// `-F`/`--file` path uses this same class rather than the looser `[^\s'"]+` it once did: that
|
|
343
|
-
// loose class let `git commit -F {m,--amend}` brace-expand into an amend of the last commit.
|
|
344
|
-
const ARG_RE = "[\\w./:@^~=+,-]+"
|
|
345
|
-
const SEG_ADD_RE = new RegExp(`^git\\s+add(\\s+${ARG_RE})*$`)
|
|
346
|
-
const SEG_COMMIT_RE = new RegExp(`^git\\s+commit(\\s+-q)?\\s+(-F\\s+|--file[=\\s])${ARG_RE}(\\s+-q)?$`)
|
|
347
|
-
const SEG_READONLY_RE = new RegExp(`^git\\s+(status|diff|log|rev-parse|show)(\\s+${ARG_RE})*$`)
|
|
348
|
-
|
|
349
|
-
/**
|
|
350
|
-
* `git push`, exactly: `[-u|--set-upstream] <remote> HEAD:<branch>` or
|
|
351
|
-
* `HEAD:refs/heads/<branch>` — the ONLY exempt form, same as js/hosts/hooks.mjs's
|
|
352
|
-
* `pushSegmentOk`. An explicit refspec is immune to `push.default`/`remote.*.push`
|
|
353
|
-
* redirection and refuses to coexist with a mirror/`+`/`:`-prefixed form; nothing else is.
|
|
354
|
-
*/
|
|
355
|
-
function pushSegmentOk(seg, branch) {
|
|
356
|
-
const m = /^git\s+push(?:\s+(.*))?$/.exec(seg)
|
|
357
|
-
if (!m) return false
|
|
358
|
-
const rest = (m[1] || "").trim()
|
|
359
|
-
if (!rest) return false
|
|
360
|
-
const tokens = rest.split(/\s+/)
|
|
361
|
-
let i = 0
|
|
362
|
-
if (tokens[i] === "-u" || tokens[i] === "--set-upstream") i += 1
|
|
363
|
-
const remote = tokens[i]
|
|
364
|
-
if (remote === undefined || !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(remote)) return false
|
|
365
|
-
i += 1
|
|
366
|
-
const ref = tokens[i]
|
|
367
|
-
i += 1
|
|
368
|
-
if (ref === undefined || i !== tokens.length) return false
|
|
369
|
-
return ref === `HEAD:${branch}` || ref === `HEAD:refs/heads/${branch}`
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
const LOOP_STATE_MARKER = "<!-- loop-state:begin -->"
|
|
373
|
-
|
|
374
|
-
/**
|
|
375
|
-
* Whether `<root>/LOOP.md` carries the engine's state marker — read at most 64 KB.
|
|
376
|
-
* `lstatSync` (never `statSync`, which follows a link) must find an ordinary file: a FIFO
|
|
377
|
-
* would block the read, a symlink could point anywhere outside the repo.
|
|
378
|
-
*/
|
|
379
|
-
function loopLaunched(gitRoot) {
|
|
380
|
-
let fd
|
|
381
|
-
try {
|
|
382
|
-
const p = path.join(gitRoot, "LOOP.md")
|
|
383
|
-
if (!lstatSync(p).isFile()) return false
|
|
384
|
-
fd = openSync(p, "r")
|
|
385
|
-
const buf = Buffer.alloc(65536)
|
|
386
|
-
const n = readSync(fd, buf, 0, buf.length, 0)
|
|
387
|
-
return buf.toString("utf8", 0, n).includes(LOOP_STATE_MARKER)
|
|
388
|
-
} catch {
|
|
389
|
-
return false
|
|
390
|
-
} finally {
|
|
391
|
-
if (fd !== undefined) { try { closeSync(fd) } catch { /* already gone */ } }
|
|
392
|
-
}
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
/**
|
|
396
|
-
* The branch checked out in `cwd`, and the directory holding `.git` for it, read from
|
|
397
|
-
* .git/HEAD without spawning git — a worktree's `.git` is a FILE naming its gitdir, so both
|
|
398
|
-
* shapes are followed. `{ branch: null, root: null }` on anything unexpected (detached HEAD,
|
|
399
|
-
* no repo, unreadable): the caller then leaves the ask alone.
|
|
400
|
-
*/
|
|
401
|
-
function currentBranch(cwd) {
|
|
402
|
-
try {
|
|
403
|
-
let dir = path.resolve(cwd)
|
|
404
|
-
for (;;) {
|
|
405
|
-
const dotgit = path.join(dir, ".git")
|
|
406
|
-
if (existsSync(dotgit)) {
|
|
407
|
-
let gitdir = dotgit
|
|
408
|
-
if (statSync(dotgit).isFile()) {
|
|
409
|
-
const m = /^gitdir:\s*(.+?)\s*$/m.exec(readFileSync(dotgit, "utf8"))
|
|
410
|
-
if (!m) return { branch: null, root: null }
|
|
411
|
-
gitdir = path.resolve(dir, m[1])
|
|
412
|
-
}
|
|
413
|
-
const ref = /^ref:\s*refs\/heads\/(.+?)\s*$/m.exec(readFileSync(path.join(gitdir, "HEAD"), "utf8"))
|
|
414
|
-
return { branch: ref ? ref[1] : null, root: dir }
|
|
415
|
-
}
|
|
416
|
-
const up = path.dirname(dir)
|
|
417
|
-
if (up === dir) return { branch: null, root: null }
|
|
418
|
-
dir = up
|
|
419
|
-
}
|
|
420
|
-
} catch {
|
|
421
|
-
return { branch: null, root: null }
|
|
422
|
-
}
|
|
423
|
-
}
|
|
424
|
-
|
|
425
|
-
/**
|
|
426
|
-
* Consent Before Push's loop/* exemption — the twin of js/hosts/hooks.mjs's `loopExempt`.
|
|
427
|
-
* True only when the command carries none of the unsafe characters, every `&&`/`;`/`||`/`|`
|
|
428
|
-
* segment is one of the three whitelisted git shapes (every push segment also passing
|
|
429
|
-
* `pushSegmentOk`), and the branch checked out in `cwd` starts with `loop/` AND its git root
|
|
430
|
-
* carries a launched LOOP.md.
|
|
431
|
-
*/
|
|
432
|
-
function loopExempt(command, cwd) {
|
|
433
|
-
if (typeof command !== "string" || UNSAFE_CHARS_RE.test(command)) return false
|
|
434
|
-
const { branch, root } = currentBranch(cwd)
|
|
435
|
-
if (!branch || !branch.startsWith("loop/")) return false
|
|
436
|
-
if (!root || !loopLaunched(root)) return false
|
|
437
|
-
const segments = command.split(/&&|\|\||[;|]/).map((s) => s.trim()).filter(Boolean)
|
|
438
|
-
if (!segments.length) return false
|
|
439
|
-
return segments.every((seg) => SEG_ADD_RE.test(seg) || SEG_COMMIT_RE.test(seg)
|
|
440
|
-
|| SEG_READONLY_RE.test(seg) || pushSegmentOk(seg, branch))
|
|
441
|
-
}
|
|
442
|
-
|
|
443
549
|
export const GeneseedGuard = async (ctx) => {
|
|
444
|
-
// The session's working directory, the same resolution the context plugin uses
|
|
445
|
-
// worktree session's `.git` is a FILE, which `currentBranch` already follows.
|
|
550
|
+
// The session's working directory, the same resolution the context plugin uses.
|
|
446
551
|
const root = () => ctx?.worktree || ctx?.directory || process.cwd()
|
|
447
552
|
return {
|
|
448
553
|
"tool.execute.before": async (input, output) => {
|
|
@@ -459,24 +564,47 @@ export const GeneseedGuard = async (ctx) => {
|
|
|
459
564
|
throw new Error(`[geneseed-guard] blocked: ${why} — set GENESEED_GUARD=off to allow`)
|
|
460
565
|
}
|
|
461
566
|
try {
|
|
567
|
+
// `apply_patch`'s own tool resolves every marker path against `instance.directory`
|
|
568
|
+
// (`apply_patch.ts:72` in the verdict's evidence), NOT the worktree root `root()`
|
|
569
|
+
// uses for the sovereign-repo bypass above — a worktree session started in a
|
|
570
|
+
// subdirectory of its git root must see the same (narrower) root the write
|
|
571
|
+
// actually lands under, or a protected-check/wiki/secret hit one directory up
|
|
572
|
+
// would be missed.
|
|
573
|
+
const cwd = ctx?.directory || process.cwd()
|
|
462
574
|
if (hasAny(tool, WRITE_TOOLS)) {
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
const
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
await deny(
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
575
|
+
// `apply_patch` (gpt-5*) can carry several marker paths in one call — every
|
|
576
|
+
// one of them is a write target, so every one runs every gate; the first hit
|
|
577
|
+
// blocks the whole call (see `pickPath` for why this is not a single path).
|
|
578
|
+
const paths = pickPath(args, cwd)
|
|
579
|
+
for (const p of paths) {
|
|
580
|
+
if (SECRET_RE.some((re) => re.test(p))) { await deny(`write to secret/key file ${p} (Sealed Secrets)`, "law-1"); return }
|
|
581
|
+
if (SECRET_WARN_RE.some((re) => re.test(p))) log(`WARN: writing ${p} — keep secrets out of tracked files (Sealed Secrets)`)
|
|
582
|
+
const sensor = protectedCheck(p)
|
|
583
|
+
if (sensor) { await deny(`${p} is a protected check (${sensor}, listed in ${SENSOR_LIST}) — a check the agent can edit is not a check (External Gate)`, "rigor-5"); return }
|
|
584
|
+
const store = ruleStoreTarget(p)
|
|
585
|
+
if (store && !RULE_STORE_BUMPED.has(p)) {
|
|
586
|
+
RULE_STORE_BUMPED.add(p)
|
|
587
|
+
await deny(`writing to ${store} — a standing rule, or a fact to remember? That ` +
|
|
588
|
+
`choice is the user's (Persist Insight). Settle it through the rule skill, ` +
|
|
589
|
+
`then re-issue this write`, "process-1")
|
|
590
|
+
return
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
// Content scan (Sealed Secrets, the twin of js/hosts/hooks.mjs's ruleDecide): per
|
|
594
|
+
// FILE, not pooled — a patch touching `.env` AND an ordinary file must still catch a
|
|
595
|
+
// secret in the ordinary file; only the hunk whose OWN path is `.env`-shaped is
|
|
596
|
+
// exempt, same as the path WARN tier above.
|
|
597
|
+
for (const { path: cp, content } of pickAddedContentByPath(args, cwd)) {
|
|
598
|
+
if (SECRET_WARN_RE.some((re) => re.test(cp))) continue
|
|
599
|
+
if (SECRET_CONTENT_RE.test(content)) {
|
|
600
|
+
await deny(`${cp} would carry a credential-shaped string (Sealed Secrets) — secrets `
|
|
601
|
+
+ "live in .env or a secret manager, never in a tracked file", "law-1")
|
|
602
|
+
return
|
|
603
|
+
}
|
|
475
604
|
}
|
|
476
605
|
}
|
|
477
606
|
if (hasAny(tool, WIKI_MUTATE_TOOLS)) {
|
|
478
|
-
const p
|
|
479
|
-
if (p) {
|
|
607
|
+
for (const p of pickPath(args, cwd)) {
|
|
480
608
|
const abs = (path.isAbsolute(p) ? p : path.resolve(p)).replace(/\\/g, "/").toLowerCase()
|
|
481
609
|
const hit = (await protectedPrefixes()).find(
|
|
482
610
|
(x) => abs.startsWith(x.prefix) || abs === x.prefix.slice(0, -1))
|
|
@@ -488,6 +616,12 @@ export const GeneseedGuard = async (ctx) => {
|
|
|
488
616
|
if (hasAny(tool, SHELL_TOOLS)) {
|
|
489
617
|
const c = pickCommand(args)
|
|
490
618
|
if (c && SHELL_BLOCK_RE.some((re) => re.test(c))) { await deny(`catastrophic command (Deletion Is Deliberate): ${c.slice(0, 80)}`, "law-4"); return }
|
|
619
|
+
const scan = c && rootScan(c)
|
|
620
|
+
if (scan) {
|
|
621
|
+
await deny(`\`${scan.slice(0, 80)}\` recursively scans a whole filesystem root (Commands Must Return) — ` +
|
|
622
|
+
"search a specific directory instead: the project, node_modules, ~/.npm, %APPDATA%", "ops-2")
|
|
623
|
+
return
|
|
624
|
+
}
|
|
491
625
|
if (c && SHELL_WARN_RE.some((re) => re.test(c))) log(`WARN: irreversible op — confirm intent (Deletion Is Deliberate): ${c.slice(0, 80)}`)
|
|
492
626
|
}
|
|
493
627
|
} catch (err) {
|
|
@@ -496,23 +630,6 @@ export const GeneseedGuard = async (ctx) => {
|
|
|
496
630
|
log(`inspect error (ignored): ${err?.message ?? err}`)
|
|
497
631
|
}
|
|
498
632
|
},
|
|
499
|
-
"permission.ask": async (input, output) => {
|
|
500
|
-
// Never throw from a permission hook — leave `output.status` untouched on any
|
|
501
|
-
// error, exactly as a crashed check here must default to the static ask standing.
|
|
502
|
-
try {
|
|
503
|
-
if (OFF) return
|
|
504
|
-
// Never loosen anything but the plain "ask" this hook itself would otherwise leave
|
|
505
|
-
// alone — a deny (or any other status another hook already set) is never overwritten.
|
|
506
|
-
if (output?.status && output.status !== "ask") return
|
|
507
|
-
if (input?.type !== "bash") return
|
|
508
|
-
// ONLY `input.metadata?.command` — no pattern/title fallback. A value this hook did
|
|
509
|
-
// not itself read in full is a value it must not reason about.
|
|
510
|
-
const command = input?.metadata?.command
|
|
511
|
-
if (typeof command !== "string" || !command || !GIT_GATE_RE.test(command)) return
|
|
512
|
-
if (await sovereignBypass(root())) return
|
|
513
|
-
if (loopExempt(command, root())) output.status = "allow"
|
|
514
|
-
} catch { /* leave output.status untouched */ }
|
|
515
|
-
},
|
|
516
633
|
}
|
|
517
634
|
}
|
|
518
635
|
|
package/bin/geneseed-hook.mjs
CHANGED
|
@@ -38,7 +38,12 @@
|
|
|
38
38
|
* subparsers (25 invocable names — `update` is an alias of `upgrade`); a hook entry that
|
|
39
39
|
* silently accepted `doctor` and did nothing would be the worst available failure, because
|
|
40
40
|
* every Geneseed hook returns 0 and signals through stdout — so "did nothing" and "worked"
|
|
41
|
-
* are the same observation.
|
|
41
|
+
* are the same observation. That refusal exits **1**, never 2: on Claude Code and Bob, exit 2
|
|
42
|
+
* is the one code that blocks a tool call (or, on `Stop`/`SubagentStop`, keeps the turn going
|
|
43
|
+
* without running the hook again), so a verb this build does not know yet — the forward case,
|
|
44
|
+
* when a newer Geneseed names a verb this stale checkout has never heard of, under the
|
|
45
|
+
* machine-wide last-writer-wins shim (see I3/B1 below) — must not read as a block. Exit 1 still
|
|
46
|
+
* shows a `<hook name> hook error` notice; it just does not stop anything (see I3/B1).
|
|
42
47
|
*/
|
|
43
48
|
// FIRST, before anything it could fail to run: an old Node gets one sentence, not a stack trace.
|
|
44
49
|
import '../js/lib/node-floor.mjs';
|
|
@@ -55,6 +60,13 @@ import { printErr } from '../js/lib/fs.mjs';
|
|
|
55
60
|
// specifiers stay literal strings — `tests/unit/hook_cli.test.mjs`'s import walk reads them.
|
|
56
61
|
const HOSTED = { '--root': 'root', '--host': 'host' };
|
|
57
62
|
const gates = () => import('../js/hosts/hooks.mjs');
|
|
63
|
+
// Verbs PreToolUse/PostToolUse actually gate a tool call with. The machine-wide, last-writer-wins
|
|
64
|
+
// shim (this file's header) means a stale checkout can be handed a flag only a newer install
|
|
65
|
+
// knows (e.g. `--no-consent`); on Claude Code, exit 2 from one of THESE verbs blocks the tool
|
|
66
|
+
// outright, with no ask. `main` below answers an argv error on a gate verb with exit 0 instead —
|
|
67
|
+
// a gate's own decision still exits 0 and signals through stdout (`askDecision`), so this only
|
|
68
|
+
// changes what used to be a hard lockout into "the gate is silent this one call" (see I3/B1).
|
|
69
|
+
const GATE_VERBS = new Set(['git-gate', 'rule-gate', 'tool-gate']);
|
|
58
70
|
const VERBS = {
|
|
59
71
|
context: { load: () => import('../js/hosts/hooks-context.mjs'), fn: 'cmdContext', flags: HOSTED },
|
|
60
72
|
'git-gate': {
|
|
@@ -65,7 +77,8 @@ const VERBS = {
|
|
|
65
77
|
learn: {
|
|
66
78
|
load: () => import('../js/hosts/hooks-learn.mjs'),
|
|
67
79
|
fn: 'cmdLearn',
|
|
68
|
-
|
|
80
|
+
// `--host` only keys the global stand-down's marker (js/hosts/hosts.mjs); learn has no dialect.
|
|
81
|
+
flags: { '--memory': 'memory', '--host': 'host' },
|
|
69
82
|
switches: { '--consolidate': 'consolidate' },
|
|
70
83
|
positional: 'file',
|
|
71
84
|
},
|
|
@@ -118,7 +131,9 @@ async function main(argv) {
|
|
|
118
131
|
|
|
119
132
|
const verb = argv[0];
|
|
120
133
|
if (!verb || verb === '-h' || verb === '--help') {
|
|
121
|
-
|
|
134
|
+
// 1, not 2: see this file's header (I3/B1) — 2 is the one code that blocks a tool call on
|
|
135
|
+
// Claude Code and Bob, and a bare invocation is not a gate decision.
|
|
136
|
+
return die(1, `the following arguments are required: cmd (one of ${
|
|
122
137
|
Object.keys(VERBS).join(', ')})`);
|
|
123
138
|
}
|
|
124
139
|
const spec = VERBS[verb];
|
|
@@ -130,7 +145,11 @@ async function main(argv) {
|
|
|
130
145
|
// The COMMAND is not a table: `geneseed` is this package's `bin` entry for the CLI
|
|
131
146
|
// (package.json), it answers every non-hook verb, and it survives the deletion of the
|
|
132
147
|
// interpreter-plus-script invocation this line used to print.
|
|
133
|
-
|
|
148
|
+
//
|
|
149
|
+
// 1, not 2 (I3/B1): the forward case is the one that matters — settings emitted by a NEWER
|
|
150
|
+
// Geneseed name a verb this stale checkout, running from the machine-wide last-writer-wins
|
|
151
|
+
// shim, has never heard of. That must not read as a block.
|
|
152
|
+
return die(1, `invalid choice: '${verb}'. This entry point carries only the HOOK `
|
|
134
153
|
+ `verbs (${Object.keys(VERBS).join(', ')}); every other harness subcommand lives `
|
|
135
154
|
+ 'elsewhere — run `geneseed ' + verb + '`.');
|
|
136
155
|
}
|
|
@@ -146,7 +165,19 @@ async function main(argv) {
|
|
|
146
165
|
if (rc !== null) return rc;
|
|
147
166
|
}
|
|
148
167
|
const parsed = parse(spec, argv.slice(1));
|
|
149
|
-
if (parsed.error)
|
|
168
|
+
if (parsed.error) {
|
|
169
|
+
// A gate verb must never exit 2 on an argv error: that is indistinguishable, to Claude's
|
|
170
|
+
// PreToolUse, from the gate deliberately blocking (see I3/B1 above). Print the same message
|
|
171
|
+
// to stderr for a human debugging the shim, but return 0 with no stdout, exactly like a gate
|
|
172
|
+
// that chose not to act.
|
|
173
|
+
if (GATE_VERBS.has(verb)) { printErr(`geneseed-hook: error: ${parsed.error}\n`); return 0; }
|
|
174
|
+
// `context` and `learn` are not gate verbs, but their own argv errors are not a decision
|
|
175
|
+
// either, so still 1, not 2 (I3/B1): a `<hook name> hook error` notice, and nothing more.
|
|
176
|
+
// `learn` is the one that matters here — it runs on Stop/SubagentStop, where exit 2 ALSO has
|
|
177
|
+
// an effect (it sends Claude back to keep working instead of letting the turn end; docs
|
|
178
|
+
// `hooks.md`, "Stop, SubagentStop, TaskCompleted, and TeammateIdle").
|
|
179
|
+
return die(1, parsed.error);
|
|
180
|
+
}
|
|
150
181
|
return (await spec.load())[spec.fn](parsed.args);
|
|
151
182
|
}
|
|
152
183
|
|
package/docs/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
[← Back to README](../README.md)
|
|
4
4
|
|
|
5
|
-
**New to agent harnesses?** Read **Understand** first
|
|
5
|
+
**New to agent harnesses?** Read **Understand** first: the track every other page assumes. Its last section, *Why it works this way*, is reference, to consult when a question comes up. The same pages open the **Docs** tab of the web console (`geneseed web`), which groups each part into sections, adds an interactive map of your own install, and filters host-specific passages to the tool you use.
|
|
6
6
|
|
|
7
7
|
## 1 · Understand
|
|
8
8
|
|
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
|
|
13
13
|
**Day to day** — [A day with the harness](understand/a-day.md): the moments you will actually meet · [Enforced vs. asked](understand/enforced-vs-asked.md): what is guaranteed by code, and what the model is only asked to do.
|
|
14
14
|
|
|
15
|
+
**Why it works this way** — [Why Geneseed works this way](understand/design-choices.md): every decision already taken, one line each, and where it is argued · [The shape of the harness](understand/design-choices-shape.md) · [Gates](understand/design-choices-gates.md) · [Hosts](understand/design-choices-hosts.md) · [Install, uninstall, update](understand/design-choices-install.md) · [Engineering, skills and agents](understand/design-choices-engineering.md) · [Considered and refused](design-history.md#considered-and-refused).
|
|
16
|
+
|
|
15
17
|
## 2 · Guides
|
|
16
18
|
|
|
17
19
|
| Section | Guide | When |
|