Skip to content
Merged
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
178 changes: 178 additions & 0 deletions HOOKS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
# Hooks

xConsole's agent supports **lifecycle hooks** — the same model
[Claude Code](https://docs.claude.com/en/docs/claude-code/hooks) uses. A hook is one
of *your* shell commands that the agent runs at a defined point in a turn. A hook can:

- **block a tool** before it runs (a guardrail),
- **inject context** the model sees this turn,
- **feed a tool's result back** to the model, or
- fire a **side-effect** when the turn ends (notification, formatter, audit log).

Hooks are **opt-in**: with none configured the agent loop skips the hook path entirely
(0 ms overhead). Configure them in **Settings → Hooks**, or edit the file directly.

## Configuration

Hooks live in `hooks.json` in the agent home
(`%APPDATA%\com.xconsole.app\agent\hooks.json` on Windows). The format is Claude Code's
`settings.json` `hooks` block — either wrapped in `"hooks"` or bare:

```json
{
"hooks": {
"PreToolUse": [
{
"matcher": "run_command|run_command_all",
"hooks": [
{ "type": "command", "command": "my-guard.sh", "timeout": 30 }
]
}
]
}
}
```

The config is **snapshotted at startup** (exactly like Claude Code), so a mid-session
edit — including one the agent itself might write — only takes effect after **Save & apply**
or **Reload** in Settings → Hooks (or a restart). Toggle the whole system off without
deleting your config via the **Enabled** switch (the `agent.hooks_enabled` setting).

## Events

| Event | When | Can it block? |
|---|---|---|
| `UserPromptSubmit` | before a turn runs | yes — rejects the turn |
| `PreToolUse` | before a tool runs | yes — the tool is not run |
| `PostToolUse` | after a tool runs | feeds a note back to the model |
| `Stop` | when the turn finishes | no (fire-and-forget side-effects) |

`PreToolUse`/`PostToolUse` are **tool-scoped** — their `matcher` selects on the tool
name. `UserPromptSubmit`/`Stop` ignore the matcher.

> xConsole runs the agent's tools itself only for its own providers (Ollama / OpenAI /
> Anthropic). Autonomous CLI providers (Cursor/Codex/OpenCode) do their own tool use, so
> `PreToolUse`/`PostToolUse` don't fire for them; `UserPromptSubmit`/`Stop` still do.

### Matcher

A `matcher` selects which tool a tool-event hook applies to:

- omitted, `""`, or `"*"` → **every** tool
- an exact tool name → that tool (`"run_command"`, `"write_file"`, …)
- `a|b|c` → any of several (`"run_command|run_command_all|local_run_command"`)

(Full regex isn't supported — alternation + wildcard covers the practical cases without
a new dependency.) Common tool names: `run_command`, `run_command_all`, `write_file`,
`read_file`, `local_run_command`, `local_write_file`, `upload_file`, `download_file`,
`web_search`, `web_fetch`, `terminal_send`, `canvas_open_terminal`, `memory_save`.

## Input (stdin)

Each command receives the event as a JSON object on **stdin**:

```json
{
"session_id": "…",
"cwd": "…",
"hook_event_name": "PreToolUse",
"tool_name": "run_command",
"tool_input": { "command": "rm -rf /", "vps_id": "…" },
"tool_response": "…", // PostToolUse only
"prompt": "…", // UserPromptSubmit only
"workspace_id": "…", // when a workspace is active
"vps_targets": ["…"] // selected VPS ids
}
```

## Output (control protocol)

A hook controls the agent through its **exit code** and/or a **JSON object on stdout**:

| Exit code | Meaning |
|---|---|
| `0` | success. For `UserPromptSubmit`, plain stdout is added to the model's context. |
| `2` | **blocking** error — the tool/prompt is blocked; stderr is the reason shown to the model. |
| other | non-blocking error (logged; the agent proceeds). |

For finer control, print a JSON object on stdout (combinable with the exit code):

```jsonc
{ "decision": "block", "reason": "explained to the model" }
{ "continue": false, "stopReason": "halt the whole turn" }
{ "systemMessage": "shown to the user, not the model" }
{ "hookSpecificOutput": { "additionalContext": "injected for the model" } }
{ "hookSpecificOutput": { "permissionDecision": "deny", "permissionDecisionReason": "…" } }
```

