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
471 changes: 456 additions & 15 deletions Cargo.lock

Large diffs are not rendered by default.

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 @@ -360,6 +362,19 @@ Actions: **Block push** (exit non-zero, prevents `git push`) or **Warn** (logs b

Scope: **Session** (evaluate all tool calls in the session) or **Validation** (evaluate only tools called after `tracevault validation-start`).

## 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 @@ -412,10 +427,10 @@ export DATABASE_URL=postgres://user:password@host:5432/tracevault?sslmode=requir

| Command | Description |
|---------|-------------|
| `tracevault init [--server-url URL] [--claude-settings shared\|local]` | Initialize Visdom Trace in current repo, install pre-push hook and Claude Code hooks. `--claude-settings` chooses between `.claude/settings.json` (default) and `.claude/settings.local.json`; prompts interactively if omitted on a TTY |
| `tracevault init [--server-url URL] [--claude-settings shared\|local] [--agent <name>]...` | Initialize Visdom Trace in current repo, install pre-push hook and agent hooks. Claude Code hooks are always installed; repeat `--agent <name>` to additionally install hooks for other agents, e.g. `--agent codex`. `--claude-settings` chooses between `.claude/settings.json` (default) and `.claude/settings.local.json`; prompts interactively if omitted on a TTY |
| `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 validation-start [--session-id ID]` | Open a validation window. Call this when work is complete and you are ready to run pre-push validation tools. Validation-scoped policies only evaluate tools called after this point. Calling it again invalidates the previous window. |
Expand Down
21 changes: 20 additions & 1 deletion 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::{self, BufRead, IsTerminal, Write};
use std::path::Path;
use tracevault_core::agent_adapter::AgentAdapterRegistry;

/// Which Claude Code settings file to install hooks into.
#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
Expand Down Expand Up @@ -90,6 +91,7 @@ pub async fn init_in_directory(
server_url: Option<&str>,
claude_settings: Option<ClaudeSettingsTarget>,
no_gitignore: bool,
agents: Option<&[String]>,
) -> Result<ClaudeSettingsTarget, io::Error> {
// Check for git repository
if !project_root.join(".git").exists() {
Expand Down Expand Up @@ -133,9 +135,26 @@ pub async fn init_in_directory(
config.to_toml(),
)?;

// Install Claude Code hooks into the chosen settings file
// Install Claude Code hooks into the chosen settings file.
// Claude is always installed via this path so it stays byte-equivalent
// with the single-agent (main) behaviour, including the --claude-settings target.
install_claude_hooks(project_root, target)?;

// Install hooks for any additional agents (e.g. codex) requested via --agent.
if let Some(agents) = agents {
let registry = AgentAdapterRegistry::new();
for agent in agents {
if agent == "claude" || agent == "claude-code" {
// Claude is already installed above via the settings target.
continue;
}
match registry.try_get(agent) {
Some(adapter) => adapter.install_hooks(project_root)?,
None => eprintln!("Warning: unknown agent '{}', skipping hooks", agent),
}
}
}

// Install git hooks
install_git_hook(project_root)?;
install_post_commit_hook(project_root)?;
Expand Down
34 changes: 19 additions & 15 deletions crates/tracevault-cli/src/commands/stream.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +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::{
extract_is_error_from_transcript, StreamEventRequest, StreamEventType,
};
use tracevault_core::agent_adapter::AgentAdapterRegistry;
use tracevault_core::hooks::parse_hook_event;
use tracevault_core::streaming::{extract_is_error_from_transcript, 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 +83,11 @@ pub fn drain_pending(pending_path: &Path) -> Result<Vec<String>, io::Error> {
Ok(lines)
}

pub async fn run_stream(event_type: &str) -> Result<(), Box<dyn std::error::Error>> {
// 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. `project_root` is
// resolved internally from `hook_event.cwd`, matching single-agent (main) behaviour.
pub async fn run_stream(_event_type: &str, agent: &str) -> Result<(), Box<dyn std::error::Error>> {
// 1. Read HookEvent from stdin
let mut input = String::new();
io::stdin().read_to_string(&mut input)?;
Expand Down Expand Up @@ -114,12 +117,13 @@ pub async fn run_stream(event_type: &str) -> Result<(), Box<dyn std::error::Erro
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);

// Extract is_error from transcript for this tool_use_id
let tool_is_error = hook_event
Expand All @@ -128,8 +132,8 @@ pub async fn run_stream(event_type: &str) -> Result<(), Box<dyn std::error::Erro
.and_then(|uid| extract_is_error_from_transcript(uid, &transcript_lines));

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 @@ -210,8 +214,8 @@ pub async fn run_stream(event_type: &str) -> Result<(), Box<dyn std::error::Erro
}
}

// 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
31 changes: 29 additions & 2 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 @@ -25,6 +26,9 @@ enum Cli {
/// .gitignore separately or you want to commit the Claude settings files.
#[arg(long)]
no_gitignore: bool,
/// Additional AI agents to install hooks for (e.g. codex, gemini)
#[arg(long = "agent")]
agents: Vec<String>,
},
/// Show current session status
Status,
Expand All @@ -35,6 +39,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 @@ -104,20 +111,40 @@ async fn main() {
server_url,
claude_settings,
no_gitignore,
agents,
} => {
let cwd = env::current_dir().expect("Cannot determine current directory");
match commands::init::init_in_directory(
&cwd,
server_url.as_deref(),
claude_settings,
no_gitignore,
if agents.is_empty() {
None
} else {
Some(&agents)
},
)
.await
{
Ok(target) => {
let entry = target.gitignore_entry();
println!("TraceVault initialized in {}", cwd.display());
println!("Claude Code hooks installed ({entry})");
let registry = AgentAdapterRegistry::new();
for agent in &agents {
if agent == "claude" || agent == "claude-code" {
// Claude is always installed above via the settings target.
continue;
}
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);
Comment on lines +140 to +145
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Success output uses registry.get(agent) and may print hooks installed for unknown agents that init_in_directory skipped. This can report installation that never happened.

Show fix
Suggested change
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);
if let Some(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);
}
Details

✨ AI Reasoning
​​1) The initialization flow attempts to install extra agent hooks only when lookup succeeds; unknown agent names are explicitly skipped with a warning.
​2) The success output flow iterates the same agents input but resolves via a different lookup call that does not preserve the previous skip decision.
​3) This means a user can be told hooks were installed for an agent that was actually skipped.
​4) That is a concrete logic inconsistency in control flow and user-facing outcome, not a style issue.

Reply @AikidoSec feedback: [FEEDBACK] to get better review comments in the future.
Reply @AikidoSec ignore: [REASON] to ignore this issue.
More info

}
}
println!("Git hooks installed (pre-push, post-commit)");
println!("Added .tracevault/ and {entry} to .gitignore");
println!(
Expand All @@ -137,8 +164,8 @@ async fn main() {
std::process::exit(code);
}
}
Cli::Stream { event } => {
if let Err(e) = commands::stream::run_stream(&event).await {
Cli::Stream { event, agent } => {
if let Err(e) = commands::stream::run_stream(&event, &agent).await {
eprintln!("Stream error: {e}");
}
}
Expand Down
Loading
Loading