v1.91.194.0 feat(memorable): per-remote recall opt-in for read-only repos; recall veto fails closed (#3359) (#3381)

* feat(memorable): per-remote recall opt-in for read-only repos; recall veto fails closed on a corrupt policy store (#3359)

Closes #3359

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(repo-policy): reserve only the two metadata keys, so the _unattributed sentinel stays a valid policy key

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(memorable): recall lookup is a pure snapshot read that fails closed on malformed, dangling or unsearchable stores, and keys on the configured origin URL (security review)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(memorable): check every configured origin URL, and confirm a missing store through its ancestors (security review, second pass)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(memorable): read origin URLs NUL-delimited, and keep a dangling ancestor symlink off the no-store fast path (security review, third pass)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(memorable): use each NUL-delimited origin URL verbatim (security review, fourth pass)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
Garry Tanandcapy-ai[bot] authored and GitHub committed 2026-10-25 05:06:30 +00:00
1 parent 1b29e5dc5d
commit f344103a3d
11 files changed
+694 -30

No files matched your search

+28
View File
@@ -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 <remote-url> <allow|off>`** 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 [<remote-url>]` prints `<tier>\t<allow|off>`, 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 `<tier>\t<allow|off>` 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.
+1 -1
View File
@@ -1 +1 @@
1.91.193.0
1.91.194.0
+1 -1
View File
@@ -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,
+182 -8
View File
@@ -15,10 +15,22 @@
# rather than bypass a set policy.
#
# gstack-gbrain-repo-policy set <remote-url> <read-write|read-only|deny>
# 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 [<remote-url>]
# Print "<tier>\t<allow|off>": 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 <remote-url> <allow|off>
# 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 "<key>\t<tier>", sorted by key.
# Print every entry as "<key>\t<tier>", sorted by key, with a third
# column "memorable=allow" on remotes opted in to Memorable recall.
#
# gstack-gbrain-repo-policy normalize <url>
# 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 — "<tier> <allow|off>" 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 <remote-url> <allow|off>"; 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 [<remote-url>]|set <remote-url> <allow|off>}" ;;
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
+34 -5
View File
@@ -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`
@@ -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 <url> 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<void> {
@@ -446,7 +464,7 @@ export async function main(): Promise<void> {
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 <origin-url> 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
+74 -3
View File
@@ -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 <url>` (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 "<tier>\t<allow|off>" 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
+1 -1
View File
@@ -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",
+52 -1
View File
@@ -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" });
});
});
+178
View File
@@ -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<string, string> = {}) => 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');
});
});
+115
View File
@@ -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<string, string> = {};
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 <origin-url> 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.