diff --git a/.structlint.yaml b/.structlint.yaml
index ffb9690..00c6edd 100644
--- a/.structlint.yaml
+++ b/.structlint.yaml
@@ -6,10 +6,13 @@ dir_structure:
- "services/**"
- "helpers/**"
- "integration/**"
+ - "internal/**"
- "docs/**"
- "dist/**"
- ".github/**"
- ".claude/**"
+ - ".agents/**"
+ - ".codex/**"
disallowedPaths:
- "vendor/**"
- "node_modules/**"
@@ -36,9 +39,14 @@ file_naming_pattern:
- "*.md"
- "*.txt"
- "*.png"
+ - "*.gif"
- "*.jpg"
- "*.svg"
- "*.puml"
+ - "*.cast"
+ - "*.css"
+ - "*.js"
+ - "*.tmpl"
- "README*"
- "LICENSE*"
- "CHANGELOG*"
@@ -83,6 +91,8 @@ ignore:
- ".idea"
- ".vscode"
- ".DS_Store"
+ - ".agents"
+ - ".codex"
- "*.log"
- "*.tmp"
- "aigate"
diff --git a/docs/aigate-demo.cast b/docs/aigate-demo.cast
new file mode 100644
index 0000000..0d59e5a
--- /dev/null
+++ b/docs/aigate-demo.cast
@@ -0,0 +1,164 @@
+{"version": 2, "width": 130, "height": 30, "timestamp": 1775685449, "idle_time_limit": 3.0, "env": {"SHELL": "/usr/bin/zsh", "TERM": "tmux-256color"}, "title": "aigate — sandbox AI agents in seconds"}
+[0.011109, "o", "\u001b[H\u001b[J\u001b[3J"]
+[0.515434, "o", "\u001b[2;36m# aigate — wrap any command in an OS-level sandbox for AI agents\u001b[0m\r\n"]
+[2.316579, "o", "\u001b[2;36m# every example below uses the default rules from ~/.aigate/config.yaml\u001b[0m\r\n"]
+[4.318677, "o", "\u001b[H\u001b[J\u001b[3J"]
+[4.719883, "o", "\u001b[2;36m# 1) deny_exec — shell tools blocked even when installed on the host\u001b[0m\r\n"]
+[6.121717, "o", "\u001b[1;32m$\u001b[0m a"]
+[6.186953, "o", "i"]
+[6.252542, "o", "g"]
+[6.31701, "o", "a"]
+[6.382896, "o", "t"]
+[6.449218, "o", "e"]
+[6.515163, "o", " "]
+[6.581809, "o", "r"]
+[6.635178, "o", "u"]
+[6.687339, "o", "n"]
+[6.74003, "o", " "]
+[6.79346, "o", "-"]
+[6.846505, "o", "-"]
+[6.899489, "o", " "]
+[6.952924, "o", "c"]
+[7.006153, "o", "u"]
+[7.059022, "o", "r"]
+[7.112076, "o", "l"]
+[7.164782, "o", " "]
+[7.217827, "o", "h"]
+[7.270647, "o", "t"]
+[7.322659, "o", "t"]
+[7.375458, "o", "p"]
+[7.429369, "o", "s"]
+[7.481472, "o", ":"]
+[7.533275, "o", "/"]
+[7.585457, "o", "/"]
+[7.64712, "o", "a"]
+[7.709539, "o", "p"]
+[7.770963, "o", "i"]
+[7.833362, "o", "."]
+[7.894528, "o", "g"]
+[7.95609, "o", "i"]
+[8.018667, "o", "t"]
+[8.079994, "o", "h"]
+[8.142215, "o", "u"]
+[8.203565, "o", "b"]
+[8.267329, "o", "."]
+[8.331411, "o", "c"]
+[8.393408, "o", "o"]
+[8.455442, "o", "m"]
+[8.768926, "o", "\r\n"]
+[8.772711, "o", "[aigate] sandbox active\r\n[aigate] deny_read: .env, .env.*, secrets/, credentials/, ~/.ssh/, *.pem, *.key, *.p12, ~/.aws/, ~/.gcloud/, ~/.kube/config, ~/.npmrc, ~/.pypirc, terraform.tfstate, *.tfvars\r\n[aigate] deny_exec: curl, wget, nc, ncat, netcat, ssh, scp, rsync, ftp, kubectl delete, kubectl exec\r\n[aigate] allow_net: api.anthropic.com, api.openai.com, api.github.com, registry.npmjs.org, proxy.golang.org (all other outbound connections will be blocked)\r\n[aigate] mask_stdout: openai, anthropic, aws_key, github, bearer; +1 custom pattern(s)\r\ncommand is blocked by deny rules: \"curl\" is in the deny_exec list\r\n"]
+[12.277041, "o", "\u001b[H\u001b[J\u001b[3J"]
+[12.678972, "o", "\u001b[2;36m# 2) deny_read — secrets are hidden from the sandboxed process\u001b[0m\r\n"]
+[14.080608, "o", "\u001b[2;36m# first, what the host sees:\u001b[0m\r\n"]
+[14.882601, "o", "\u001b[1;32m$\u001b[0m c"]
+[14.92618, "o", "a"]
+[14.969463, "o", "t"]
+[15.012669, "o", " "]
+[15.055577, "o", "."]
+[15.098407, "o", "e"]
+[15.141096, "o", "n"]
+[15.183574, "o", "v"]
+[15.477939, "o", "\r\n"]
+[15.479518, "o", "OPENAI_API_KEY=sk-proj-fake-DEMO-key-1234567890\r\nDB_PASSWORD=hunter2\r\n"]
+[17.681149, "o", "\u001b[2;36m# now from inside the sandbox:\u001b[0m\r\n"]
+[18.482612, "o", "\u001b[1;32m$\u001b[0m "]
+[18.482642, "o", "a"]
+[18.542319, "o", "i"]
+[18.602688, "o", "g"]
+[18.658458, "o", "a"]
+[18.713295, "o", "t"]
+[18.768658, "o", "e"]
+[18.823415, "o", " "]
+[18.879534, "o", "r"]
+[18.934989, "o", "u"]
+[18.991067, "o", "n"]
+[19.046959, "o", " "]
+[19.103129, "o", "-"]
+[19.159111, "o", "-"]
+[19.215067, "o", " "]
+[19.271536, "o", "c"]
+[19.327361, "o", "a"]
+[19.383821, "o", "t"]
+[19.43905, "o", " "]
+[19.494332, "o", "."]
+[19.549987, "o", "e"]
+[19.605914, "o", "n"]
+[19.666133, "o", "v"]
+[19.977953, "o", "\r\n"]
+[19.980355, "o", "[aigate] sandbox active\r\n[aigate] deny_read: .env, .env.*, secrets/, credentials/, ~/.ssh/, *.pem, *.key, *.p12, ~/.aws/, ~/.gcloud/, ~/.kube/config, ~/.npmrc, ~/.pypirc, terraform.tfstate, *.tfvars\r\n[aigate] deny_exec: curl, wget, nc, ncat, netcat, ssh, scp, rsync, ftp, kubectl delete, kubectl exec\r\n[aigate] allow_net: api.anthropic.com, api.openai.com, api.github.com, registry.npmjs.org, proxy.golang.org (all other outbound connections will be blocked)\r\n[aigate] mask_stdout: openai, anthropic, aws_key, github, bearer; +1 custom pattern(s)\r\n"]
+[19.980778, "o", "\u001b[90m\u001b[90m23:57:49\u001b[0m\u001b[0m \u001b[32mINFO \u001b[0m starting bwrap network-filtered sandbox \u001b[36mallow_net=\u001b[0m[\"api.anthropic.com\",\"api.openai.com\",\"api.github.com\",\"registry.npmjs.org\",\"proxy.golang.org\"] \u001b[36mdns_servers=\u001b[0m[\"1.1.1.1\",\"1.0.0.1\",\"1.1.1.1\",\"1.0.0.1\",\"8.8.8.8\"]\r\n"]
+[20.475756, "o", "[aigate] access denied: this file is protected by sandbox policy. See /tmp/.aigate-policy for all active restrictions.\r\n"]
+[24.496781, "o", "\u001b[H\u001b[J\u001b[3J"]
+[24.898714, "o", "\u001b[2;36m# 3) mask_stdout — secrets that slip out are redacted on the way back\u001b[0m\r\n"]
+[26.300264, "o", "\u001b[1;32m$\u001b[0m a"]
+[26.346693, "o", "i"]
+[26.393402, "o", "g"]
+[26.439559, "o", "a"]
+[26.48566, "o", "t"]
+[26.531279, "o", "e"]
+[26.577147, "o", " "]
+[26.620166, "o", "r"]
+[26.66312, "o", "u"]
+[26.706201, "o", "n"]
+[26.749177, "o", " "]
+[26.792617, "o", "-"]
+[26.836017, "o", "-"]
+[26.878714, "o", " "]
+[26.921408, "o", "p"]
+[26.965167, "o", "r"]
+[27.008495, "o", "i"]
+[27.051596, "o", "n"]
+[27.095046, "o", "t"]
+[27.138411, "o", "f"]
+[27.181549, "o", " "]
+[27.224143, "o", "'"]
+[27.267298, "o", "o"]
+[27.310091, "o", "p"]
+[27.353028, "o", "e"]
+[27.395705, "o", "n"]
+[27.438599, "o", "a"]
+[27.480523, "o", "i"]
+[27.522922, "o", " "]
+[27.566044, "o", "k"]
+[27.614399, "o", "e"]
+[27.661275, "o", "y"]
+[27.709589, "o", ":"]
+[27.757745, "o", " "]
+[27.806121, "o", "s"]
+[27.853978, "o", "k"]
+[27.902123, "o", "-"]
+[27.951075, "o", "p"]
+[27.999664, "o", "r"]
+[28.047941, "o", "o"]
+[28.096834, "o", "j"]
+[28.1455, "o", "-"]
+[28.193679, "o", "a"]
+[28.242345, "o", "b"]
+[28.290521, "o", "c"]
+[28.338888, "o", "1"]
+[28.386976, "o", "2"]
+[28.434915, "o", "3"]
+[28.482619, "o", "d"]
+[28.530065, "o", "e"]
+[28.579807, "o", "f"]
+[28.646161, "o", "4"]
+[28.713249, "o", "5"]
+[28.780359, "o", "6"]
+[28.84742, "o", "g"]
+[28.915209, "o", "h"]
+[28.982761, "o", "i"]
+[29.049949, "o", "7"]
+[29.117158, "o", "8"]
+[29.184676, "o", "9"]
+[29.250339, "o", "\\"]
+[29.316657, "o", "n"]
+[29.383747, "o", "'"]
+[29.702308, "o", "\r\n"]
+[29.705, "o", "[aigate] sandbox active\r\n[aigate] deny_read: .env, .env.*, secrets/, credentials/, ~/.ssh/, *.pem, *.key, *.p12, ~/.aws/, ~/.gcloud/, ~/.kube/config, ~/.npmrc, ~/.pypirc, terraform.tfstate, *.tfvars\r\n[aigate] deny_exec: curl, wget, nc, ncat, netcat, ssh, scp, rsync, ftp, kubectl delete, kubectl exec\r\n[aigate] allow_net: api.anthropic.com, api.openai.com, api.github.com, registry.npmjs.org, proxy.golang.org (all other outbound connections will be blocked)\r\n[aigate] mask_stdout: openai, anthropic, aws_key, github, bearer; +1 custom pattern(s)\r\n"]
+[29.705258, "o", "\u001b[90m\u001b[90m23:57:59\u001b[0m\u001b[0m \u001b[32mINFO \u001b[0m starting bwrap network-filtered sandbox \u001b[36mallow_net=\u001b[0m[\"api.anthropic.com\",\"api.openai.com\",\"api.github.com\",\"registry.npmjs.org\",\"proxy.golang.org\"] \u001b[36mdns_servers=\u001b[0m[\"1.1.1.1\",\"1.0.0.1\",\"1.1.1.1\",\"1.0.0.1\",\"8.8.8.8\"]\r\n"]
+[29.770698, "o", "openai key: sk-***\r\n"]
+[33.795432, "o", "\u001b[H\u001b[J\u001b[3J"]
+[34.197103, "o", "\u001b[2;36m# 4) wrap claude itself — the full interactive TUI works the same way\u001b[0m\r\n"]
+[137.01605, "o", "\u001b[1;32m$\u001b[0m aigate run -- claude\r\n"]
+[137.020615, "o", "[aigate] sandbox active\r\n[aigate] deny_read: .env, .env.*, secrets/, credentials/, ~/.ssh/, *.pem, *.key, *.p12, ~/.aws/, ~/.gcloud/, ~/.kube/config, ~/.npmrc, ~/.pypirc, terraform.tfstate, *.tfvars\r\n[aigate] deny_exec: curl, wget, nc, ncat, netcat, ssh, scp, rsync, ftp, kubectl delete, kubectl exec\r\n[aigate] allow_net: api.anthropic.com, api.openai.com, api.github.com, registry.npmjs.org, proxy.golang.org (all other outbound connections will be blocked)\r\n[aigate] mask_stdout: openai, anthropic, aws_key, github, bearer; +1 custom pattern(s)\r\n23:58:05 INFO starting bwrap network-filtered sandbox allow_net=[\"api.anthropic.com\",\"api.openai.com\",\"api.github.com\",\"registry.npmjs.org\",\"proxy.golang.org\"] dns_servers=[\"1.1.1.1\",\"1.0.0.1\",\"1.1.1.1\",\"1.0.0.1\",\"8.8.8.8\"]\r\n ▐▛███▜▌ Claude Code v2.1.97\r\n▝▜█████▛▘ Opus 4.6 (1M context) · Claude Max\r\n ▘▘ ▝▝ ~/Documents/workspace/axeforge/git/aigate\r\n ⎿ SessionStart:startup says: {\"content\":[{\"type\":\"text\",\"text\":\"\"}]}\r\n\r\n View Observations Live @ http://localhost:37777\r\n\r\n❯ what is 2+2? answer in one short sentence.\r\n\r\n● 4.\r\n\r\n\r\n─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\r\n❯ \r\n─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\r\n"]
+[137.020879, "o", "\r\n\u001b[1;32m✓ claude (inside aigate sandbox) answered:\u001b[0m \u001b[1;33m4.\u001b[0m\r\n"]
diff --git a/docs/aigate-demo.gif b/docs/aigate-demo.gif
new file mode 100644
index 0000000..b865474
Binary files /dev/null and b/docs/aigate-demo.gif differ
diff --git a/docs/cast.yaml b/docs/cast.yaml
new file mode 100644
index 0000000..1e6b09b
--- /dev/null
+++ b/docs/cast.yaml
@@ -0,0 +1,138 @@
+# Demo recording spec for the README — see docs/aigate-demo.gif
+#
+# Render with: python3 cast.py docs/cast.yaml (run from repo root)
+# (cast.py lives in AxeForging/scripts → cast/cast.py)
+#
+# The demo deliberately operates in /tmp/aigate-demo using a synthetic .env
+# so no real host secrets / SSH filenames are ever rendered.
+#
+# Why no shell-alias wrapper? Earlier drafts piped aigate through a tiny
+# `arun` helper to suppress the verbose [aigate] startup header. That made
+# every demo line read `arun run -- …`, which falsely implied `arun` was
+# part of aigate. The recording now uses plain `aigate run -- …` so what
+# you see is exactly what you would type yourself.
+
+output: docs/aigate-demo.gif
+
+recording:
+ cols: 130
+ rows: 30
+ idle_time_limit: 3
+ title: "aigate — sandbox AI agents in seconds"
+
+render:
+ theme: dracula
+ font: "JetBrains Mono"
+ font_size: 15
+ line_height: 1.4
+ speed: 1.3
+ idle_time_limit: 2
+
+defaults:
+ type_delay: 50
+ pause: 1.6
+
+scenes:
+ # ── silent setup ─────────────────────────────────────────────────────────
+ # Re-route bare `aigate` calls to the locally-built binary so the GIF
+ # always reflects this repo's current build, regardless of which version
+ # happens to be in $PATH on the host. The shell function is invisible to
+ # the recording — viewers only ever see `aigate ...` typed at the prompt.
+ - bash: "_AIGATE_BIN=$PWD/aigate && _AIGATE_REPO=$PWD"
+ # Trailing `|| true` keeps the cast script alive past commands that exit
+ # non-zero (deny rules, claude --bare, etc). At an interactive prompt the
+ # exit code is just background info, so this matches what a viewer would
+ # experience typing the same commands themselves — it's a path-redirect
+ # plus error-tolerance, not a behavioural change to aigate.
+ - bash: |
+ aigate() { "$_AIGATE_BIN" "$@" || true; }
+ # Stage a synthetic workdir with a fake .env so we never render any real
+ # host secrets or filenames in the GIF.
+ - bash: |
+ rm -rf /tmp/aigate-demo
+ mkdir -p /tmp/aigate-demo
+ cd /tmp/aigate-demo
+ printf 'OPENAI_API_KEY=sk-proj-fake-DEMO-key-1234567890\nDB_PASSWORD=hunter2\n' > .env
+
+ # ── intro ────────────────────────────────────────────────────────────────
+ - comment: "aigate — wrap any command in an OS-level sandbox for AI agents"
+ pause: 1.8
+ - comment: "every example below uses the default rules from ~/.aigate/config.yaml"
+ pause: 2.0
+
+ # ── 1) deny_exec ─────────────────────────────────────────────────────────
+ - clear: true
+ pause: 0.4
+ - comment: "1) deny_exec — shell tools blocked even when installed on the host"
+ pause: 1.4
+ - type: "aigate run -- curl https://api.github.com"
+ pause: 3.5
+
+ # ── 2) deny_read (synthetic .env, no real secrets) ───────────────────────
+ - clear: true
+ pause: 0.4
+ - comment: "2) deny_read — secrets are hidden from the sandboxed process"
+ pause: 1.4
+ - comment: "first, what the host sees:"
+ pause: 0.8
+ - type: "cat .env"
+ pause: 2.2
+ - comment: "now from inside the sandbox:"
+ pause: 0.8
+ - type: "aigate run -- cat .env"
+ pause: 4.0
+
+ # ── 3) mask_stdout ───────────────────────────────────────────────────────
+ - clear: true
+ pause: 0.4
+ - comment: "3) mask_stdout — secrets that slip out are redacted on the way back"
+ pause: 1.4
+ - type: "aigate run -- printf 'openai key: sk-proj-abc123def456ghi789\\n'"
+ pause: 4.0
+
+ # ── 4) wrap interactive claude ───────────────────────────────────────────
+ # Interactive TUIs can't be typed into from a plain recorded shell, so
+ # we drive claude in a detached tmux session: launch it inside aigate,
+ # send the prompt via `tmux send-keys`, wait for the answer, then
+ # `tmux capture-pane` the rendered TUI back into the visible recording.
+ # No yoink/tmux machinery is shown to the viewer — they only see what
+ # they would themselves type at the prompt.
+ - clear: true
+ pause: 0.4
+ - comment: "4) wrap claude itself — the full interactive TUI works the same way"
+ pause: 1.8
+ - bash: |
+ tmux kill-session -t aigate-demo-cl 2>/dev/null || true
+ # Launch claude with the repo as cwd — it's an already-trusted folder,
+ # so claude skips its first-run "trust this folder" dialog. (Launching
+ # from /tmp/aigate-demo would block on that prompt forever.)
+ tmux new-session -d -s aigate-demo-cl -x 130 -y 22 -c "$_AIGATE_REPO" "$_AIGATE_BIN run -- claude"
+ # Let claude finish booting (sandbox setup + TUI splash + plugin load).
+ sleep 6
+ tmux send-keys -t aigate-demo-cl "what is 2+2? answer in one short sentence." Enter
+ # Claude latency inside the sandbox is ~60–90s on first call (cold
+ # start + post-answer "stop hooks"). We wait long enough for the
+ # status spinner to clear so the captured pane shows a clean answer
+ # frame, not a half-rendered cogitating one. The cast's idle_time_limit
+ # collapses this dead air into ~2s in the rendered GIF.
+ sleep 95
+ # Render a green prompt line so the captured pane reads as a real
+ # session, then dump the pane. Trim trailing blank rows AND drop
+ # claude's transient status spinner lines ("Cogitating…",
+ # "Fiddle-faddling…", "running stop hooks", etc) — they're noise
+ # by the time the answer is in.
+ printf '\e[1;32m$\e[0m aigate run -- claude\n'
+ tmux capture-pane -p -t aigate-demo-cl \
+ | sed -E '/(Cogitating|Osmosing|Fiddle-faddling|Wrangling|running (stop|pre)? ?hooks|esc to interrupt)/d' \
+ | sed -e :a -e '/^[[:space:]]*$/{$d;N;ba' -e '}'
+ # Highlight the answer one more time, BELOW the pane, in a colour
+ # that pops — the answer line inside claude's TUI is small and easy
+ # to miss in a fast-playing GIF.
+ printf '\n\e[1;32m✓ claude (inside aigate sandbox) answered:\e[0m \e[1;33m4.\e[0m\n'
+ tmux kill-session -t aigate-demo-cl 2>/dev/null || true
+ # Hold the final answer frame long enough that it's unmissable on
+ # loop. The render's `idle_time_limit: 2` caps silent gaps at 2s, so
+ # we emit a no-op write (space+backspace) once a second for 8s to
+ # generate "activity" the renderer won't compress away.
+ for _ in 1 2 3 4 5 6 7 8; do printf ' \b'; sleep 1; done
+ - pause: 0.4
diff --git a/go.mod b/go.mod
index 71379bf..5da2446 100644
--- a/go.mod
+++ b/go.mod
@@ -1,6 +1,6 @@
module github.com/AxeForging/aigate
-go 1.25.8
+go 1.25.10
require (
github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2
diff --git a/internal/web/handlers.go b/internal/web/handlers.go
new file mode 100644
index 0000000..bb04eec
--- /dev/null
+++ b/internal/web/handlers.go
@@ -0,0 +1,22 @@
+package web
+
+import (
+ "encoding/json"
+ "net/http"
+)
+
+func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/" {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ if err := s.templates.ExecuteTemplate(w, "layout", s.buildOverview()); err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ }
+}
+
+func (s *Server) handleOverview(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(s.buildOverview())
+}
diff --git a/internal/web/server.go b/internal/web/server.go
new file mode 100644
index 0000000..92b3f56
--- /dev/null
+++ b/internal/web/server.go
@@ -0,0 +1,177 @@
+package web
+
+import (
+ "context"
+ "embed"
+ "errors"
+ "fmt"
+ "html/template"
+ "io/fs"
+ "net/http"
+ "sort"
+ "time"
+
+ "github.com/AxeForging/aigate/domain"
+ "github.com/AxeForging/aigate/services"
+)
+
+//go:embed templates/*.html.tmpl
+var templatesFS embed.FS
+
+//go:embed static
+var staticFS embed.FS
+
+type Server struct {
+ addr string
+ mux *http.ServeMux
+ templates *template.Template
+ configSvc *services.ConfigService
+ auditSvc *services.AuditService
+}
+
+type Options struct {
+ Addr string
+ ConfigSvc *services.ConfigService
+ AuditSvc *services.AuditService
+}
+
+func New(opts Options) (*Server, error) {
+ if opts.Addr == "" {
+ opts.Addr = "127.0.0.1:8080"
+ }
+ if opts.ConfigSvc == nil {
+ opts.ConfigSvc = services.NewConfigService()
+ }
+ if opts.AuditSvc == nil {
+ opts.AuditSvc = services.NewAuditService(opts.ConfigSvc)
+ }
+ t, err := template.ParseFS(templatesFS, "templates/*.html.tmpl")
+ if err != nil {
+ return nil, fmt.Errorf("parse templates: %w", err)
+ }
+ s := &Server{
+ addr: opts.Addr,
+ mux: http.NewServeMux(),
+ templates: t,
+ configSvc: opts.ConfigSvc,
+ auditSvc: opts.AuditSvc,
+ }
+ s.routes()
+ return s, nil
+}
+
+func (s *Server) Addr() string { return s.addr }
+
+func (s *Server) Handler() http.Handler { return s.mux }
+
+func (s *Server) ListenAndServe(ctx context.Context) error {
+ srv := &http.Server{Addr: s.addr, Handler: s.mux}
+ go func() {
+ <-ctx.Done()
+ shutdownCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+ defer cancel()
+ _ = srv.Shutdown(shutdownCtx)
+ }()
+ err := srv.ListenAndServe()
+ if errors.Is(err, http.ErrServerClosed) {
+ return nil
+ }
+ return err
+}
+
+func (s *Server) routes() {
+ sub, _ := fs.Sub(staticFS, "static")
+ s.mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.FS(sub))))
+ s.mux.HandleFunc("/", s.handleIndex)
+ s.mux.HandleFunc("/api/overview", s.handleOverview)
+}
+
+type overview struct {
+ Initialized bool `json:"initialized"`
+ ConfigPath string `json:"config_path"`
+ AuditPath string `json:"audit_path"`
+ Rules rulesOverview `json:"rules"`
+ Counters countersOverview `json:"counters"`
+ Events []services.AuditEvent `json:"events"`
+ LastBlocked *services.AuditEvent `json:"last_blocked,omitempty"`
+}
+
+type rulesOverview struct {
+ DenyRead []string `json:"deny_read"`
+ DenyExec []string `json:"deny_exec"`
+ AllowNet []string `json:"allow_net"`
+}
+
+type countersOverview struct {
+ BlockedTotal int `json:"blocked_total"`
+ BlockedToday int `json:"blocked_today"`
+ RunsTotal int `json:"runs_total"`
+ ByRule map[string]int `json:"by_rule"`
+ BySource map[string]int `json:"by_source"`
+}
+
+func (s *Server) buildOverview() overview {
+ cfgPath, _ := s.configSvc.GlobalConfigPath()
+ auditPath, _ := s.auditSvc.Path()
+ data := overview{
+ ConfigPath: cfgPath,
+ AuditPath: auditPath,
+ Rules: rulesOverview{
+ DenyRead: []string{},
+ DenyExec: []string{},
+ AllowNet: []string{},
+ },
+ Counters: countersOverview{
+ ByRule: map[string]int{"deny_read": 0, "deny_exec": 0, "allow_net": 0},
+ BySource: map[string]int{},
+ },
+ }
+ cfg, err := s.configSvc.LoadGlobal()
+ if err == nil && cfg != nil {
+ data.Initialized = true
+ data.Rules = rulesFromConfig(*cfg)
+ }
+ events, err := s.auditSvc.Recent(200)
+ if err == nil {
+ data.Events = events
+ }
+ today := time.Now().Format("2006-01-02")
+ for _, event := range data.Events {
+ if event.Kind == "run_started" {
+ data.Counters.RunsTotal++
+ continue
+ }
+ if event.Kind != "blocked" {
+ continue
+ }
+ data.Counters.BlockedTotal++
+ if event.Time.Format("2006-01-02") == today {
+ data.Counters.BlockedToday++
+ }
+ if event.Rule != "" {
+ data.Counters.ByRule[event.Rule]++
+ }
+ if event.Source != "" {
+ data.Counters.BySource[event.Source]++
+ }
+ if data.LastBlocked == nil || event.Time.After(data.LastBlocked.Time) {
+ copyEvent := event
+ data.LastBlocked = ©Event
+ }
+ }
+ sort.SliceStable(data.Events, func(i, j int) bool {
+ return data.Events[i].Time.After(data.Events[j].Time)
+ })
+ if len(data.Events) > 80 {
+ data.Events = data.Events[:80]
+ }
+ return data
+}
+
+func rulesFromConfig(cfg domain.Config) rulesOverview {
+ return rulesOverview{
+ DenyRead: append([]string(nil), cfg.DenyRead...),
+ DenyExec: append([]string(nil), cfg.DenyExec...),
+ AllowNet: append([]string(nil), cfg.AllowNet...),
+ }
+}
diff --git a/internal/web/static/app.js b/internal/web/static/app.js
new file mode 100644
index 0000000..49df54b
--- /dev/null
+++ b/internal/web/static/app.js
@@ -0,0 +1,209 @@
+const state = {
+ refreshTimer: null,
+ events: [],
+ filters: {
+ query: '',
+ kind: '',
+ rule: '',
+ source: '',
+ },
+};
+
+const labels = {
+ deny_read: 'protected reads',
+ deny_exec: 'blocked commands',
+ allow_net: 'network boundary',
+};
+
+const colors = {
+ deny_read: 'oklch(68% 0.13 246)',
+ deny_exec: 'oklch(67% 0.19 28)',
+ allow_net: 'oklch(72% 0.15 157)',
+};
+
+function text(selector, value) {
+ const el = document.querySelector(selector);
+ if (!el) return;
+ if (el.textContent !== String(value)) {
+ el.textContent = value;
+ el.animate([
+ { transform: 'translateY(6px)', opacity: 0.55 },
+ { transform: 'translateY(0)', opacity: 1 },
+ ], { duration: 260, easing: 'cubic-bezier(0.16, 1, 0.3, 1)' });
+ }
+}
+
+function list(selector, items, empty) {
+ const el = document.querySelector(selector);
+ if (!el) return;
+ const values = items && items.length ? items : [empty];
+ el.innerHTML = values.map((item, index) => {
+ const cls = items && items.length ? '' : ' class="muted"';
+ return `
${escapeHtml(item)} `;
+ }).join('');
+}
+
+function renderBars(counters) {
+ const el = document.querySelector('[data-field="bars"]');
+ if (!el) return;
+ const entries = Object.entries(counters.by_rule || {});
+ const max = Math.max(1, ...entries.map(([, value]) => value));
+ el.innerHTML = entries.map(([key, value], index) => {
+ const width = Math.max(4, Math.round((value / max) * 100));
+ return `
+
+
+ ${escapeHtml(labels[key] || key)}
+ ${value}
+
+
+
+ `;
+ }).join('');
+}
+
+function renderLatest(event) {
+ const el = document.querySelector('[data-field="latest-block"]');
+ if (!el) return;
+ if (!event) {
+ el.innerHTML = 'quiet No blocked sandbox activity has been captured yet. Run commands through aigate and this panel will fill in. ';
+ return;
+ }
+ el.innerHTML = `
+ ${escapeHtml(event.rule || 'blocked')}
+ ${escapeHtml(event.command || 'unknown command')}
+ ${escapeHtml(event.detail || event.source || '')}
+ `;
+}
+
+function renderEvents(events) {
+ const el = document.querySelector('[data-list="events"]');
+ if (!el) return;
+ if (!events || !events.length) {
+ const hasFilters = Object.values(state.filters).some(Boolean);
+ const message = hasFilters
+ ? 'No events match the active filters.'
+ : '`aigate run -- ...` will start populating this view.';
+ el.innerHTML = `No audit events shown. ${escapeHtml(message)}
`;
+ updateFilterCount(0, state.events.length);
+ return;
+ }
+ el.innerHTML = events.map((event, index) => `
+
+ ${formatTime(event.time)}
+ ${escapeHtml(event.kind || '')}
+ ${escapeHtml(event.rule || event.source || '')}
+
+ ${escapeHtml(event.command || event.detail || '')}
+ ${event.detail ? `${escapeHtml(event.detail)} ` : ''}
+
+
+ `).join('');
+ updateFilterCount(events.length, state.events.length);
+}
+
+function applyFilters() {
+ const query = state.filters.query.toLowerCase();
+ const filtered = state.events.filter((event) => {
+ if (state.filters.kind && event.kind !== state.filters.kind) return false;
+ if (state.filters.rule && event.rule !== state.filters.rule) return false;
+ if (state.filters.source && event.source !== state.filters.source) return false;
+ if (!query) return true;
+ const haystack = [
+ event.kind,
+ event.rule,
+ event.source,
+ event.command,
+ event.detail,
+ event.work_dir,
+ ].filter(Boolean).join(' ').toLowerCase();
+ return haystack.includes(query);
+ });
+ renderEvents(filtered);
+}
+
+function updateFilterCount(shown, total) {
+ const el = document.querySelector('[data-field="filter-count"]');
+ if (!el) return;
+ el.textContent = `${shown} shown / ${total} total`;
+}
+
+function renderSourceOptions(events) {
+ const el = document.querySelector('[data-filter="source"]');
+ if (!el) return;
+ const current = el.value;
+ const sources = [...new Set(events.map((event) => event.source).filter(Boolean))].sort();
+ el.innerHTML = 'Any source ' + sources.map((source) => (
+ `${escapeHtml(source)} `
+ )).join('');
+ if (sources.includes(current)) {
+ el.value = current;
+ } else {
+ state.filters.source = '';
+ }
+}
+
+async function refresh() {
+ const res = await fetch('/api/overview', { headers: { accept: 'application/json' } });
+ if (!res.ok) return;
+ const data = await res.json();
+ text('[data-field="blocked-total"]', data.counters.blocked_total);
+ text('[data-field="blocked-today"]', data.counters.blocked_today);
+ text('[data-field="runs-total"]', data.counters.runs_total);
+ text('[data-field="rule-read"]', data.rules.deny_read.length);
+ text('[data-field="rule-exec"]', data.rules.deny_exec.length);
+ text('[data-field="rule-net"]', data.rules.allow_net.length);
+ list('[data-list="deny-read"]', data.rules.deny_read, 'none');
+ list('[data-list="deny-exec"]', data.rules.deny_exec, 'none');
+ list('[data-list="allow-net"]', data.rules.allow_net, 'all outbound allowed');
+ renderBars(data.counters);
+ renderLatest(data.last_blocked);
+ state.events = data.events || [];
+ renderSourceOptions(state.events);
+ applyFilters();
+}
+
+function formatTime(value) {
+ const date = new Date(value);
+ if (Number.isNaN(date.getTime())) return '';
+ return date.toLocaleString([], {
+ month: 'short',
+ day: '2-digit',
+ hour: '2-digit',
+ minute: '2-digit',
+ second: '2-digit',
+ });
+}
+
+function escapeHtml(value) {
+ return String(value)
+ .replaceAll('&', '&')
+ .replaceAll('<', '<')
+ .replaceAll('>', '>')
+ .replaceAll('"', '"')
+ .replaceAll("'", ''');
+}
+
+document.querySelector('[data-action="refresh"]')?.addEventListener('click', refresh);
+document.querySelector('[data-action="clear-filters"]')?.addEventListener('click', () => {
+ state.filters = { query: '', kind: '', rule: '', source: '' };
+ document.querySelectorAll('[data-filter]').forEach((control) => {
+ control.value = '';
+ });
+ applyFilters();
+});
+document.querySelectorAll('[data-filter]').forEach((control) => {
+ control.addEventListener('input', () => {
+ state.filters[control.dataset.filter] = control.value;
+ applyFilters();
+ });
+});
+renderBars({
+ by_rule: {
+ deny_read: 0,
+ deny_exec: 0,
+ allow_net: 0,
+ },
+});
+refresh();
+state.refreshTimer = setInterval(refresh, 5000);
diff --git a/internal/web/static/style.css b/internal/web/static/style.css
new file mode 100644
index 0000000..78670db
--- /dev/null
+++ b/internal/web/static/style.css
@@ -0,0 +1,731 @@
+:root {
+ color-scheme: dark;
+ --bg: oklch(11% 0.018 252);
+ --bg-2: oklch(14% 0.02 252);
+ --panel: oklch(18% 0.022 252 / 0.9);
+ --panel-strong: oklch(22% 0.026 252 / 0.94);
+ --line: oklch(88% 0.018 76 / 0.13);
+ --line-strong: oklch(88% 0.018 76 / 0.2);
+ --text: oklch(96% 0.012 78);
+ --muted: oklch(70% 0.035 252);
+ --soft: oklch(82% 0.026 78);
+ --forge: oklch(68% 0.21 41);
+ --forge-deep: oklch(56% 0.19 35);
+ --green: oklch(72% 0.15 157);
+ --red: oklch(67% 0.19 28);
+ --blue: oklch(68% 0.13 246);
+ --shadow: 0 28px 80px oklch(4% 0.012 252 / 0.48);
+ --radius-lg: 14px;
+ --radius-md: 9px;
+ --space-xs: 0.5rem;
+ --space-sm: 0.75rem;
+ --space-md: 1rem;
+ --space-lg: 1.5rem;
+ --space-xl: 2rem;
+ --space-2xl: 3rem;
+ font-family: "Aptos Display", "Segoe UI Variable", "Helvetica Neue", Arial, sans-serif;
+ font-variant-numeric: tabular-nums;
+}
+
+* { box-sizing: border-box; }
+
+html {
+ background: var(--bg);
+ scroll-behavior: smooth;
+}
+
+body {
+ min-height: 100dvh;
+ margin: 0;
+ overflow-x: hidden;
+ background:
+ radial-gradient(circle at 0% 0%, oklch(48% 0.17 35 / 0.28), transparent 24rem),
+ radial-gradient(circle at 100% 8%, oklch(68% 0.21 41 / 0.13), transparent 28rem),
+ linear-gradient(145deg, oklch(10% 0.018 252), oklch(13% 0.02 260) 48%, oklch(11% 0.022 20));
+ color: var(--text);
+}
+
+body::after {
+ position: fixed;
+ inset: 0;
+ z-index: -2;
+ pointer-events: none;
+ content: "";
+ opacity: 0.24;
+ background-image:
+ linear-gradient(oklch(92% 0.02 78 / 0.045) 1px, transparent 1px),
+ linear-gradient(90deg, oklch(92% 0.02 78 / 0.035) 1px, transparent 1px);
+ background-size: 44px 44px;
+ mask-image: linear-gradient(to bottom, black 0 68%, transparent 100%);
+}
+
+button,
+input,
+select {
+ font: inherit;
+}
+
+button {
+ min-height: 40px;
+ border: 1px solid oklch(80% 0.1 45 / 0.34);
+ border-radius: 8px;
+ background: linear-gradient(180deg, oklch(66% 0.21 41 / 0.18), oklch(42% 0.15 38 / 0.12));
+ color: oklch(88% 0.105 60);
+ cursor: pointer;
+ font-weight: 800;
+ letter-spacing: 0.01em;
+ padding: 0.68rem 0.9rem;
+ transition: transform 180ms cubic-bezier(0.16, 1, 0.3, 1), border-color 180ms ease, background 180ms ease;
+}
+
+button:hover {
+ border-color: oklch(78% 0.16 48 / 0.74);
+ background: linear-gradient(180deg, oklch(68% 0.21 41 / 0.28), oklch(45% 0.16 38 / 0.16));
+}
+
+button:focus-visible,
+input:focus-visible,
+select:focus-visible {
+ outline: 2px solid oklch(78% 0.16 48 / 0.86);
+ outline-offset: 3px;
+}
+
+button:active { transform: translateY(1px) scale(0.99); }
+
+.ambient-grid {
+ position: fixed;
+ inset: auto -18vw -24vh auto;
+ z-index: -1;
+ width: min(62vw, 760px);
+ aspect-ratio: 1;
+ border: 1px solid oklch(72% 0.18 42 / 0.14);
+ border-radius: 50%;
+ background:
+ radial-gradient(circle, transparent 0 42%, oklch(68% 0.18 42 / 0.08) 43% 44%, transparent 45%),
+ repeating-conic-gradient(from 18deg, oklch(68% 0.19 42 / 0.13) 0deg 3deg, transparent 3deg 16deg);
+ opacity: 0.55;
+ animation: slow-spin 56s linear infinite;
+}
+
+.shell {
+ width: min(1480px, calc(100% - 32px));
+ margin: 0 auto;
+ padding: 28px 0 56px;
+}
+
+.topbar {
+ display: grid;
+ grid-template-columns: 1fr auto;
+ align-items: end;
+ gap: var(--space-lg);
+ margin-bottom: var(--space-lg);
+}
+
+.brand-lockup {
+ display: flex;
+ align-items: center;
+ gap: var(--space-md);
+ min-width: 0;
+}
+
+.brand-mark {
+ display: grid;
+ flex: 0 0 54px;
+ width: 54px;
+ height: 54px;
+ place-items: center;
+ border: 1px solid oklch(78% 0.15 44 / 0.34);
+ border-radius: 12px;
+ background:
+ linear-gradient(135deg, oklch(68% 0.21 41), oklch(52% 0.19 31)),
+ var(--forge);
+ box-shadow: 0 14px 34px oklch(48% 0.2 36 / 0.28);
+ color: oklch(98% 0.01 78);
+ font-weight: 950;
+ letter-spacing: -0.08em;
+}
+
+.eyebrow,
+.panel-label {
+ margin: 0 0 8px;
+ color: oklch(82% 0.07 58);
+ font: 820 0.72rem/1 "Segoe UI Variable", Arial, sans-serif;
+ letter-spacing: 0.1em;
+ text-transform: uppercase;
+}
+
+h1 {
+ max-width: 920px;
+ margin: 0;
+ color: var(--text);
+ font-size: clamp(2.2rem, 5vw, 5.8rem);
+ font-weight: 920;
+ letter-spacing: -0.055em;
+ line-height: 0.88;
+ text-wrap: balance;
+}
+
+h2 {
+ margin: 0;
+ font-size: 1.05rem;
+ font-weight: 820;
+ letter-spacing: -0.015em;
+}
+
+.status-pill {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 0.62rem;
+ min-width: 158px;
+ border: 1px solid oklch(70% 0.18 28 / 0.34);
+ border-radius: 999px;
+ padding: 0.72rem 0.86rem;
+ color: var(--red);
+ background: oklch(30% 0.1 28 / 0.13);
+ font: 820 0.78rem/1 "Segoe UI Variable", Arial, sans-serif;
+ text-transform: uppercase;
+}
+
+.status-pill span {
+ width: 0.55rem;
+ height: 0.55rem;
+ border-radius: 50%;
+ background: currentColor;
+ box-shadow: 0 0 18px currentColor;
+ animation: breathe 1.7s ease-in-out infinite;
+}
+
+.status-pill.is-ready {
+ color: var(--green);
+ border-color: oklch(74% 0.14 157 / 0.34);
+ background: oklch(36% 0.1 157 / 0.13);
+}
+
+.hero-grid,
+.dashboard-grid {
+ display: grid;
+ gap: var(--space-md);
+}
+
+.hero-grid {
+ grid-template-columns: minmax(0, 1.55fr) minmax(310px, 0.78fr);
+ margin-bottom: var(--space-md);
+}
+
+.scoreboard,
+.last-block,
+.panel {
+ position: relative;
+ overflow: hidden;
+ border: 1px solid var(--line);
+ border-radius: var(--radius-lg);
+ background:
+ linear-gradient(145deg, var(--panel-strong), var(--panel)),
+ var(--panel);
+ box-shadow: var(--shadow);
+}
+
+.scoreboard::before,
+.panel::before,
+.last-block::before {
+ position: absolute;
+ inset: 0;
+ pointer-events: none;
+ content: "";
+ background:
+ radial-gradient(circle at 12% 0%, oklch(70% 0.2 43 / 0.14), transparent 18rem),
+ linear-gradient(120deg, oklch(100% 0 0 / 0.08), transparent 22%, transparent 72%, oklch(68% 0.2 41 / 0.08));
+ opacity: 0.72;
+}
+
+.scoreboard {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) minmax(190px, 0.32fr);
+ min-height: 294px;
+}
+
+.score-main {
+ position: relative;
+ display: grid;
+ align-content: space-between;
+ padding: 28px;
+}
+
+.score-main p,
+.score-side span,
+.last-block span,
+.last-block small,
+.path-note,
+.event-row span,
+.event-row p,
+.empty-state span {
+ color: var(--muted);
+}
+
+.score-main p {
+ margin: 0;
+ font-weight: 780;
+ letter-spacing: 0.01em;
+}
+
+.score-main strong {
+ display: block;
+ color: var(--forge);
+ font: 950 clamp(5.4rem, 15vw, 14rem)/0.76 "Segoe UI Variable", Arial, sans-serif;
+ letter-spacing: -0.08em;
+ text-shadow: 0 0 42px oklch(58% 0.2 40 / 0.24);
+}
+
+.scanline {
+ position: absolute;
+ inset: auto 24px 24px 24px;
+ height: 46px;
+ border-top: 1px solid oklch(78% 0.15 44 / 0.34);
+ background: linear-gradient(90deg, transparent, oklch(68% 0.19 42 / 0.2), transparent);
+ opacity: 0.72;
+ animation: scan 2.8s cubic-bezier(0.16, 1, 0.3, 1) infinite;
+}
+
+.score-side {
+ display: grid;
+ border-inline-start: 1px solid var(--line);
+}
+
+.score-side div {
+ display: grid;
+ align-content: end;
+ gap: 10px;
+ padding: 22px;
+}
+
+.score-side div + div { border-top: 1px solid var(--line); }
+
+.score-side strong,
+.rule-counters strong {
+ font: 900 2.1rem/1 "Segoe UI Variable", Arial, sans-serif;
+ letter-spacing: -0.04em;
+}
+
+.score-side div:first-child strong { color: var(--red); }
+.score-side div:last-child strong { color: var(--blue); }
+
+.last-block {
+ min-height: 294px;
+ padding: 24px;
+}
+
+.last-block > * { position: relative; }
+
+.last-block div:last-child {
+ display: grid;
+ align-content: end;
+ min-height: 198px;
+ gap: 10px;
+}
+
+.last-block strong {
+ color: oklch(84% 0.12 58);
+ font-size: 1.8rem;
+ letter-spacing: -0.03em;
+}
+
+.last-block small { line-height: 1.5; }
+
+.radar {
+ position: absolute;
+ inset: 18px 18px auto auto;
+ width: 92px;
+ aspect-ratio: 1;
+ border: 1px solid oklch(75% 0.16 44 / 0.28);
+ border-radius: 50%;
+ background:
+ radial-gradient(circle, transparent 0 30%, oklch(70% 0.18 43 / 0.13) 31% 32%, transparent 33% 58%, oklch(70% 0.18 43 / 0.12) 59% 60%, transparent 61%),
+ conic-gradient(from 0deg, oklch(68% 0.2 41 / 0.34), transparent 72deg);
+ animation: slow-spin 5.6s linear infinite;
+}
+
+.radar span {
+ position: absolute;
+ inset: 42px 8px auto 44px;
+ height: 1px;
+ background: var(--forge);
+ transform-origin: left center;
+}
+
+.dashboard-grid {
+ grid-template-columns: minmax(0, 1fr) 390px;
+ margin-bottom: var(--space-md);
+}
+
+.panel { padding: 22px; }
+
+.panel-head {
+ position: relative;
+ display: grid;
+ grid-template-columns: 1fr auto;
+ align-items: center;
+ gap: var(--space-md);
+ margin-bottom: 18px;
+}
+
+.rule-counters {
+ position: relative;
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ border: 1px solid var(--line);
+ border-radius: var(--radius-md);
+ background: oklch(100% 0 0 / 0.026);
+}
+
+.rule-counters div {
+ padding: 16px;
+}
+
+.rule-counters div + div { border-inline-start: 1px solid var(--line); }
+.rule-counters span { display: block; color: var(--muted); margin-bottom: 8px; }
+.rule-counters div:nth-child(1) strong { color: var(--blue); }
+.rule-counters div:nth-child(2) strong { color: var(--red); }
+.rule-counters div:nth-child(3) strong { color: var(--green); }
+
+.rule-lists {
+ position: relative;
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 18px;
+ margin-top: 20px;
+}
+
+.rule-lists h2 {
+ margin-bottom: 10px;
+ font-size: 0.92rem;
+}
+
+ul {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ list-style: none;
+ margin: 0;
+ padding: 0;
+}
+
+li {
+ max-width: 100%;
+ overflow-wrap: anywhere;
+ border: 1px solid oklch(88% 0.018 76 / 0.13);
+ border-radius: 7px;
+ padding: 0.44rem 0.6rem;
+ color: var(--soft);
+ background: oklch(100% 0 0 / 0.045);
+ font: 720 0.82rem/1.1 "Cascadia Mono", "SFMono-Regular", Consolas, monospace;
+}
+
+li.muted { color: var(--muted); }
+
+.breakdown-panel {
+ display: grid;
+ align-content: start;
+}
+
+.bars {
+ position: relative;
+ display: grid;
+ gap: 14px;
+ margin: 20px 0;
+}
+
+.bar-row {
+ display: grid;
+ gap: 8px;
+ animation: rise 360ms cubic-bezier(0.16, 1, 0.3, 1) both;
+}
+
+.bar-meta {
+ display: flex;
+ justify-content: space-between;
+ color: var(--muted);
+ font-size: 0.86rem;
+}
+
+.bar-track {
+ height: 12px;
+ overflow: hidden;
+ border: 1px solid oklch(100% 0 0 / 0.08);
+ border-radius: 999px;
+ background: oklch(100% 0 0 / 0.045);
+}
+
+.bar-fill {
+ width: var(--value);
+ height: 100%;
+ border-radius: inherit;
+ background: var(--bar-color, var(--forge));
+ box-shadow: 0 0 24px color-mix(in oklch, var(--bar-color, var(--forge)) 36%, transparent);
+ transition: width 420ms cubic-bezier(0.16, 1, 0.3, 1);
+}
+
+.path-note {
+ position: relative;
+ margin: 0;
+ font: 0.76rem/1.45 "Cascadia Mono", "SFMono-Regular", Consolas, monospace;
+ overflow-wrap: anywhere;
+}
+
+.events-panel { padding: 0; }
+.events-panel .panel-head { padding: 18px 22px 0; }
+
+.filters {
+ position: relative;
+ display: grid;
+ grid-template-columns: minmax(240px, 1fr) 150px 150px 150px auto auto;
+ align-items: end;
+ gap: 10px;
+ padding: 0 22px 18px;
+}
+
+.filters label {
+ display: grid;
+ gap: 7px;
+}
+
+.filters span,
+.filters output {
+ color: var(--muted);
+ font: 820 0.72rem/1 "Segoe UI Variable", Arial, sans-serif;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+}
+
+.filters input,
+.filters select {
+ width: 100%;
+ min-height: 40px;
+ border: 1px solid oklch(88% 0.018 76 / 0.14);
+ border-radius: 8px;
+ background: oklch(100% 0 0 / 0.055);
+ color: var(--text);
+ font: 650 0.9rem/1 "Segoe UI Variable", Arial, sans-serif;
+ outline: none;
+ padding: 0.58rem 0.7rem;
+ transition: border-color 160ms ease, background 160ms ease, box-shadow 160ms ease;
+}
+
+.filters input:focus,
+.filters select:focus {
+ border-color: oklch(78% 0.16 48 / 0.68);
+ background: oklch(66% 0.2 41 / 0.08);
+ box-shadow: 0 0 0 3px oklch(66% 0.2 41 / 0.1);
+}
+
+.filters select option {
+ background: oklch(17% 0.024 252);
+ color: var(--text);
+}
+
+.filters output {
+ justify-self: end;
+ min-width: 128px;
+ border: 1px solid var(--line);
+ border-radius: 999px;
+ padding: 0.76rem 0.78rem;
+ text-align: center;
+ color: oklch(86% 0.09 58);
+ background: oklch(68% 0.2 41 / 0.07);
+}
+
+.event-list {
+ position: relative;
+ display: grid;
+}
+
+.event-row {
+ position: relative;
+ display: grid;
+ grid-template-columns: 138px 110px 116px minmax(0, 1fr);
+ align-items: center;
+ gap: var(--space-md);
+ border-top: 1px solid var(--line);
+ padding: 15px 22px;
+ animation: row-in 280ms cubic-bezier(0.16, 1, 0.3, 1) both;
+}
+
+.event-row::before {
+ position: absolute;
+ inset: 10px 14px auto auto;
+ width: 9px;
+ height: 9px;
+ border-radius: 50%;
+ content: "";
+ background: var(--green);
+ box-shadow: 0 0 16px currentColor;
+}
+
+.event-row.rule-deny_read::before { background: var(--blue); }
+.event-row.rule-deny_exec::before { background: var(--red); }
+.event-row.rule-allow_net::before { background: var(--green); }
+
+.event-row time,
+.event-row strong,
+.event-row span {
+ font: 780 0.82rem/1 "Cascadia Mono", "SFMono-Regular", Consolas, monospace;
+}
+
+.event-row p {
+ display: grid;
+ gap: 4px;
+ margin: 0;
+ overflow-wrap: anywhere;
+}
+
+.event-row p b {
+ color: var(--soft);
+ font-weight: 700;
+}
+
+.event-row p small {
+ color: var(--muted);
+ line-height: 1.35;
+}
+
+.empty-state {
+ display: grid;
+ gap: 8px;
+ padding: 34px 22px;
+ border-top: 1px solid var(--line);
+}
+
+@keyframes breathe {
+ 0%, 100% { transform: scale(0.88); opacity: 0.72; }
+ 50% { transform: scale(1.16); opacity: 1; }
+}
+
+@keyframes scan {
+ 0% { transform: translateY(-110px); opacity: 0; }
+ 20%, 70% { opacity: 0.72; }
+ 100% { transform: translateY(26px); opacity: 0; }
+}
+
+@keyframes slow-spin {
+ to { transform: rotate(360deg); }
+}
+
+@keyframes row-in {
+ from { opacity: 0; transform: translateY(8px); }
+ to { opacity: 1; transform: translateY(0); }
+}
+
+@keyframes rise {
+ from { opacity: 0; transform: translateX(-8px); }
+ to { opacity: 1; transform: translateX(0); }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ *,
+ *::before,
+ *::after {
+ animation-duration: 1ms !important;
+ animation-iteration-count: 1 !important;
+ scroll-behavior: auto !important;
+ }
+}
+
+@media (max-width: 980px) {
+ .topbar,
+ .hero-grid,
+ .dashboard-grid,
+ .scoreboard,
+ .rule-lists {
+ grid-template-columns: 1fr;
+ }
+
+ .topbar {
+ align-items: start;
+ }
+
+ .score-side {
+ grid-template-columns: 1fr 1fr;
+ border-top: 1px solid var(--line);
+ border-inline-start: 0;
+ }
+
+ .score-side div + div {
+ border-top: 0;
+ border-inline-start: 1px solid var(--line);
+ }
+
+ .event-row {
+ grid-template-columns: 1fr;
+ gap: 7px;
+ padding-inline-end: 34px;
+ }
+
+ .filters {
+ grid-template-columns: 1fr 1fr;
+ }
+
+ .filters .search-box,
+ .filters output {
+ grid-column: 1 / -1;
+ }
+
+ .filters output {
+ justify-self: stretch;
+ }
+}
+
+@media (max-width: 560px) {
+ .shell {
+ width: min(100% - 20px, 1480px);
+ padding-top: 18px;
+ }
+
+ .brand-lockup {
+ align-items: flex-start;
+ }
+
+ .brand-mark {
+ flex-basis: 44px;
+ width: 44px;
+ height: 44px;
+ border-radius: 10px;
+ font-size: 0.84rem;
+ }
+
+ .status-pill {
+ justify-self: start;
+ }
+
+ .score-main,
+ .last-block,
+ .panel {
+ padding: 18px;
+ }
+
+ .rule-counters {
+ grid-template-columns: 1fr;
+ }
+
+ .rule-counters div + div {
+ border-top: 1px solid var(--line);
+ border-inline-start: 0;
+ }
+
+ .score-side {
+ grid-template-columns: 1fr;
+ }
+
+ .score-side div + div {
+ border-top: 1px solid var(--line);
+ border-inline-start: 0;
+ }
+
+ .panel-head {
+ grid-template-columns: 1fr;
+ }
+
+ .filters {
+ grid-template-columns: 1fr;
+ padding: 0 18px 18px;
+ }
+}
diff --git a/internal/web/templates/layout.html.tmpl b/internal/web/templates/layout.html.tmpl
new file mode 100644
index 0000000..5d3ab5e
--- /dev/null
+++ b/internal/web/templates/layout.html.tmpl
@@ -0,0 +1,160 @@
+{{define "layout"}}
+
+
+
+
+
+ AxeForge AiGate
+
+
+
+
+
+
+
+
+
+
+
blocked attempts
+
{{.Counters.BlockedTotal}}
+
+
+
+
+ today
+ {{.Counters.BlockedToday}}
+
+
+ runs observed
+ {{.Counters.RunsTotal}}
+
+
+
+
+
+
+ last intercept
+
+ {{if .LastBlocked}}
+ {{.LastBlocked.Rule}}
+ {{.LastBlocked.Command}}
+ {{.LastBlocked.Detail}}
+ {{else}}
+ quiet
+ No blocked sandbox activity has been captured yet.
+ Run commands through aigate and this panel will fill in.
+ {{end}}
+
+
+
+
+
+
+
+
+
active policy
+
Rules enforced by the operating system
+
+
Sync
+
+
+
deny read {{len .Rules.DenyRead}}
+
deny exec {{len .Rules.DenyExec}}
+
allow net {{len .Rules.AllowNet}}
+
+
+
+
Protected reads
+
{{range .Rules.DenyRead}}{{.}} {{else}}none {{end}}
+
+
+
Blocked commands
+
{{range .Rules.DenyExec}}{{.}} {{else}}none {{end}}
+
+
+
Allowed network
+
{{range .Rules.AllowNet}}{{.}} {{else}}all outbound allowed {{end}}
+
+
+
+
+
+ block mix
+ Where the sandbox is taking pressure
+
+ audit log: {{.AuditPath}}
+
+
+
+
+
+
+
timeline
+
Recent decisions
+
+
config: {{.ConfigPath}}
+
+
+
+ Search command or detail
+
+
+
+ Event
+
+ All events
+ Blocked only
+ Runs only
+
+
+
+ Rule
+
+ Any rule
+ Deny read
+ Deny exec
+ Network
+
+
+
+ Source
+
+ Any source
+
+
+ Clear
+ 0 shown
+
+
+ {{range .Events}}
+
+ {{.Time.Format "Jan 02 15:04:05"}}
+ {{.Kind}}
+ {{.Rule}}
+ {{.Command}}
+
+ {{else}}
+
+ No audit events yet.
+ `aigate run -- ...` will start populating this view.
+
+ {{end}}
+
+
+
+
+
+
+{{end}}
diff --git a/main.go b/main.go
index 9bc5e7b..c25744f 100644
--- a/main.go
+++ b/main.go
@@ -1,11 +1,13 @@
package main
import (
+ "context"
"fmt"
"os"
"github.com/AxeForging/aigate/actions"
"github.com/AxeForging/aigate/helpers"
+ "github.com/AxeForging/aigate/internal/web"
"github.com/AxeForging/aigate/services"
"github.com/urfave/cli"
@@ -23,8 +25,9 @@ func main() {
platform := services.DetectPlatform()
configSvc := services.NewConfigService()
+ auditSvc := services.NewAuditService(configSvc)
ruleSvc := services.NewRuleService()
- runnerSvc := services.NewRunnerService(platform)
+ runnerSvc := services.NewRunnerServiceWithAudit(platform, auditSvc)
initAction := actions.NewInitAction(configSvc)
setupAction := actions.NewSetupAction(platform, configSvc)
@@ -128,6 +131,30 @@ func main() {
Usage: "Check sandbox prerequisites and show active isolation mode",
Action: doctorAction.Execute,
},
+ {
+ Name: "serve",
+ Usage: "Run the local web dashboard",
+ Flags: []cli.Flag{
+ cli.StringFlag{
+ Name: "addr",
+ Value: "127.0.0.1:8080",
+ Usage: "Address to listen on (host:port)",
+ EnvVar: "AIGATE_ADDR",
+ },
+ },
+ Action: func(c *cli.Context) error {
+ srv, err := web.New(web.Options{
+ Addr: c.String("addr"),
+ ConfigSvc: configSvc,
+ AuditSvc: auditSvc,
+ })
+ if err != nil {
+ return fmt.Errorf("init web server: %w", err)
+ }
+ fmt.Printf("\n Open http://%s in your browser.\n\n", srv.Addr())
+ return srv.ListenAndServe(context.Background())
+ },
+ },
{
Name: "help-ai",
Usage: "Show AI-friendly usage examples",
diff --git a/services/audit_service.go b/services/audit_service.go
new file mode 100644
index 0000000..9114aad
--- /dev/null
+++ b/services/audit_service.go
@@ -0,0 +1,205 @@
+package services
+
+import (
+ "bufio"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/AxeForging/aigate/domain"
+)
+
+const auditLogFile = "audit.jsonl"
+
+// AuditEvent is a compact event record used by the local dashboard.
+type AuditEvent struct {
+ Time time.Time `json:"time"`
+ Kind string `json:"kind"`
+ Rule string `json:"rule,omitempty"`
+ Command string `json:"command,omitempty"`
+ WorkDir string `json:"work_dir,omitempty"`
+ Source string `json:"source,omitempty"`
+ Detail string `json:"detail,omitempty"`
+ Counts map[string]int `json:"counts,omitempty"`
+ Meta map[string]string `json:"meta,omitempty"`
+}
+
+type AuditService struct {
+ configSvc *ConfigService
+ mu sync.Mutex
+}
+
+func NewAuditService(configSvc *ConfigService) *AuditService {
+ if configSvc == nil {
+ configSvc = NewConfigService()
+ }
+ return &AuditService{configSvc: configSvc}
+}
+
+func (s *AuditService) Log(event AuditEvent) error {
+ if event.Time.IsZero() {
+ event.Time = time.Now()
+ }
+ path, err := s.Path()
+ if err != nil {
+ return err
+ }
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+ return fmt.Errorf("create audit dir: %w", err)
+ }
+ b, err := json.Marshal(event)
+ if err != nil {
+ return fmt.Errorf("marshal audit event: %w", err)
+ }
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
+ if err != nil {
+ return fmt.Errorf("open audit log: %w", err)
+ }
+ defer func() { _ = f.Close() }()
+ if _, err := f.Write(append(b, '\n')); err != nil {
+ return fmt.Errorf("write audit log: %w", err)
+ }
+ return nil
+}
+
+func (s *AuditService) LogRunStarted(profile domain.SandboxProfile, cmd string, args []string) {
+ _ = s.Log(AuditEvent{
+ Kind: "run_started",
+ Command: formatCommand(cmd, args),
+ WorkDir: profile.WorkDir,
+ Counts: map[string]int{
+ "deny_read": len(profile.Config.DenyRead),
+ "deny_exec": len(profile.Config.DenyExec),
+ "allow_net": len(profile.Config.AllowNet),
+ "masking": len(profile.Config.MaskStdout.Presets) + len(profile.Config.MaskStdout.Patterns),
+ },
+ })
+}
+
+func (s *AuditService) LogBlocked(profile domain.SandboxProfile, cmd string, args []string, rule, source, detail string) {
+ _ = s.Log(AuditEvent{
+ Kind: "blocked",
+ Rule: rule,
+ Command: formatCommand(cmd, args),
+ WorkDir: profile.WorkDir,
+ Source: source,
+ Detail: detail,
+ })
+}
+
+func (s *AuditService) Path() (string, error) {
+ dir, err := s.configSvc.GlobalConfigDir()
+ if err != nil {
+ return "", err
+ }
+ return filepath.Join(dir, auditLogFile), nil
+}
+
+func (s *AuditService) Recent(limit int) ([]AuditEvent, error) {
+ path, err := s.Path()
+ if err != nil {
+ return nil, err
+ }
+ f, err := os.Open(path)
+ if err != nil {
+ if errors.Is(err, os.ErrNotExist) {
+ return nil, nil
+ }
+ return nil, err
+ }
+ defer func() { _ = f.Close() }()
+
+ var events []AuditEvent
+ scanner := bufio.NewScanner(f)
+ for scanner.Scan() {
+ var event AuditEvent
+ if err := json.Unmarshal(scanner.Bytes(), &event); err == nil {
+ events = append(events, event)
+ }
+ }
+ if err := scanner.Err(); err != nil {
+ return nil, err
+ }
+ for i, j := 0, len(events)-1; i < j; i, j = i+1, j-1 {
+ events[i], events[j] = events[j], events[i]
+ }
+ if limit > 0 && len(events) > limit {
+ events = events[:limit]
+ }
+ return events, nil
+}
+
+func formatCommand(cmd string, args []string) string {
+ if len(args) == 0 {
+ return cmd
+ }
+ return strings.Join(append([]string{cmd}, args...), " ")
+}
+
+type auditWriter struct {
+ dst io.Writer
+ audit *AuditService
+ profile domain.SandboxProfile
+ cmd string
+ args []string
+ source string
+ buf string
+}
+
+func NewAuditWriter(dst io.Writer, audit *AuditService, profile domain.SandboxProfile, cmd string, args []string, source string) io.Writer {
+ if dst == nil || audit == nil {
+ return dst
+ }
+ return &auditWriter{dst: dst, audit: audit, profile: profile, cmd: cmd, args: args, source: source}
+}
+
+func (w *auditWriter) Write(p []byte) (int, error) {
+ n, err := w.dst.Write(p)
+ w.observe(string(p))
+ return n, err
+}
+
+func (w *auditWriter) observe(chunk string) {
+ w.buf += chunk
+ for {
+ idx := strings.IndexByte(w.buf, '\n')
+ if idx < 0 {
+ if len(w.buf) > 4096 {
+ w.inspect(w.buf)
+ w.buf = ""
+ }
+ return
+ }
+ line := w.buf[:idx]
+ w.buf = w.buf[idx+1:]
+ w.inspect(line)
+ }
+}
+
+func (w *auditWriter) inspect(line string) {
+ line = strings.TrimSpace(line)
+ if line == "" || !strings.Contains(line, "[aigate]") {
+ return
+ }
+ rule := ""
+ switch {
+ case strings.Contains(line, "access denied"):
+ rule = "deny_read"
+ case strings.Contains(line, "blocked") || strings.Contains(line, "denied by sandbox policy"):
+ rule = "deny_exec"
+ case strings.Contains(line, "network"):
+ rule = "allow_net"
+ }
+ if rule == "" {
+ return
+ }
+ w.audit.LogBlocked(w.profile, w.cmd, w.args, rule, w.source, line)
+}
diff --git a/services/audit_service_test.go b/services/audit_service_test.go
new file mode 100644
index 0000000..b6d9be8
--- /dev/null
+++ b/services/audit_service_test.go
@@ -0,0 +1,41 @@
+package services
+
+import (
+ "path/filepath"
+ "testing"
+
+ "github.com/AxeForging/aigate/domain"
+)
+
+func TestAuditService_LogAndRecent(t *testing.T) {
+ t.Setenv("HOME", t.TempDir())
+ configSvc := NewConfigService()
+ auditSvc := NewAuditService(configSvc)
+
+ profile := domain.SandboxProfile{WorkDir: "/tmp/project"}
+ auditSvc.LogRunStarted(profile, "echo", []string{"hello"})
+ auditSvc.LogBlocked(profile, "curl", []string{"example.test"}, "deny_exec", "preflight", "curl is blocked")
+
+ events, err := auditSvc.Recent(10)
+ if err != nil {
+ t.Fatalf("Recent() error = %v", err)
+ }
+ if len(events) != 2 {
+ t.Fatalf("Recent() returned %d events, want 2", len(events))
+ }
+ if events[0].Kind != "blocked" {
+ t.Fatalf("newest event kind = %q, want blocked", events[0].Kind)
+ }
+ if events[0].Command != "curl example.test" {
+ t.Fatalf("blocked command = %q", events[0].Command)
+ }
+
+ path, err := auditSvc.Path()
+ if err != nil {
+ t.Fatalf("Path() error = %v", err)
+ }
+ wantSuffix := filepath.Join(".aigate", "audit.jsonl")
+ if len(path) < len(wantSuffix) || filepath.ToSlash(path[len(path)-len(wantSuffix):]) != filepath.ToSlash(wantSuffix) {
+ t.Fatalf("Path() = %q, want suffix %q", path, wantSuffix)
+ }
+}
diff --git a/services/runner_service.go b/services/runner_service.go
index da83124..4f076bc 100644
--- a/services/runner_service.go
+++ b/services/runner_service.go
@@ -13,13 +13,21 @@ import (
type RunnerService struct {
platform Platform
+ audit *AuditService
}
func NewRunnerService(platform Platform) *RunnerService {
return &RunnerService{platform: platform}
}
+func NewRunnerServiceWithAudit(platform Platform, audit *AuditService) *RunnerService {
+ return &RunnerService{platform: platform, audit: audit}
+}
+
func (s *RunnerService) Run(profile domain.SandboxProfile, cmd string, args []string) error {
+ if s.audit != nil {
+ s.audit.LogRunStarted(profile, cmd, args)
+ }
// Extract the base command name for deny_exec checking
baseName := filepath.Base(cmd)
for _, denied := range profile.Config.DenyExec {
@@ -29,6 +37,9 @@ func (s *RunnerService) Run(profile domain.SandboxProfile, cmd string, args []st
if parts[0] == baseName || parts[0] == cmd {
for _, arg := range args {
if arg == parts[1] {
+ if s.audit != nil {
+ s.audit.LogBlocked(profile, cmd, args, "deny_exec", "preflight", fmt.Sprintf("%q with subcommand %q is in the deny_exec list", cmd, parts[1]))
+ }
return fmt.Errorf("%w: %q with subcommand %q is in the deny_exec list", helpers.ErrCommandBlocked, cmd, parts[1])
}
}
@@ -36,6 +47,9 @@ func (s *RunnerService) Run(profile domain.SandboxProfile, cmd string, args []st
} else {
// Full command rule: block all usage
if denied == baseName || denied == cmd {
+ if s.audit != nil {
+ s.audit.LogBlocked(profile, cmd, args, "deny_exec", "preflight", fmt.Sprintf("%q is in the deny_exec list", cmd))
+ }
return fmt.Errorf("%w: %q is in the deny_exec list", helpers.ErrCommandBlocked, cmd)
}
}
@@ -48,6 +62,10 @@ func (s *RunnerService) Run(profile domain.SandboxProfile, cmd string, args []st
if mw, ok := stderr.(*MaskingWriter); ok {
defer mw.Flush() //nolint:errcheck
}
+ if s.audit != nil {
+ stdout = NewAuditWriter(stdout, s.audit, profile, cmd, args, "stdout")
+ stderr = NewAuditWriter(stderr, s.audit, profile, cmd, args, "stderr")
+ }
return s.platform.RunSandboxed(profile, cmd, args, stdout, stderr)
}