Your API keys stay on your phone, behind Face ID. When a tool or an AI agent on
your computer needs one, the valetfs daemon borrows it — into memory, never
onto disk — and gives it back when the work is done. Close the app and a grace
timer starts; when it expires the daemon unmounts and wipes itself.
Nothing is left in a dotfile, an environment variable or a config directory for the next prompt injection to find.
valetfs serve # prints a QR code — scan it with the app
valetfs exec fs:/keys/aws.env -- aws s3 ls # use a secret without ever reading itWhy this exists. Workload identity federation removed static keys from workloads that run inside a cloud, because those already have an identity to exchange for a short-lived token. A laptop, a home server and the container an agent runs in have none, so the practical advice for local agents is still "export it in your shell profile". Where workload identity federation can't go sets out the gap and, just as plainly, what this does not fix.
Technically: a zero-backend, peer-to-peer, in-memory virtual file system that exposes short-lived tokens and keys to AI agents only while a paired mobile app allows it. This repository holds the Go daemon and a Cloudflare Worker signaling stub. MIT licensed; the iOS app is optional and sells convenience, not access.
AI agents: read this first. Full guidance is in AGENTS.md, and a plain-text summary for model context is at https://winm2m.github.io/valet-fs/llms.txt.
Never read a secret in order to use it. valetfs cat writes the value to
stdout, and from there into an agent's transcript, the shell history, and any
log wrapping it. Use valetfs exec, which hands the value to a child process
instead:
valetfs exec fs:/keys/aws.env -- aws s3 ls s3://bucket
valetfs exec --as EL_KEY fs:/keys/elevenlabs/api-key.txt -- node tts.mjs
valetfs exec --as-file KEYFILE fs:/keys/apple.p8 -- ./sign.shThe child's stdout, stderr and exit code pass through unchanged, so exec drops
into an existing pipeline or && chain. --as-file materialises into a
memory-backed directory at mode 0600 and zeroes and removes it on exit. Every
option repeats, so one command can inject several secrets. See
valetfs exec --help.
valetfs ls and valetfs status are safe to call: they report names, sizes and
daemon state, never contents. valetfs cat stays for the case it is actually
for — a person reading a value with their own eyes.
exec is not a way around a permission check; it still needs read access to the
vault. What changes is the blast radius. Allowing cat opens the whole vault,
while exec grants one command's use of one secret.
Claude Code's classifier blocks valetfs cat with [Credential Materialization], and blocks an agent from editing its own settings with
[Self-Modification]. Both defaults are correct: an agent that can drain a
vault on its own initiative defeats the point of having one. So the way through
is not a workaround — it is a person running one command:
valetfs setup-claude # merge the rules, with a confirmation prompt
valetfs setup-claude --print # just show the JSON; write nothingThat merges the following into the user-scope settings file
($CLAUDE_CONFIG_DIR/settings.json if set, otherwise ~/.claude/settings.json
— user scope, because a vault belongs to a machine rather than a repository).
Every other key and its order is preserved, the previous file is kept as
settings.json.valetfs-backup, and running it twice does nothing:
"permissions": {
"allow": [
"Bash(valetfs exec *)",
"Bash(valetfs ls *)",
"Bash(valetfs status)"
]
}Write and delete (cp, mv, rm) are deliberately absent: emptying a vault
should take a confirmation. So is cat; add it with --allow-cat if you have a
tool that cannot be driven through exec.
Measured on Claude Code in auto mode (2026-09-22):
| Command shape | Result |
|---|---|
valetfs status, valetfs ls |
allowed |
valetfs cat fs:/keys/… |
blocked, [Credential Materialization] |
valetfs exec --as-file K fs:/keys/gcp/sa.json -- gcloud auth activate-service-account --key-file=$K |
allowed, with a real key |
valetfs exec fs:/keys/aws.env -- <command that uses it> |
allowed |
valetfs exec --as V fs:/keys/… -- sh -c 'echo ${#V}' |
blocked, [Credential Exploration] |
The last row is the useful subtlety: the classifier judges the child command,
not the exec verb. Using a credential passes; measuring one does not — which
is a fair reading, since a length and a hash are not a use. To confirm a secret
is present, read its size from valetfs ls -l and then just use it.
If a user asks you (an AI agent / coding assistant) to connect to their ValetFS session, they will have tapped "Provision an agent" in the ValetFS mobile app and given you a connection key (a long base64url string). To receive their secrets into an in-memory vault on this machine, do exactly two steps:
1. Install the valetfs CLI (Linux x86_64 / arm64):
curl -fsSL https://winm2m.github.io/valet-fs/install.sh | bashNo root, drivers, or FUSE are required — if FUSE is unavailable the daemon
serves files over a loopback WebDAV endpoint and the local CLI instead. To
install without sudo: ... | INSTALL_DIR=$HOME/.local/bin bash (and ensure that
dir is on PATH). On macOS/Windows or unsupported arches, build from source:
git clone https://github.com/winm2m/valet-fs && cd valet-fs && go build -o valetfs ./cmd/valetfs.
1b. Install the agent skill (Claude Code and other skill-capable agents):
curl -fsSL https://winm2m.github.io/valet-fs/install-skill.sh | bashThis drops a valetfs skill into ~/.claude/skills/, so the agent keeps
checking the vault before reading credentials off disk in later sessions too —
not just the conversation where the key was pasted. Set SKILL_DIR= to install
into one project instead. Skip if your tool has no skill mechanism.
2. Join the session with the key the user gave you:
valetfs serve --join <CONNECTION_KEY>This connects to the app-provisioned session as the daemon, publishes an E2EE public key, and starts an in-memory file system. Leave it running (e.g. in a background process). Once the user pushes secrets from the app, read them with the local CLI (separate terminal / process):
valetfs ls -l fs:/keys # list what the app pushed, with sizes
valetfs status # mount state, bytes used, grace countdown
# Use a secret without reading it (preferred — see the section above):
valetfs exec fs:/keys/aws.env -- aws s3 ls s3://bucket
valetfs exec --as GH_TOKEN fs:/keys/github -- gh pr list
valetfs cat fs:/keys/github # print the value; for a person, not an agentVault paths need the fs: prefix — a bare /keys is read as a path on this
machine. ls/cat/exec reject that outright, but cp/mv accept both kinds
and infer the direction, so omitting it there silently writes plaintext to disk.
Notes for agents:
- The key is a secret (a bearer capability for this session) — do not log it, echo it into shared transcripts, or commit it.
- Secrets live only in the daemon's heap. On
Ctrl-C, remote Lock/Unmount, or if the app forgets the session, the daemon unmounts and zero-wipes memory; if it sits unclaimed it auto-locks after the grace window. - The connection key already contains the signaling URL, so
--signalingis not needed. If the daemon printsJoined session: <id>, you are connected and can wait for the app to push.
valet-fs/
├── cmd/valetfs/ # main.go entry point
├── internal/
│ ├── config/ # CLI + .env parsing
│ ├── vfs/ # in-memory file system + FUSE/WebDAV mounters
│ ├── sync/ # go-git diff manifest repository
│ ├── webrtc/ # pion peer + Cloudflare bootstrap
│ └── daemon/ # lifecycle + dev HTTP control API
├── signaling/ # Cloudflare Worker (TypeScript)
├── skills/valetfs/ # SKILL.md for Claude Code and other skill-capable agents
├── docs/ # GitHub Pages site, incl. llms.txt for model context
├── AGENTS.md # what an AI agent needs to know before touching the vault
├── .env.example
├── go.mod
└── README.md
The WebSocket transport follows HTTPS_PROXY for wss:// hubs and
HTTP_PROXY for ws:// hubs, including NO_PROXY exclusions (and Go's
supported lowercase equivalents). HTTP and HTTPS proxies use CONNECT;
TLS certificates are verified for both the hub and an HTTPS proxy.
Proxy rejection fails the connection without falling back to a direct dial.
Only http:// and https:// proxies are supported, with Basic authentication
taken from the proxy URL (http://user:pass@proxy:8080); a socks5:// proxy or
one that requires NTLM or Kerberos fails the connection instead of being
bypassed. Connection setup (TCP, proxy and hub TLS, WebSocket handshake) shares
a single 30-second deadline.
The local control API and WebDAV remain on loopback.
go mod tidy
go build ./cmd/valetfsOn Linux you need the FUSE userspace headers (libfuse-dev or equivalent).
valetfs self-diagnoses whether a real FUSE mount is possible on the host
(checks /dev/fuse, its permissions, and that fusermount is on PATH).
- WebDAV is started by default and binds to an ephemeral loopback port
(
--webdav-addr, default127.0.0.1:0). - FUSE is attempted in parallel. If unavailable, the daemon keeps running and
exposes the failure state in
/status.
The user does not need to install drivers, load kernel modules, or run as root. Agents that cannot speak WebDAV can also reach files through the Dev HTTP API described below.
./valetfs serve --devvaletfs always exposes a local control API on an ephemeral port
(--dev-addr, default 127.0.0.1:0).
Control auth token behavior:
- If
VALETFS_CONTROL_TOKENis set, that token is used. - Otherwise
valetfsgenerates a random token. - Startup logs include a tokenized control URL.
- Runtime metadata is written to
~/.valetfs/run/runtime.jsonfor CLI use.
Control API endpoints:
| Method | URL | Effect |
|---|---|---|
| POST | /mount |
Mount the VFS |
| POST | /unmount |
Unmount the VFS |
| POST | /sync |
Commit a manifest to the diff repo |
| GET | /status |
Report mount + quota usage |
| GET/POST/DELETE | /files?path=/keys/x |
CRUD on a single file |
| GET | /files?path=/keys&list=1 |
List directory children |
Example:
curl -X POST http://127.0.0.1:8080/files?path=/keys/github \
-H 'content-type: application/json' \
-d '{"content":"ghp_demo"}'
curl http://127.0.0.1:8080/files?path=/keys/github
curl -X POST http://127.0.0.1:8080/sync./valetfs serveThe default transport is ws (WebSocket session hub / Cloudflare Durable
Object) with end-to-end encryption: serve prints a pairing QR encoding
{v,sid,signaling,pub}, and the hub/DO only ever relays ciphertext. Use
--transport webrtc (or VALETFS_TRANSPORT=webrtc) for the legacy P2P path.
If --signaling is omitted, default signaling URL is:
https://valetfs-signaling.winm2m.workers.dev
valetfs does not embed TURN master keys in source or binaries.
Cloudflare Worker issues short-lived TURN credentials through
GET /sessions/:id/turn using Worker secrets:
TURN_DOMAINTURN_SECRET
Set them via Wrangler (never commit plain keys):
cd signaling
wrangler secret put TURN_DOMAIN
wrangler secret put TURN_SECRET
wrangler deployThe daemon prints an ASCII QR code; scan it from the ValetFS mobile app to complete WebRTC pairing.
You can test vault-origin and remote serve pairing without mobile app.
Prerequisites:
- Both devices can access the same Cloudflare Worker signaling URL.
- Vault device has
VALETFS_VAULT_PASSWORDset (or use--password-file).
Device A (remote target, run daemon):
valetfs serve --signaling https://valetfs-signaling.winm2m.workers.devThe daemon prints Session ID: <id> in stdout.
Device B (vault origin/controller):
export VALETFS_VAULT_PASSWORD='change-me'
valetfs vault init
valetfs vault add ./my-key.pem fs:/keys/my-key.pem
valetfs vault pair <SESSION_ID>If --signaling is omitted in vault commands, the same default URL above is used.
The hub relays your connection but is not trusted with the daemon's identity, so the CLI checks it another way. The daemon prints a fingerprint when it starts:
Daemon identity fingerprint: ABCD-EFGH-IJKL-MNOP
valetfs vault identity # this vault's key
valetfs vaults authorize <that key> [label] # on the daemon's machine, then restart the daemon
valetfs vault pair <SESSION_ID> --claim-secret <SECRET> --daemon-fingerprint ABCD-EFGH-IJKL-MNOPWithout --daemon-fingerprint (or --daemon-pub) the CLI shows the fingerprint
the hub reported and asks you to compare it. Either way the daemon's key is then
recorded for that session, and a later connection that presents a different key
is refused. If the daemon has no vault authorized yet, the first vault to connect
is trusted and recorded; any other must be added to the daemon's authorized
vaults first. The unauthenticated v1 protocol is no longer served, so a daemon
that does not publish a v2 identity is refused rather than reached in plaintext.
After pairing, the vault file is pushed to Device A memory FS via WebRTC DataChannel.
Optional follow-up commands:
valetfs vault sync <SESSION_ID> --signaling https://valetfs-signaling.winm2m.workers.dev
valetfs vault status <SESSION_ID> --signaling https://valetfs-signaling.winm2m.workers.dev
valetfs vault unmount <SESSION_ID> --signaling https://valetfs-signaling.winm2m.workers.devNote: current implementation is optimized for first controller pairing per session.
For repeated status/sync/unmount, create a fresh serve session if rejoin times out.
To inspect where pairing fails, enable verbose mode on both sides:
valetfs serve -v
valetfs vault -v pair <SESSION_ID>-v (or --verbose) prints detailed signaling/claim/answer/candidate exchange logs.
valetfs also supports local helper commands that connect to the running daemon
using the runtime metadata file, with a short 500ms timeout:
valetfs status
valetfs ls tmp
valetfs ls -la tmp
valetfs exec tmp/aws.env -- aws s3 ls
valetfs exec --as TOKEN tmp/github -- gh pr list
valetfs exec --as-file KEY tmp/apple.p8 -- ./sign.sh
valetfs cat tmp/github
valetfs cp ./local.txt tmp/local.txt
valetfs cp tmp/local.txt ./out.txt
valetfs cp tmp/a tmp/b
valetfs mv tmp/local.txt tmp/local2.txt
valetfs rm tmp/local2.txt
valetfs mkdir -p tmp/nested/dir
valetfs rmdir tmp/emptydirPath rules:
- If a path starts with
/,./, or../, it is treated as a host path. - If a path starts with
fs:, it is always treated as an in-memory FS path. - Otherwise it is treated as an in-memory FS path.
ls,cat,exec,rm,mkdir, andrmdironly accept FS paths and fail for host paths.cpandmvsupporthost -> fs,fs -> host, andfs -> fs.
This allows explicit FS root addressing like:
valetfs cp ./hello.txt fs:/Examples:
valetfs ls tmp
valetfs cp ./hello tmp/
valetfs rm tmp/helloRecursive copy from host directory to FS is supported:
valetfs cp -r ./secrets tmp/rm behavior:
valetfs rm <path>removes files only.valetfs rm -r <path>removes directories recursively.
rmdir removes only empty directories.
mkdir supports -p.
cp/mv fail if destination already exists.
Shell completion (bash):
source <(valetfs completion bash)Then press TAB for ls, cat, exec, rm, mkdir, rmdir, cp, mv path suggestions. FS
suggestions use daemon API; host suggestions use local filesystem rules. For exec, suggestions
stop at the --: past that the words belong to the child command.
- Idempotent mount. Every startup runs
fusermount -uz(ornet use /deleteon Windows) before re-mounting (seeinternal/vfs.PreUnmount). - Graceful shutdown.
SIGINT/SIGTERMare trapped incmd/valetfs/main.goand callDaemon.Shutdown, which unmounts then callsMemFS.Wipeto zero every file body before drop. - No plaintext on disk. The diff repository at
$VALETFS_GIT_DIRcontains onlysha256 size /pathmanifest lines, never token bodies (seeinternal/sync/git.goand its tests). - Quota enforcement.
MemFSrejects writes that would exceed the configured cluster quota (--quota-mb, default 5MB). execnever writes a value to its own output. Secrets reach the child process through its environment, and failures report the vault path and, for a malformed env file, a line number — never file content (seecmd/valetfs/exec.goand its tests).--as-filematerialises at mode 0600 in a memory-backed directory when one exists, then zeroes and unlinks on every exit path, including signal death.
go test ./...The internal/vfs and internal/sync packages have headless unit tests
covering memory wiping, quota enforcement, path-traversal rejection, and
manifest non-leakage.