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 = 'quietNo 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) => ` +
+ + ${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 = '' + sources.map((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 + + + + +
+
+
+ AF +
+

AxeForge security console

+

AiGate command boundary

+
+
+
+ + {{if .Initialized}}initialized{{else}}not initialized{{end}} +
+
+ +
+
+
+

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

+
+ +
+
+
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}} +
+
+ + + + + + 0 shown +
+
+ {{range .Events}} +
+ + {{.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) }