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:
@@ -170,7 +170,9 @@ full sub-category breakdown of every category.
|
||||
|
||||
When set to 1, agents-vault also pushes on wrapper launch. Defaults to
|
||||
off: the vault commits locally on every launch and pushes from the
|
||||
Claude Code SessionEnd hook or an explicit agents-vault --push.
|
||||
Claude Code SessionEnd hook or an explicit agents-vault --push. That
|
||||
push is synchronous, so with autopush on the pull and the push are
|
||||
each capped at 20 seconds; an explicit --push is left uncapped.
|
||||
|
||||
NOTE:
|
||||
With autopush off and no SessionEnd hook installed, backups accumulate
|
||||
|
||||
+95
-13
@@ -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
|
||||
|
||||
@@ -563,6 +563,54 @@ set -l dirty_new_slug git.rootiest.dev-rootiest-dirty
|
||||
check "fallback finds sanitized local entry" dirty-precious (cat $vroot2/agent-vault/projects/$dirty_new_slug/claude/memory/keep.md)
|
||||
check "fallback old entry removed" false (test -d $vroot2/agent-vault/projects/$dirty_local_slug; and echo true; or echo false)
|
||||
|
||||
# Same fallback path, but the live memory directory is a real populated
|
||||
# directory rather than a symlink -- the shape a machine ends up in when
|
||||
# an agent wrote memory while the link was missing. The migration then
|
||||
# tries to clear the live path out of the relink's way, and clearing a
|
||||
# directory is not something it may do: that is somebody's memory, and
|
||||
# _agents_repo_ensure_symlink already folds it into the vault without
|
||||
# clobbering. Refusing is therefore correct, but refusing in rm's voice
|
||||
# is not: a --silent run that did the right thing and returned 0 still
|
||||
# printed "rm: cannot remove ...: Is a directory", which is the only
|
||||
# thing the user sees and reads as a failure.
|
||||
set -l real_root (mktemp -d); set -ga TMPDIRS $real_root
|
||||
set -l rp "$real_root/proj"
|
||||
mkdir -p "$rp"
|
||||
git -C "$rp" init -q
|
||||
git -C "$rp" config user.email t@t
|
||||
git -C "$rp" config user.name t
|
||||
git -C "$rp" config commit.gpgsign false
|
||||
git -C "$rp" config core.hooksPath /dev/null
|
||||
|
||||
set -l rmangled (string replace -a '/' '-' -- $rp | string replace -a '.' '-')
|
||||
mkdir -p $croot2/$rmangled/memory
|
||||
echo "banked" >$croot2/$rmangled/memory/old.md
|
||||
|
||||
pushd $rp >/dev/null
|
||||
agents-vault --silent
|
||||
set -l real_local_slug (_agents_repo_slug $rp)
|
||||
popd >/dev/null
|
||||
|
||||
# Replace the link with a real directory holding memory the vault has
|
||||
# never seen, then change the slug so the migration runs.
|
||||
rm -f $croot2/$rmangled/memory
|
||||
mkdir -p $croot2/$rmangled/memory
|
||||
echo "written-live" >$croot2/$rmangled/memory/fresh.md
|
||||
git -C $rp remote add origin https://git.rootiest.dev/rootiest/realdir.git
|
||||
|
||||
set -l rerr (mktemp); set -ga TMPDIRS $rerr
|
||||
pushd $rp >/dev/null
|
||||
set -l real_rc (agents-vault --silent 2>$rerr; echo $status)
|
||||
popd >/dev/null
|
||||
set -l real_err (cat $rerr)
|
||||
set -l real_new_slug git.rootiest.dev-rootiest-realdir
|
||||
check "real-directory migration succeeds" 0 "$real_rc"
|
||||
check "real-directory migration stays silent" "" "$real_err"
|
||||
check "real-directory migration keeps banked memory" banked (cat $vroot2/agent-vault/projects/$real_new_slug/claude/memory/old.md 2>/dev/null)
|
||||
check "real-directory migration keeps live memory" written-live (cat $vroot2/agent-vault/projects/$real_new_slug/claude/memory/fresh.md 2>/dev/null)
|
||||
check "real-directory migration relinks" true (test -L $croot2/$rmangled/memory; and echo true; or echo false)
|
||||
check "real-directory old entry removed" false (test -d $vroot2/agent-vault/projects/$real_local_slug; and echo true; or echo false)
|
||||
|
||||
set -e __fish_agent_vault_dir
|
||||
set -e __fish_agent_vault_claude_root
|
||||
|
||||
@@ -699,6 +747,30 @@ printf 'transcript\n' >$agy5/knowledge/session.jsonl
|
||||
: >$agy5/knowledge/conversations.db-wal
|
||||
: >$agy5/knowledge/conversations.db-shm
|
||||
|
||||
# Symlinks inside the store. The extension allowlist bounds what *kind* of
|
||||
# file is collected; it says nothing about whose. A store-relative walk
|
||||
# that dereferenced links would pull qualifying .md and .json out of
|
||||
# whatever the link names -- a real home directory is full of them -- so
|
||||
# the store boundary has to hold on its own. $outside5 stands in for that
|
||||
# home: a directory the store has no business reaching into, planted with
|
||||
# exactly the shapes that qualify.
|
||||
set -l outside5 (mktemp -d); set -ga TMPDIRS $outside5
|
||||
mkdir -p $outside5/nested
|
||||
echo SECRET-OUTSIDE-KNOWLEDGE >$outside5/leaked.json
|
||||
echo SECRET-OUTSIDE-KNOWLEDGE >$outside5/target.md
|
||||
echo SECRET-OUTSIDE-KNOWLEDGE >$outside5/nested/deep.md
|
||||
ln -s $outside5 $agy5/knowledge/linked
|
||||
ln -s $outside5/target.md $agy5/knowledge/alias.md
|
||||
# Not a cycle, so a recursive glob's cycle guard does not catch it: a link
|
||||
# to the filesystem root simply makes the walk enormous. This one is here
|
||||
# for the clock as much as for the contents -- the copy runs synchronously
|
||||
# in front of every agent launch, and a `**` glob over this fixture did
|
||||
# not return within 20s.
|
||||
ln -s / $agy5/knowledge/root
|
||||
# A genuine cycle too, since the walk must not depend on the glob's guard.
|
||||
mkdir -p $agy5/knowledge/cyc
|
||||
ln -s $agy5/knowledge $agy5/knowledge/cyc/loop
|
||||
|
||||
# A global (non-per-project) Claude memory directory with a sentinel file.
|
||||
# __fish_agent_vault_claude_home is what keeps this off the real ~/.claude:
|
||||
# if agents-vault ignored the override, these checks would fail here *and*
|
||||
@@ -707,9 +779,11 @@ mkdir -p $chome5/memory
|
||||
echo global-memory >$chome5/memory/g.md
|
||||
|
||||
set -l gp (new_repo https://git.rootiest.dev/rootiest/globals.git)
|
||||
set -l t5_start (date +%s)
|
||||
pushd $gp >/dev/null
|
||||
agents-vault --silent
|
||||
popd >/dev/null
|
||||
set -l t5_elapsed (math (date +%s) - $t5_start)
|
||||
|
||||
check "agy knowledge copied" learned (cat $vroot5/agent-vault/global/agy/knowledge/fact.md)
|
||||
check "agy settings copied" '{"model":"x"}' (cat $vroot5/agent-vault/global/agy/settings.json)
|
||||
@@ -726,9 +800,27 @@ end
|
||||
# Not merely absent from the worktree: absent from the history, which is
|
||||
# what actually leaves the machine on a push.
|
||||
check "knowledge: no secret reached a commit" false (git -C $vroot5/agent-vault grep -q SECRET-INSIDE-KNOWLEDGE HEAD -- global 2>/dev/null; and echo true; or echo false)
|
||||
|
||||
# Symlinks: nothing the links name may appear, under any name. The linked
|
||||
# directory must not exist in the vault at all (following it would recreate
|
||||
# its tree wholesale), and the aliased file must not exist either, even
|
||||
# though its own name qualifies -- a dereferencing copy writes a real file
|
||||
# at the link's name and the extension rule waves it through.
|
||||
for escapee in linked linked/leaked.json linked/nested/deep.md alias.md root cyc/loop
|
||||
check "knowledge: symlinked $escapee stayed out" false (test -e $vroot5/agent-vault/global/agy/knowledge/$escapee; and echo true; or echo false)
|
||||
end
|
||||
check "knowledge: nothing outside the store reached a commit" false (git -C $vroot5/agent-vault grep -q SECRET-OUTSIDE-KNOWLEDGE HEAD 2>/dev/null; and echo true; or echo false)
|
||||
# The clock, not the contents: a walk that descends a link to / does not
|
||||
# finish, and this is the every-launch path. Generous enough that a loaded
|
||||
# machine cannot fail it by being slow.
|
||||
check "knowledge: a link to / does not stall the launch path" true (test $t5_elapsed -lt 20; and echo true; or echo false)
|
||||
# A torn database with its completing write-ahead log deliberately excluded
|
||||
# is worse than no database at all, so the scaffold ignores all three.
|
||||
check "scaffolded .gitignore excludes *.db" true (grep -qxF '*.db' $vroot5/agent-vault/.gitignore; and echo true; or echo false)
|
||||
# Both stash fallbacks, or a crash mid-rename leaves a shadow copy of an
|
||||
# entry sitting at the vault root for the next `git add -A` to commit.
|
||||
check "scaffolded .gitignore excludes /.adopt-stash" true (grep -qxF '/.adopt-stash' $vroot5/agent-vault/.gitignore; and echo true; or echo false)
|
||||
check "scaffolded .gitignore excludes /.migrate-stash" true (grep -qxF '/.migrate-stash' $vroot5/agent-vault/.gitignore; and echo true; or echo false)
|
||||
|
||||
check "global claude memory in the vault" global-memory (cat $vroot5/agent-vault/global/claude/memory/g.md)
|
||||
check "global claude memory is now a link" true (test -L $chome5/memory; and echo true; or echo false)
|
||||
@@ -1369,6 +1461,33 @@ popd >/dev/null
|
||||
set -e __fish_agent_vault_autopush
|
||||
check "failing autopush returns non-zero" 1 "$fp2rc"
|
||||
|
||||
# Autopush runs synchronously in front of every agent launch, so it must be
|
||||
# bounded. Nothing in git bounds it: there is no HTTP connect timeout in
|
||||
# its configuration, http.lowSpeedLimit/http.lowSpeedTime only start
|
||||
# counting once bytes move, and GIT_TERMINAL_PROMPT/GIT_ASKPASS close the
|
||||
# credential prompt rather than the socket. Unwrapped, this remote took
|
||||
# 135s to give up. 192.0.2.1 is TEST-NET-1: reserved, unrouted, and
|
||||
# therefore a blackhole rather than a fast refusal. A network that does
|
||||
# refuse it quickly makes this pass without proving much, which is the
|
||||
# right way round for a test that must never fail spuriously.
|
||||
#
|
||||
# This test costs its own bound in wall time. That is the price of
|
||||
# measuring a timeout, and this defect -- a network call on the launch
|
||||
# path -- has now been introduced twice.
|
||||
set -l blackhole https://192.0.2.1/vault.git
|
||||
agents-vault --remote=$blackhole --silent
|
||||
echo pushed-into-the-void >$croot11/$pmang/memory/p6.md
|
||||
set -g __fish_agent_vault_autopush 1
|
||||
set -l bh_start (date +%s)
|
||||
pushd $pp >/dev/null
|
||||
set -l bhrc (agents-vault --silent 2>/dev/null; echo $status)
|
||||
popd >/dev/null
|
||||
set -l bh_elapsed (math (date +%s) - $bh_start)
|
||||
set -e __fish_agent_vault_autopush
|
||||
check "autopush to a blackholed remote returns non-zero" 1 "$bhrc"
|
||||
check "autopush to a blackholed remote is bounded" true (test $bh_elapsed -lt 60; and echo true; or echo false)
|
||||
check "a bounded autopush still committed locally" true (git -C $vroot11/agent-vault ls-files --error-unmatch projects/$pslug/claude/memory/p6.md >/dev/null 2>&1; and echo true; or echo false)
|
||||
|
||||
set -e __fish_agent_vault_dir
|
||||
set -e __fish_agent_vault_claude_root
|
||||
set -g __fish_agent_vault_claude_home $HERMETIC_HOME/claude
|
||||
|
||||
Reference in New Issue
Block a user