fix(agents-vault): keep the knowledge walk and the launch push inside their bounds

The agy knowledge allowlist walked the store with `**` and copied with
plain cp, so a symlink inside the store was both followed and dereferenced.
The extension rule still bounded what kind of file was collected, but not
whose: a link to a home directory hands over settings.json, CLAUDE.md and
every cached .json in it, and those reached a commit. A link to / made the
walk itself unbounded, on the path that runs before every agent launch.
The tree is now walked a level at a time and nothing that is a symlink is
followed or copied.

Autopush had the same shape one layer out. Neither GIT_TERMINAL_PROMPT nor
GIT_ASKPASS closes a socket, and git has no connect timeout to set: against
a blackholed address a push took 135s with http.lowSpeedLimit and
http.lowSpeedTime set as well as without them. ssh can time itself out and
is now told to; the autopush pull and push are additionally capped with
timeout(1). An explicit --push stays uncapped, since it is watched and has
to report what a real transfer really did.

Also: scaffold /.migrate-stash into .gitignore beside /.adopt-stash, which
the comment already claimed was covered; and drop the live memory path
during a slug migration only when it is a link. Reached from the
path-derived fallback candidate it can be a real populated directory, where
rm -f correctly refuses -- but said so in rm's voice, so a --silent run that
had succeeded printed what read as an error.
This commit is contained in:
2026-09-03 18:56:46 -04:00
parent 16ea31289d
commit d9b56790c5
3 changed files with 217 additions and 14 deletions
+95 -13
View File
@@ -38,7 +38,9 @@
# allowlist runs all the way down, not just at the top: inside agy's
# knowledge store only *.md and *.json files are copied, so a credential
# file or a conversation database appearing there is left behind by the
# same rule rather than by being known about in advance.
# same rule rather than by being known about in advance. Symlinks found
# inside the store are neither followed nor copied, so the allowlist
# bounds whose files it collects and not merely what kind.
#
# Global state that belongs to no project is tracked as well. Claude's
# global memory directory (~/.claude/memory) is symlinked into the vault
@@ -131,9 +133,13 @@
#
# NOTES
# Set __fish_agent_vault_dir to relocate the vault. Set
# __fish_agent_vault_autopush to 1 to also push on wrapper launch;
# it defaults to off so a backgrounded push can never hang or prompt
# invisibly underneath a starting agent.
# __fish_agent_vault_autopush to 1 to also push on wrapper launch; it
# defaults to off because that push is synchronous and so delays every
# launch. With it on, the pull and the push are each capped at 20
# seconds, since git has no connect timeout of its own and an
# unreachable remote otherwise blocks for minutes. An explicit --push
# is left uncapped: it is watched, and it must report what a real
# transfer really did.
#
# --adopt rebinds an entry; it does not pin its name. The slug is
# re-derived from the project on every run, so the next ordinary run
@@ -329,10 +335,12 @@ function agents-vault --description 'track curated agent memory in a host-scoped
'*.db-wal' \
'*.db-shm' \
'' \
'# --adopt stashes the entry it is about to overwrite inside .git/,' \
'# out of reach of `git add -A`. This covers the fallback location' \
'# it uses when .git is not a directory.' \
'/.adopt-stash' >"$vault/.gitignore"
'# --adopt and the slug migration each stash the entry they are' \
'# about to overwrite inside .git/, out of reach of `git add -A`.' \
'# These cover the fallback locations they use when .git is not a' \
'# directory.' \
'/.adopt-stash' \
'/.migrate-stash' >"$vault/.gitignore"
set changed 1
end
@@ -627,7 +635,39 @@ function agents-vault --description 'track curated agent memory in a host-scoped
# Fish wildcards skip dot-led names at every path component, so
# dotfiles and hidden subdirectories are already out; the extension
# allowlist is what keeps them out on purpose rather than by luck.
set -l kfiles $knowledge/**.md $knowledge/**.json
#
# The tree is walked a level at a time rather than globbed with **,
# because a symlink has to stop the walk and ** has no way to say
# so. A recursive ** descends through a symlinked directory and cp
# follows a symlinked file, which between them undo the whole point
# of the allowlist twice over. A `ln -s ~ knowledge/x` puts every
# qualifying .md and .json in the home directory -- settings.json,
# CLAUDE.md, cache and status files -- into the vault and into a
# commit: the extension rule still bounds what *kind* of file goes
# in, but the store boundary that decides *whose* files they are is
# gone. Worse, `ln -s / knowledge/x` makes the walk itself
# unbounded, and this runs synchronously in front of every agent
# launch. Fish does stop a true self-referential cycle; a symlink
# to a merely enormous tree is not a cycle.
#
# So nothing that is a symlink is ever followed or copied, whether
# it names a file or a directory. The knowledge store's own root
# may still be a link -- that one is the configured location of the
# store rather than something found inside it.
set -l kfiles
set -l kdirs "$knowledge"
while set -q kdirs[1]
set -l dir $kdirs[1]
set -e kdirs[1]
for e in $dir/*
test -L "$e"; and continue
if test -d "$e"
set -a kdirs "$e"
else if string match -qr '\.(md|json)$' -- "$e"
set -a kfiles "$e"
end
end
end
set -l kfailed 0
for f in $kfiles
test -f "$f"; or continue
@@ -836,7 +876,18 @@ function agents-vault --description 'track curated agent memory in a host-scoped
end
printf 'renamed: %s → %s (%s)\n' "$prev_slug" "$slug" (date -I) \
>>"$entry/origin"
rm -f "$live"
# Only a link is dropped here, and only so the relink below has
# somewhere to put the new one. Usually $live is exactly that: a
# symlink at the old entry, now dangling. But the migration is
# also reachable from the path-derived fallback candidate, and
# there $live can be a real, populated directory -- someone's
# actual memory. rm -f cannot delete it, which is the right
# outcome, but it says so on stderr in rm's own voice, so a
# --silent run that succeeded printed what reads as an error.
# The directory case needs no removal anyway:
# _agents_repo_ensure_symlink copies a populated live directory
# into the vault without clobbering before it replaces it.
test -L "$live"; and rm -f "$live"
set changed 1
test $verbose -eq 1; and echo "$c_ok→ Migrated vault entry $prev_slug → $slug$c_reset"
end
@@ -930,10 +981,41 @@ function agents-vault --description 'track curated agent memory in a host-scoped
# helper, which git consults before it ever falls back to
# prompting; they only close off the interactive last resort,
# which under a starting agent is indistinguishable from a hang.
#
# They close the prompt, not the socket, and git has no knob
# that closes the socket either: there is no HTTP connect
# timeout in its configuration at all, and http.lowSpeedLimit /
# http.lowSpeedTime -- the usual suggestion -- only start
# counting once bytes are moving, so they expire never against
# an address that simply blackholes the SYN. Measured against
# 192.0.2.1 with both set: no return inside 30s; unset: 135s.
#
# ssh is the one transport that can time itself out, so it is
# told to, unless the user has already said how to run ssh.
# Everything else is bounded from outside with timeout(1).
set -l gitenv GIT_TERMINAL_PROMPT=0 GIT_ASKPASS=true
set -q GIT_SSH_COMMAND
or set -a gitenv 'GIT_SSH_COMMAND=ssh -o ConnectTimeout=10'
set -l gitnet env $gitenv
# Only autopush is wrapped. An explicit --push is a thing the
# user asked for and is watching, and it must report the real
# exit status of a real transfer, so it is allowed to take as
# long as the transfer honestly takes. Autopush runs
# synchronously in front of every agent launch, which is the
# block this whole design exists to remove; there an
# unreachable remote costs a bounded 20s per operation instead
# of an open-ended wait. timeout's own 124 is a non-zero exit
# like any other, so a bounded push still reports as failed and
# the commit it could not send has already landed locally. A
# push too slow for the bound can always be run by hand.
if not set -q _flag_push; and type -q timeout
set gitnet timeout 20 $gitnet
end
set -l reached 1
if git -C "$vault" rev-parse --abbrev-ref --symbolic-full-name '@{u}' >/dev/null 2>&1
if not GIT_TERMINAL_PROMPT=0 GIT_ASKPASS=true \
git -C "$vault" pull --rebase --autostash -q >/dev/null 2>/dev/null
if not $gitnet git -C "$vault" pull --rebase --autostash -q \
>/dev/null 2>/dev/null
# Two unrelated failures land here and reporting them as
# one sends the user hunting for a conflict that never
# existed. A rebase that genuinely started and stopped
@@ -956,7 +1038,7 @@ function agents-vault --description 'track curated agent memory in a host-scoped
# push would only fail a second time, more confusingly, and the
# pull has already said exactly what went wrong.
if test $reached -eq 1
if GIT_TERMINAL_PROMPT=0 GIT_ASKPASS=true git -C "$vault" push -q origin HEAD
if $gitnet git -C "$vault" push -q origin HEAD
test $verbose -eq 1; and echo "$c_ok→ Pushed the vault to origin$c_reset"
else
# The commit above did happen, so the memory is safe