Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .structlint.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,13 @@ dir_structure:
- "services/**"
- "helpers/**"
- "integration/**"
- "internal/**"
- "docs/**"
- "dist/**"
- ".github/**"
- ".claude/**"
- ".agents/**"
- ".codex/**"
disallowedPaths:
- "vendor/**"
- "node_modules/**"
Expand All @@ -36,9 +39,14 @@ file_naming_pattern:
- "*.md"
- "*.txt"
- "*.png"
- "*.gif"
- "*.jpg"
- "*.svg"
- "*.puml"
- "*.cast"
- "*.css"
- "*.js"
- "*.tmpl"
- "README*"
- "LICENSE*"
- "CHANGELOG*"
Expand Down Expand Up @@ -83,6 +91,8 @@ ignore:
- ".idea"
- ".vscode"
- ".DS_Store"
- ".agents"
- ".codex"
- "*.log"
- "*.tmp"
- "aigate"
164 changes: 164 additions & 0 deletions docs/aigate-demo.cast
Original file line number Diff line number Diff line change
@@ -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"]
Binary file added docs/aigate-demo.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
138 changes: 138 additions & 0 deletions docs/cast.yaml
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
@@ -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
Expand Down
22 changes: 22 additions & 0 deletions internal/web/handlers.go
Original file line number Diff line number Diff line change
@@ -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())
}
Loading
Loading