> `permissionDecision: "deny"` blocks the tool. xConsole's **command-approval safety mode
> is independent** of hooks: a hook's `"allow"` does **not** bypass the approval gate (a
> deliberate, safer divergence from Claude Code — a hook can add a guardrail but can't
> silently remove the one the user set).

## Examples

**Block destructive commands on production targets** (`PreToolUse`, exit 2 = block):

```json
{
"hooks": {
"PreToolUse": [
{
"matcher": "run_command|run_command_all",
"hooks": [{ "type": "command", "command": "guard-rm.sh" }]
}
]
}
}
```

```bash
#!/usr/bin/env bash
# guard-rm.sh — read the event, block obviously destructive commands.
cmd="$(jq -r '.tool_input.command // ""')"
case "$cmd" in
*"rm -rf /"*|*"mkfs"*|*":(){ :|:&};:"*)
echo "refusing destructive command: $cmd" >&2
exit 2 ;;
esac
exit 0
```

**Inject a standing reminder every turn** (`UserPromptSubmit`, stdout → context):

```json
{ "hooks": { "UserPromptSubmit": [ { "hooks": [
{ "type": "command", "command": "echo Production servers are read-only unless I say otherwise." }
] } ] } }
```

**Notify when a turn finishes** (`Stop`, side-effect):

```json
{ "hooks": { "Stop": [ { "hooks": [
{ "type": "command", "command": "notify-send 'xConsole' 'Agent finished a turn'" }
] } ] } }
```

## Security

Hooks run shell commands **you** configure, with your account's permissions — the same
trust model as Claude Code. Only add commands you trust. Because the config is
snapshotted at startup, a prompt-injected agent can't add a hook that takes effect in the
same session. The command-approval safety mode still applies to every tool regardless of
hooks.

## Implementation / verification

- Engine: [`src-tauri/src/ai/hooks.rs`](src-tauri/src/ai/hooks.rs) — config parsing,
matcher matching, and output interpretation are pure (unit-tested); only the runner
spawns a process. Wired into the agent loop in
[`ai/agent.rs`](src-tauri/src/ai/agent.rs) (UserPromptSubmit / Stop) and
[`ai/tools.rs`](src-tauri/src/ai/tools.rs) `dispatch` (Pre/PostToolUse).
- Tests: `xconsole-bench selftest` runs the pure-logic checks **plus live hook
subprocesses** (exit-2 blocks, exit-0 allows). The `#[cfg(test)]` units in `hooks.rs`
cover the same logic.
- Benchmark: `xconsole-bench hooks` measures the per-tool-call overhead — see
[`bench/README.md`](bench/README.md). Baseline: ~135 ns to decide whether a hook fires;
~38 ms for one no-op hook subprocess on Windows; **0 ms when no hooks are configured**.
17 changes: 16 additions & 1 deletion bench/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,12 @@ Run:
# Raw model latency (TTFT / gen tok/s) with and without the tool payload
./src-tauri/target/release/xconsole-bench.exe llm --model qwen3.5:9b

# Pure-logic self-tests (reflection / self-improvement + voice prompt) — no Ollama needed
# Pure-logic self-tests (reflection + voice prompt + hooks) + live hook subprocesses — no Ollama
./src-tauri/target/release/xconsole-bench.exe selftest

# Hooks dispatch overhead — what a PreToolUse hook adds per tool call (no Ollama)
./src-tauri/target/release/xconsole-bench.exe hooks --out bench/results/hooks.json

