Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 18 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@

AI code governance platform for enterprises. Captures what AI coding agents do in your repos — which files they touch, how many tokens they burn, what tools they call, what percentage of code is AI-generated — then enforces policies and produces tamper-evident audit trails for regulatory compliance.

Supports **Claude Code**, **Codex CLI**, and is extensible to other agents via the AgentAdapter architecture.

Built for financial institutions and regulated industries where AI-generated code needs the same audit rigor as human-written code.

[Learn more at VirtusLab](https://virtuslab.com/services/tracevault)
Expand Down Expand Up @@ -67,7 +69,7 @@ See exactly what AI wrote, line by line. The code browser overlays AI attributio
Three Rust crates in a Cargo workspace:

- **tracevault-core** — domain types, policy engine (7 condition types), attribution engine (tree-sitter based), secret redactor
- **tracevault-cli** — CLI binary that hooks into Claude Code, captures traces locally, checks policies, pushes to server
- **tracevault-cli** — CLI binary that hooks into Claude Code and Codex CLI, captures traces locally, checks policies, pushes to server
- **tracevault-server** — axum HTTP server backed by PostgreSQL with Ed25519 signing, audit logging, RBAC, code browser

Plus a SvelteKit web dashboard and a GitHub Action for CI verification.
Expand Down Expand Up @@ -280,6 +282,19 @@ tracevault init

That's it. From this point on, every Claude Code session in this repo is automatically traced — tool calls, file edits, token usage, and model info are captured and streamed to the TraceVault server as they happen. When you `git push`, the pre-push hook evaluates policies and blocks the push if any rule fails.

## Using with Codex CLI

[Codex CLI](https://github.com/openai/codex) (OpenAI's coding agent) is also supported. Initialize with the `--agent codex` flag to install Codex hooks:

```sh
npm install -g @openai/codex
cd /path/to/your/repo
tracevault login --server-url https://your-tracevault-server.example.com
tracevault init --agent codex
```

`--agent` selects exactly which agents to install — passing it replaces the default. `tracevault init --agent codex` installs Codex hooks in `.codex/hooks.json` only; to enable both agents in the same repo, pass each one explicitly: `tracevault init --agent claude-code --agent codex`. Codex sessions are traced including transcript parsing, token usage, and file changes via `apply_patch`. The session detail view shows a Codex badge to distinguish agent types.

## Keys & Secrets

### Encryption key (`TRACEVAULT_ENCRYPTION_KEY`)
Expand Down Expand Up @@ -332,10 +347,10 @@ export DATABASE_URL=postgres://user:password@host:5432/tracevault?sslmode=requir

| Command | Description |
|---------|-------------|
| `tracevault init [--server-url URL]` | Initialize TraceVault in current repo, install pre-push hook and Claude Code hooks |
| `tracevault init [--server-url URL] [--agent <name>]...` | Initialize TraceVault in current repo, install pre-push hook and agent hooks. With no `--agent` flag, Claude Code hooks are installed by default. Passing `--agent <name>` (repeatable) selects exactly which agents to install and replaces the default — to enable both, pass `--agent claude-code --agent codex`. |
| `tracevault login --server-url URL [--no-browser]` | Authenticate via device auth flow. Prints the URL and opens a browser when possible; `--no-browser` (or a headless env) skips the auto-open. |
| `tracevault logout` | Clear local credentials |
| `tracevault stream --event <type>` | Handle a Claude Code hook event (reads JSON from stdin) and stream it to the server |
| `tracevault stream --event <type> [--agent <name>]` | Handle an agent hook event (reads JSON from stdin) and stream it to the server (`--agent`: `claude-code` (default), `codex`) |
| `tracevault sync` | Sync repo metadata with the server |
| `tracevault check` | Evaluate policies against server rules, exit non-zero if blocked |
| `tracevault stats` | Show local session statistics |
Expand Down
139 changes: 51 additions & 88 deletions crates/tracevault-cli/src/commands/init.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ use crate::config::TracevaultConfig;
use std::fs;
use std::io;
use std::path::Path;
use tracevault_core::agent_adapter::{AgentAdapter, AgentAdapterRegistry};

pub fn git_remote_url(project_root: &Path) -> Option<String> {
std::process::Command::new("git")
Expand Down Expand Up @@ -33,7 +34,8 @@ fn parse_github_org(remote_url: &str) -> Option<String> {
pub async fn init_in_directory(
project_root: &Path,
server_url: Option<&str>,
) -> Result<(), io::Error> {
agents: Option<&[String]>,
) -> Result<Vec<String>, io::Error> {
// Check for git repository
if !project_root.join(".git").exists() {
return Err(io::Error::new(
Expand All @@ -48,9 +50,6 @@ pub async fn init_in_directory(
fs::create_dir_all(config_dir.join("sessions"))?;
fs::create_dir_all(config_dir.join("cache"))?;

// Keep all tracevault files local — update root .gitignore
update_root_gitignore(project_root)?;

// Register repo on server if authenticated, server URL known, and git remote available
let remote_url = git_remote_url(project_root);
if remote_url.is_none() {
Expand All @@ -72,8 +71,47 @@ pub async fn init_in_directory(
config.to_toml(),
)?;

// Install Claude Code hooks into .claude/settings.json
install_claude_hooks(project_root)?;
// Resolve agents up front. When --agent is omitted entirely, Claude Code
// is installed as the default. When --agent is provided, only the listed
// agents are installed (Claude is no longer added implicitly). Aliases
// (e.g. "claude" → "claude-code") are resolved by the registry;
// deduplication uses the adapter's canonical `name()`.
let registry = AgentAdapterRegistry::new();
let requested: Vec<&str> = match agents {
Some(extra) => extra.iter().map(String::as_str).collect(),
None => vec!["claude-code"],
};

let mut resolved: Vec<&dyn AgentAdapter> = Vec::new();
let mut effective: Vec<String> = Vec::new();
let mut hook_paths: Vec<String> = Vec::new();
for raw in requested {
match registry.try_get(raw) {
Some(adapter) => {
let id = adapter.name().to_string();
if !effective.contains(&id) {
let path = adapter.hooks_install_path();
if !path.is_empty() {
hook_paths.push(path.to_string());
}
effective.push(id);
resolved.push(adapter);
}
}
None => eprintln!("Warning: unknown agent '{}', skipping hooks", raw),
}
}

// Keep tracevault and agent hook files local — update root .gitignore
// before installing hook files. Matches main's ordering: even if a
// subsequent `install_hooks` fails, `.gitignore` is already updated so
// any partial files left on disk stay untracked.
update_root_gitignore(project_root, &hook_paths)?;

// Install agent-specific hooks
for adapter in &resolved {
adapter.install_hooks(project_root)?;
}

// Install git hooks
install_git_hook(project_root)?;
Expand Down Expand Up @@ -128,20 +166,22 @@ pub async fn init_in_directory(
}
}

Ok(())
Ok(effective)
}

fn update_root_gitignore(project_root: &Path) -> Result<(), io::Error> {
fn update_root_gitignore(project_root: &Path, hook_paths: &[String]) -> Result<(), io::Error> {
let path = project_root.join(".gitignore");
let existing = if path.exists() {
fs::read_to_string(&path)?
} else {
String::new()
};

let needed: Vec<&str> = [".tracevault/", ".claude/settings.json"]
.iter()
.copied()
let mut entries: Vec<&str> = vec![".tracevault/"];
entries.extend(hook_paths.iter().map(String::as_str));

let needed: Vec<&str> = entries
.into_iter()
.filter(|entry| !existing.lines().any(|line| line.trim() == *entry))
.collect();

Expand Down Expand Up @@ -284,83 +324,6 @@ fn install_post_commit_hook(project_root: &Path) -> Result<(), io::Error> {
Ok(())
}

fn install_claude_hooks(project_root: &Path) -> Result<(), io::Error> {
let claude_dir = project_root.join(".claude");
fs::create_dir_all(&claude_dir)?;

let settings_path = claude_dir.join("settings.json");
let mut settings: serde_json::Value = if settings_path.exists() {
let content = fs::read_to_string(&settings_path)?;
serde_json::from_str(&content).map_err(|e| {
io::Error::new(
io::ErrorKind::InvalidData,
format!("Failed to parse .claude/settings.json: {e}"),
)
})?
} else {
serde_json::json!({})
};

let hooks = tracevault_hooks();

// Merge hooks into existing settings
let settings_obj = settings.as_object_mut().ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidData,
".claude/settings.json is not a JSON object",
)
})?;

settings_obj.insert("hooks".to_string(), hooks);

let formatted = serde_json::to_string_pretty(&settings)
.map_err(|e| io::Error::other(format!("Failed to serialize settings: {e}")))?;
fs::write(&settings_path, formatted)?;

Ok(())
}

pub fn tracevault_hooks() -> serde_json::Value {
serde_json::json!({
"PreToolUse": [{
"matcher": "Write|Edit|Bash",
"hooks": [{
"type": "command",
"command": "tracevault stream --event pre-tool-use",
"timeout": 10,
"statusMessage": "TraceVault: streaming pre-tool event"
}]
}],
"PostToolUse": [{
"matcher": "",
"hooks": [{
"type": "command",
"command": "tracevault stream --event post-tool-use",
"timeout": 10,
"statusMessage": "TraceVault: streaming post-tool event"
}]
}],
"Notification": [{
"matcher": "",
"hooks": [{
"type": "command",
"command": "tracevault stream --event notification",
"timeout": 10,
"statusMessage": "TraceVault: streaming notification"
}]
}],
"Stop": [{
"matcher": "",
"hooks": [{
"type": "command",
"command": "tracevault stream --event stop",
"timeout": 10,
"statusMessage": "TraceVault: finalizing session"
}]
}]
})
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down
32 changes: 19 additions & 13 deletions crates/tracevault-cli/src/commands/stream.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@ use std::fs::{self, OpenOptions};
use std::io::{self, BufRead, Read, Seek, SeekFrom, Write};
use std::path::Path;

use tracevault_core::hooks::{parse_hook_event, HookResponse};
use tracevault_core::streaming::{StreamEventRequest, StreamEventType};
use tracevault_core::agent_adapter::AgentAdapterRegistry;
use tracevault_core::hooks::parse_hook_event;
use tracevault_core::streaming::StreamEventRequest;

pub fn next_event_index(counter_path: &Path) -> Result<i32, io::Error> {
let current = if counter_path.exists() {
Expand Down Expand Up @@ -84,7 +85,11 @@ pub fn drain_pending(pending_path: &Path) -> Result<Vec<String>, io::Error> {

pub async fn run_stream(
project_root: &Path,
event_type: &str,
// Unused: routing is driven by `hook_event.hook_event_name` from stdin
// (see `adapter.map_event_type` below). The `--event` CLI flag is kept
// only because the installed hooks pass it for shell-log readability.
_event_type: &str,
agent: &str,
) -> Result<(), Box<dyn std::error::Error>> {
// 1. Read HookEvent from stdin
let mut input = String::new();
Expand All @@ -107,16 +112,17 @@ pub async fn run_stream(
let offset_path = session_dir.join(".stream_offset");
let (transcript_lines, new_offset) = read_new_transcript_lines(transcript_path, &offset_path)?;

// 5. Build StreamEventRequest
let stream_event_type = match event_type {
"notification" => StreamEventType::SessionStart,
"stop" => StreamEventType::SessionEnd,
_ => StreamEventType::ToolUse,
};
// 5. Map hook event to stream event type via the agent adapter.
// Resolve the adapter once; it owns the wire protocol version and the
// canonical tool name so user-supplied aliases (e.g. "claude" → "claude-code")
// produce the same wire bytes as the canonical name.
let registry = AgentAdapterRegistry::new();
let adapter = registry.get(agent);
let stream_event_type = adapter.map_event_type(&hook_event.hook_event_name);

let mut req = StreamEventRequest {
protocol_version: 1,
tool: Some("claude-code".to_string()),
protocol_version: adapter.wire_protocol_version(),
tool: Some(adapter.name().to_string()),
event_type: stream_event_type,
session_id: hook_event.session_id.clone(),
timestamp: chrono::Utc::now(),
Expand Down Expand Up @@ -195,8 +201,8 @@ pub async fn run_stream(
}
}

// 12. Always print HookResponse::allow() to stdout
let response = HookResponse::allow();
// 12. Always print agent-specific hook response to stdout
let response = adapter.hook_response();
println!("{}", serde_json::to_string(&response)?);

Ok(())
Expand Down
42 changes: 35 additions & 7 deletions crates/tracevault-cli/src/main.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
use clap::Parser;
use std::env;
use tracevault_core::agent_adapter::AgentAdapterRegistry;

mod api_client;
mod commands;
Expand All @@ -15,6 +16,9 @@ enum Cli {
/// TraceVault server URL for repo registration
#[arg(long)]
server_url: Option<String>,
/// Additional AI agents to install hooks for (e.g. codex, gemini)
#[arg(long = "agent")]
agents: Vec<String>,
},
/// Show current session status
Status,
Expand All @@ -25,6 +29,9 @@ enum Cli {
Stream {
#[arg(long)]
event: String,
/// AI coding agent name (claude-code, codex)
#[arg(long, default_value = "claude-code")]
agent: String,
},
/// Check session policies before pushing
Check,
Expand Down Expand Up @@ -66,14 +73,35 @@ enum Cli {
async fn main() {
let cli = Cli::parse();
match cli {
Cli::Init { server_url } => {
Cli::Init { server_url, agents } => {
let cwd = env::current_dir().expect("Cannot determine current directory");
match commands::init::init_in_directory(&cwd, server_url.as_deref()).await {
Ok(()) => {
match commands::init::init_in_directory(
&cwd,
server_url.as_deref(),
if agents.is_empty() {
None
} else {
Some(&agents)
},
)
.await
{
Ok(installed) => {
println!("TraceVault initialized in {}", cwd.display());
println!("Claude Code hooks installed (.claude/settings.json)");
let registry = AgentAdapterRegistry::new();
let mut gitignore_paths = vec![".tracevault/".to_string()];
for agent in &installed {
let adapter = registry.get(agent);
let path = adapter.hooks_install_path();
if path.is_empty() {
println!("{} hooks installed", adapter.display_name());
} else {
println!("{} hooks installed ({})", adapter.display_name(), path);
gitignore_paths.push(path.to_string());
}
}
println!("Git hooks installed (pre-push, post-commit)");
println!("Added .tracevault/ and .claude/settings.json to .gitignore");
println!("Added {} to .gitignore", gitignore_paths.join(", "));
println!(
"Nothing needs to be committed — all TraceVault files are local only."
);
Expand All @@ -91,9 +119,9 @@ async fn main() {
std::process::exit(code);
}
}
Cli::Stream { event } => {
Cli::Stream { event, agent } => {
let cwd = env::current_dir().expect("Cannot determine current directory");
if let Err(e) = commands::stream::run_stream(&cwd, &event).await {
if let Err(e) = commands::stream::run_stream(&cwd, &event, &agent).await {
eprintln!("Stream error: {e}");
}
}
Expand Down
Loading
Loading