diff --git a/CHANGELOG.md b/CHANGELOG.md index 13271bf1a..06ad4a089 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,33 @@ # Changelog +## [1.91.194.0] - 2026-10-25 + +**A read-only repo can now opt in to Memorable recall, one remote at a time, and a corrupt policy store no longer turns the recall veto off.** #3359 (exGeni) asked for a way to keep a repo out of the brain while still letting its prompts reach Memorable: today the recall hook skips every `deny` and `read-only` repo, and `read-only` is also the tier people pick just to stop code ingest. Garry approved a per-remote flag on 2026-10-24: off by default, and a `deny` always wins. While building the negative controls we found that the hook's policy lookup went through `get`, which quarantines a corrupt store and answers "unset", so a truncated `gbrain-repo-policy.json` let recall run in every repo, read-only and deny included, and reset the store on the way. + +### Added +- **`bin/gstack-gbrain-repo-policy memorable set `** stores the opt-in under a reserved `_memorable_recall` map in the policy store. It is refused unless the remote is read-only (deny vetoes recall; read-write never needed it). `memorable get []` prints `\t`, and `list` adds a `memorable=allow` column. Changing a remote's tier clears its opt-in, and the two metadata keys (`_schema_version`, `_memorable_recall`) are refused as remotes. +- **`lib/gbrain-repo-policy-client.ts`**: `memorableRecallPolicy(url)` returns the tier and the opt-in in one spawn. Anything but exactly `\t` reads as `unreadable`. + +### Changed +- **`hosts/claude/hooks/memorable-user-prompt-hook.ts`**: a `read-only` remote is recalled only when it is opted in; `deny` is always skipped. The skip line in `hook-errors.log` names the opt-in command. + +### Fixed +- The recall hook's policy lookup is now a pure read that fails closed in either state root: a corrupt, empty or non-object store, a present key with an unknown tier, a path that is not a readable file (a dangling symlink included) and a path whose existence an unsearchable ancestor directory hides are all errors, never "no policy", and nothing is migrated, quarantined or rewritten. The opt-in counts only when it is exactly `"allow"` and every store that makes the remote read-only (including `~/.gstack` under a custom state root) opts it in. +- The hook looks policy up by every configured `remote.origin.url`, as `get` and `/sync-gbrain` read it, instead of the first URL after `url.*.insteadOf` rewriting. A rewrite to another host, or a second origin URL, no longer dodges a deny: any denied or not-opted-in read-only URL skips recall. `hasRepoPolicyStore()` treats only confirmed absence as "no store", so a dangling symlink at or above the store, a file in its path, or an unreadable directory reaches the script's fail-closed checks instead of the no-policy fast path. Origin URLs are read NUL-delimited and used verbatim, so a valid local path containing a newline or edge whitespace is looked up whole. + +### Docs +- `docs/memorable-workflow-memory.md` has a short "Recall in a read-only repo" section and updated refusal and troubleshooting lines. + +### Tests +- `test/memorable-user-prompt-hook.test.ts` drives the real hook: read-only stays blocked by default, the opt-in recalls on that remote and not on a second read-only remote, `deny` with a hand-written opt-in is skipped, and a corrupt store skips recall and is left in place. +- `test/gbrain-repo-policy.test.ts` covers the CLI: per-remote keying (a different remote, a prefix-sharing remote and an unset remote stay off; the SSH spelling of the same remote matches), refusal on deny, read-write and unset, clearing on a tier change, malformed opt-ins reading off, corrupt and non-file stores in both roots failing with exit 2, and the second-store rule. +- `test/gbrain-repo-policy-client.test.ts` covers `memorableRecallPolicy`: fast path, per-remote opt-in, deny, corrupt store, an unknown tier, a dangling-symlink store and a timeout. +- Review hardening, each test red at the pre-review head: empty, whitespace, `null`, array, number and two-document stores and `false`/`null`/unknown tier values fail with exit 2 in either root; dangling symlinks and an unsearchable directory or ancestor fail closed; the lookup leaves a schema-less store byte-for-byte unchanged; and the hook refuses a deny remote whose origin is rewritten to another host or carries a second, unlisted URL in either order, or whose local-path URL contains a newline or edge whitespace; and a dangling symlink above the store or a file in its path skips the no-store fast path. + +### Gate integrity +- One deliberate widening of a privacy veto, approved by Garry: a read-only remote with an explicit opt-in is recalled. Nothing else is loosened, and the corrupt-store path is tightened. Seventeen mutations of the new gate were run against the focused tests. Sixteen turn at least one test red: the hook ignoring the opt-in, the hook dropping deny, the CLI reporting the opt-in on any tier, an unkeyed opt-in, a corrupt store read as no policy, the second store ignored, an opt-in accepted on deny, the hook using the rewritten URL, an existsSync fast path, any JSON document accepted, a non-file path read as absent, the hook checking only the last origin URL, a missing path trusted without checking its ancestors, origin URLs split on newlines or trimmed, and the client fast path ignoring a dangling ancestor symlink. The seventeenth, the client reading unknown script output as no policy, stays green because the script can no longer produce such output. That check is a second layer and is left in place. No threshold, budget, timeout, trial count or case kind changed. +- Known limit, unchanged by this PR: remotes that the store already treats as one key (`host:8443/x` and `host/8443/x`, or paths that differ only in case) share the opt-in exactly as they already share a tier. + ## [1.91.193.0] - 2026-10-25 **Windows: the browse terminal agent stops respawning every 60 seconds and stops flashing a console when Bun came from npm (#2295; ported from #3357 by @adammartin0087).** The agent was spawned as a bare `bun`. With Bun installed through `npm install -g bun`, that name resolves to npm's `bun.cmd` shim and runs as `cmd.exe /c bun …`, so `windowsHide` hid only `cmd.exe` while `bun.exe` opened its own visible console, and the recorded pid was `cmd.exe`'s. The agent's startup check never matched its record, it exited after 2 seconds, and the watchdog respawned it. diff --git a/VERSION b/VERSION index 15112a52f..49986b594 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.91.193.0 +1.91.194.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index 0fde9150f..7cc162640 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.91.193.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.91.194.0 — regenerate/re-copy after upgrading gstack Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed for agent hosts without a full skill install. The full skills add workflows, diff --git a/bin/gstack-gbrain-repo-policy b/bin/gstack-gbrain-repo-policy index 5b23caa9a..f98c39e19 100755 --- a/bin/gstack-gbrain-repo-policy +++ b/bin/gstack-gbrain-repo-policy @@ -15,10 +15,22 @@ # rather than bypass a set policy. # # gstack-gbrain-repo-policy set -# Persist a tier for the given remote. Exits 0 on success. +# Persist a tier for the given remote. Exits 0 on success. Changing a +# remote's tier clears its Memorable recall opt-in. +# +# gstack-gbrain-repo-policy memorable get [] +# Print "\t": the tier (as `get` prints it) and whether +# Memorable recall is opted in for this remote. Read-only lookup for the +# recall hook: a corrupt store is a hard error (exit 2), never quarantined. +# +# gstack-gbrain-repo-policy memorable set +# Opt a read-only remote in to (or back out of) Memorable recall. Off by +# default. Refused unless the remote's tier is read-only: a deny tier +# always vetoes recall, and read-write never needed an opt-in. # # gstack-gbrain-repo-policy list -# Print every entry as "\t", sorted by key. +# Print every entry as "\t", sorted by key, with a third +# column "memorable=allow" on remotes opted in to Memorable recall. # # gstack-gbrain-repo-policy normalize # Print the normalized (canonical) key for a given remote URL. @@ -33,8 +45,11 @@ # { # "_schema_version": 2, # "github.com/foo/bar": "read-write", -# "github.com/baz/qux": "deny" +# "github.com/baz/qux": "deny", +# "_memorable_recall": { "github.com/foo/bar": "allow" } # } +# The keys _schema_version and _memorable_recall are reserved; other keys +# name remotes (including the _unattributed sentinel memory-ingest uses). # # Tier semantics: # read-write — agent may search AND write new pages from this repo. @@ -43,6 +58,11 @@ # decision.) # deny — no gbrain interaction at all. # +# Memorable recall opt-in: the recall hook skips deny and read-only remotes. +# A read-only remote listed in _memorable_recall with exactly "allow" is +# recalled anyway; any other value or shape counts as off. Every store that +# makes the remote read-only (see the merge below) must opt it in. +# # Legacy migration: # On any read of a file missing `_schema_version` (or with version < 2), # legacy `allow` values are atomically rewritten to `read-write`, and @@ -187,6 +207,24 @@ ensure_file() { fi } +# memorable_optin FILE KEY — "allow" when FILE's _memorable_recall map holds +# exactly the string "allow" for KEY; any other value or shape is "off". +memorable_optin() { + jq -r --arg key "$2" '(._memorable_recall | if type == "object" then .[$key] else null end) as $v + | if $v == "allow" then "allow" else "off" end' "$1" +} + +# jq filter that removes $key's opt-in (and an emptied map). +DROP_MEMORABLE_OPTIN='if (._memorable_recall | type) == "object" then (._memorable_recall |= del(.[$key])) | (if ._memorable_recall == {} then del(._memorable_recall) else . end) else . end' + +# _schema_version and _memorable_recall hold store metadata; a remote that +# normalizes to one of them cannot be stored. +reject_reserved_key() { + case "$1" in + _schema_version|_memorable_recall) die "'$1' is a reserved key in the policy store, not a remote" ;; + esac +} + _tier_rank() { case "$1" in deny) echo 0 ;; read-only) echo 1 ;; read-write) echo 2 ;; *) echo 3 ;; esac } @@ -295,13 +333,146 @@ cmd_set() { local key key=$(normalize "$url") [ -z "$key" ] && die "cannot normalize remote URL: $url" + reject_reserved_key "$key" ensure_file - local tmp + local previous opted tmp + previous=$(jq -r --arg key "$key" '.[$key] // "unset"' "$POLICY_FILE") + opted=$(memorable_optin "$POLICY_FILE" "$key") tmp=$(mktemp "$POLICY_FILE.tmp.XXXXXX") - jq --arg key "$key" --arg tier "$tier" '.[$key] = $tier' "$POLICY_FILE" > "$tmp" + if [ "$previous" != "$tier" ]; then + jq --arg key "$key" --arg tier "$tier" ".[\$key] = \$tier | $DROP_MEMORABLE_OPTIN" "$POLICY_FILE" > "$tmp" + else + jq --arg key "$key" --arg tier "$tier" '.[$key] = $tier' "$POLICY_FILE" > "$tmp" + fi mv "$tmp" "$POLICY_FILE" chmod 0600 "$POLICY_FILE" echo "Set $key → $tier" + if [ "$previous" != "$tier" ] && [ "$opted" = "allow" ]; then + echo "Cleared the Memorable recall opt-in for $key (the tier changed; opt in again with: gstack-gbrain-repo-policy memorable set $key allow)" + fi +} + +# memorable_store_answer FILE KEY — " " for KEY in one store, +# read from a single snapshot of the file with no write, migration or +# quarantine (tier is none when the file or the key is absent; a legacy +# "allow" reads as read-write in memory). Dies (exit 2) on anything else: a +# path that is present but not a readable regular file, a path whose +# existence cannot be confirmed (an unsearchable ancestor), content that is not exactly one JSON object, or a +# present key whose value is not a known tier. +memorable_store_answer() { + local store="$1" key="$2" up raw + if ! [ -e "$store" ] && ! [ -L "$store" ]; then + # Absent only if the nearest existing ancestor is a searchable directory: + # an unsearchable one hides an existing store from every test above. + up=$(dirname "$store") + while ! [ -e "$up" ] && ! [ -L "$up" ] && [ "$up" != "/" ] && [ "$up" != "." ]; do up=$(dirname "$up"); done + if [ -d "$up" ] && [ -x "$up" ]; then + printf 'none off\n' + return 0 + fi + die "cannot confirm whether $store exists ($up is not a searchable directory) — refusing the Memorable recall lookup." + fi + if ! [ -f "$store" ] || ! [ -r "$store" ]; then + die "policy store $store exists but is not a readable file — refusing the Memorable recall lookup. Inspect or remove it." + fi + raw=$(cat -- "$store") || die "cannot read policy store $store — refusing the Memorable recall lookup." + printf '%s' "$raw" | jq -e -s -r --arg key "$key" ' + if length == 1 and (.[0] | type) == "object" then .[0] else error("not one JSON object") end + | (if has($key) then + (.[$key] | if . == "allow" then "read-write" + elif . == "deny" or . == "read-only" or . == "read-write" then . + else error("invalid tier") end) + else "none" end) as $tier + | ((._memorable_recall | if type == "object" then .[$key] else null end) == "allow") as $opt + | "\($tier) \(if $opt then "allow" else "off" end)"' 2>/dev/null \ + || die "policy store $store is corrupt (not one JSON object, or an invalid tier for $key) — refusing the Memorable recall lookup. Inspect with: gstack-gbrain-repo-policy list; re-run /setup-gbrain to rebuild the store." +} + +# memorable get — tier plus the effective Memorable recall opt-in, for the +# recall hook. Pure read: each store is read once, never migrated, never +# quarantined, and any doubt about a present store is a hard error (exit 2), +# because reading it as "no policy" would silently lift a deny or read-only +# veto. The tier merges as get does (the ~/.gstack store contributes only deny +# or read-only). The opt-in counts only when the merged tier is read-only and +# every store that restricts the remote (deny or read-only) opts it in, so an +# opt-in can never relax a restriction another store imposed. +cmd_memorable_get() { + local url="${1:-}" + require_jq + if [ -z "$url" ]; then + url=$(git config --get remote.origin.url 2>/dev/null || true) + fi + local key + key=$(normalize "$url") + if [ -z "$key" ]; then + printf 'unset\toff\n' + return 0 + fi + local answer p_tier p_opt l_tier="none" l_opt="off" + answer=$(memorable_store_answer "$POLICY_FILE" "$key") || exit 2 + read -r p_tier p_opt <<<"$answer" + if [ -n "$_gstack_legacy_root" ]; then + answer=$(memorable_store_answer "$_gstack_legacy_root/gbrain-repo-policy.json" "$key") || exit 2 + read -r l_tier l_opt <<<"$answer" + fi + local merged="$p_tier" + case "$l_tier" in + deny|read-only) + if [ "$(_tier_rank "$l_tier")" -lt "$(_tier_rank "$merged")" ]; then merged="$l_tier"; fi ;; + esac + if [ "$merged" != "read-only" ]; then + [ "$merged" = "none" ] && merged="unset" + printf '%s\toff\n' "$merged" + return 0 + fi + local optin="allow" + case "$p_tier" in deny|read-only) [ "$p_opt" = "allow" ] || optin="off" ;; esac + case "$l_tier" in deny|read-only) [ "$l_opt" = "allow" ] || optin="off" ;; esac + printf 'read-only\t%s\n' "$optin" +} + +cmd_memorable_set() { + local url="${1:-}" value="${2:-}" + if [ -z "$url" ] || [ -z "$value" ]; then die "usage: memorable set "; fi + case "$value" in + allow|off) ;; + *) die "invalid value '$value' (must be allow or off)" ;; + esac + local key + key=$(normalize "$url") + [ -z "$key" ] && die "cannot normalize remote URL: $url" + reject_reserved_key "$key" + if [ "$value" = "allow" ]; then + local current + current=$(cmd_memorable_get "$url" | cut -f1) + case "$current" in + read-only) ;; + deny) die "$key is deny: a deny tier always vetoes Memorable recall, so an opt-in is refused. Change the tier first if you mean it: gstack-gbrain-repo-policy set $key read-only" ;; + *) die "$key is $current, not read-only: the opt-in only applies to read-only remotes (read-write is already recalled; an unset remote has no veto)" ;; + esac + fi + ensure_file + local tmp + tmp=$(mktemp "$POLICY_FILE.tmp.XXXXXX") + if [ "$value" = "allow" ]; then + jq --arg key "$key" '._memorable_recall = ((._memorable_recall | if type == "object" then . else {} end) + {($key): "allow"})' "$POLICY_FILE" > "$tmp" + else + jq --arg key "$key" "$DROP_MEMORABLE_OPTIN" "$POLICY_FILE" > "$tmp" + fi + mv "$tmp" "$POLICY_FILE" + chmod 0600 "$POLICY_FILE" + echo "Set $key memorable recall → $value" + if [ "$value" = "allow" ] && [ "$(cmd_memorable_get "$url" | cut -f2)" != "allow" ]; then + echo "gstack-gbrain-repo-policy: $LEGACY_POLICY_FILE also makes $key read-only and does not opt it in, so recall stays off. Opt in there too: GSTACK_STATE_ROOT=$_gstack_legacy_root gstack-gbrain-repo-policy memorable set $key allow" >&2 + fi +} + +cmd_memorable() { + case "${1:-}" in + get) shift; cmd_memorable_get "$@" ;; + set) shift; cmd_memorable_set "$@" ;; + *) die "usage: memorable {get []|set }" ;; + esac } cmd_list() { @@ -310,7 +481,9 @@ cmd_list() { return 0 fi ensure_file - jq -r 'to_entries[] | select(.key != "_schema_version") | "\(.key)\t\(.value)"' "$POLICY_FILE" | sort + jq -r '(._memorable_recall | if type == "object" then . else {} end) as $m + | to_entries[] | select(.key != "_schema_version" and .key != "_memorable_recall") + | "\(.key)\t\(.value)" + (if $m[.key] == "allow" then "\tmemorable=allow" else "" end)' "$POLICY_FILE" | sort } cmd_normalize() { @@ -323,8 +496,9 @@ case "${1:-}" in get) shift; cmd_get "$@" ;; set) shift; cmd_set "$@" ;; list) shift; cmd_list "$@" ;; + memorable) shift; cmd_memorable "$@" ;; normalize) shift; cmd_normalize "$@" ;; - --help|-h|help) sed -n '2,54p' "$0" | sed 's/^# \{0,1\}//' ;; - "") die "usage: gstack-gbrain-repo-policy {get|set|list|normalize|--help}" ;; + --help|-h|help) sed -n '2,73p' "$0" | sed 's/^# \{0,1\}//' ;; + "") die "usage: gstack-gbrain-repo-policy {get|set|memorable|list|normalize|--help}" ;; *) die "unknown subcommand: $1" ;; esac diff --git a/docs/memorable-workflow-memory.md b/docs/memorable-workflow-memory.md index 0fe7021c8..9cf8ba715 100644 --- a/docs/memorable-workflow-memory.md +++ b/docs/memorable-workflow-memory.md @@ -94,10 +94,11 @@ It refuses the hand-off, with one rate-limited line in - the prompt carries a HIGH-tier credential shape (checked on the raw bytes and on the decoded string values, so a JSON-escaped key does not slip by), or is larger than 1 MiB; -- the repo's per-remote trust policy is `deny` or `read-only` (judged by the - session's working directory, so a session that touches other repositories - is not covered), or that policy could not be looked up at all (git could - not read the repository, the store is unreadable): the lookup fails closed; +- the repo's per-remote trust policy is `deny`, or `read-only` without the + recall opt-in below (judged by the session's working directory, so a + session that touches other repositories is not covered), or that policy + could not be looked up at all (git could not read the repository, the store + is unreadable or corrupt): the lookup fails closed; - the receipt cannot be written, or the hook's 4.5 s budget cannot afford the next step (the secret scan of a very large prompt, or the vendor spawn). @@ -163,6 +164,34 @@ whatever it stored are untouched: `memorable disable` stops capture, The entry also comes out with `gstack-uninstall` (named in its summary) and survives `./setup --no-team`, which only tears down team-mode hooks. +## Recall in a read-only repo (per-remote opt-in) + +`read-only` keeps a repo's content out of the brain, and by default it also +keeps that repo's prompts away from Memorable. If you chose `read-only` only +to stop code ingest, and you have no objection to Memorable seeing prompts +from that repo, opt that one remote in: + +```bash +gstack-gbrain-repo-policy memorable set github.com/acme/client-a allow +gstack-gbrain-repo-policy memorable get github.com/acme/client-a # read-only allow +gstack-gbrain-repo-policy memorable set github.com/acme/client-a off +``` + +The opt-in is off by default and covers only the remote you name (the SSH and +HTTPS spellings of it are one key). It is refused on a `deny` remote, and a +`deny` vetoes recall even if an opt-in was written into the store by hand. +Changing the remote's tier clears it, so moving a repo back to `read-only` +later never silently re-opens recall. When `~/.gstack` also makes the remote +read-only (see [state roots](state-root.md)), that store has to opt it in too. +A corrupt, empty or unreadable policy store (or a dangling symlink in its +place) skips recall for every repo until you fix it; the hook never resets it +to "no policy" and never rewrites it. The lookup checks every configured +`remote.origin.url`, not the URL after `url.*.insteadOf` rewriting, and any +denied or not-opted-in read-only one skips recall. `gstack-gbrain-repo-policy list` shows +opted-in remotes with a `memorable=allow` column. Nothing about gbrain +ingest changes: the repo stays `read-only` for `/sync-gbrain` and every +other gbrain writer. + ## If you run gbrain Memorable can keep its procedures in your own gbrain database instead of its @@ -189,7 +218,7 @@ and `/sync-gbrain` are unrelated to it. (roughly 4 seconds after gstack's own work). That is usually network. - **`hook-errors.log` names a refusal.** `refused:redaction-high` means a credential shape was in the prompt; `trust policy ... deny or read-only` - means the repo is protected; `receipt-unwritable` means + means the repo is protected (see the per-remote opt-in above); `receipt-unwritable` means `~/.gstack/security` is not writable. - **`enable` says the stable install predates this bridge.** Run `./setup` (or `/gstack-upgrade`) so the hook registered at `~/.claude/skills/gstack` diff --git a/hosts/claude/hooks/memorable-user-prompt-hook.ts b/hosts/claude/hooks/memorable-user-prompt-hook.ts index 26cd1925a..3f01d7d01 100644 --- a/hosts/claude/hooks/memorable-user-prompt-hook.ts +++ b/hosts/claude/hooks/memorable-user-prompt-hook.ts @@ -10,7 +10,8 @@ * and clean removal. This file is that mediation. * * stdin JSON -> cap 1 MiB -> parse -> MEMORABLE=0? -> gate memorable_recall == on? - * -> win32? -> trust policy (deny / read-only veto, by session cwd; fail-closed) + * -> win32? -> trust policy (deny / read-only veto, by session cwd; fail-closed; + * a read-only remote's explicit per-remote opt-in lifts it, deny never) * -> HIGH-tier secret scan (raw bytes AND decoded string leaves, each admitted by the clock) * -> resolve vendor -> budget >= 500 ms? -> gate re-check * -> receipt (fail-closed: no receipt, no send) @@ -57,7 +58,7 @@ import { } from '../../../lib/egress-receipt'; import { wrapUntrustedTrackerContent } from '../../../lib/tracker-guard'; import { scan } from '../../../lib/redact-engine'; -import { hasRepoPolicyStore, repoPolicyTier } from '../../../lib/gbrain-repo-policy-client'; +import { hasRepoPolicyStore, memorableRecallPolicy } from '../../../lib/gbrain-repo-policy-client'; import { LOG_RATE_LIMIT_MS, logHookError as sharedLogHookError } from './hook-log'; export { LOG_RATE_LIMIT_MS }; @@ -393,13 +394,30 @@ async function policyVeto(cwd: string, timeoutMs: number, remaining: () => numbe if (git.status === GIT_NO_REMOTE) return 'ok'; if (git.status === GIT_FATAL && NOT_A_REPO_RE.test(git.stderrTail.trim())) return 'ok'; if (git.status !== 0) return 'error'; - const url = git.stdout.toString('utf8').trim(); - if (!url) return 'ok'; - const res = repoPolicyTier(url, process.env, Math.max(1, Math.min(STAGE_CAP_MS, remaining()))); - if (res.error) return 'error'; - // `deny` and `read-only` are the tiers a user picks so a repo's content - // never lands in a shared store; a third-party memory service is one. - return res.tier === 'deny' || res.tier === 'read-only' ? 'skip' : 'ok'; + if (!git.stdout.toString('utf8').trim()) return 'ok'; + // The store is keyed by the configured URLs (what `gstack-gbrain-repo-policy + // get` and /sync-gbrain read), not the url.*.insteadOf rewrite that + // `remote get-url` applies, and every configured origin URL is checked: + // neither a rewrite nor an added URL may dodge a deny. + const raw = await runExternal('git', ['config', '--null', '--get-all', 'remote.origin.url'], { + cwd, timeoutMs: Math.max(1, Math.min(timeoutMs, remaining())), maxBuffer: GIT_MAX_BUFFER, env: gitEnv(process.env), + onSpawn: (kill) => { killInflight = kill; }, + }); + killInflight = null; + if (raw.timedOut || raw.error || raw.status !== 0) return 'error'; + const urls = raw.stdout.toString('utf8').split('\0').filter((u) => u !== ''); + if (urls.length === 0) return 'error'; + for (const url of urls) { + const res = memorableRecallPolicy(url, process.env, Math.max(1, Math.min(STAGE_CAP_MS, remaining()))); + if (res.error) return 'error'; + // `deny` and `read-only` are the tiers a user picks so a repo's content + // never lands in a shared store; a third-party memory service is one. + // Only an explicit per-remote opt-in (`gstack-gbrain-repo-policy memorable + // set allow`) lifts the read-only veto; nothing lifts deny. + if (res.tier === 'deny') return 'skip'; + if (res.tier === 'read-only' && !res.memorableOptIn) return 'skip'; + } + return 'ok'; } function writeStdout(text: string): Promise { @@ -446,7 +464,7 @@ export async function main(): Promise { const payloadCwd = (payload as { cwd?: unknown }).cwd; const cwd = typeof payloadCwd === 'string' && isDirectory(payloadCwd) ? payloadCwd : process.cwd(); const veto = await policyVeto(cwd, Math.min(STAGE_CAP_MS, remaining()), remaining); - if (veto === 'skip') { logHookError(`trust policy for ${cwd} is deny or read-only, recall skipped`, Date.now(), 'trust policy skip'); return; } + if (veto === 'skip') { logHookError(`trust policy for ${cwd} is deny or read-only, recall skipped (a read-only remote can opt in: gstack-gbrain-repo-policy memorable set allow)`, Date.now(), 'trust policy skip'); return; } if (veto === 'error') { logHookError('trust policy lookup failed (store or repository unreadable), recall skipped (fail-closed)'); return; } // HIGH-tier pre-scan over the raw text and the decoded string leaves (a JSON diff --git a/lib/gbrain-repo-policy-client.ts b/lib/gbrain-repo-policy-client.ts index f62d0a163..941a4c9d0 100644 --- a/lib/gbrain-repo-policy-client.ts +++ b/lib/gbrain-repo-policy-client.ts @@ -17,8 +17,8 @@ */ import { spawnSync } from "child_process"; -import { existsSync } from "fs"; -import { join } from "path"; +import { lstatSync, statSync } from "fs"; +import { dirname, join } from "path"; import { mergedStateRoots, resolveStateRoot } from "./state-root"; export type RepoPolicyTierValue = "deny" | "read-only" | "read-write" | "none"; @@ -46,9 +46,36 @@ export function repoPolicyStorePath(env: NodeJS.ProcessEnv = process.env): strin /** * No store on disk = no policy was ever set (the fast path — no subprocess). * The ~/.gstack store counts too: its deny tiers merge into every state root. + * Only confirmed absence is "no store": ENOENT whose nearest existing + * ancestor resolves (so no dangling symlink cut the walk short). A dangling + * symlink at the store or above it, a non-directory component, or a path that + * cannot be checked (an unsearchable directory) counts as a store, so the + * caller spawns the script and its own fail-closed rules apply. */ export function hasRepoPolicyStore(env: NodeJS.ProcessEnv = process.env): boolean { - return mergedStateRoots(env).some((root) => existsSync(join(root, "gbrain-repo-policy.json"))); + return mergedStateRoots(env).some((root) => { + let path = join(root, "gbrain-repo-policy.json"); + try { + lstatSync(path); + return true; + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== "ENOENT") return true; + } + for (let up = dirname(path); up !== path; path = up, up = dirname(up)) { + try { + lstatSync(up); + } catch { + continue; + } + try { + statSync(up); + return false; + } catch { + return true; + } + } + return false; + }); } /** The bash script that owns the store — resolved relative to this file (lib/ → bin/), never cwd. */ @@ -94,6 +121,50 @@ export function repoPolicyTier( return { tier: "none", error: "unreadable" }; // unexpected output — a read failure, not a tier } +export interface MemorableRecallPolicy extends RepoPolicyResult { + /** + * True only when the tier is `read-only` and the store opts this remote in + * to Memorable recall. Never true alongside `error` or any other tier. + */ + memorableOptIn: boolean; +} + +/** + * Tier plus the per-remote Memorable recall opt-in, via + * `gstack-gbrain-repo-policy memorable get ` (one spawn). Unlike + * repoPolicyTier, the script never quarantines a corrupt store on this path, + * so a corrupt store reads as `unreadable` instead of as "no policy". + * Fast paths and failure classification match repoPolicyTier; anything but + * exactly "\t" is `unreadable`. POLARITY IS THE CALLER'S. + */ +export function memorableRecallPolicy( + url: string | null, + env: NodeJS.ProcessEnv = process.env, + timeoutMs: number = 10_000, +): MemorableRecallPolicy { + if (!hasRepoPolicyStore(env)) return { tier: "none", memorableOptIn: false }; + if (!url) return { tier: "none", memorableOptIn: false }; + const [cmd, args]: [string, string[]] = + process.platform === "win32" + ? ["bash", [POLICY_SCRIPT, "memorable", "get", url]] + : [POLICY_SCRIPT, ["memorable", "get", url]]; + const res = spawnSync(cmd, args, { + encoding: "utf-8", + timeout: Math.max(1, timeoutMs), + env: { ...env } as NodeJS.ProcessEnv, + }); + if (res.error) { + const code = (res.error as NodeJS.ErrnoException).code; + return { tier: "none", memorableOptIn: false, error: code === "ENOENT" ? "spawn-failed" : "unreadable" }; + } + const unreadable: MemorableRecallPolicy = { tier: "none", memorableOptIn: false, error: "unreadable" }; + if (res.status !== 0) return unreadable; + const match = /^(deny|read-only|read-write|unset)\t(allow|off)\n?$/.exec(res.stdout || ""); + if (!match) return unreadable; + const tier = match[1] === "unset" ? "none" : (match[1] as RepoPolicyTierValue); + return { tier, memorableOptIn: tier === "read-only" && match[2] === "allow" }; +} + /** * Bulk trust-tier lookup via `gstack-gbrain-repo-policy get --batch` — ONE * spawn total for the whole url list (memory-ingest checks every distinct diff --git a/package.json b/package.json index fc98e57f0..1b04d4a8e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.91.193", + "version": "1.91.194", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.", "license": "MIT", "type": "module", diff --git a/test/gbrain-repo-policy-client.test.ts b/test/gbrain-repo-policy-client.test.ts index 89a6b1247..0988a5ecb 100644 --- a/test/gbrain-repo-policy-client.test.ts +++ b/test/gbrain-repo-policy-client.test.ts @@ -19,7 +19,7 @@ import * as path from "path"; import * as os from "os"; import { spawnSync } from "child_process"; -import { repoPolicyTier, repoPolicyTierBatch } from "../lib/gbrain-repo-policy-client"; +import { hasRepoPolicyStore, memorableRecallPolicy, repoPolicyTier, repoPolicyTierBatch } from "../lib/gbrain-repo-policy-client"; import { canonicalizeRemote } from "../lib/gstack-memory-helpers"; const ROOT = path.resolve(import.meta.dir, ".."); @@ -219,3 +219,54 @@ describe("repoPolicyTier timeoutMs (hook deadline seam)", () => { expect(repoPolicyTier(url, env(), 1)).toEqual({ tier: "none", error: "unreadable" }); }); }); + +describe("memorableRecallPolicy (#3359: per-remote Memorable recall opt-in)", () => { + const A = "https://github.com/acme/client-a.git"; + test("no store: tier none, opt-in false, no error (fast path)", () => { + expect(memorableRecallPolicy(A, env())).toEqual({ tier: "none", memorableOptIn: false }); + expect(fs.existsSync(policyFile())).toBe(false); + }); + test("read-only defaults to off; the explicit opt-in turns it on for that remote only", () => { + expect(run(["set", A, "read-only"]).status).toBe(0); + expect(run(["set", "https://github.com/acme/client-b", "read-only"]).status).toBe(0); + expect(memorableRecallPolicy(A, env())).toEqual({ tier: "read-only", memorableOptIn: false }); + expect(run(["memorable", "set", A, "allow"]).status).toBe(0); + expect(memorableRecallPolicy(A, env())).toEqual({ tier: "read-only", memorableOptIn: true }); + expect(memorableRecallPolicy("https://github.com/acme/client-b", env())).toEqual({ tier: "read-only", memorableOptIn: false }); + expect(memorableRecallPolicy("https://github.com/acme/other", env())).toEqual({ tier: "none", memorableOptIn: false }); + }); + test("deny with a hand-written opt-in: opt-in false", () => { + fs.writeFileSync(policyFile(), JSON.stringify({ _schema_version: 2, "github.com/acme/client-a": "deny", _memorable_recall: { "github.com/acme/client-a": "allow" } })); + expect(memorableRecallPolicy(A, env())).toEqual({ tier: "deny", memorableOptIn: false }); + }); + test("corrupt store: unreadable, never read as no policy, and not quarantined", () => { + fs.writeFileSync(policyFile(), "{not json", { mode: 0o600 }); + expect(memorableRecallPolicy(A, env())).toEqual({ tier: "none", memorableOptIn: false, error: "unreadable" }); + expect(fs.readFileSync(policyFile(), "utf-8")).toBe("{not json"); + }); + test("a tier the client does not know (hand-edited store) is unreadable, never no-policy", () => { + fs.writeFileSync(policyFile(), JSON.stringify({ _schema_version: 2, "github.com/acme/client-a": 7, _memorable_recall: { "github.com/acme/client-a": "allow" } })); + expect(memorableRecallPolicy(A, env())).toEqual({ tier: "none", memorableOptIn: false, error: "unreadable" }); + }); + test("a dangling symlink as the only store is not the no-store fast path: unreadable", () => { + fs.symlinkSync(path.join(tmpHome, "missing.json"), policyFile()); + expect(hasRepoPolicyStore(env())).toBe(true); + expect(memorableRecallPolicy(A, env())).toEqual({ tier: "none", memorableOptIn: false, error: "unreadable" }); + }); + test("a dangling symlink above the store, or a file in its path, is not the no-store fast path", () => { + fs.symlinkSync(path.join(tmpHome, "missing-dir"), path.join(tmpHome, "link")); + const viaLink = { ...env(), GSTACK_TEST_LEGACY_ROOT: path.join(tmpHome, "link", "child") }; + expect(hasRepoPolicyStore(viaLink)).toBe(true); + expect(memorableRecallPolicy(A, viaLink)).toEqual({ tier: "none", memorableOptIn: false, error: "unreadable" }); + fs.writeFileSync(path.join(tmpHome, "plain-file"), "x"); + expect(hasRepoPolicyStore({ ...env(), GSTACK_TEST_LEGACY_ROOT: path.join(tmpHome, "plain-file", "child") })).toBe(true); + fs.mkdirSync(path.join(tmpHome, "real-dir")); + fs.symlinkSync(path.join(tmpHome, "real-dir"), path.join(tmpHome, "good-link")); + expect(hasRepoPolicyStore({ ...env(), GSTACK_TEST_LEGACY_ROOT: path.join(tmpHome, "good-link", "child") })).toBe(false); + expect(hasRepoPolicyStore({ ...env(), GSTACK_TEST_LEGACY_ROOT: path.join(tmpHome, "no", "such", "root") })).toBe(false); + }); + test("a timed-out spawn classifies as unreadable", () => { + expect(run(["set", A, "read-only"]).status).toBe(0); + expect(memorableRecallPolicy(A, env(), 1)).toEqual({ tier: "none", memorableOptIn: false, error: "unreadable" }); + }); +}); diff --git a/test/gbrain-repo-policy.test.ts b/test/gbrain-repo-policy.test.ts index b95964646..60436c450 100644 --- a/test/gbrain-repo-policy.test.ts +++ b/test/gbrain-repo-policy.test.ts @@ -418,3 +418,181 @@ describe('gstack-gbrain-sync code stage honors the repo policy (#2140 sync path) expect(r.text).not.toContain('refused'); }); }); + +describe('memorable recall opt-in (#3359)', () => { + const A = 'https://github.com/acme/client-a.git'; + const memo = (url: string, extra: Record = {}) => run(['memorable', 'get', url], { env: extra }); + + test('off by default: a read-only remote reports off until it is opted in', () => { + expect(run(['set', A, 'read-only']).status).toBe(0); + expect(memo(A).stdout).toBe('read-only\toff'); + const r = run(['memorable', 'set', A, 'allow']); + expect(r.status).toBe(0); + expect(memo(A).stdout).toBe('read-only\tallow'); + expect(readPolicy()._memorable_recall).toEqual({ 'github.com/acme/client-a': 'allow' }); + expect(run(['list']).stdout).toBe('github.com/acme/client-a\tread-only\tmemorable=allow'); + }); + + test('the opt-in is per remote: another remote, a prefix-sharing remote and an unset remote stay off; the SSH spelling of the same remote is the same key', () => { + for (const u of [A, 'https://github.com/acme/client-b', 'https://github.com/acme/client-a-fork', 'https://github.com/acme/client']) { + expect(run(['set', u, 'read-only']).status).toBe(0); + } + expect(run(['memorable', 'set', A, 'allow']).status).toBe(0); + expect(memo('https://github.com/acme/client-b').stdout).toBe('read-only\toff'); + expect(memo('https://github.com/acme/client-a-fork').stdout).toBe('read-only\toff'); + expect(memo('https://github.com/acme/client').stdout).toBe('read-only\toff'); + expect(memo('https://gitlab.com/acme/client-a').stdout).toBe('unset\toff'); + expect(memo('git@github.com:acme/client-a.git').stdout).toBe('read-only\tallow'); + }); + + test('deny always wins: the opt-in is refused on deny, and a hand-written opt-in on a deny remote reads off', () => { + expect(run(['set', A, 'deny']).status).toBe(0); + const before = fs.readFileSync(policyFile(), 'utf-8'); + const r = run(['memorable', 'set', A, 'allow']); + expect(r.status).toBe(2); + expect(r.stderr).toContain('deny tier always vetoes'); + expect(fs.readFileSync(policyFile(), 'utf-8')).toBe(before); + fs.writeFileSync(policyFile(), JSON.stringify({ ...readPolicy(), _memorable_recall: { 'github.com/acme/client-a': 'allow' } })); + expect(memo(A).stdout).toBe('deny\toff'); + }); + + test('the opt-in is refused on read-write and unset remotes (nothing to lift)', () => { + expect(run(['set', A, 'read-write']).status).toBe(0); + expect(run(['memorable', 'set', A, 'allow']).status).toBe(2); + expect(run(['memorable', 'set', 'https://github.com/acme/never-set', 'allow']).status).toBe(2); + expect(readPolicy()._memorable_recall).toBeUndefined(); + }); + + test('changing the tier clears the opt-in, re-setting the same tier keeps it, and off removes it', () => { + run(['set', A, 'read-only']); + run(['memorable', 'set', A, 'allow']); + expect(run(['set', A, 'read-only']).stdout).not.toContain('Cleared'); + expect(memo(A).stdout).toBe('read-only\tallow'); + const flip = run(['set', A, 'read-write']); + expect(flip.stdout).toContain('Cleared the Memorable recall opt-in'); + run(['set', A, 'read-only']); + expect(memo(A).stdout).toBe('read-only\toff'); + run(['memorable', 'set', A, 'allow']); + expect(run(['memorable', 'set', A, 'off']).status).toBe(0); + expect(memo(A).stdout).toBe('read-only\toff'); + expect(readPolicy()._memorable_recall).toBeUndefined(); + }); + + test('a malformed opt-in reads off: any value but "allow", or a map that is not an object', () => { + run(['set', A, 'read-only']); + for (const bad of [{ 'github.com/acme/client-a': true }, { 'github.com/acme/client-a': 'ALLOW' }, 'allow', ['github.com/acme/client-a'], null]) { + fs.writeFileSync(policyFile(), JSON.stringify({ _schema_version: 2, 'github.com/acme/client-a': 'read-only', _memorable_recall: bad })); + expect(memo(A).stdout).toBe('read-only\toff'); + } + }); + + test('a corrupt store fails closed: exit 2, never quarantined, store left as is', () => { + fs.writeFileSync(policyFile(), '{"_schema_version":2,"github.com/acme/client-a":"read-only",', { mode: 0o600 }); + const r = memo(A); + expect(r.status).toBe(2); + expect(r.stdout).toBe(''); + expect(r.stderr).toContain('corrupt'); + expect(fs.readFileSync(policyFile(), 'utf-8')).toContain('"read-only",'); + expect(fs.readdirSync(tmpHome).some((f) => f.includes('.corrupt-'))).toBe(false); + }); + + test('a second store that makes the remote read-only must opt it in too; its deny still wins; a corrupt second store fails closed', () => { + const legacy = path.join(tmpHome, 'legacy'); + fs.mkdirSync(legacy); + const extra = { GSTACK_TEST_LEGACY_ROOT: legacy }; + const legacyStore = path.join(legacy, 'gbrain-repo-policy.json'); + run(['set', A, 'read-only'], { env: extra }); + expect(run(['memorable', 'set', A, 'allow'], { env: extra }).status).toBe(0); + expect(memo(A, extra).stdout).toBe('read-only\tallow'); + fs.writeFileSync(legacyStore, JSON.stringify({ _schema_version: 2, 'github.com/acme/client-a': 'read-only' })); + expect(memo(A, extra).stdout).toBe('read-only\toff'); + expect(run(['memorable', 'set', A, 'allow'], { env: extra }).stderr).toContain('Opt in there too'); + fs.writeFileSync(legacyStore, JSON.stringify({ _schema_version: 2, 'github.com/acme/client-a': 'read-only', _memorable_recall: { 'github.com/acme/client-a': 'allow' } })); + expect(memo(A, extra).stdout).toBe('read-only\tallow'); + fs.writeFileSync(legacyStore, JSON.stringify({ _schema_version: 2, 'github.com/acme/client-a': 'deny', _memorable_recall: { 'github.com/acme/client-a': 'allow' } })); + expect(memo(A, extra).stdout).toBe('deny\toff'); + fs.writeFileSync(legacyStore, '{'); + expect(memo(A, extra).status).toBe(2); + }); + + test('a store path that is not a readable file fails closed, in either root', () => { + fs.mkdirSync(policyFile()); + expect(memo(A).status).toBe(2); + fs.rmdirSync(policyFile()); + const legacy = path.join(tmpHome, 'legacy'); + fs.mkdirSync(path.join(legacy, 'gbrain-repo-policy.json'), { recursive: true }); + const r = memo(A, { GSTACK_TEST_LEGACY_ROOT: legacy }); + expect(r.status).toBe(2); + expect(r.stderr).toContain('not a readable file'); + }); + + test('review hardening: empty, non-object or multi-document stores, and invalid tier values, fail closed in either root', () => { + const legacy = path.join(tmpHome, 'legacy'); + fs.mkdirSync(legacy); + const extra = { GSTACK_TEST_LEGACY_ROOT: legacy }; + const legacyStore = path.join(legacy, 'gbrain-repo-policy.json'); + run(['set', A, 'read-only']); + for (const bad of ['', ' \n', 'null', '[]', '7', '{}{}']) { + fs.writeFileSync(legacyStore, bad); + const r = memo(A, extra); + expect([bad, r.status, r.stdout]).toEqual([bad, 2, '']); + } + fs.rmSync(legacyStore); + for (const value of [false, null, 7, 'none', 'Read-Only']) { + fs.writeFileSync(policyFile(), JSON.stringify({ _schema_version: 2, 'github.com/acme/client-a': value })); + expect([value, memo(A).status]).toEqual([value, 2]); + } + fs.writeFileSync(policyFile(), JSON.stringify({ _schema_version: 2, 'github.com/acme/client-a': false })); + fs.writeFileSync(legacyStore, JSON.stringify({ 'github.com/acme/client-a': 'read-only', _memorable_recall: { 'github.com/acme/client-a': 'allow' } })); + expect(memo(A, extra).status).toBe(2); + }); + + test('review hardening: a dangling symlink or an unsearchable directory is never read as no store', () => { + fs.symlinkSync(path.join(tmpHome, 'missing.json'), policyFile()); + expect(memo(A).status).toBe(2); + fs.rmSync(policyFile()); + const legacy = path.join(tmpHome, 'legacy'); + fs.mkdirSync(legacy); + fs.symlinkSync(path.join(tmpHome, 'missing.json'), path.join(legacy, 'gbrain-repo-policy.json')); + expect(memo(A, { GSTACK_TEST_LEGACY_ROOT: legacy }).status).toBe(2); + fs.rmSync(path.join(legacy, 'gbrain-repo-policy.json')); + if (!canRevokeReads()) return; + fs.writeFileSync(path.join(legacy, 'gbrain-repo-policy.json'), JSON.stringify({ 'github.com/acme/client-a': 'deny' })); + fs.chmodSync(legacy, 0o600); + try { + expect(memo(A, { GSTACK_TEST_LEGACY_ROOT: legacy }).status).toBe(2); + } finally { + fs.chmodSync(legacy, 0o700); + } + const nested = path.join(tmpHome, 'ancestor', 'nested'); + fs.mkdirSync(nested, { recursive: true }); + fs.writeFileSync(path.join(nested, 'gbrain-repo-policy.json'), JSON.stringify({ 'github.com/acme/client-a': 'deny' })); + fs.chmodSync(path.join(tmpHome, 'ancestor'), 0o600); + try { + const r = memo(A, { GSTACK_TEST_LEGACY_ROOT: nested }); + expect([r.status, r.stdout]).toEqual([2, '']); + expect(r.stderr).toContain('not a searchable directory'); + } finally { + fs.chmodSync(path.join(tmpHome, 'ancestor'), 0o700); + } + const absentUnder = path.join(tmpHome, 'no', 'such', 'root'); + expect(memo(A, { GSTACK_TEST_LEGACY_ROOT: absentUnder }).stdout).toBe('unset\toff'); + }); + + test('review hardening: the lookup never writes: a schema-less store is not migrated or rewritten', () => { + const legacyShape = JSON.stringify({ 'github.com/acme/client-a': 'read-only', 'github.com/acme/old': 'allow', _memorable_recall: { 'github.com/acme/client-a': 'allow' } }); + fs.writeFileSync(policyFile(), legacyShape); + expect(memo(A).stdout).toBe('read-only\tallow'); + expect(memo('https://github.com/acme/old').stdout).toBe('read-write\toff'); + expect(fs.readFileSync(policyFile(), 'utf-8')).toBe(legacyShape); + }); + + test('the two metadata keys are never stored as remotes; the _unattributed sentinel still is', () => { + const r = run(['set', '_memorable_recall', 'read-write']); + expect(r.status).toBe(2); + expect(r.stderr).toContain('reserved key'); + expect(run(['set', '_schema_version', 'deny']).status).toBe(2); + expect(run(['set', '_unattributed', 'deny']).status).toBe(0); + expect(run(['list']).stdout).toBe('_unattributed\tdeny'); + }); +}); diff --git a/test/memorable-user-prompt-hook.test.ts b/test/memorable-user-prompt-hook.test.ts index f307b5c1c..ef2506b99 100644 --- a/test/memorable-user-prompt-hook.test.ts +++ b/test/memorable-user-prompt-hook.test.ts @@ -243,6 +243,121 @@ describe('gate on: the mediated hand-off', () => { } }); + test('#3359 per-remote opt-in: lifts read-only for that remote only; default, other remotes, deny and a corrupt store stay blocked', () => { + gateOn(); + const policy = (...args: string[]) => spawnSync('bash', [POLICY, ...args], { env, encoding: 'utf8', timeout: 20_000 }); + const store = path.join(home, '.gstack', 'gbrain-repo-policy.json'); + const repos: Record = {}; + try { + for (const [name, url] of [['a', 'https://github.com/example/client-a.git'], ['b', 'https://github.com/example/client-b.git']]) { + const repo = fs.mkdtempSync(path.join(os.tmpdir(), `gstack-memo-optin-${name}-`)); + spawnSync('git', ['init', '-q'], { cwd: repo, timeout: 10_000 }); + spawnSync('git', ['remote', 'add', 'origin', url], { cwd: repo, timeout: 10_000 }); + isolateGitRemote(repo, url); + expect(policy('set', url, 'read-only').status).toBe(0); + repos[name] = repo; + } + const hook = (name: string) => { + fs.rmSync(path.join(home, 'calls.log'), { force: true }); + return runHook(JSON.stringify({ prompt: 'hello', cwd: repos[name] }), {}, repos[name]); + }; + // default: read-only stays blocked + expect(hook('a').stdout).toBe(''); + expect(calls()).toBe(''); + expect(errLog()).toContain('memorable set allow'); + // opt-in on client-a: recall runs there, and does not leak to client-b + expect(policy('memorable', 'set', 'https://github.com/example/client-a.git', 'allow').status).toBe(0); + expect(hook('a').stdout).toContain('remembered'); + expect(calls()).toBe('hook user-prompt\n'); + expect(hook('b').stdout).toBe(''); + expect(calls()).toBe(''); + // deny beats an opt-in, even one written into the store by hand + expect(policy('set', 'https://github.com/example/client-a.git', 'deny').status).toBe(0); + const s = JSON.parse(fs.readFileSync(store, 'utf8')); + fs.writeFileSync(store, JSON.stringify({ ...s, _memorable_recall: { 'github.com/example/client-a': 'allow' } })); + expect(hook('a').stdout).toBe(''); + expect(calls()).toBe(''); + // a corrupt store fails closed and is left in place (never quarantined into "no policy") + fs.writeFileSync(store, '{"_schema_version":2,"github.com/example/client-b":"read-only",'); + expect(hook('b')).toEqual({ status: 0, stdout: '', stderr: '' }); + expect(calls()).toBe(''); + expect(errLog()).toContain('trust policy lookup failed'); + expect(fs.readFileSync(store, 'utf8')).toContain('"read-only",'); + expect(hook('b').stdout).toBe(''); + expect(calls()).toBe(''); + } finally { + for (const repo of Object.values(repos)) fs.rmSync(repo, { recursive: true, force: true }); + } + }); + + test('#3359 review: a url.insteadOf rewrite to another host does not dodge a deny, and a dangling-symlink store fails closed', () => { + gateOn(); + const url = 'https://fixture.invalid/org/denied.git'; + const repo = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-memo-rewrite-')); + try { + spawnSync('git', ['init', '-q'], { cwd: repo, timeout: 10_000 }); + spawnSync('git', ['remote', 'add', 'origin', url], { cwd: repo, timeout: 10_000 }); + spawnSync('git', ['config', '--local', 'url.https://mirror.invalid/.insteadOf', 'https://fixture.invalid/'], { cwd: repo, timeout: 10_000 }); + expect(spawnSync('git', ['remote', 'get-url', 'origin'], { cwd: repo, encoding: 'utf8', timeout: 10_000 }).stdout.trim()).toBe('https://mirror.invalid/org/denied.git'); + expect(spawnSync('bash', [POLICY, 'set', url, 'deny'], { env, encoding: 'utf8', timeout: 20_000 }).status).toBe(0); + const prompt = JSON.stringify({ prompt: 'hello', cwd: repo }); + expect(runHook(prompt, {}, repo).stdout).toBe(''); + expect(calls()).toBe(''); + const store = path.join(home, '.gstack', 'gbrain-repo-policy.json'); + fs.rmSync(store); + fs.symlinkSync(path.join(home, 'missing.json'), store); + expect(runHook(prompt, {}, repo)).toEqual({ status: 0, stdout: '', stderr: '' }); + expect(calls()).toBe(''); + expect(errLog()).toContain('trust policy lookup failed'); + } finally { + fs.rmSync(repo, { recursive: true, force: true }); + } + }); + + test('#3359 review: a second configured origin URL cannot dodge the first URL\'s deny, in either order', () => { + gateOn(); + const denied = 'https://fixture.invalid/org/denied.git'; + const other = 'https://fixture.invalid/org/unlisted.git'; + expect(spawnSync('bash', [POLICY, 'set', denied, 'deny'], { env, encoding: 'utf8', timeout: 20_000 }).status).toBe(0); + for (const order of [[denied, other], [other, denied]]) { + const repo = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-memo-multi-')); + try { + spawnSync('git', ['init', '-q'], { cwd: repo, timeout: 10_000 }); + spawnSync('git', ['remote', 'add', 'origin', order[0]], { cwd: repo, timeout: 10_000 }); + spawnSync('git', ['config', '--local', '--add', 'remote.origin.url', order[1]], { cwd: repo, timeout: 10_000 }); + for (const u of order) spawnSync('git', ['config', '--local', `url.${u}.insteadOf`, u], { cwd: repo, timeout: 10_000 }); + fs.rmSync(path.join(home, 'calls.log'), { force: true }); + expect(runHook(JSON.stringify({ prompt: 'hello', cwd: repo }), {}, repo).stdout).toBe(''); + expect(calls()).toBe(''); + } finally { + fs.rmSync(repo, { recursive: true, force: true }); + } + } + }); + + test('#3359 review: an origin URL with a newline or edge whitespace (valid local paths) is looked up verbatim, so its deny holds', () => { + gateOn(); + for (const name of ['remote\nname.git', 'remote.git ', ' remote.git']) { + const parent = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-memo-nl-')); + try { + const remote = path.join(parent, name); + fs.mkdirSync(remote); + expect(spawnSync('git', ['init', '-q', '--bare', remote], { timeout: 10_000 }).status).toBe(0); + const repo = path.join(parent, 'work'); + fs.mkdirSync(repo); + spawnSync('git', ['init', '-q'], { cwd: repo, timeout: 10_000 }); + spawnSync('git', ['remote', 'add', 'origin', remote], { cwd: repo, timeout: 10_000 }); + expect(spawnSync('bash', [POLICY, 'set', remote, 'deny'], { env, encoding: 'utf8', timeout: 20_000 }).status).toBe(0); + expect(spawnSync('bash', [POLICY, 'memorable', 'get', remote], { env, encoding: 'utf8', timeout: 20_000 }).stdout).toBe('deny\toff\n'); + fs.rmSync(path.join(home, 'calls.log'), { force: true }); + expect([name, runHook(JSON.stringify({ prompt: 'hello', cwd: repo }), {}, repo).stdout]).toEqual([name, '']); + expect(calls()).toBe(''); + } finally { + fs.rmSync(parent, { recursive: true, force: true }); + } + } + }); + test('gate flipped off between two runs: the second run spawns nothing (the mid-flight re-check itself cannot be interleaved from outside)', () => { // The pre-spawn re-check reads the same store; a deterministic mid-flight flip would need a // seam inside main(). This pins the observable contract only: once off, no spawn.