# Both eval + latency
./src-tauri/target/release/xconsole-bench.exe all --model qwen3.5:9b --out bench/results/all.json
```
Expand Down Expand Up @@ -58,6 +61,18 @@ must get right; the pass-rate is the quality signal we track:
Columns: `ttft_ms` (time to first token), `total_ms` (whole turn), `gen_t/s`
(generation tokens/sec), `ptok` (prompt tokens — how heavy the system prompt is).

**Hooks overhead** (`hooks` mode) — measures the cost of the Claude Code–style hooks
system (see [`HOOKS.md`](../HOOKS.md)):

| metric | meaning | dev-machine baseline |
|---|---|---|
| `pure_select_ns` | per-tool-call cost to decide whether a hook fires | ~135 ns |
| `live_hook_ms` | one no-op hook subprocess (spawn + JSON on stdin) | ~38 ms (Windows `cmd /C`) |

With **no hooks configured the loop skips the hook path entirely (0 ms)** — hooks are
opt-in, so they cost nothing until you add one. The `live_hook_ms` figure is dominated
by process-spawn latency (lower on Unix `sh -c`); a hook that does real work adds its own time.

## 2. `ollama_latency.ps1` — zero-build latency probe

Quick TTFT / tok/s read without compiling, straight against `/api/chat`:
Expand Down
7 changes: 7 additions & 0 deletions bench/results/hooks.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"block_works": true,
"live_hook_ms": 38.03333333333333,
"live_runs": 30,
"mode": "hooks",
"pure_select_ns": 135
}
54 changes: 54 additions & 0 deletions installer/build-single-exe.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,58 @@
# stub that embeds both into a single exe. On the MSVC toolchain the loader is
# static-linked, so the installer is already a single exe and no stub is built.
#
# Code signing (the durable fix for AV false positives) is applied automatically when a
# certificate is configured via environment variables — otherwise it is skipped. See
# installer/ANTIVIRUS.md for the full rationale and how to get a certificate.
# $env:XCONSOLE_SIGN_PFX = 'C:\path\to\cert.pfx' # PFX file, OR...
# $env:XCONSOLE_SIGN_THUMBPRINT= 'ABCD...' # ...a cert already in your store
# $env:XCONSOLE_SIGN_PASSWORD = '...' # PFX password (if using a PFX)
# $env:XCONSOLE_SIGN_TIMESTAMP = 'http://timestamp.digicert.com' # optional override
#
# Usage: installer\build-single-exe.ps1
$ErrorActionPreference = 'Stop'
$installer = Split-Path -Parent $MyInvocation.MyCommand.Path

# --- code signing helpers ---------------------------------------------------------------

function Find-SignTool {
$st = Get-Command signtool.exe -ErrorAction SilentlyContinue
if ($st) { return $st.Source }
$kits = "${env:ProgramFiles(x86)}\Windows Kits\10\bin"
if (Test-Path $kits) {
# Newest SDK build, x64 binary.
$found = Get-ChildItem -Path $kits -Recurse -Filter signtool.exe -ErrorAction SilentlyContinue |
Where-Object { $_.FullName -match '\\x64\\' } |
Sort-Object FullName -Descending | Select-Object -First 1
if ($found) { return $found.FullName }
}
return $null
}

function Invoke-Sign([string]$path) {
$pfx = $env:XCONSOLE_SIGN_PFX
$thumb = $env:XCONSOLE_SIGN_THUMBPRINT
if (-not $pfx -and -not $thumb) {
Write-Host " (unsigned: set XCONSOLE_SIGN_PFX + XCONSOLE_SIGN_PASSWORD, or XCONSOLE_SIGN_THUMBPRINT, to sign — see installer/ANTIVIRUS.md)" -ForegroundColor DarkYellow
return
}
$signtool = Find-SignTool
if (-not $signtool) {
Write-Warning "signtool.exe not found (install the Windows SDK / 'App Installer') — cannot sign $path"
return
}
$ts = if ($env:XCONSOLE_SIGN_TIMESTAMP) { $env:XCONSOLE_SIGN_TIMESTAMP } else { 'http://timestamp.digicert.com' }
if ($pfx) {
& $signtool sign /fd SHA256 /tr $ts /td SHA256 /f $pfx /p $env:XCONSOLE_SIGN_PASSWORD $path
} else {
& $signtool sign /fd SHA256 /tr $ts /td SHA256 /sha1 $thumb $path
}
if ($LASTEXITCODE -ne 0) { Write-Warning "signing failed for $path" }
else { Write-Host " signed: $path" -ForegroundColor Green }
}

# --- build ------------------------------------------------------------------------------

Write-Host '[1/2] Building the installer...' -ForegroundColor Cyan
Push-Location $installer
try { cargo build --release } finally { Pop-Location }
Expand All @@ -20,14 +68,20 @@ if (-not (Test-Path -LiteralPath $innerDll)) {
Write-Host ''
Write-Host 'WebView2Loader is statically linked (MSVC) - the installer is ALREADY a single exe:' -ForegroundColor Green
Write-Host " $innerExe"
Invoke-Sign $innerExe
exit 0
}

Write-Host '[2/2] Building the single-exe stub (embeds the installer + WebView2Loader.dll)...' -ForegroundColor Cyan
# Sign the INNER exe before it is embedded, so the unpacked installer is signed too.
Invoke-Sign $innerExe
Push-Location (Join-Path $installer 'stub')
try { cargo build --release } finally { Pop-Location }

$out = Join-Path $installer 'stub\target\release\xConsole-Setup.exe'
# Sign the final single-file launcher — this is the artifact users actually run.
Invoke-Sign $out

Write-Host ''
Write-Host 'Single-file installer (ship THIS one):' -ForegroundColor Green
Write-Host " $out"
Expand Down
47 changes: 47 additions & 0 deletions installer/stub/app.manifest
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<!--
Application manifest for the xConsole Setup launcher.

A blank-metadata, manifest-less PE that writes an executable and runs it is the exact
profile AV machine-learning heuristics flag (Symantec ML.Attribute, Elastic, etc.).
Declaring a normal application identity, asInvoker privileges (NOT requireAdministrator,
which is far more suspicious), DPI awareness, and supported-OS GUIDs makes this read as
ordinary desktop software instead of an anonymous dropper.
-->
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
<assemblyIdentity
type="win32"
name="xConsole.Setup.Launcher"
version="0.1.0.0"
processorArchitecture="amd64"/>
<description>xConsole Setup</description>

<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
<security>
<requestedPrivileges>
<!-- Run with the caller's rights; never silently request elevation. -->
<requestedExecutionLevel level="asInvoker" uiAccess="false"/>
</requestedPrivileges>
</security>
</trustInfo>

<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<!-- Windows 10 / 11 -->
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}"/>
<!-- Windows 8.1 -->
<supportedOS Id="{1f676c76-80e1-4239-95bb-83d0f6d0da78}"/>
<!-- Windows 8 -->
<supportedOS Id="{4a2f28e3-53b9-4441-ba9c-d69d4a4a6e38}"/>
<!-- Windows 7 -->
<supportedOS Id="{35138b9a-5d96-4fbd-8e2d-a2440225f93a}"/>
</application>
</compatibility>

<application xmlns="urn:schemas-microsoft-com:asm.v3">
<windowsSettings>
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true</dpiAware>
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
</windowsSettings>
</application>
</assembly>
49 changes: 49 additions & 0 deletions installer/stub/app.rc
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
/*
* Win32 resource script for the xConsole Setup launcher (compiled by windres in build.rs).
*
* Embeds:
* 1. the application manifest (RT_MANIFEST, id 1), and
* 2. a full VERSIONINFO block so the launcher carries CompanyName / ProductName /
* FileDescription / versions / copyright like ordinary software.
*
* The launcher previously shipped with ZERO version metadata, which — combined with its
* embedded inner exe — made AV ML models score it as a malicious dropper. Numeric
* constants are hardcoded so this needs no SDK headers (windres alone, no winver.h).
*/

1 24 "app.manifest"

#define VER_FILEVERSION 0,1,0,0
#define VER_PRODUCTVERSION 0,1,0,0
#define VS_VERSION_INFO 1
#define VOS_NT_WINDOWS32 0x00040004L
#define VFT_APP 0x00000001L

VS_VERSION_INFO VERSIONINFO
FILEVERSION VER_FILEVERSION
PRODUCTVERSION VER_PRODUCTVERSION
FILEFLAGSMASK 0x3fL
FILEFLAGS 0x0L
FILEOS VOS_NT_WINDOWS32
FILETYPE VFT_APP
FILESUBTYPE 0x0L
BEGIN
BLOCK "StringFileInfo"
BEGIN
BLOCK "040904b0"
BEGIN
VALUE "CompanyName", "xConsole"
VALUE "FileDescription", "xConsole Setup"
VALUE "FileVersion", "0.1.0.0"
VALUE "InternalName", "xConsole-Setup"
VALUE "LegalCopyright", "Copyright (C) 2026 xConsole. Licensed under MIT."
VALUE "OriginalFilename", "xConsole-Setup.exe"
VALUE "ProductName", "xConsole Setup"
VALUE "ProductVersion", "0.1.0.0"
END
END
BLOCK "VarFileInfo"
BEGIN
VALUE "Translation", 0x409, 1200
END
END
Loading
Loading