From 318877bcfc1215c90881edecc84e81ab3d93337d Mon Sep 17 00:00:00 2001 From: Lucas Machado Date: Thu, 12 Feb 2026 21:07:13 +0100 Subject: [PATCH] feat: split init into setup + init, add help-ai command Split `aigate init` into two commands to fix sudo bug where `sudo aigate init` wrote config to /root/.aigate/ instead of the user's home directory. - `aigate setup` (requires sudo): creates OS group/user - `aigate init` (no sudo): writes ~/.aigate/config.yaml - `aigate help-ai`: prints comprehensive AI-friendly usage examples --- README.md | 9 ++- actions/help_ai.go | 139 ++++++++++++++++++++++++++++++++++++++++++++ actions/init.go | 45 ++------------ actions/setup.go | 55 ++++++++++++++++++ docs/user/README.md | 33 +++++++---- main.go | 19 +++++- 6 files changed, 244 insertions(+), 56 deletions(-) create mode 100644 actions/help_ai.go create mode 100644 actions/setup.go diff --git a/README.md b/README.md index faaf283..4aaf3a0 100644 --- a/README.md +++ b/README.md @@ -18,8 +18,9 @@ curl -L https://github.com/AxeForging/aigate/releases/latest/download/aigate-linux-amd64.tar.gz | tar xz sudo mv aigate-linux-amd64 /usr/local/bin/aigate -# Initialize sandbox -sudo aigate init +# Set up sandbox +sudo aigate setup # One-time: create OS group/user for ACLs +aigate init # Create default config # Add restrictions aigate deny read .env secrets/ *.pem @@ -63,13 +64,15 @@ AI coding tools rely on application-level permission systems that can be bypasse ## Commands ```sh -aigate init # Create sandbox group/user/config +sudo aigate setup # Create OS group/user (one-time) +aigate init # Create default config aigate deny read .env secrets/ *.pem # Block file access aigate deny exec curl wget ssh # Block commands aigate deny net --except api.anthropic.com # Restrict network aigate allow read .env # Remove a deny rule aigate run -- claude # Run AI tool in sandbox aigate status # Show current rules +aigate help-ai # Show AI-friendly usage examples aigate reset --force # Remove everything ``` diff --git a/actions/help_ai.go b/actions/help_ai.go new file mode 100644 index 0000000..1bb13c7 --- /dev/null +++ b/actions/help_ai.go @@ -0,0 +1,139 @@ +package actions + +import ( + "fmt" + + "github.com/urfave/cli" +) + +type HelpAIAction struct{} + +func NewHelpAIAction() *HelpAIAction { + return &HelpAIAction{} +} + +func (a *HelpAIAction) Execute(c *cli.Context) error { + fmt.Print(helpAIText) + return nil +} + +const helpAIText = `aigate — AI Agent Usage Examples +================================= + +SETUP (one-time) + sudo aigate setup # Create OS group "ai-agents" and user "ai-runner" + aigate init # Create default config at ~/.aigate/config.yaml + aigate init --force # Re-create config (overwrites existing) + +FILE RESTRICTIONS (deny read) + aigate deny read .env # Block a single file + aigate deny read .env secrets/ *.pem # Block multiple files/dirs/globs + aigate deny read .aws/ .gcloud/ .kube/ # Block cloud credential dirs + aigate deny read terraform.tfstate *.tfvars # Block Terraform state/secrets + aigate allow read .env # Remove a deny rule + +COMMAND RESTRICTIONS (deny exec) + aigate deny exec curl wget # Block network tools + aigate deny exec ssh scp rsync # Block remote access tools + aigate deny exec nc ncat netcat # Block raw socket tools + aigate allow exec curl # Remove a deny rule + +NETWORK RESTRICTIONS (deny net) + aigate deny net --except api.anthropic.com --except api.github.com + # Block all outbound except listed domains + aigate allow net api.github.com # Remove a domain from allow list + +RUNNING SANDBOXED + aigate run -- claude # Run Claude in sandbox + aigate run -- aider # Run any AI tool + aigate run -- bash -c "npm test" # Run arbitrary commands + aigate run --config ./project.yaml -- claude # Use project-specific config + aigate run --verbose -- claude # Show debug logging + +CHECKING STATUS + aigate status # Show all rules, group/user, limits + +CONFIGURATION + Global config: ~/.aigate/config.yaml + Project config: .aigate.yaml (in project root, merged with global) + + Project configs add to global rules (deny_read, deny_exec, allow_net are + unioned). Resource limits in project config override global values. + + Example ~/.aigate/config.yaml: + group: ai-agents + user: ai-runner + deny_read: + - .env + - .env.* + - secrets/ + - credentials/ + - .ssh/ + - "*.pem" + - "*.key" + deny_exec: + - curl + - wget + - ssh + allow_net: + - api.anthropic.com + - api.openai.com + - api.github.com + - registry.npmjs.org + - proxy.golang.org + resource_limits: + max_memory: 4G + max_cpu_percent: 80 + max_pids: 1000 + + Example .aigate.yaml (project-level, adds to global): + deny_read: + - .stripe-key + - production.env + allow_net: + - api.stripe.com + +WHAT THE AI AGENT SEES INSIDE THE SANDBOX + Startup banner on stderr: + [aigate] sandbox active + [aigate] deny_read: .env, secrets/, *.pem + [aigate] deny_exec: curl, wget, ssh + [aigate] allow_net: api.anthropic.com (all other outbound connections will be blocked) + + Denied files contain a marker instead of their content: + [aigate] access denied: this file is protected by sandbox policy. See /tmp/.aigate-policy for all active restrictions. + + Denied directories contain a .aigate-denied marker file: + [aigate] access denied: this directory is protected by sandbox policy. Run 'cat /tmp/.aigate-policy' to see all active restrictions. + + Policy summary file at /tmp/.aigate-policy lists all active restrictions. + + Network connections to non-allowed hosts are rejected (connection refused). + +COMMON PATTERNS + Node.js project: + aigate deny read .env .npmrc + aigate deny net --except registry.npmjs.org --except api.anthropic.com + aigate run -- claude + + Python project: + aigate deny read .env .pypirc secrets/ + aigate deny net --except pypi.org --except api.anthropic.com + aigate run -- claude + + Go project: + aigate deny read .env *.key + aigate deny net --except proxy.golang.org --except api.anthropic.com + aigate run -- claude + + Terraform project: + aigate deny read terraform.tfstate *.tfvars .aws/ + aigate deny exec curl wget ssh + aigate run -- claude + + Full lockdown: + aigate deny read .env .env.* secrets/ credentials/ .ssh/ .aws/ .gcloud/ *.pem *.key + aigate deny exec curl wget nc ncat netcat ssh scp rsync ftp + aigate deny net --except api.anthropic.com + aigate run -- claude +` diff --git a/actions/init.go b/actions/init.go index 75757d6..26e8ad9 100644 --- a/actions/init.go +++ b/actions/init.go @@ -1,7 +1,6 @@ package actions import ( - "errors" "fmt" "github.com/AxeForging/aigate/helpers" @@ -10,12 +9,11 @@ import ( ) type InitAction struct { - platform services.Platform configSvc *services.ConfigService } -func NewInitAction(p services.Platform, c *services.ConfigService) *InitAction { - return &InitAction{platform: p, configSvc: c} +func NewInitAction(c *services.ConfigService) *InitAction { + return &InitAction{configSvc: c} } func (a *InitAction) Execute(c *cli.Context) error { @@ -23,52 +21,21 @@ func (a *InitAction) Execute(c *cli.Context) error { helpers.SetupLogger("debug") } - group := c.String("group") - user := c.String("user") - if !c.Bool("force") && a.configSvc.ConfigExists() { return fmt.Errorf("%w: use --force to reinitialize", helpers.ErrAlreadyInit) } - helpers.Log.Info().Str("platform", a.platform.Name()).Msg("Initializing aigate sandbox") - - // Create group — skip if already exists - helpers.Log.Info().Str("group", group).Msg("Creating sandbox group") - if err := a.platform.CreateGroup(group); err != nil { - if errors.Is(err, helpers.ErrAlreadyInit) { - helpers.Log.Info().Str("group", group).Msg("Sandbox group already exists, skipping") - } else { - return fmt.Errorf("failed to create group: %w", err) - } - } - - // Create user — skip if already exists - helpers.Log.Info().Str("user", user).Str("group", group).Msg("Creating sandbox user") - if err := a.platform.CreateUser(user, group); err != nil { - if errors.Is(err, helpers.ErrAlreadyInit) { - helpers.Log.Info().Str("user", user).Msg("Sandbox user already exists, skipping") - } else { - return fmt.Errorf("failed to create user: %w", err) - } - } - - // Write default config cfg := a.configSvc.InitDefaultConfig() - cfg.Group = group - cfg.User = user if err := a.configSvc.SaveGlobal(cfg); err != nil { return fmt.Errorf("failed to save config: %w", err) } configPath, _ := a.configSvc.GlobalConfigPath() - helpers.Log.Info().Str("config", configPath).Msg("Default config created") - fmt.Printf("aigate initialized successfully\n") - fmt.Printf(" Group: %s\n", group) - fmt.Printf(" User: %s\n", user) + fmt.Printf("aigate initialized\n") fmt.Printf(" Config: %s\n", configPath) fmt.Printf("\nNext steps:\n") - fmt.Printf(" aigate deny read .env secrets/ # Add file restrictions\n") - fmt.Printf(" aigate deny exec curl wget # Block commands\n") - fmt.Printf(" aigate run -- claude # Run AI tool in sandbox\n") + fmt.Printf(" sudo aigate setup # Create OS group/user (one-time)\n") + fmt.Printf(" aigate deny read .env secrets/ # Add file restrictions\n") + fmt.Printf(" aigate run -- claude # Run AI tool in sandbox\n") return nil } diff --git a/actions/setup.go b/actions/setup.go new file mode 100644 index 0000000..05e0328 --- /dev/null +++ b/actions/setup.go @@ -0,0 +1,55 @@ +package actions + +import ( + "errors" + "fmt" + + "github.com/AxeForging/aigate/helpers" + "github.com/AxeForging/aigate/services" + "github.com/urfave/cli" +) + +type SetupAction struct { + platform services.Platform + configSvc *services.ConfigService +} + +func NewSetupAction(p services.Platform, c *services.ConfigService) *SetupAction { + return &SetupAction{platform: p, configSvc: c} +} + +func (a *SetupAction) Execute(c *cli.Context) error { + if c.Bool("verbose") { + helpers.SetupLogger("debug") + } + + group := c.String("group") + user := c.String("user") + + helpers.Log.Info().Str("platform", a.platform.Name()).Msg("Setting up aigate system resources") + + // Create group — skip if already exists + helpers.Log.Info().Str("group", group).Msg("Creating sandbox group") + if err := a.platform.CreateGroup(group); err != nil { + if errors.Is(err, helpers.ErrAlreadyInit) { + helpers.Log.Info().Str("group", group).Msg("group already exists, skipping") + } else { + return fmt.Errorf("failed to create group: %w", err) + } + } + + // Create user — skip if already exists + helpers.Log.Info().Str("user", user).Str("group", group).Msg("Creating sandbox user") + if err := a.platform.CreateUser(user, group); err != nil { + if errors.Is(err, helpers.ErrAlreadyInit) { + helpers.Log.Info().Str("user", user).Msg("user already exists, skipping") + } else { + return fmt.Errorf("failed to create user: %w", err) + } + } + + fmt.Printf("System setup complete\n") + fmt.Printf(" Group: %s\n", group) + fmt.Printf(" User: %s\n", user) + return nil +} diff --git a/docs/user/README.md b/docs/user/README.md index 45496e1..3ac9c48 100644 --- a/docs/user/README.md +++ b/docs/user/README.md @@ -60,14 +60,17 @@ go install github.com/AxeForging/aigate@latest ## Quick Start ```sh -# 1. Initialize (creates OS group + user + default config) -sudo aigate init +# 1. System setup (creates OS group + user, requires sudo) +sudo aigate setup -# 2. Add custom restrictions +# 2. Create default config +aigate init + +# 3. Add custom restrictions aigate deny read .env secrets/ terraform.tfstate aigate deny exec curl wget ssh -# 3. Run your AI tool inside the sandbox +# 4. Run your AI tool inside the sandbox aigate run -- claude aigate run -- cursor aigate run -- aider @@ -75,14 +78,22 @@ aigate run -- aider ## Commands +### setup + +Creates the OS group (`ai-agents`) and user (`ai-runner`). Requires `sudo`. Safe to re-run (skips existing group/user). + +```sh +sudo aigate setup # Default group/user +sudo aigate setup --group mygroup --user myuser # Custom names +``` + ### init -Creates the sandbox group (`ai-agents`), user (`ai-runner`), and default config. Safe to re-run (skips existing group/user). +Creates default config at `~/.aigate/config.yaml`. Does not require sudo. ```sh -sudo aigate init # Default setup -sudo aigate init --group mygroup --user myuser # Custom names -sudo aigate init --force # Reinitialize +aigate init # Create default config +aigate init --force # Re-create config (overwrites existing) ``` ### deny @@ -246,13 +257,13 @@ cgroups v2 enforce memory, CPU, and PID limits (Linux only). ## Troubleshooting ### "operation requires elevated privileges" -`init` and `reset` need `sudo` to create/delete OS users and groups. `deny`, `allow`, `run`, and `status` do not. +`setup` and `reset` need `sudo` to create/delete OS users and groups. `init`, `deny`, `allow`, `run`, and `status` do not. ### ACL warnings on deny/allow -If you see "Failed to apply ACLs", the AI agent group may not exist yet. Run `sudo aigate init` first. +If you see "Failed to apply ACLs", the AI agent group may not exist yet. Run `sudo aigate setup` first. ### "aigate not initialized" -Run `sudo aigate init` to create the sandbox group, user, and default config. +Run `sudo aigate setup` to create the sandbox group and user, then `aigate init` to create the default config. ### "slirp4netns not found" warning Install `slirp4netns` for network filtering on Linux (see [Prerequisites](#prerequisites)). Without it, `allow_net` rules are ignored and the sandboxed process has unrestricted network access. diff --git a/main.go b/main.go index a4d5173..d7d4613 100644 --- a/main.go +++ b/main.go @@ -26,7 +26,9 @@ func main() { ruleSvc := services.NewRuleService() runnerSvc := services.NewRunnerService(platform) - initAction := actions.NewInitAction(platform, configSvc) + initAction := actions.NewInitAction(configSvc) + setupAction := actions.NewSetupAction(platform, configSvc) + helpAIAction := actions.NewHelpAIAction() denyAction := actions.NewDenyAction(ruleSvc, configSvc, platform) allowAction := actions.NewAllowAction(ruleSvc, configSvc, platform) runAction := actions.NewRunAction(runnerSvc, configSvc, platform) @@ -42,10 +44,16 @@ func main() { { Name: "init", Aliases: []string{"i"}, - Usage: "Create AI sandbox group, user, and default config", - Flags: []cli.Flag{groupFlag, userFlag, verboseFlag, forceFlag}, + Usage: "Create default config (~/.aigate/config.yaml)", + Flags: []cli.Flag{verboseFlag, forceFlag}, Action: initAction.Execute, }, + { + Name: "setup", + Usage: "Create OS group and user for sandbox (requires sudo)", + Flags: []cli.Flag{groupFlag, userFlag, verboseFlag}, + Action: setupAction.Execute, + }, { Name: "deny", Usage: "Add deny rules for AI agent isolation", @@ -114,6 +122,11 @@ func main() { Flags: []cli.Flag{forceFlag, verboseFlag}, Action: resetAction.Execute, }, + { + Name: "help-ai", + Usage: "Show AI-friendly usage examples", + Action: helpAIAction.Execute, + }, { Name: "version", Usage: "Show version information",