Runtime security layer and permission firewall for autonomous AI agents. Owner: solo build. Stack: Go 1.23+, CEL (cel-go), SQLite (modernc.org/sqlite), Telegram Bot API, BubbleTea TUI, Cobra CLI.
Document status: Normative. If code and this document disagree during v0.1, fix the code or amend this document in the same commit.
- End-to-End System Architecture
- Complete Directory Structure
- Core Data Structures & Schemas
- Module-by-Module Implementation Blueprint
- 8-Week Execution Roadmap (granular)
- Testing Strategy
- Risk Register & Non-Goals
- Fail closed. Any error in policy evaluation, approval delivery, or the audit pipeline results in DENY, never silent allow.
- Single binary.
agentvaultis one static binary. Shims, the MCP proxy, the egress proxy, the approval daemon, and the TUI are all subcommands of the same binary. - Local-first. No telemetry, no phone-home, no required network dependency. Telegram is optional; TTY prompt is the offline fallback.
- Honest enforcement. v0.1 is guardrails + tamper-evident audit, not a kernel sandbox. Shims and proxies can be bypassed by a sufficiently adversarial agent; kernel-level confinement (macOS Seatbelt / Linux Landlock) is Phase 2 (§7). The README must say this plainly — honesty is a feature, not a weakness.
An agent (OpenCode, OpenClaw, a shell script, anything) acts on the world through exactly four channels. AgentVault v0.1 covers all four:
| # | Channel | Mechanism | Fidelity | Known limitation |
|---|---|---|---|---|
| 1 | MCP tool calls | AgentVault poses as the MCP server over stdio and proxies to the real server. Intercepts every tools/call JSON-RPC message. |
Full args, tool name, structured | Only covers tools routed through MCP |
| 2 | Shell commands | ~/.agentvault/shims/ is prepended to PATH. Shims for dangerous binaries (rm, curl, wget, ssh, git, …) are symlinks to agentvault __shim <name>, which evaluates policy then execs the real binary. |
argv, cwd, env | Agent can call the real binary by absolute path (Phase 2 sandbox closes this) |
| 3 | Network egress | Child env gets HTTP_PROXY/HTTPS_PROXY pointing at a local CONNECT proxy on 127.0.0.1:<random>. Allow/deny by domain:port. |
domain, port | Honors only proxy-aware clients; TLS content not inspected in v0.1 |
| 4 | Filesystem | v0.1: covered indirectly — MCP fs.* tools via channel 1, shell commands via channel 2. Phase 2: Seatbelt/Landlock profiles generated from the same policy YAML. |
path, op | No direct syscall interception in v0.1 |
┌────────────────────────────────────────────┐
│ agentvault run │
│ (supervisor process) │
│ │
│ ┌────────────┐ ┌─────────────────────┐ │
│ │ Policy │ │ Audit Logger │ │
│ │ Evaluator │──▶│ (hash-chain → │ │
│ │ (CEL) │ │ SQLite + SIG) │ │
│ └─────▲──────┘ └─────────▲───────────┘ │
│ │ │ │
│ ┌─────┴──────┐ ┌─────────┴───────────┐ │
│ │ Approval │ │ Egress Proxy │ │
│ │ Daemon │ │ (CONNECT, 127.0.0.1)│ │
│ │ (Telegram/ │ └─────────▲───────────┘ │
│ │ TTY) │ │ HTTP_PROXY │
│ └─────▲──────┘ │ │
└────────┼─────────────────────┼──────────────┘
unix socket ~/.agentvault/run/<session>.sock│
│ │ │
┌────────────────────────┼────────┼────────────────────┼───────────┐
│ │ │ │ │
┌──┴─────────┐ ┌──────────┴───┐ │ ┌────────────────┴────┐ │
│ MCP proxy │ │ PATH shims │ │ │ CHILD PROCESS │ │
│ (per MCP │ │ rm/curl/ssh/ │ │ │ (e.g. opencode) │ │
│ server) │ │ git → __shim │───┼──▶│ env: PATH+shims, │ │
└──┬─────────┘ └──────────────┘ │ │ HTTP(S)_PROXY │ │
│ stdio JSON-RPC │ └───────────────────────┘ │
▼ │ ▲ stdout/stderr passthrough
┌──────────────┐ └───────────┼───────────────────────┘
│ real MCP │ │
│ server │◀───────── proxied ──────────────┘
└──────────────┘ user's terminal
agentvault run -- opencodeparsesagentvault.yaml, compiles CEL expressions once (fail fast on compile errors), opens/creates the audit DB, generates an ephemeral Ed25519 session keypair.- Supervisor starts: egress proxy listener, MCP proxy per configured server, approval daemon, and a unix socket at
~/.agentvault/run/<session_id>.sock(mode 0600) for shim → supervisor IPC. - Child spawned with modified env:
PATH=~/.agentvault/shims:$PATH,HTTP(S)_PROXY=http://127.0.0.1:<port>,AGENTVAULT_SESSION=<id>,AGENTVAULT_SOCK=<path>. - Any action on channels 1–3 produces an
InterceptedEvent(§3.3) sent to the supervisor over the socket (shims) or in-process (MCP/egress). - Policy Evaluator returns
ALLOW | DENY | REQUIRE_APPROVAL(+ which rule matched). ALLOW→ log async → execute.DENY→ log → synthetic error returned to the agent ("blocked by AgentVault policy: ") — the agent sees a normal tool error and can adapt.REQUIRE_APPROVAL→ log as pending → Approval Daemon → on response log decision → execute or reject.- Every audit row is hash-chained (
row_hash = SHA256(prev_row_hash || canonical_json)) and the chain head is Ed25519-signed on session close.agentvault verifyrecomputes the chain.
event(REQUIRE_APPROVAL)
→ daemon creates ApprovalRequest{id, event, expires_at = now + cfg.timeout}
→ dispatches to ALL configured channels concurrently:
• Telegram: inline keyboard [✅ Allow once] [🔁 Allow rule] [❌ Deny]
• TTY (if foreground): inline prompt, countdown
→ first decisive response wins; others receive "already resolved"
→ timeout or daemon unreachable → DENY (fail closed)
→ decision + responder + latency logged to audit DB
"Allow rule" writes a session-scoped memoized approval (never persisted to the YAML) so a build running 40 npm calls doesn't page the user 40 times. Memo key = (rule_name, action_type, normalized_target).
agentvault/
├── .github/
│ ├── workflows/
│ │ ├── ci.yml # lint, test, race, cross-compile matrix
│ │ └── release.yml # goreleaser on tag v*
│ └── ISSUE_TEMPLATE/
│ ├── bug_report.yml
│ └── feature_request.yml
├── cmd/
│ └── agentvault/
│ └── main.go # os.Exit(cli.Execute()); nothing else
├── internal/
│ ├── cli/
│ │ ├── root.go # cobra root: --config --verbose --no-color
│ │ ├── run.go # `agentvault run -- <cmd>`
│ │ ├── log.go # `agentvault log` (TUI + --since/--export)
│ │ ├── init.go # `agentvault init` (wizard → agentvault.yaml)
│ │ ├── policy.go # `agentvault policy check|test`
│ │ ├── verify.go # `agentvault verify` (audit chain verification)
│ │ ├── approve.go # `agentvault approve list|allow|deny`
│ │ ├── daemon.go # `agentvault daemon` (systemd/launchd mode)
│ │ └── shim.go # hidden: `agentvault __shim <bin> [args...]`
│ ├── config/
│ │ ├── config.go # Policy struct, Load(), Validate()
│ │ ├── config_test.go
│ │ └── defaults.go # DefaultPolicy() — secure-by-default template
│ ├── event/
│ │ ├── event.go # Event struct, ActionType, CanonicalJSON()
│ │ ├── event_test.go
│ │ └── verdict.go # Verdict, RuleRef, Decision types
│ ├── policy/
│ │ ├── engine.go # Engine iface: Evaluate(Event) → Verdict
│ │ ├── cel.go # cel-go compiler + evaluator
│ │ ├── cel_test.go
│ │ ├── matcher.go # glob (paths), cmd (shell), cidr (net) helpers
│ │ └── testdata/ # .yaml policy fixtures + expected verdicts
│ ├── supervisor/
│ │ ├── supervisor.go # orchestrates run: spawn, wait, cleanup
│ │ ├── child.go # exec.Cmd env assembly, signal forwarding
│ │ ├── socket.go # unix socket server for shim IPC
│ │ └── supervisor_test.go
│ ├── proxy/
│ │ ├── mcp/
│ │ │ ├── proxy.go # stdio MCP proxy, JSON-RPC framing
│ │ │ ├── intercept.go # tools/call interception point
│ │ │ └── proxy_test.go
│ │ └── egress/
│ │ ├── connect.go # HTTP CONNECT proxy
│ │ ├── allowlist.go # domain:port verdict lookup
│ │ └── connect_test.go
│ ├── shim/
│ │ ├── shim.go # __shim: build Event → RPC → exec/deny
│ │ ├── install.go # writes ~/.agentvault/shims symlinks
│ │ └── shim_test.go
│ ├── approval/
│ │ ├── daemon.go # request lifecycle, dedupe, memo cache
│ │ ├── telegram.go # Bot API long-poll, inline keyboards
│ │ ├── telegram_test.go
│ │ ├── tty.go # interactive terminal fallback
│ │ └── daemon_test.go
│ ├── audit/
│ │ ├── logger.go # write path, buffered, flush-on-signal
│ │ ├── store.go # sqlite open, migrate, query
│ │ ├── chain.go # sha256 hash chain + Ed25519 sign/verify
│ │ ├── schema.go # DDL statements (§3.4)
│ │ └── audit_test.go
│ └── tui/
│ ├── logview.go # BubbleTea model: filterable event table
│ ├── detail.go # event detail pane (args, rule, timing)
│ ├── styles.go # lipgloss theme
│ └── logview_test.go
├── pkg/ # empty in v0.1 — no public API until stable
├── docs/
│ ├── SPEC.md # this file
│ ├── integrations/
│ │ ├── opencode.md
│ │ └── openclaw.md
│ └── img/
│ └── demo.gif # THE gif — produced week 7
├── test/
│ ├── e2e/
│ │ ├── harness.go # fake agent binary + scripted scenarios
│ │ ├── run_block_test.go # rm -rf blocked end-to-end
│ │ ├── approval_test.go # telegram-mock approve flow
│ │ └── mcp_test.go # fake MCP server roundtrip
│ └── fixtures/
│ ├── fakeagent.sh # scripted agent that attempts bad things
│ └── policies/
├── scripts/
│ ├── demo.sh # reproducible asciinema demo recording
│ └── install.sh # curl | sh installer (checksum-verified)
├── .goreleaser.yml # darwin/linux × amd64/arm64, brew tap
├── go.mod
├── go.sum
├── Makefile # build, test, lint, demo, release
├── README.md # hook + gif + install + quickstart
├── SECURITY.md # threat model + vuln disclosure
├── LICENSE # Apache-2.0
└── CONTRIBUTING.md
Layout rationale: everything under internal/ means zero accidental public API in v0.1 — freedom to refactor without breaking importers. main.go is 3 lines; all wiring lives in internal/cli. Tests sit beside their package; cross-package scenarios live only in test/e2e.
# agentvault.yaml — complete annotated example
version: 1
agent:
name: "opencode" # free-form label used in audit rows
command: ["opencode"] # filled by `run`, overridable here
defaults: # evaluated when no rule matches
action: deny # allow | deny | require_approval (RECOMMEND: deny)
approval_timeout: 60s
budgets:
daily_spend_usd: 2.00 # REQUIRE_APPROVAL beyond this (needs cost feed, §7)
max_actions_per_minute: 120 # rate limit; excess → REQUIRE_APPROVAL
rules: # FIRST match wins, in document order
- name: "protect-credentials"
match:
action: [fs.read, fs.write, fs.delete]
path: ["~/.ssh/**", "~/.aws/**", "~/.config/gcloud/**"]
effect: deny
message: "Credential paths are off-limits to agents."
- name: "block-destructive-shell"
match:
action: [shell.exec]
cel: 'event.cmd in ["rm","dd","mkfs","shutdown","reboot"] ||
event.argv.exists(a, a.startsWith("-rf")) ||
event.raw.matches("\\| *sh\\b")'
effect: deny
- name: "workdir-is-free"
match:
action: [fs.read, fs.write]
path: ["./**", "~/projects/**"]
effect: allow
- name: "net-egress-ask"
match:
action: [net.egress]
not_host: ["api.anthropic.com", "api.openai.com", "*.githubusercontent.com"]
effect: require_approval
- name: "git-push-ask"
match:
action: [shell.exec]
cel: 'event.cmd == "git" && event.argv.size() > 1 && event.argv[1] == "push"'
effect: require_approval
approvals:
timeout: 60s # per-request override of defaults.approval_timeout
channels:
telegram:
enabled: true
bot_token_env: AGENTVAULT_TELEGRAM_TOKEN # never a literal secret
chat_id_env: AGENTVAULT_TELEGRAM_CHAT_ID
tty:
enabled: true # fallback when stdout is a terminal
mcp_servers: # wrapped MCP servers (channel 1)
- name: filesystem
command: ["npx", "-y", "@modelcontextprotocol/server-filesystem", "."]
tool_policies: # optional per-tool overrides
- tool: "delete_file"
effect: require_approval
shims: # channel 2
binaries: [rm, curl, wget, ssh, scp, git, npm, npx, pip, brew, dd]
egress: # channel 3
listen: "127.0.0.1:0" # 0 = random port
default: deny # deny | allow | require_approval
allow_hosts: ["api.anthropic.com", "api.openai.com"]
audit:
path: "~/.agentvault/audit.db"
retention_days: 90 # 0 = keep forever
sign_on_close: true// internal/config/config.go
package config
import "time"
type Policy struct {
Version int `yaml:"version"`
Agent AgentCfg `yaml:"agent"`
Defaults DefaultsCfg `yaml:"defaults"`
Budgets BudgetsCfg `yaml:"budgets"`
Rules []Rule `yaml:"rules"`
Approvals ApprovalsCfg `yaml:"approvals"`
MCPServers []MCPServerCfg `yaml:"mcp_servers"`
Shims ShimsCfg `yaml:"shims"`
Egress EgressCfg `yaml:"egress"`
Audit AuditCfg `yaml:"audit"`
}
type AgentCfg struct {
Name string `yaml:"name"`
Command []string `yaml:"command"`
}
type DefaultsCfg struct {
Action Effect `yaml:"action"`
ApprovalTimeout time.Duration `yaml:"approval_timeout"`
}
type BudgetsCfg struct {
DailySpendUSD float64 `yaml:"daily_spend_usd"`
MaxActionsPerMinute int `yaml:"max_actions_per_minute"`
}
// Effect is the outcome vocabulary shared by config, engine, and audit.
type Effect string
const (
EffectAllow Effect = "allow"
EffectDeny Effect = "deny"
EffectRequireApproval Effect = "require_approval"
)
type Rule struct {
Name string `yaml:"name"`
Match Match `yaml:"match"`
Effect Effect `yaml:"effect"`
Message string `yaml:"message,omitempty"`
}
// Match: all populated fields are ANDed. Within a list, OR.
// CEL (if non-empty) must also evaluate true.
type Match struct {
Action []ActionType `yaml:"action,omitempty"`
Path []string `yaml:"path,omitempty"` // glob, ~ expanded, ** supported
Host []string `yaml:"host,omitempty"` // exact or *.suffix
NotHost []string `yaml:"not_host,omitempty"`
Tool []string `yaml:"tool,omitempty"` // MCP tool names
CEL string `yaml:"cel,omitempty"` // arbitrary expression over event
}
type ApprovalsCfg struct {
Timeout time.Duration `yaml:"timeout"`
Channels ChannelsCfg `yaml:"channels"`
}
type ChannelsCfg struct {
Telegram TelegramCfg `yaml:"telegram"`
TTY TTYCfg `yaml:"tty"`
}
type TelegramCfg struct {
Enabled bool `yaml:"enabled"`
BotTokenEnv string `yaml:"bot_token_env"`
ChatIDEnv string `yaml:"chat_id_env"`
}
type TTYCfg struct{ Enabled bool `yaml:"enabled"` }
type MCPServerCfg struct {
Name string `yaml:"name"`
Command []string `yaml:"command"`
ToolPolicies []ToolPolicy `yaml:"tool_policies,omitempty"`
}
type ToolPolicy struct {
Tool string `yaml:"tool"`
Effect Effect `yaml:"effect"`
}
type ShimsCfg struct{ Binaries []string `yaml:"binaries"` }
type EgressCfg struct {
Listen string `yaml:"listen"`
Default Effect `yaml:"default"`
AllowHosts []string `yaml:"allow_hosts"`
}
type AuditCfg struct {
Path string `yaml:"path"`
RetentionDays int `yaml:"retention_days"`
SignOnClose bool `yaml:"sign_on_close"`
}
// Load reads, expands ~ and $ENV, validates. Any error is fatal (fail closed).
func Load(path string) (*Policy, error)
// Validate catches: empty rules + default allow (warn), bad globs,
// CEL compile errors, unknown effects, missing env vars for telegram.
func (p *Policy) Validate() error// internal/event/event.go
package event
import "time"
// ActionType is the normalized vocabulary across all 4 channels.
type ActionType string
const (
ActionFSRead ActionType = "fs.read"
ActionFSWrite ActionType = "fs.write"
ActionFSDelete ActionType = "fs.delete"
ActionShell ActionType = "shell.exec"
ActionNetEgress ActionType = "net.egress"
ActionMCPTool ActionType = "mcp.tool"
)
// Source identifies which interception channel produced the event.
type Source string
const (
SourceMCP Source = "mcp"
SourceShim Source = "shim"
SourceEgress Source = "egress"
)
// Event is the single canonical payload every channel must build.
// Zero-valued optional fields are omitted from CanonicalJSON.
type Event struct {
ID string `json:"id"` // ULID, generated at interception
SessionID string `json:"session_id"`
Timestamp time.Time `json:"ts"` // UTC, RFC3339Nano in JSON
Source Source `json:"source"`
Action ActionType `json:"action"`
// shell.exec
Cmd string `json:"cmd,omitempty"` // basename: "rm"
Argv []string `json:"argv,omitempty"`
Raw string `json:"raw,omitempty"` // full joined command line
// fs.* and mcp filesystem tools
Path string `json:"path,omitempty"` // absolute, ~ expanded, symlink-resolved
// net.egress
Host string `json:"host,omitempty"`
Port int `json:"port,omitempty"`
// mcp.tool
Server string `json:"server,omitempty"`
Tool string `json:"tool,omitempty"`
ToolArgs json.RawMessage `json:"tool_args,omitempty"`
Cwd string `json:"cwd"`
Env map[string]string `json:"-"` // NEVER serialized or logged (secret leakage)
PID int `json:"pid"`
}
// CanonicalJSON returns deterministic JSON (sorted keys, no env) used
// for hashing into the audit chain. MUST NOT include Env.
func (e Event) CanonicalJSON() []byte
// internal/event/verdict.go
package event
import "time"
type Effect string // mirrors config.Effect; duplicated to avoid import cycle
const (
Allow Effect = "allow"
Deny Effect = "deny"
RequireApproval Effect = "require_approval"
)
type Verdict struct {
Effect Effect `json:"effect"`
RuleName string `json:"rule_name,omitempty"` // "" when default fired
Message string `json:"message,omitempty"` // human-facing reason
EvalMicros int64 `json:"eval_micros"` // policy eval latency
}
type Decision struct {
EventID string `json:"event_id"`
FinalEffect Effect `json:"final_effect"` // after approval resolution
ApprovedBy string `json:"approved_by,omitempty"` // "telegram:<chat_id>" | "tty" | ""
Waited time.Duration `json:"waited,omitempty"`
TimedOut bool `json:"timed_out,omitempty"`
}Length-prefixed JSON over the session unix socket. One request/response per connection keeps the shim dead simple:
// Request (shim → supervisor)
{"type":"eval","event":{ /* Event */ }}
// Response (supervisor → shim)
{"type":"verdict","effect":"allow","rule_name":"workdir-is-free","message":""}
// On any socket/parse error the shim exits 77 with stderr:
// "agentvault: policy check failed — refusing to run <cmd> (fail closed)"PRAGMA journal_mode = WAL;
PRAGMA synchronous = FULL; -- durability over speed; this is an audit log
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY, -- ULID
started_at TEXT NOT NULL, -- RFC3339Nano UTC
ended_at TEXT,
agent_name TEXT NOT NULL,
agent_command TEXT NOT NULL, -- JSON array
policy_hash TEXT NOT NULL, -- sha256 of agentvault.yaml bytes
pubkey BLOB, -- Ed25519 session public key (32 B)
head_hash BLOB, -- final chain head
head_sig BLOB -- Ed25519 sig over head_hash
);
CREATE TABLE IF NOT EXISTS events (
id TEXT PRIMARY KEY, -- ULID == Event.ID
session_id TEXT NOT NULL REFERENCES sessions(id),
ts TEXT NOT NULL,
seq INTEGER NOT NULL, -- per-session monotonic
source TEXT NOT NULL,
action TEXT NOT NULL,
payload TEXT NOT NULL, -- Event.CanonicalJSON()
rule_name TEXT,
verdict TEXT NOT NULL, -- allow|deny|require_approval
final_effect TEXT, -- post-approval outcome
approved_by TEXT,
wait_ms INTEGER,
eval_micros INTEGER,
prev_hash BLOB NOT NULL, -- 32 B; genesis = sha256("agentvault:v1")
row_hash BLOB NOT NULL, -- sha256(prev_hash || payload || verdict || rule_name || ts)
UNIQUE(session_id, seq)
);
CREATE INDEX IF NOT EXISTS idx_events_ts ON events(ts);
CREATE INDEX IF NOT EXISTS idx_events_verdict ON events(verdict);
CREATE INDEX IF NOT EXISTS idx_events_action ON events(action);
CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY,
applied_at TEXT NOT NULL
);
INSERT INTO schema_migrations(version, applied_at) VALUES (1, strftime('%Y-%m-%dT%H:%M:%fZ','now'));Tamper-evidence guarantees: deleting or editing any row breaks every subsequent row_hash; deleting a suffix of rows breaks sessions.head_hash; re-computing the whole chain is impossible without invalidating head_sig (session private key is destroyed at exit). agentvault verify walks the chain and reports the first divergent seq.
Cobra-based. main.go:
package main
import (
"os"
"github.com/YOURUSER/agentvault/internal/cli"
)
func main() { os.Exit(cli.Execute()) }Subcommands contract:
| Command | Behavior | Exit codes |
|---|---|---|
run [flags] -- <cmd...> |
Load policy → start supervisor (§4.2) → spawn child → forward signals → on child exit: flush audit, sign chain, exit with child's code | child's code; 2 = policy load failure |
log [--since 24h] [--verdict deny] [--action shell.exec] [--session <id>] [--export json|csv] |
No flags → BubbleTea TUI. With --export → stream to stdout (pipeable) |
0 |
init [--non-interactive] |
Wizard: detects installed agents (opencode, claude in PATH), asks which channels to protect, writes agentvault.yaml, installs shims, optionally walks Telegram bot setup (paste token → sends test message) |
0, 1 user abort |
policy check |
Parse + compile everything; per-rule compile status; warn on shadowed rules (a rule that can never fire because an earlier rule strictly subsumes it) | 0 ok, 2 invalid |
policy test <fixture.json> |
Evaluate a JSON-encoded Event against the policy, print verdict + matched rule. Used by the unit-test harness too | 0 |
verify [--session <id>] |
Recompute hash chain, verify head_sig, print OK or first bad seq |
0 valid, 3 tampered |
approve list|allow <id>|deny <id> |
Talk to a running supervisor socket; approve headless sessions from any terminal on the machine | 0 |
daemon |
Approval daemon detached from any child (for always-on agents like OpenClaw) | 0 |
__shim <bin> [args...] |
Hidden. Build Event → socket RPC → syscall.Exec(realBin) or exit 126 with deny message. Real binary resolved by scanning PATH after the shim dir |
child's code; 126 denied; 77 check failed |
Startup sequence (strict order; any failure aborts before the child spawns):
audit.Store.Open()— migrate schema, insertsessionsrow, generate Ed25519 keypair.policy.NewEngine()— compile all CEL; error → exit 2 with exact rule name + CEL error.egress.Listen("127.0.0.1:0")→ get actual port.mcp.StartProxies(cfg.MCPServers)— spawn each real MCP server, hold its stdio pipes.shim.EnsureInstalled(cfg.Shims.Binaries)— idempotent; shims are symlinks toos.Executable(); binaries not on PATH are skipped with a warning.socket.Serve(sockPath)—net.Listen("unix"), chmod 0600.- Spawn child (
child.go):cmd := exec.Command(argv[0], argv[1:]...) cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr cmd.Env = append(filteredEnv(), "PATH="+shimDir+":"+os.Getenv("PATH"), "HTTP_PROXY=http://"+egressAddr, "HTTPS_PROXY=http://"+egressAddr, "AGENTVAULT_SESSION="+sessionID, "AGENTVAULT_SOCK="+sockPath)
- Forward
SIGINT/SIGTERM/SIGHUPto the child process group. On child exit or supervisor signal:logger.Flush()(hard 5s deadline) →chain.SignAndClose()→ remove socket →os.Exit(childExitCode).
Shim handler (internal/shim/shim.go) — hot path, must add <15ms overhead:
- Build
Event{Source: shim, Action: shell.exec, Cmd: filepath.Base(argv0), Argv, Raw, Cwd, PID}. - Dial socket (200ms dial timeout) → send → read verdict (blocks up to approval timeout).
allow→syscall.Exec(realBin, argv, env)— replaces process, zero wrapper overhead after verdict.deny→ stderr:agentvault: denied by rule "block-destructive-shell": <message>; exit 126.- Socket error → exit 77 (fail closed).
MCP proxy (internal/proxy/mcp/): newline-delimited JSON-RPC framing both directions. Pass through everything verbatim except method == "tools/call": pause the message, map params.name + params.arguments → Event{Source: mcp, Action: mcp.tool, Tool, ToolArgs} (fs tools additionally set Path), evaluate, then forward to the real server or return JSON-RPC error -32000, "blocked by AgentVault: <rule>". Responses proxied back unchanged. Per-tool overrides in mcp_servers[].tool_policies are checked before the global rule list.
Egress proxy (internal/proxy/egress/connect.go): minimal CONNECT implementation — read CONNECT host:port, build Event{Action: net.egress, Host, Port}, evaluate, 200 Connection Established + bidirectional io.Copy on allow, 403 on deny. No TLS interception, no payload logging (only host:port is audited — privacy by design).
type Engine interface {
Evaluate(e event.Event) event.Verdict // goroutine-safe, <1ms p99
}
// cel.go compiles at NewEngine time:
// env, _ := cel.NewEnv(cel.Variable("event", cel.MapType(cel.StringType, cel.DynType)))
// Per rule, evaluators run cheapest-first:
// 1. structural matchers (action ∈ list, glob match, host match)
// 2. CEL program last (short-circuits if structural already failed)
// Evaluate: for rule in rules { if match(rule, e) { return verdict(rule) } }
// return verdict(defaults.action, ruleName="")Matching helpers (matcher.go): matchPath via github.com/bmatcuk/doublestar/v4 (** support, ~ pre-expanded); matchHost exact or *.suffix; shell CEL context exposes event.cmd, event.argv, event.raw, event.cwd. Ordering guarantee: first match in document order wins — documented loudly, as it's the #1 user error in firewall configs. policy check includes a shadowing linter (rule B unreachable if rule A's match ⊇ B's match).
Rate budget (budgets.max_actions_per_minute): token bucket in the supervisor; overflow converts any verdict to require_approval. Spend budget: v0.1 parses the field but logs "not yet enforced" (needs per-provider cost feed, §7) so the schema stays stable.
type Daemon struct {
pending map[string]*Request // keyed by Event.ID, RWMutex-guarded
memo *MemoCache // LRU, key=(rule, action, target), session-scoped
channels []Channel // telegram, tty
timeout time.Duration
}
type Channel interface {
Ask(ctx context.Context, r Request) (<-chan Decision, error)
Name() string
}Flow: Submit(event) → memo hit? return memoized verdict → create Request → fan out Ask to all channels → select first decision vs ctx.Done() (timeout → deny) → memo-store if "allow rule" → broadcast "resolved" to other channels → return.
Telegram (telegram.go): long-poll getUpdates. Outgoing message:
🛡 AgentVault — approval requested
Agent: opencode · Rule: git-push-ask
Action: shell.exec
$ git push origin main
cwd: ~/projects/agentvault
[✅ Allow once] [🔁 Allow this rule] [❌ Deny] (expires 0:47)
Inline keyboard callback_data: av:<event_id>:once|:rule|:deny (≤64 bytes; ULID fits). Only updates from configured chat_id are honored; all others are logged as suspicious. TTY (tty.go): renders the same card on stderr (stdout stays clean for the child), raw-mode single-key response, countdown via \r redraw — auto-disabled when stderr isn't a TTY.
type Logger struct {
store *Store
ch chan record // buffered 4096
done chan struct{}
hasher *Chain // single-goroutine owner of prev_hash
}
// Log() never blocks the agent's hot path: on a full channel it spills to
// ~/.agentvault/overflow.log and increments a dropped_events counter.
func (l *Logger) Log(e event.Event, v event.Verdict, d *event.Decision)
func (l *Logger) Flush(deadline time.Duration) errorchain.go: Append(payload []byte) (rowHash [32]byte) maintains prev; SignAndClose() writes head_hash+head_sig into sessions then zeroes the private key. store.go owns all SQL via database/sql + modernc.org/sqlite (driver sqlite), WAL mode, single writer goroutine. Retention: archive-then-delete — sessions with valid head_sig older than the cutoff are exported to ~/.agentvault/archive/<id>.jsonl (sig included) before rows are removed, because deleting rows mid-chain would destroy verifiability.
BubbleTea model, three states: table (columns: time, action, target, verdict badge, rule; / filter, v cycle verdict filter, enter detail), detail (pretty-printed payload JSON, approval info, eval latency), help. Live-tail polls SQLite every 500ms for rows newer than cursor (works because supervisor and TUI share the DB — no extra IPC). Color-blind safe: verdict is a text badge [ALLOW]/[DENY]/[ASK], never color alone.
Legend: AC = acceptance criteria. Every week ends with a tagged release (v0.0.w) and a short devlog post — shipping publicly every week is part of the growth engine, not an afterthought.
- Mon: repo init, go.mod, Cobra root, CI (lint+test+cross-compile), LICENSE/README stub.
- Tue–Wed:
internal/config(parse, expand, validate) +internal/event+policy.Enginewith structural matchers. - Thu–Fri: CEL integration,
policy check+policy testcommands, 30 fixture tests inpolicy/testdata/. - AC:
agentvault policy checkcompiles the full example YAML in §3.1;policy testreturns correct verdicts for 30 fixtures; CI green.
- Mon–Tue: supervisor startup sequence, child env assembly, signal forwarding, socket server.
- Wed–Thu:
__shimhandler +EnsureInstalled; wire engine + naive synchronous file audit (SQLite lands next week). - Fri: e2e harness +
run_block_test.go. - AC:
agentvault run -- fakeagent.shwhere fakeagent runsrm -rf ~/fake-dir→ blocked with rule name in stderr, exit 126; allowed commands pass through with <15ms added latency (hyperfinebenchmark in CI); demo GIF #1 (terminal block) recorded.
- Mon–Tue:
internal/auditstore + migrations + async logger with overflow. - Wed: hash chain + Ed25519 sign-on-close;
agentvault verify. - Thu: tamper tests (flip a byte in the DB →
verifyreports exact badseq; delete suffix → head mismatch). - Fri: retention archive-then-delete.
- AC: 10k-event soak test with zero lost records (counter check); all tamper mutations detected;
go test -race ./...clean.
- Mon–Tue: daemon lifecycle, memo cache, timeout-deny path, TTY channel.
- Wed–Thu: Telegram channel with inline keyboards, chat_id pinning, "resolved elsewhere" updates, mock-server tests.
- Fri:
agentvault approve list/allow/denyagainst a live supervisor socket. - AC:
git pushinside a wrapped shell → Telegram message arrives <3s → tapping Deny blocks it and the audit row showsapproved_by=telegram:*+ wait time; timeout path denies at exactly 60s; demo GIF #2 (phone deny) recorded — this is the launch asset.
- Mon–Tue: JSON-RPC stdio framing both directions; fake MCP server fixture.
- Wed:
tools/callinterception, tool_policies precedence, JSON-RPC error synthesis. - Thu–Fri: real-server integration test (
@modelcontextprotocol/server-filesystem), per-toolrequire_approval→ Telegram flow. - AC: agent calling MCP
delete_fileon~/.ssh→ denied with-32000error the agent gracefully handles; all non-tool messages pass through byte-identical (diffed in test).
- Mon–Tue: CONNECT proxy, host allowlist,
default: deny, curl-through-proxy tests. - Wed–Fri:
agentvault logTUI (table/detail/filter/live-tail) +--export json|csv. - AC:
curl https://evil.exampleinside wrapped agent → 403 + audit row; TUI renders 100k-row log without lag (paged queries); live-tail shows events from a running session.
- Mon:
initwizard with agent auto-detection + Telegram bot guided setup. - Tue: README (hook headline → GIF → 60-second quickstart → honest limitations), SECURITY.md threat model.
- Wed:
scripts/demo.sh(deterministic asciinema recording) → final demo.gif. - Thu: install.sh + goreleaser dry-run;
docs/integrations/opencode.md+openclaw.md. - Fri:
v0.1.0tag; full clean-machine install test (fresh macOS VM + fresh Linux container). - AC: a stranger can go
install.sh → init → run -- opencode → trigger a blockin under 5 minutes without reading anything but the README.
- Mon: soft-launch in OpenClaw + OpenCode Discords and subreddits; gather bug reports.
- Tue: fix-forward pass.
- Wed 8–9am ET: Show HN ("AgentVault — a permission firewall for AI coding agents") + r/LocalLLaMA + X thread with the GIF; newsletter submission forms.
- Thu–Fri: respond to everything within hours; ship
v0.1.1with launch-week fixes. - AC: launch post is factually airtight (limitations stated, no overclaiming — security tools die on overclaiming); every issue answered <24h.
| Layer | What | How |
|---|---|---|
| Unit | matchers, CEL compile, config validate, chain math, memo cache | table-driven; fixtures in testdata/ |
| Integration | shim↔socket, MCP roundtrip, egress allow/deny, telegram channel | fake MCP server, httptest Telegram API mock |
| E2E | full run against scripted fake agent |
test/e2e + fixtures/fakeagent.sh |
| Tamper | DB mutation matrix | flip byte / delete row / delete suffix / reorder rows → verify must catch each |
| Performance | shim latency <15ms p99; eval <1ms p99 | hyperfine benchmark job in CI |
| Race/fuzz | -race on everything; fuzz JSON-RPC framing + YAML parse |
go test -race, go test -fuzz |
CI gates: lint (golangci-lint), tests, race detector, benchmark regression check, cross-compile matrix (darwin/linux × amd64/arm64), and goreleaser --snapshot.
| Risk | Mitigation |
|---|---|
| "Shims are bypassable — is this snake oil?" | Say so in README; v0.1 = guardrails + tamper-evident audit. Phase 2: macOS Seatbelt / Linux Landlock profiles generated from the same YAML → real confinement. That upgrade path is the v0.2 headline. |
| Telegram down / no network | TTY channel always available when attached; timeout defaults to deny; never blocks on network. |
| Latency complaints | Benchmarks in CI; shim exec-replacement keeps post-verdict overhead at zero. |
| YAML/CEL complexity scares users | init wizard writes a sane default; policy check gives exact line numbers; ship 5 copy-paste "recipe" policies in docs. |
| Approval fatigue | Memoized "allow this rule"; rate budget as a backstop instead of per-action prompts. |
v0.1 non-goals (explicit): TLS content inspection, kernel sandboxing, spend enforcement, multi-user/team policies, any cloud service. Each is a Phase 2+ item with the config schema already forward-compatible. Windows IS supported in v0.1 (amended): shims are .cmd wrappers instead of symlinks, IPC is loopback TCP with a session token instead of a unix socket, and signal forwarding is Interrupt-only.