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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
*.swp
.DS_Store
crates/tracevault-server/data/repos
data/
.playwright-mcp
docs/plans/
docs/infra/
Expand Down
22 changes: 19 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 @@ -264,6 +266,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. When you `git push`, the pre-push hook evaluates policies and uploads traces.

## 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
```

This installs hooks in `.codex/hooks.json` in addition to the Claude Code hooks. 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 @@ -316,10 +331,11 @@ 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 hooks (Claude Code by default, `--agent` adds extra agents e.g. `codex`) |
| `tracevault login --server-url URL` | Authenticate via device auth flow (opens browser) |
| `tracevault logout` | Clear local credentials |
| `tracevault hook --event <type>` | Handle a Claude Code hook event (reads JSON from stdin) |
| `tracevault hook --event <type>` | Handle a hook event from any agent (reads JSON from stdin) |
| `tracevault stream --event <type> [--agent <name>]` | Stream hook events to 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 push` | Push collected traces to the server |
Expand Down
94 changes: 92 additions & 2 deletions crates/tracevault-cli/src/commands/init.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ fn parse_github_org(remote_url: &str) -> Option<String> {
pub async fn init_in_directory(
project_root: &Path,
server_url: Option<&str>,
agents: Option<&[String]>,
) -> Result<(), io::Error> {
// Check for git repository
if !project_root.join(".git").exists() {
Expand Down Expand Up @@ -75,8 +76,16 @@ pub async fn init_in_directory(
"sessions/\ncache/\n*.local.toml\n",
)?;

// Install Claude Code hooks into .claude/settings.json
install_claude_hooks(project_root)?;
// Install agent-specific hooks (defaults to claude when none specified)
let default_agents = [String::from("claude")];
let agents = agents.unwrap_or(&default_agents);
for agent in agents {
match agent.as_str() {
"claude" => install_claude_hooks(project_root)?,
"codex" => install_codex_hooks(project_root)?,
other => eprintln!("Warning: unknown agent '{}', skipping hooks", other),
}
}

// Install git hooks
install_git_hook(project_root)?;
Expand Down Expand Up @@ -333,6 +342,87 @@ pub fn tracevault_hooks() -> serde_json::Value {
})
}

fn codex_hooks() -> serde_json::Value {
serde_json::json!({
"hooks": {
"SessionStart": [{
"matcher": "startup|resume",
"hooks": [{
"type": "command",
"command": "tracevault stream --agent codex --event session-start",
"timeout": 10,
"statusMessage": "TraceVault: streaming session start"
}]
}],
"PreToolUse": [{
"matcher": "Bash",
"hooks": [{
"type": "command",
"command": "tracevault stream --agent codex --event pre-tool-use",
"timeout": 10,
"statusMessage": "TraceVault: streaming pre-tool event"
}]
}],
"PostToolUse": [{
"matcher": "Bash",
"hooks": [{
"type": "command",
"command": "tracevault stream --agent codex --event post-tool-use",
"timeout": 10,
"statusMessage": "TraceVault: streaming post-tool event"
}]
}],
"Stop": [{
"hooks": [{
"type": "command",
"command": "tracevault stream --agent codex --event stop",
"timeout": 10,
"statusMessage": "TraceVault: finalizing session"
}]
}]
}
})
}

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

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

let hooks = codex_hooks();

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

// Set hooks key from our template
if let Some(hooks_value) = hooks.get("hooks") {
config_obj.insert("hooks".to_string(), hooks_value.clone());
}

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

Ok(())
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down
20 changes: 14 additions & 6 deletions crates/tracevault-cli/src/commands/stream.rs
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ pub fn drain_pending(pending_path: &Path) -> Result<Vec<String>, io::Error> {
pub async fn run_stream(
project_root: &Path,
event_type: &str,
agent: &str,
) -> Result<(), Box<dyn std::error::Error>> {
// 1. Read HookEvent from stdin
let mut input = String::new();
Expand All @@ -108,15 +109,16 @@ pub async fn run_stream(
let (transcript_lines, new_offset) = read_new_transcript_lines(transcript_path, &offset_path)?;

// 5. Build StreamEventRequest
// Claude Code sends "notification" for SessionStart, Codex sends "session-start"
let stream_event_type = match event_type {
"notification" => StreamEventType::SessionStart,
"notification" | "session-start" => StreamEventType::SessionStart,
"stop" => StreamEventType::SessionEnd,
_ => StreamEventType::ToolUse,
};

let req = StreamEventRequest {
protocol_version: 1,
tool: Some("claude-code".to_string()),
protocol_version: 2,
tool: Some(agent.to_string()),
event_type: stream_event_type,
session_id: hook_event.session_id.clone(),
timestamp: chrono::Utc::now(),
Expand Down Expand Up @@ -193,9 +195,15 @@ pub async fn run_stream(
}
}

// 12. Always print HookResponse::allow() to stdout
let response = HookResponse::allow();
println!("{}", serde_json::to_string(&response)?);
// 12. Always print hook response to stdout
// Codex expects empty JSON {}, Claude Code expects {"suppress_output": true}
match agent {
"codex" => println!("{{}}"),
_ => {
let response = HookResponse::allow();
println!("{}", serde_json::to_string(&response)?);
}
}

Ok(())
}
27 changes: 23 additions & 4 deletions crates/tracevault-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,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 @@ -27,6 +30,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 @@ -63,12 +69,25 @@ 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 {
match commands::init::init_in_directory(
&cwd,
server_url.as_deref(),
if agents.is_empty() {
None
} else {
Some(&agents)
},
)
.await
{
Ok(()) => {
println!("TraceVault initialized in {}", cwd.display());
println!("Claude Code hooks installed in .claude/settings.json");
for agent in &agents {
println!("{} hooks installed", agent);
}
println!("Git pre-push hook installed");
}
Err(e) => eprintln!("Error: {e}"),
Expand All @@ -81,9 +100,9 @@ async fn main() {
eprintln!("Hook error: {e}");
}
}
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
4 changes: 2 additions & 2 deletions crates/tracevault-cli/tests/e2e_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ async fn full_flow_init_hook_and_local_stats() {
let tmp = tmp_git_repo();

// 1. Init
tracevault_cli::commands::init::init_in_directory(tmp.path(), None)
tracevault_cli::commands::init::init_in_directory(tmp.path(), None, None)
.await
.unwrap();
assert!(tmp.path().join(".tracevault/config.toml").exists());
Expand Down Expand Up @@ -91,7 +91,7 @@ async fn full_flow_init_hook_and_local_stats() {
#[tokio::test]
async fn multiple_sessions_tracked_independently() {
let tmp = tmp_git_repo();
tracevault_cli::commands::init::init_in_directory(tmp.path(), None)
tracevault_cli::commands::init::init_in_directory(tmp.path(), None, None)
.await
.unwrap();

Expand Down
Loading
Loading