If you discover a security vulnerability in Suvadu, please report it responsibly:
- Do NOT open a public GitHub issue
- Email: madhu@appachi.tech
- Include: description, steps to reproduce, and potential impact
We aim to acknowledge reports within 48 hours and provide a fix timeline within 7 days.
- All history is stored locally in a SQLite database (WAL mode)
- No history or other data is transmitted to external servers
- No telemetry or analytics are collected
- The only request Suvadu makes on its own is the once-a-day release check below, which reads a version number and sends nothing about you
Every path below is resolved at runtime from the tech.appachi.suvadu
application identifier, except the agent hook scripts, which are written to
~/.config/suvadu/hooks/ on every platform, macOS included.
| What | macOS | Linux |
|---|---|---|
| Data directory | ~/Library/Application Support/tech.appachi.suvadu/ |
$XDG_DATA_HOME/suvadu/ (default ~/.local/share/suvadu/) |
| Config directory | the same directory as the data directory | $XDG_CONFIG_HOME/suvadu/ (default ~/.config/suvadu/) |
| Agent hook scripts | ~/.config/suvadu/hooks/ |
~/.config/suvadu/hooks/ |
Inside the data directory:
| File | Holds |
|---|---|
history.db (+ -wal, -shm) |
everything in the table below |
backups/ |
full database copies — see What is stored, and how long |
prompts/ |
the live prompt cache pairing an agent prompt with the commands run for it, as <session>.prompt files. The only thing anything prunes: suv gc deletes cache files older than 7 days |
aliases.sh |
the managed alias file your shell sources |
reconcile.key |
32 random bytes, used to key the HMAC that links a command to its prompt without storing a bare hash of the prompt text |
last-capture-error |
written only when recording, a transcript import or a prompt cache had to fall back because config.toml or a .suvadu.toml did not load: when, which file, and the parse error. Written 0o600, overwritten by the next different failure, reported by suv doctor while that file still fails. Nothing prunes it; delete it freely |
up-arrow-binding |
written only when the zsh hook finds, at a shell's first prompt, Up-arrow bound to something other than Suvadu's recall: when it was first seen, the key sequence and the widget name — never a command. Written 0o600, overwritten only by a different binding, removed by the next zsh that finds Up is Suvadu's again, and reported by suv doctor. Delete it freely |
config.toml lives in the config directory, and a per-project
.suvadu.toml lives wherever you put it in your own repositories.
suv uninstall prints the real config and data directories it did not
remove, resolved the same way — never a hardcoded guess.
- Command history is stored unencrypted in the SQLite file. Secret redaction (below) is applied before writing, but the database itself is plaintext — treat it like your shell history file.
- On Unix the data directory and
backups/are0o700, andhistory.db, the prompt cache,aliases.shandreconcile.keyare written0o600(owner-only). On Linux the config directory is a separate directory and is created with no explicit mode, so it follows your umask. suv backupand the automatic snapshot taken beforesuv deletewrite copies into<data_dir>/backups/. These copies are also unencrypted, and nothing prunes them — they accumulate until you delete them yourself;--no-backupskips the pre-delete snapshot. A backup restores by copying the file overhistory.db: it carries the whole database, including agent sessions and summaries, not just command history.- Cross-machine transfer uses explicit
suv export/suv import. The export file is plaintext JSONL; protect it in transit yourself (e.g.scp, an encrypted volume).
- Commands prefixed with a space are never recorded
- Configurable exclusion patterns (regex or substring) to ignore sensitive commands
suv deleteremoves entries matching a pattern from the database — this is a delete, not a secure erase (see Deleting data)suv pausefor temporary recording suspension (per-shell)suv disablefor global recording opt-out
Nothing is deleted on a schedule: Suvadu has no retention timer, no size cap
and no automatic pruning. Everything below is kept until you remove it.
suv doctor prints the current size of each category next to the rule that
governs it.
| Category | Holds | Goes away when |
|---|---|---|
| Commands | command text, directory, exit code, start/end times, duration, executor type and name, an optional tag, and a context JSON blob (which holds the git branch, when there was one); plus their notes, bookmarks, aliases, tags and the two trigram search indexes (one over the command as typed, one with its separators removed); and, only with failure-output capture on, the last 4 KB a failed command printed (entry_output: which stream, the cleaned and redacted text, and how many bytes were left out before it) |
you run suv delete <pattern>; suv gc --failure-output removes every stored failure output and keeps the commands |
| Sessions | shell sessions (id, machine hostname, creation time, tag), and agent sessions with their captured transcript events and the adapter checkpoints that record which transcript files were read and how far (for a Cursor conversation, which Cursor pushes event by event: a 64-bit fingerprint of each stored event, taken after redaction, so a repeated hook run is stored once, and the count of events seen); and, unless agent.record_git_snapshots is off, up to two git snapshots per agent session (ai_snapshots: when, the directory, the repository root, the commit and branch checked out, and up to 500 changed paths with their git status — never contents or diffs) |
an agent session is removed with suv agent delete-session <id>, snapshots included; suv gc removes snapshots a day old whose session left no other record; a shell session row stays behind after its commands are deleted |
| Summaries | session summaries written over MCP, with their cited evidence IDs, the session revision they were written from, and the caller-declared agent and model | their agent session is deleted — editing or deleting commands only marks a summary stale, it does not remove it |
| Skills | shared skill documents; and, for each repo skill file you trusted, its project root, its path inside the project, a sha256 of its content and when you trusted it (skill_trust — never the file's text) |
you run suv skills remove; a sync rewrites agent-side copies, not the stored row; suv skills untrust forgets a repository's trust |
| Backups | full database copies from suv backup, the automatic pre-delete snapshot, and the pre-import snapshot taken by suv import --from atuin-db |
you delete the files yourself |
The context blob of a command recorded live carries git_branch — the
branch checked out where the command started, or the short commit hash of a
detached HEAD — when that directory is inside a git repository, and is
otherwise empty for an ordinary typed command. It also carries the
captured agent prompt and turn id for an agent-run command (re-redacted with
that command's own directory policy before it is stored), and import
provenance — source, import time, whether the timestamp was real or
synthetic, and which fields the source did not have — for an imported one.
The Bash, Zsh and Atuin importers all write that provenance. A JSONL import
keeps the exported context — so a restored row still says where it was
originally recorded — and adds one key of its own, restored_at, which
records when this database received the row (replacing that key if the
export already carried one). Either way suv status and
suv doctor treat an imported row as stored history, never as evidence that
this machine's shell hook captured anything.
An Atuin import also stores that row's free text (atuin_intent,
atuin_author, atuin_shell, atuin_host, atuin_user) there. Every one of
those passes through the same redaction and exclusion policy as the command
before it is written; a field an exclusion pattern matched is withheld
entirely and named in context.withheld_fields, so a missing field is
visible rather than silent.
Failure output ([capture] failure_output, Keep Failure Output in
suv settings, off by default) is the only command output Suvadu ever
stores. It is kept only for a command that exited non-zero (not 130, Ctrl+C),
and only from a path that already holds the output: an agent hook whose
payload carries it (Claude Code's PostToolUseFailure error text, or stdout
and stderr on a PostToolUse that reports a non-zero exit; Cursor's
afterShellExecution output; a Codex PostToolUse response that carries an
exit code and output, aggregated_output or stdout/stderr text;
OpenCode's bash tool output, sent by the plugin on suv add's stdin) and
suv wrap, which tees the command's stderr. The zsh and Bash hooks never
see output and never send it: capturing a terminal means standing between
the program and the terminal, which breaks anything interactive. On every
path the output is cut to its last 64 KB first, then terminal escape
sequences and carriage-return redraws are stripped, then it is redacted with
the same rules and the same per-directory policy as the command text, and
only then cut to its last 4 KB at a line start — so a secret crossing the
cut is replaced before the cut. A window cut from a longer output also drops
its first, partial line. An excluded command keeps nothing. A project's
.suvadu.toml can turn it off but not on. Turning it off stops new capture;
what was kept stays until its command is deleted or you run
suv gc --failure-output. JSON and JSONL exports carry it as
failure_output beside its command; CSV exports do not. Recall's detail
pane and the MCP failure tools show it; MCP responses put every line of it
behind | , flattened to one line each, and say it is the command's own
text, not instructions — an agent reading it is reading what a program
printed, which may itself have been written to look like instructions.
Agent git snapshots ([agent] record_git_snapshots, Record Agent Git
Snapshots in suv settings, on by default) are taken by the Claude Code
(UserPromptSubmit, Stop/SessionEnd), Codex (UserPromptSubmit,
Stop/SessionEnd), OpenCode (prompt, session.idle) and Cursor
(beforeSubmitPrompt, stop) hooks: one at the
first prompt of a session, kept, and one at each turn end, replacing the last.
Each runs git --no-optional-locks -c core.fsmonitor=false status --porcelain=v2 --branch -z in the session's repository, with GIT_DIR and
the like removed from its environment, no terminal prompts, and a 2-second
limit after which nothing is recorded; outside a repository no git process
starts. Only the commit id, the branch and which paths changed (with their
two-letter status and a rename's source path) are kept — never file
contents, never a diff. Each path is checked against your exclusion patterns
(a match is left out and only counted) and redacted like command text, and
the branch name is redacted too; at most 500 paths are kept, and a snapshot
records when more changed. Paused capture, a directory with recording off,
or a project .suvadu.toml that turns it off records nothing; a
.suvadu.toml can turn it off but not on. Snapshots are not exported, like
the agent sessions they belong to. When what_changed compares two of them it
asks the repository, then and there, for the commits between the two HEADs
(git rev-list, git log, git diff --name-only, under the same 2-second
limit); those subjects and paths are never stored, and pass through the same
exclusions and redaction before they are shown.
How these interact:
- Deleting commands does not delete the agent sessions or summaries that referred to them. A summary whose evidence changed is reported as stale and keeps its text.
- Deleting an agent session does delete its captured events, its adapter checkpoint, its summaries, its git snapshots and the shell commands recorded under it.
- Every
suv deletewrites a pre-delete backup first (unless--no-backup), so a delete that reduces the live database adds a full copy of the database as it was moments earlier. Backups accumulate until you remove them.suv import --from atuin-dbtakes one too; the JSONL, Bash and Zsh importers do not.
suv delete --dry-run lists the commands a pattern would remove before
anything happens, and both the preview and the real delete print what is and
is not affected.
A SQLite delete is not a secure erase. After suv delete reports success:
- The deleted rows' pages remain inside
history.dbuntil SQLite reuses them.suv doctorreports this as reclaimable space; only aVACUUMrewrites the file without them. - The pre-delete backup contains every command just deleted, and so does any
earlier backup or
suv exportfile. - Filesystem snapshots, Time Machine backups and any copy you made are untouched.
To remove the last copies, delete the files in <data_dir>/backups/ and any
exports yourself. On a copy-on-write or journalling filesystem, on an SSD, or
with full-disk snapshots in play, even that is not a guarantee that the bytes
are unrecoverable — full-disk encryption is the control that addresses that
threat, not a row delete.
The same configuration governs every path that writes to the database.
| Path | Space-prefix skipped | Exclusions applied | Redaction applied |
|---|---|---|---|
Live shell/agent recording (suv add, hooks) |
yes | yes, per directory | yes, per directory |
Failure output (agent hooks, suv add --output-stdin, suv wrap) |
n/a — kept only beside a recorded command | yes, per directory: an excluded command keeps no output | yes, per directory, after escape stripping and before the 4 KB cut |
Bash import (suv import --from bash-history, including --dry-run) |
yes | yes, global config only | yes, global config only |
Zsh import (suv import --from zsh-history, including --dry-run) |
yes | yes, global config only | yes, global config only |
Atuin import (suv import --from atuin-db, including --dry-run) |
yes | yes, per source directory — command and free-text metadata | yes, per source directory — command and free-text metadata |
JSONL import (suv import) |
n/a | no — restoring a Suvadu export is meant to reproduce it exactly, failure output included (still held to 4 KB) | no |
| Native transcript ingestion (Codex, Claude Code, OpenCode) | n/a | yes, per directory | yes, per directory |
Cursor session hooks (beforeSubmitPrompt, afterAgentResponse, stop) |
n/a | yes, per workspace: an event whose text matches is not stored | yes, per workspace: the policy of the hook's first workspace_roots entry, since Cursor sends these hooks no cwd; an event without a workspace is not stored |
| Agent git snapshots (Claude Code, Codex, OpenCode, Cursor hooks) | n/a | yes, per directory: a changed path matching an exclusion is left out and counted | yes, per directory: every path and the branch name |
| Session summaries saved over MCP | n/a | yes — matching text is refused, not trimmed | yes |
"Per directory" means the policy of the directory the command ran in —
the global config with the nearest .suvadu.toml above that directory merged
on top. Live recording resolves it from the command's own cwd; the Atuin
importer resolves it from the cwd Atuin recorded for each row, so a project
overlay governs imported history exactly as it governs new history. A row
whose directory Atuin did not record, or which no longer exists on this
machine, falls back to the global config. The Bash and Zsh importers cannot
do this: their history files have no directory at all, so only the global
config applies to them, and a project overlay's extra patterns and exclusions
will not be honoured for what they import.
A config file that does not load never turns the policy off. Live
recording, native transcript ingestion and the agent prompt caches all
resolve their policy the same way: if a .suvadu.toml does not load, the
global config applies; if config.toml itself does not load, the built-in
defaults apply, with redaction on. The failure is noted in
last-capture-error for suv doctor rather than failing the capture.
Redaction rewrites the text before it is stored, so a secret already sitting
in ~/.zsh_history is redacted on the way in rather than copied verbatim. It
is still worth removing secrets at the source: redaction recognises known
patterns and your configured redaction.extra_patterns, and cannot promise to
catch a format it has never seen.
One gap, in the prompt cache rather than the database. Before an agent
prompt is attached to a command it is written to <data_dir>/prompts/. The
Claude Code, Codex, OpenCode and Cursor prompt hooks redact and truncate it
there (Cursor with its workspace's policy). The pi.dev extension
truncates to 500 characters in JavaScript, bypassing both
agent.prompt_capture_max_chars and redaction. Its prompt is redacted again, with that command's own directory policy, before
it reaches the database — so what is stored is redacted, but the cache
file on disk may briefly hold the original text. Those files are 0o600 in
a 0o700 directory, and suv gc deletes cache files older than 7 days.
Enabled by default; disable with redaction.enabled = false in the config.
Detected secret values are replaced with ***REDACTED*** before the
command is stored. Coverage includes:
- Sensitive environment-variable assignments (
*_TOKEN=,*_SECRET=,*_PASSWORD=,AUTH=,*_API_KEY=, …), gated so names likeAUTHOR_NAMEorPASSWORD_FILEare not false-positived - Password CLI flags:
--password,--token,--secret,--api-key, and-p<pw>scoped to DB clients (mysql/mysqldump/mariadb) - Well-known key formats: AWS (
AKIA…), GitHub (ghp_…), OpenAI/Anthropic (sk-…), Slack, Stripe, npm, PyPI, Azure, and PEM private keys Authorization:headers (Bearer/Basic/token) andcurl -u user:pass(scoped to HTTP clients, sodocker run -u 1000:1000is left intact)- Database connection-string passwords (
postgres://user:pass@host), including passwords containing@and password-only URIs - Long hex / base64 secrets that follow a secret-ish key name
Limitations — redaction is best-effort pattern matching, not a guarantee:
- Novel or custom secret formats may not be detected. Use exclusion patterns (or a space prefix) for commands you never want recorded.
- Redaction applies only to newly recorded commands; it does not retroactively rewrite already-stored history.
- To keep recalled commands runnable, non-secret flags that merely look
password-shaped (e.g.
docker run -p 8080:80,ssh -p 2222) are intentionally not redacted.
suv guard and the assess_risk MCP tool match rules against the text of
a command. That is all they do.
suv guardis not a sandbox. It does not run, contain, intercept or supervise anything. It reads a string, prints a verdict and exits non-zero when the verdict is at or above the threshold. Whether that stops the command is entirely up to the caller that asked, and any caller is free to ignore it. It provides no protection against a program that is already running, and none at all against something that never asks it.- A verdict reports three separate things: the rule's severity, the
matched text (redacted and length-bounded, so a report never becomes
the thing that copies a secret into a log), and any uncertainty. A
literal match such as
rm -rf /srvsettles what the command does; a fetched script, an installed package or aneval'd string does not, and the verdict says so instead of implying otherwise.
Known limits of matching text, all verified by tests:
- A line is split into its commands first — on
;,&&,||,|,&and newlines outside quotes — and each is checked on its own, after wrappers such assudo,env,VAR=x,nohuporxargs. The text handed tobash -c,ssh host,eval,su -c,find -exec, a shell's here-doc and a$(…)substitution is checked as a command line too. What the splitter cannot see stays unseen: an alias or function defined earlier, a command held in a variable, the contents of a script file. - A command built at runtime (
eval "$CMD", a decoded payload, a substitution) can only be reported as indirection, never as what it will become. - The arguments of a program that merely searches, prints or records strings
(
grep,rg,echo,printf,alias,git commit -m,git log,man,history) are treated as a mention, not an action. Each command of a chain is judged on its own, so ingrep -r "rm -rf" . && rm -rf buildthe second command is still flagged, as arebash -c "rm -rf /",ssh host 'rm -rf /srv'andpsql -c "drop table users".
Two config keys, both under [agent], both lists of Rust regexes matched
against the raw command text:
risk_extra_patterns— adds rules. Each entry haspattern,level(low/medium/high/critical) and an optionaldescription. Matches are reported in thecustomcategory, and always with uncertainty attached: Suvadu cannot vouch for what someone else's rule implies.risk_ignore_patterns— suppresses. A command matching one of these is treated as carrying no risk at all.
[agent]
risk_ignore_patterns = ["^rm -rf \\./build"]
[[agent.risk_extra_patterns]]
pattern = "^deploy-prod"
level = "critical"
description = "Production deploy script"Precedence and scope:
risk_ignore_patternswins over everything, built-in or custom.- Otherwise the highest severity among all matching rules wins, whether it came from a built-in rule or your own; on a tie the built-in rule's description is the one reported.
- An invalid regex or an unknown level is skipped with a warning; the rest of the list still loads.
- The lists are read from the configuration once per process, from the
global config merged with any project
.suvadu.tomlthat applies to the process's working directory. They affectsuv guard, the risk columns in the TUI and theassess_riskMCP tool. For the MCP server "working directory" means the directory the server process was started in, fixed for its whole lifetime — so a rule you add for one project does not follow a command the tool is asked about from another. They do not rewrite risk levels already shown in an earlier session, and they never change what is recorded.
The MCP server opens the database read-only. Every one of its 22 read tools, 8 resources and 6 prompts reads; a short-lived writable connection is opened only for an explicitly enabled write.
There are exactly two write capabilities, both off by default:
| Tool | Opt-in | Effect when on |
|---|---|---|
save_session_summary |
mcp.allow_session_summaries |
stores a summary the calling agent generated |
propose_skill |
mcp.allow_skill_proposals |
stores a proposed skill as pending_review — never active, and never synced to an agent, until a human approves it in suv skills (Ctrl+P) |
A skill store agents can both read and write is a shared-memory poisoning target, which is why the proposal gate is checked before any database connection is opened at all. Turning an opt-in back off stops new writes; it does not delete what was already stored.
Repo skills are untrusted until you trust them. A .suvadu/skills/*.md
file arrives with whatever repository you clone, the same way a
.suvadu.toml does, so it is treated as data from a stranger. suv skills sync copies one into your agent files (Claude Code skills, Cursor rules,
AGENTS.md) only after suv skills trust has recorded a sha256 of that
file's exact content; any change to the file makes it changed since
trusted and it stops syncing, and the copy Suvadu wrote is removed, until
you trust it again. Until then it cannot shadow or replace any of your own
skills either. Suvadu only reads these files — it never writes, moves or
deletes them — and skips a symlink (which could point anywhere on disk), a
file over 256 KB, one that is not UTF-8, and one whose name could escape the
agent directories it is written into; terminal control characters are
dropped from their text before it is printed or copied.
Over MCP, list_skills and search_skills show an untrusted or changed repo
skill by name, file path and trust state only, and get_skill refuses its
body: until you have read it, its text is a stranger's instructions, which is
exactly what an agent should not be handed as a skill. (An agent that can
read files can still open the file itself; Suvadu just does not vouch for
it.)
Summaries are caller-reported text. Suvadu invokes no model and
generates no prose — a summary is whatever the connected agent wrote, stored
back and labelled provenance: caller-reported. The writing agent and model
are caller-declared metadata, not verified identity. Suvadu does check
what it can: the summary must cite evidence IDs that exist in that session,
must declare the session revision it was written from, and is rejected if
that revision has moved. Text matching one of your exclusion patterns is
refused outright rather than trimmed, and everything else goes through the
same secret redactor as a command — because an agent that read a session can
restate a secret the redactor caught on the way in.
Configuration scope and restart. Which tools and resources are
available, the two write opt-ins, mcp.exclude_dirs and the MCP query
defaults are read from the global config.toml only; a project
.suvadu.toml does not change them. All of it is read once, at server
startup, so a change does not reach a running client until you restart it.
mcp.exclude_dirs is enforced at the query level by every tool and resource
that reads history, so it reduces aggregate counts too, not just listed
commands. ~-prefixed entries are expanded and matched against the whole
subtree.
- Binary downloads are served over HTTPS from
downloads.appachi.tech suv updatealso requests the release's notes from GitHub's API (api.github.com/repos/AppachiTech/suvadu/releases/tags/<tag>); the tag names the release and nothing about you is sent. Turning update notices off does not change what an explicitsuv updaterequests- Downloads are verified with a minisign signature (the public key is compiled into the binary, so a compromised download server cannot forge updates) and a SHA256 checksum
- Update files are written to a unique temporary directory to prevent TOCTOU attacks
- The new binary is written beside the old one and renamed over it, so a failed update leaves the previous version in place; sudo is used only when the install directory is not writable by you
- Homebrew and Cargo installs are handled by their own package managers —
suv updatepoints you atbrew upgradeorcargo installinstead of replacing their files
- At most once a day, a user-facing command run at a terminal starts a background
suv update-check, which fetcheshttps://downloads.appachi.tech/version.txtover HTTPS withcurl— a plain GET with no identifier, history or configuration attached — and saves the answer asupdate-check.jsonin the data directory - The command you ran never waits for it; a failed or unexpected answer (anything that is not a version number) is ignored
- A newer version is mentioned in one line on stderr at most once a day, and in Home's status line; nothing is downloaded or installed until you run the update yourself
- Shell hooks, recall (
Ctrl+R, Up/Down,suv search),suv pause, the MCP server, scripts and pipes (stdout or stderr not a terminal) and CI (CIset) never check or print - Off with Check for Updates in
suv settings,[update] check = falsein the globalconfig.toml(a project's.suvadu.tomlcannot change it), orSUVADU_NO_UPDATE_CHECK=1
install.shalways verifies the archive's SHA256 checksum, and also its minisign signature (with the same public keysuv updateuses) whenminisignis installed; either failure aborts before anything is installed- It writes only
suvand itssuvadulink, to/usr/local/bin,~/.local/bin(--user) or a directory you name (--dir), using sudo only when that directory is not writable - Re-run without a directory, it updates only a
suvit installed itself (recognised by itssuvadulink); asuvfrom Homebrew, Cargo or anywhere else is left alone - It edits a shell startup file only if you type
yat its prompt, after showing the exact line, and keeps the previous file as.suvadu-backup;--no-modify-rcturns the prompt off
Shell hooks are installed with eval "$(suv init zsh)" or
eval "$(suv init bash)". For each command the hook sends Suvadu:
| Sent | From |
|---|---|
| The whole command line as you typed it, arguments included | zsh's preexec argument / bash's $BASH_COMMAND |
| Working directory | $PWD |
| Git branch | read by suv add from the repository's own HEAD (the nearest .git above the directory the command started in; no git process runs). A detached HEAD is stored as its 7-character commit hash; outside a repository nothing is stored. Off with Record Git Branch in suv settings or [shell] capture_git_branch = false; a project's .suvadu.toml can turn it off but not back on |
| Exit code | $? — left unrecorded rather than guessed when it is unknown |
| Start and end time (and the duration computed from them) | $EPOCHREALTIME at the prompt |
| Session id | $SUVADU_SESSION_ID, a UUID the hook generates per shell |
| Executor type and name | derived from the environment, see below |
Commands contain arguments, and arguments contain secrets. git commit -m "…", curl -H 'Authorization: …' and psql postgres://user:pw@host are
all stored as typed unless a redaction rule catches them. Redaction (below)
is on by default and rewrites recognised secrets before anything is written,
but it is pattern matching, not a guarantee. For a command you never want
stored at all, prefix it with a space or add an exclusion pattern.
Environment variables. The hook reads a fixed list of variables on
every command — $CI, $GITHUB_ACTIONS, $CLAUDE_CODE, $CODEX_THREAD_ID,
$CURSOR_AGENT, $WINDSURF, $AIDER, $TERM_PROGRAM and others, plus any
name you configure under [agents] — to work out who ran the command. Only
their presence is tested; none of their values is stored. What is stored
is the conclusion: an executor type (human, agent, ide, ci,
programmatic, unknown) and a name (claude-code, openai-codex,
terminal, …). The only environment values that reach the database are
$PWD and $SUVADU_SESSION_ID. (When a command changes directory, the
hook also passes the $PWD it started in, only so suv add can read that
directory's branch; that path is not stored.) An environment assignment typed on the
command line is part of the command text and is stored, subject to
redaction.
Also stored, once per shell session rather than per command: the machine's hostname.
Never captured from the shell: command output, file contents, and
anything another program read or wrote. Suvadu records that a command ran,
how it exited and how long it took — nothing about what it did. The opt-in
failure output above comes only from agent hooks and suv wrap, never from
these hooks.
The branch name goes through the same redaction as the command text, and is read only after the exclusion check, so an excluded command leaves no branch behind either.
Nothing is recorded at all when the command starts with a space, matches
one of your exclusion patterns, SUVADU_PAUSED is set in that shell,
recording is disabled in the config, or its directory exceeds 4096
characters. A command longer than 64 KB is redacted in full, then stored cut
to 64 KB and ending in …[truncated N bytes]; exclusion patterns are matched
against the whole command before it is cut. Bash additionally ignores PROMPT_COMMAND
itself, tab completion, and lines sourced before the first interactive
prompt.
Security fixes go into the latest release. Older releases are best-effort; there is no long-term support branch.
| Version | Supported |
|---|---|
| Latest | Yes |
| < Latest | Best-effort |
| Surface | Tested against | Enforced at runtime |
|---|---|---|
| Shells | Zsh and Bash — the only two suv init generates hooks for |
yes: suv init accepts no other shell |
| Atuin database import | Atuin 18.0.0 – 18.23.0 (history schema 20210422143411 – 20260818000000) |
yes — an untested migration is rejected by id with a next step, never guessed at |
| Codex | CLI 0.153.4, for UserPromptSubmit, PostToolUse, Stop and SessionEnd hooks |
no |
| OpenCode | CLI 1.18.30, matching the published @opencode-ai/plugin@1.18.30 types |
no |
| Claude Code, Cursor, pi.dev, Antigravity | not pinned to a version | no |
Only the Atuin importer verifies the version of what it is reading. For the
agent integrations, if a tool changes its hook or plugin contract, capture
can degrade without an error — suv doctor reports, per agent, whether its
process was detected, its integration is installed, and whether commands,
native sessions and MCP registration have actually been seen. Re-run
suv init <agent> after upgrading either side.