A terminal-based AI assistant built for CVE research that can read, write, and execute commands against a target directory using a DeepSeek LLM with an agentic ReAct loop.
Zerophage takes a target directory and lets you chat with an AI agent that can read files, run shell commands, and patch source code to investigate and exploit vulnerabilities. The agent uses a streaming chat interface so you see its reasoning and tool activity in real time.
Key goals:
- Interactive, conversational research — ask the agent to find vulnerability classes, trace data flows, explain risky patterns, or write exploit code.
- Agentic execution — the model drives its own investigation by issuing tool calls between turns without requiring manual prompting for each step.
- Transparent operation — a live statistics panel tracks token usage, API calls, context size, discovered issues, model names, and agent status.
main.go
├── agent/ ReAct agentic loop + configuration loader
│ ├── agent.go Conversation history, streaming, tool dispatch, GetTodos()
│ ├── conversation.go ConversationManager — creates/branches/closes/switches Agent instances; persists index
│ ├── config.go Loads prompts/config.json; agent/skill/tool definitions
│ ├── history.go Stub (session persistence moved to tools/session.go)
│ └── logger.go Timestamped debug logger (writes to conversations_logs/)
├── llm/ DeepSeek API client
│ ├── client.go HTTP client, streaming (SSE) and non-streaming requests
│ └── types.go All API request/response/streaming types; model pricing
├── tools/ Tool registry and built-in tools
│ ├── tools.go Registry, Subset, Definitions, Execute dispatcher; AllForSession()
│ ├── exec.go exec_command tool — runs OS commands without a shell
│ ├── edit.go edit_file tool — read/write/patch/insert files
│ ├── skill.go load_skill tool — loads skill markdown into context on demand
│ ├── subagent.go spawn_agent tool — delegates tasks to isolated sub-agent loops
│ ├── web_search.go web_search tool — DuckDuckGo search + page text extraction
│ ├── todo.go Todo tools — in-memory task list, persisted via SessionStore
│ └── session.go SessionStore — unified _session.json (history + todos + stats)
├── ui/ Bubbletea terminal UI
│ ├── model.go Root model, Update/View, multi-conversation state management
│ ├── commands.go Slash-command registry (/clear, /help, /create, /close, /switch, …)
│ └── styles.go Lipgloss styles and layout helpers
└── prompts/ Embedded prompt assets
├── config.json Agent, skill, and tool definitions
├── prompts.go embed.FS declaration ├── global.md Global harness instructions appended to every agent's prompt ├── agents/
│ ├── cve_finder.md Root agent — attack surface mapper, notes.md maintenance, delegation
│ ├── further_research.md Web intelligence analyst (≥3 searches per session)
│ ├── in_depth_sast.md Deep taint-trace specialist for one specific feature
│ ├── local_dynamic_testing.md Payload fuzzer and local script harness builder
│ └── exploit_developer.md Minimal local PoC writer
└── skills/
├── web_sast.md Web application SAST methodology (OWASP Top 10)
└── web_docker_iast.md IAST probe injection methodology for Docker-hosted apps
User types message (Ctrl+S)
│
▼
ui.Model.Update ──► startStreamCmd
│
▼
agent.Agent.Stream
│ Appends user message to history
│ Logs request to conversations_logs/<timestamp>.log
│ Builds [system, ...history] message slice
│ POST /chat/completions (stream=true)
│
├─ reasoning token ──► streamChunkMsg ──► shown dimmed in chat
├─ content token ──► streamChunkMsg ──► UI viewport update
│
└─ finish_reason == "tool_calls"
│
├─ ToolNotification chunk ──► "⚙ name args" shown in chat
│
▼
tools.Registry.Execute ×N (parallel, WaitGroup)
(exec_command runs OS command; edit_file reads/writes files)
│
▼
Tool results appended to history (ordered)
NewAPICall chunk ──► UI increments API call counter
Next LLM turn starts (loop continues)
│
▼
Final answer streamed to UI
│
▼
agent.RecordAssistantReply (appends to history + logs)
The agent implements a ReAct-style loop entirely inside a single goroutine spawned by Stream():
- Build the full message slice:
[system] + history. - Send a streaming request to DeepSeek.
- Drain the SSE stream, accumulating content tokens and tool-call delta fragments.
- If
finish_reasonistool_calls, execute all requested tools in parallel (see below), collect the results in the original order, append them astoolrole messages, and restart from step 2. - If
finish_reasonis anything else (e.g.stop), forward the final content and exit — the caller invokesRecordAssistantReplyto commit the turn to history.
This keeps the loop self-contained; the UI only sees content tokens via the shared channel and is unaware of intermediate tool-call turns.
When the model returns multiple tool_calls in a single response, the agent runs them concurrently using a sync.WaitGroup. Each goroutine writes its result to a pre-allocated []llm.Message slice at its own index (no mutex needed), so the results are always returned to the model in the same order the model requested them. After wg.Wait() the whole slice is appended to history in one shot, and the next LLM turn begins.
The API request sets "parallel_tool_calls": true on every request that includes tools. The root agent system prompt also instructs the model to batch independent tool calls into a single response to take full advantage of this — e.g. reading multiple files or running multiple commands simultaneously rather than one per turn.
Tools implement the Tool interface:
type Tool interface {
Name() string
Definition() llm.ToolDefinition // JSON Schema sent to the model
Execute(ctx context.Context, args json.RawMessage) (string, error)
}tools.Registry is a map[string]Tool. All() returns every built-in tool; Subset(names) produces the permission-scoped registry used by a given agent. Execute never returns a Go error to the caller — failures are encoded as plain text so the model can observe and react to them.
Built-in tool — exec_command
Runs an OS executable with explicit argument slices (no shell invocation), combining stdout and stderr. Output is capped at 64 KiB; execution time is capped at 30 s by default (max 300 s).
Built-in tool — edit_file
Performs file I/O on behalf of the model. Supports four operations:
| Operation | Behaviour |
|---|---|
read |
Returns the full file content |
write |
Overwrites or creates a file with the given content |
patch |
Applies an ordered list of search/replace diffs; exact match, falls back to whitespace-normalised fuzzy match |
insert |
Inserts text after a specific line number (0 = before line 1) |
All paths are resolved relative to the target working directory; path traversal outside it is rejected.
Built-in tools — Todo system
Eight tools back a persistent task list held in memory and persisted inside .zerophage_session.json alongside conversation history and statistics. This keeps the plan outside the context window and lets the agent rehydrate its state across turns without relying on conversation history.
| Tool | Description |
|---|---|
create_todo |
Add a task with optional priority (1=high/2=med/3=low), confidence (0–1), and parent_id for subtasks |
update_todo |
Change a task's status (pending/in_progress/completed) and optionally append a progress note |
list_todos |
Dump all todos with IDs, status, priority, confidence, and notes — call this before planning to avoid repeated work |
complete_todo |
Shorthand to mark a task completed |
append_note |
Record a partial finding or observation without changing status |
reprioritize_todos |
Batch-update priorities and re-sort the list via [{id, priority}, …] |
Each todo carries a confidence field (0.0–1.0). Low-confidence tasks signal uncertainty and can guide the agent toward replanning or using the smarter model. The UI's ToDo panel (bottom of the Tools column) shows a live [✓]/[→]/[ ] summary refreshed after every completed stream turn.
Built-in tool — report_issue
Records a discovered security vulnerability to the persistent issue store. The Stats panel's Discovered Issues section is refreshed live after every tool-call round.
| Argument | Values | Description |
|---|---|---|
issue_name |
string | Short name for the vulnerability (e.g. SSRF, SQL Injection) |
severity |
critical / high / medium / low / info |
Risk level |
confidence |
confirmed / tentative / speculative |
Evidence strength: confirmed = exploit verified; tentative = strong evidence; speculative = pattern match only |
The UI displays each issue as [Confirmed] SSRF - Critical. Issues persist across sessions in .zerophage_session.json alongside history and todos.
Built-in tool — update_docker_status
Updates the Docker status shown in the Tools panel. The agent is instructed via the global prompt to call this immediately after any docker build or docker run command.
| Argument | Values | Description |
|---|---|---|
status |
string | Short description, e.g. "running", "build failed", "stopped" |
port |
string (optional) | Exposed host port, e.g. "3000". If provided, displayed as running :3000 |
Built-in tool — load_skill
Loads a named skill's full methodology instructions into the conversation context as a tool result. Skills are defined in prompts/config.json and their markdown files are embedded at compile time. The agent is told which skills are available via a brief addendum appended to the system prompt at every turn — it calls load_skill when analysis falls within a skill domain.
Built-in tool — spawn_agent
Delegates a focused, self-contained task to a named sub-agent. The sub-agent runs its own isolated synchronous ReAct loop (up to 20 iterations), then returns a compact text summary. The sub-agent's intermediate reasoning, tool calls, and failures never enter the orchestrator's context window — only the final answer does. This is the primary mechanism for preventing context pollution on large investigation tasks.
| Argument | Values | Description |
|---|---|---|
agent_name |
string (must exist in prompts/config.json) |
The agent definition to instantiate for this task. |
task |
string | Full task description for the sub-agent. Must be self-contained: include what to analyse, what to look for, and what format to return. |
model |
"smart" (default) / "quick" |
Model tier. Use "smart" for judgment, code generation, or exploitation tasks. Use "quick" for structured extraction, bulk classification, or pattern matching. |
Isolation and sharing:
- The sub-agent gets its own context window with no parent history.
- Shared stores (todos, issues, docker) are inherited, so sub-agent findings appear in the UI in real time.
- The sub-agent inherits the parent agent's
allowed_tools(scoped to its own definition'sallowed_toolslist). - Sub-agents can themselves call
spawn_agentfor nested delegation. - The agent tree in the UI shows active sub-agents as child nodes under the orchestrator, marked
[working]during execution and[done]when they complete.
Decision rule — spawn only when all hold:
- Task input exceeds ~10k tokens of work (e.g. scanning a full module, auditing a large file set).
- Result can be summarised to <~2k tokens.
- Task is independent (sub-agent needs no access to your current reasoning state).
- You can write a complete, unambiguous task description before spawning.
Built-in tool — web_search
Searches DuckDuckGo and returns the plain-text content of the top result pages. No API key or configuration is required. Uses the html.duckduckgo.com/html/ endpoint, fetches each result URL, strips HTML markup, and truncates each page to 4 000 characters.
| Argument | Type | Description |
|---|---|---|
query |
string (required) | Search query, e.g. "CVE-2024-1234 exploit" or "node.js path traversal bypass". |
max_results |
integer (optional) | Number of pages to fetch and return (default 3, max 5). |
global.md contains harness-level instructions that are automatically appended to every agent's system prompt, regardless of which agent is active (including sub-agents). This keeps shared operational context out of individual agent files.
Current sections:
| Section | Content |
|---|---|
| Harness Instructions | Guidelines on parallel tool calls and the todo management workflow |
| Sub-Agent Delegation | Rules for when and how to use spawn_agent, model selection, and task description guidelines |
| Docker Status | Rule to call update_docker_status after every docker build / docker run |
| Feature Flags | Dynamic block showing current SAST / IAST / DAST enabled state (populated at runtime) |
The ConversationManager holds an ordered list of Conversation objects, each wrapping an independent Agent instance. This lets you run several investigations in parallel — for example, one session for a code audit and another for live exploit testing — without mixing context.
| Command | Behaviour |
|---|---|
/create [agent_name] |
Spawns a new Agent with a fresh, empty history and its own session file (.zerophage_session_<timestamp>.json). Immediately switches the UI to the new conversation. |
/branch [agent_name] |
Same as /create but copies the current conversation's full history into the new conversation — useful for exploring an alternative approach without losing context. |
/switch <id> |
Saves the current conversation's full display state (messages, stats, viewport, streaming state) and restores the target conversation's state. Blocked while streaming; use /pause first. |
/close |
Closes the active conversation and frees its resources. The nearest remaining conversation becomes active. Cannot close the last conversation. |
Per-conversation isolation:
- Each conversation has its own
history,todoStore,issueStore,sessionStore, and debug log file. - Token stats, costs, discovered issues, and todo items shown in the panels always reflect the active conversation only.
- The Conversations panel (Stats column) shows every open conversation with a live status indicator (
[thinking]/[working]/[waiting]) for the active one. - Switching does not interrupt an in-progress stream — streaming is only allowed on the active conversation.
Every session opens a timestamped log file under conversations_logs/<YYYY-MM-DD_HH-MM-SS>.log. The verbosity is controlled by the LOG_LEVEL environment variable (default: debug):
| Level | What is recorded |
|---|---|
error |
Session start/end markers and error events (e.g. session save failures) |
info |
Everything in error plus conversation turns (user messages, final assistant replies) and tool call names/arguments/results |
debug |
Everything in info plus full raw API request and response payloads (model, messages, reasoning content, finish reason, token usage) |
Set the level in .env:
LOG_LEVEL=info
Logging failures are non-fatal; the agent continues without a log file if the directory cannot be created or the file cannot be opened.
The following files are created at runtime and are excluded from version control:
| File | Location | Description |
|---|---|---|
.zerophage_session.json |
Target directory | Conversation 1 session: history, todo list, and runtime statistics |
.zerophage_session_<timestamp>.json |
Target directory | Session file for conversations 2, 3, … (created on /create or /branch) |
.zerophage_convs.json |
Target directory | Multi-conversation index: maps conversation IDs to their session files; used to restore all conversations on restart |
conversations_logs/*.log |
Repo root | Session logs — verbosity controlled by LOG_LEVEL |
A .env file in the working directory is loaded at startup; DEEPSEEK_API_KEY must be present there or in the environment. Copy .env.example to .env and fill in your key.
Agent and skill definitions live in prompts/config.json and are embedded at compile time via embed.FS. Each agent entry specifies:
| Field | Purpose |
|---|---|
name |
Identifier used at runtime (root_agent is required) |
path |
Path inside the FS to the markdown system prompt |
allowed_tools |
Whitelist of tool names this agent may invoke |
Each skill entry specifies:
| Field | Purpose |
|---|---|
name |
Identifier used with load_skill |
description |
One-line summary shown to the model in the system prompt addendum |
path |
Path inside the FS to the skill's markdown file |
Skills are on-demand instruction sets the agent can load mid-conversation using the load_skill tool. They keep domain-specific methodology out of the base system prompt (saving context tokens) while still making it available when relevant.
| Skill | Description |
|---|---|
web_sast |
Source code analysis methodology for web applications covering OWASP Top 10 vulnerability classes. |
web_docker_iast |
IAST methodology for Docker-hosted web apps: inject [PROBE:tag] log statements, rebuild the container, and capture internal state via docker logs during DAST to confirm vulnerabilities at runtime. |
How it works:
- Every system prompt turn includes a brief addendum listing available skills (name + description).
- When the agent determines a skill is relevant it calls
load_skill("web_sast")— the full markdown is returned as a tool result and enters the context window for the remainder of that session. - New skills are added by creating a markdown file under
prompts/skills/and registering it inprompts/config.json.
The default agent (root_agent / cve_finder) is the attack surface mapper. Its system prompt is agents/cve_finder.md. The target directory path is appended to the system prompt at runtime. Specialist sub-agents are defined in agents/further_research.md, agents/in_depth_sast.md, agents/local_dynamic_testing.md, and agents/exploit_developer.md.
| Agent | Role | Tools |
|---|---|---|
cve_finder (= root_agent) |
Broad reconnaissance: maps entry points, data sinks, deps; maintains notes.md; delegates to specialists |
All |
further_research |
Web intelligence: ≥3 targeted searches per session; CVE/advisory/PoC lookup; structured research report | web_search, todos, report_issue |
in_depth_sast |
Exhaustive taint trace of one feature end-to-end; loads web_sast skill; verdict with file:line evidence |
exec_command, edit_file, load_skill, todos, report_issue |
local_dynamic_testing |
Isolates a function into a local test script; runs full payload matrix + character enumeration; optionally uses Docker probes | exec_command, edit_file, load_skill, todos, report_issue, Docker |
exploit_developer |
Writes minimal local PoC proving exploitability under documented preconditions | exec_command, edit_file, todos, report_issue, Docker |
Built with Bubbletea (Elm Architecture) and Lipgloss.
A full-width banner ("Zerophage") spans the top of the screen. Below it are three horizontal panels:
| Panel | Content |
|---|---|
| Chat (fills remaining width) | Scrollable conversation viewport + textarea input |
| Stats (30 cols) | Working dir, token counters (cached/non-cached/out), API call count, context size, cost estimate, model names, discovered issues, agent tree |
| Tools (22 cols) | Docker / SAST / IAST / DAST status indicators + to-do items |
Chat messages are rendered with distinct styles per role:
| Role | Appearance |
|---|---|
user |
White, prefixed with You › |
assistant |
Teal — preceded by a dimmed italic reasoning block when the model uses extended thinking |
tool |
Yellow, prefixed with ⚙ — shows the tool name and truncated arguments |
system |
Muted grey — used for slash-command output and error notices |
Streaming is wired through Bubbletea's command system:
startStreamCmdcallsagent.Streamand returns astreamStartMsgcarrying the channel and active model name.nextChunkCmdreads one chunk from the channel and returns astreamChunkMsg,streamDoneMsg, orstreamErrMsg.- Synthetic chunk types emitted by the agent goroutine —
ToolNotification(tool call started),NewAPICall(new LLM turn after tool execution) — are handled inUpdateto keep the stats panel accurate without exposing agent internals to the UI. - The model appends a placeholder assistant message on stream start and updates it token-by-token, showing a blinking cursor (
▋) until done. - Message queuing: if the user submits a regular message while the agent is streaming, it is stored in a single-slot queue instead of being dropped. The divider above the textarea changes to
⏳ queued: <preview>. When streaming finishes normally, the queued message is automatically sent as the next request. Issuing/stopdiscards the queue.
Slash commands (ui/commands.go) are intercepted before a message is sent to the LLM:
| Command | Effect |
|---|---|
/clear |
Clears chat history and resets agent context |
/help |
Lists all available commands |
/reasoning <high|medium|low> |
Sets the model's reasoning effort level for all subsequent API calls |
/agent <name> |
Switches to a named agent, loading its system prompt and allowed-tool set from prompts/config.json |
/pause |
Signals the agentic loop to halt before its next API call (after tool execution completes) |
/resume |
Unblocks a paused agentic loop so it continues with the next LLM turn |
/stop |
Immediately cancels the in-flight HTTP stream. The partially generated response is shown but not saved to history. Any queued message is also discarded. |
/compact |
Condenses the entire conversation history to key findings via the quick model, shrinking the context window |
/create [agent_name] |
Opens a new independent conversation (optionally with a named agent). Immediately switches to it. |
/branch [agent_name] |
Creates a new conversation pre-loaded with the current conversation's full history. Useful for exploring a different approach. |
/close |
Closes the current conversation and switches to the nearest remaining one. Cannot close the last conversation. |
/switch <id> |
Switches to the conversation with the given numeric ID. IDs are shown in the Conversations panel. |
/sast <enable|disable> |
Enable or disable static analysis (SAST). Persisted to config file. |
/iast <enable|disable> |
Enable or disable interactive analysis (IAST). Persisted to config file. |
/dast <enable|disable> |
Enable or disable dynamic analysis (DAST). Persisted to config file. |
Thin HTTP wrapper around the DeepSeek /chat/completions endpoint.
Complete— synchronous, reads the full response body.Stream— setsstream: true, parsesdata: ...SSE lines, and forwardsStreamChunkvalues over a channel. The response body is drained inside a goroutine; the caller'scontext.Contextis the cancellation mechanism.
llm/types.go also defines per-model context windows (1 000 000 tokens) and per-1M-token pricing (InputCachePerM, InputNoCachePerM, OutputPerM) used by the stats panel for cost estimation.
Two model slots are configured at startup:
| Env var | Default | Used for |
|---|---|---|
DEEPSEEK_SMART |
deepseek-v4-pro |
All reasoning turns |
DEEPSEEK_QUICK |
deepseek-v4-flash |
Fast/cheap calls (reserved) |
LOG_LEVEL |
debug |
Log verbosity: error / info / debug |
| Requirement | Details |
|---|---|
| Go | 1.24.2 or later |
| DEEPSEEK_API_KEY | Required. Set as an environment variable or in a .env file in the working directory. |
| DEEPSEEK_SMART | Optional. DeepSeek model name for reasoning turns. Defaults to deepseek-v4-pro. |
| DEEPSEEK_QUICK | Optional. DeepSeek model name for fast turns. Defaults to deepseek-v4-flash. |
| LOG_LEVEL | Optional. Log verbosity for conversations_logs/*.log. error = errors only; info = errors + conversation + tool entries; debug = full API payloads (default). |
| Terminal | A modern terminal with ANSI color and alt-screen support (e.g. Windows Terminal, iTerm2, Alacritty). |
Managed via go.mod:
| Package | Purpose |
|---|---|
charmbracelet/bubbletea |
TUI framework (Elm Architecture) |
charmbracelet/bubbles |
Viewport and textarea components |
charmbracelet/lipgloss |
Terminal styling and layout |
# Set API key
export DEEPSEEK_API_KEY=sk-...
# Run against a target directory
go run . /path/to/target
# Start a fresh session (purges .zerophage_session.json)
go run . --new /path/to/target
# Use a custom config file for default settings
go run . --config zerophage.config.json /path/to/target
# Or build first
go build -o zerophage .
./zerophage /path/to/target
./zerophage --new /path/to/target
./zerophage --config zerophage.config.json /path/to/target
Controls:
| Key / Flag | Action |
|---|---|
Ctrl+S |
Send message |
Ctrl+C |
Quit |
--new |
Purge the previous session (.zerophage_session.json) before starting |
--config <file> |
Load a JSON config file to set default reasoning effort and agent |
/clear |
Clear chat history and reset agent context |
/help |
List available slash commands |
A .env file in the current working directory is loaded automatically; existing environment variables take precedence over values in the file.
The optional JSON config file sets startup defaults that apply when no persisted session exists for the target. If a session is already present the persisted values always take priority.
{
"default_reasoning_effort": "medium",
"default_agent": "root_agent"
}| Field | Values | Description |
|---|---|---|
default_reasoning_effort |
low / medium / high |
Initial reasoning effort level (overridden by persisted session) |
default_agent |
agent name string | Initial active agent (must match a name in prompts/config.json) |
sast_enabled |
true / false |
Whether SAST analysis is enabled. Default: true (omit field to use default). |
iast_enabled |
true / false |
Whether IAST analysis is enabled. Default: true (omit field to use default). |
dast_enabled |
true / false |
Whether DAST analysis is enabled. Default: true (omit field to use default). |
A ready-to-use sample is provided at zerophage.config.json in the repository root. If a zerophage.config.json exists in the current working directory it is loaded automatically without requiring --config.
