mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-01 06:24:41 +01:00
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:
1 parent
1b29e5dc5d
commit
f344103a3d
11 files changed
+694
-30
No files matched your search
@@ -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,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,
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user