Skip to content
Merged
34 changes: 34 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,40 @@ greywall check
greywall setup
```

### Agent profiles

Greywall ships with built-in profiles for popular AI coding agents (Claude, Codex, Cursor, Aider, Goose, Gemini, OpenCode, Amp, Cline, Copilot, Kilo, Auggie, Droid) and toolchains (Node, Python, Go, Rust, Java, Ruby, Docker).

On first run, greywall shows what the profile allows and lets you apply, edit, or skip:

```bash
$ greywall -- claude

[greywall] Running claude in a sandbox.
A built-in profile is available. Without it, only the current directory is accessible.

Allow read: ~/.claude ~/.claude.json ~/.config/claude ~/.local/share/claude ~/.gitconfig ... + working dir
Allow write: ~/.claude ~/.claude.json ~/.cache/claude ~/.config/claude ... + working dir
Deny read: ~/.ssh/id_* ~/.gnupg/** .env .env.*
Deny write: ~/.bashrc ~/.zshrc ~/.ssh ~/.gnupg

[Y] Use profile (recommended) [e] Edit first [s] Skip (restrictive) [n] Don't ask again
>
```

Combine agent and toolchain profiles with `--template`:

```bash
# Agent + Python toolchain (allows access to ~/.cache/uv, ~/.local/pipx, etc.)
greywall --template claude,python -- claude

# Agent + multiple toolchains
greywall --template opencode,node,go -- opencode

# List all available profiles and saved templates
greywall templates list
```

### Learning mode

Greywall can trace a command's filesystem access and generate a config template automatically:
Expand Down
118 changes: 89 additions & 29 deletions cmd/greywall/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@

"github.com/GreyhavenHQ/greywall/internal/config"
"github.com/GreyhavenHQ/greywall/internal/platform"
"github.com/GreyhavenHQ/greywall/internal/profiles"
_ "github.com/GreyhavenHQ/greywall/internal/profiles/agents" // register built-in agent profiles

Check failure on line 19 in cmd/greywall/main.go

View workflow job for this annotation

GitHub Actions / Lint

File is not properly formatted (gofumpt)
_ "github.com/GreyhavenHQ/greywall/internal/profiles/toolchains" // register built-in toolchain profiles
"github.com/GreyhavenHQ/greywall/internal/proxy"
"github.com/GreyhavenHQ/greywall/internal/sandbox"
"github.com/spf13/cobra"
Expand Down Expand Up @@ -108,7 +111,7 @@
rootCmd.Flags().BoolVarP(&showVersion, "version", "v", false, "Show version information")
rootCmd.Flags().BoolVar(&linuxFeatures, "linux-features", false, "Show available Linux security features and exit")
rootCmd.Flags().BoolVar(&learning, "learning", false, "Run in learning mode: trace filesystem access and generate a config template")
rootCmd.Flags().StringVar(&templateName, "template", "", "Load a specific learned template by name (see: greywall templates list)")
rootCmd.Flags().StringVar(&templateName, "template", "", "Load templates by name, comma-separated (e.g. --template claude,uv)")

rootCmd.Flags().SetInterspersed(true)

Expand Down Expand Up @@ -193,20 +196,27 @@
// Extract command name for learned template lookup
cmdName := extractCommandName(args, cmdString)

// Load learned template (when NOT in learning mode)
// Load templates (when NOT in learning mode)
if !learning {
// Determine which template to load: --template flag takes priority
var templatePath string
var templateLabel string
if templateName != "" {
templatePath = sandbox.LearnedTemplatePath(templateName)
templateLabel = templateName
// Explicit --template flag: resolve each comma-separated name
names := strings.Split(templateName, ",")
for _, name := range names {
name = strings.TrimSpace(name)
if name == "" {
continue
}
resolved, err := resolveTemplate(name, debug)
if err != nil {
return err
}
if resolved != nil {
cfg = config.Merge(cfg, resolved)
}
}
} else if cmdName != "" {
templatePath = sandbox.LearnedTemplatePath(cmdName)
templateLabel = cmdName
}

if templatePath != "" {
// Auto-detect by command name
templatePath := sandbox.LearnedTemplatePath(cmdName)
learnedCfg, loadErr := config.Load(templatePath)
switch {
case loadErr != nil:
Expand All @@ -216,15 +226,16 @@
case learnedCfg != nil:
cfg = config.Merge(cfg, learnedCfg)
if debug {
fmt.Fprintf(os.Stderr, "[greywall] Auto-loaded learned template for %q\n", templateLabel)
fmt.Fprintf(os.Stderr, "[greywall] Auto-loaded learned template for %q\n", cmdName)
}
case templateName != "":
// Explicit --template but file doesn't exist
return fmt.Errorf("learned template %q not found at %s\nRun: greywall templates list", templateName, templatePath)
case cmdName != "":
if debug {
// No template found for this command - suggest creating one
fmt.Fprintf(os.Stderr, "[greywall] No learned template for %q. Run with --learning to create one.\n", cmdName)
default:
// No saved template; try first-run UX for known agents
profileCfg, profileErr := profiles.ResolveFirstRun(cmdName, false, debug)
if profileErr != nil && debug {
fmt.Fprintf(os.Stderr, "[greywall] Warning: first-run profile error: %v\n", profileErr)
}
if profileCfg != nil {
cfg = config.Merge(cfg, profileCfg)
}
}
}
Expand Down Expand Up @@ -413,6 +424,40 @@
return nil
}

// resolveTemplate resolves a single template name to a config.
// It tries a saved learned template first, then falls back to a built-in profile.
// Returns an error if the name can't be resolved at all.
func resolveTemplate(name string, debug bool) (*config.Config, error) {
// Try saved learned template first
templatePath := sandbox.LearnedTemplatePath(name)
learnedCfg, loadErr := config.Load(templatePath)
if loadErr != nil {
if debug {
fmt.Fprintf(os.Stderr, "[greywall] Warning: failed to load learned template %q: %v\n", name, loadErr)
}
}
if learnedCfg != nil {
if debug {
fmt.Fprintf(os.Stderr, "[greywall] Loaded learned template for %q\n", name)
}
return learnedCfg, nil
}

// Fall back to built-in profile (agent or toolchain)
canonical := profiles.IsKnownAgent(name)
if canonical != "" {
profile := profiles.GetAgentProfile(canonical)
if profile != nil {
if debug {
fmt.Fprintf(os.Stderr, "[greywall] Using built-in profile for %q\n", name)
}
return profile, nil
}
}

return nil, fmt.Errorf("template %q not found (no learned template and no built-in profile)\nRun: greywall templates list", name)
}

// extractCommandName extracts a human-readable command name from the arguments.
// For args like ["opencode"], returns "opencode".
// For -c "opencode --foo", returns "opencode".
Expand Down Expand Up @@ -651,18 +696,33 @@
if err != nil {
return fmt.Errorf("failed to list templates: %w", err)
}
if len(templates) == 0 {
fmt.Println("No learned templates found.")
if len(templates) > 0 {
fmt.Printf("Saved templates (%s):\n\n", sandbox.LearnedTemplateDir())
for _, t := range templates {
fmt.Printf(" %s\n", t.Name)
}
fmt.Println()
}

available := profiles.ListAvailableProfiles()
if len(available) > 0 {
fmt.Println("Built-in profiles (not yet saved):")
fmt.Println()
for _, a := range available {
fmt.Printf(" %s\n", a)
}
fmt.Println()
}

if len(templates) == 0 && len(available) == 0 {
fmt.Println("No templates found.")
fmt.Printf("Create one with: greywall --learning -- <command>\n")
return nil
}
fmt.Printf("Learned templates (%s):\n\n", sandbox.LearnedTemplateDir())
for _, t := range templates {
fmt.Printf(" %s\n", t.Name)
}
fmt.Println()
fmt.Println("Show a template: greywall templates show <name>")
fmt.Println("Use a template: greywall --template <name> -- <command>")

fmt.Println("Show a template: greywall templates show <name>")
fmt.Println("Use a template: greywall --template <name> -- <command>")
fmt.Println("Combine templates: greywall --template claude,python -- claude")
return nil
},
}
Expand Down
20 changes: 20 additions & 0 deletions internal/profiles/agents/aider.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package agents

import (
"github.com/GreyhavenHQ/greywall/internal/config"
"github.com/GreyhavenHQ/greywall/internal/profiles"
)

func init() {
profiles.Register(profiles.AgentDef{
Names: []string{"aider"},
Overlay: func() *config.Config {
return &config.Config{
Filesystem: config.FilesystemConfig{
AllowRead: []string{"~/.aider*", "~/.config/aider", "~/.cache/aider", "~/.local/share/aider"},
AllowWrite: []string{"~/.aider*", "~/.config/aider", "~/.cache/aider", "~/.local/share/aider"},
},
}
},
})
}
20 changes: 20 additions & 0 deletions internal/profiles/agents/amp.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package agents

import (
"github.com/GreyhavenHQ/greywall/internal/config"
"github.com/GreyhavenHQ/greywall/internal/profiles"
)

func init() {
profiles.Register(profiles.AgentDef{
Names: []string{"amp"},
Overlay: func() *config.Config {
return &config.Config{
Filesystem: config.FilesystemConfig{
AllowRead: []string{"~/.amp", "~/.config/amp", "~/.cache/amp", "~/.local/share/amp", "~/.local/state/amp", "~/.claude"},
AllowWrite: []string{"~/.amp", "~/.config/amp", "~/.cache/amp", "~/.local/share/amp", "~/.local/state/amp"},
},
}
},
})
}
20 changes: 20 additions & 0 deletions internal/profiles/agents/auggie.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package agents

import (
"github.com/GreyhavenHQ/greywall/internal/config"
"github.com/GreyhavenHQ/greywall/internal/profiles"
)

func init() {
profiles.Register(profiles.AgentDef{
Names: []string{"auggie"},
Overlay: func() *config.Config {
return &config.Config{
Filesystem: config.FilesystemConfig{
AllowRead: []string{"~/.augment"},
AllowWrite: []string{"~/.augment"},
},
}
},
})
}
37 changes: 37 additions & 0 deletions internal/profiles/agents/claude.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package agents

import (
"github.com/GreyhavenHQ/greywall/internal/config"
"github.com/GreyhavenHQ/greywall/internal/profiles"
)

func init() {
profiles.Register(profiles.AgentDef{
Names: []string{"claude", "claude-code"},
Overlay: func() *config.Config {
return &config.Config{
Filesystem: config.FilesystemConfig{
AllowRead: []string{
"~/.claude",
"~/.claude.json",
"~/.claude.json.*",
"~/.config/claude",
"~/.local/share/claude",
"~/.local/state/claude",
"~/.mcp.json",
},
AllowWrite: []string{
"~/.claude",
"~/.claude.json",
"~/.claude.lock",
"~/.cache/claude",
"~/.config/claude",
"~/.local/state/claude",
"~/.local/share/claude",
"~/.mcp.json",
},

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I found that Claude makes frequent use of the temp directory, which on osx is /private/tmp/, without it many of the tool calls would fail. Worth adding maybe?
Sidenote: Should default templates be os-specific?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, claude is not the /tmp, but /tmp/claude-xxx no ?
I feel showing the whole /tmp could be dangerous as many application leave some socket in it

I will do another pass for the os-specific - maybe i got them lost during internal refactoring.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@elo-siema actually, i wonder if greywall could redirect the TMPDIR env to be local and deleted when done, that would prevent giving wide access. What do you think?

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yup, TMPDIR redirection sounds the cleanest, agreed

Copy link

@elo-siema elo-siema Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

/tmp/claude-xxx

yes, but that xxx was unpredictable. Agreed on fishyness of sharing whole tmp

},
}
},
})
}
20 changes: 20 additions & 0 deletions internal/profiles/agents/cline.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package agents

import (
"github.com/GreyhavenHQ/greywall/internal/config"
"github.com/GreyhavenHQ/greywall/internal/profiles"
)

func init() {
profiles.Register(profiles.AgentDef{
Names: []string{"cline"},
Overlay: func() *config.Config {
return &config.Config{
Filesystem: config.FilesystemConfig{
AllowRead: []string{"~/.cline", "~/.config/cline", "~/.cache/cline", "~/.local/share/cline", "~/.local/state/cline"},
AllowWrite: []string{"~/.cline", "~/.config/cline", "~/.cache/cline", "~/.local/share/cline", "~/.local/state/cline"},
},
}
},
})
}
20 changes: 20 additions & 0 deletions internal/profiles/agents/codex.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package agents

import (
"github.com/GreyhavenHQ/greywall/internal/config"
"github.com/GreyhavenHQ/greywall/internal/profiles"
)

func init() {
profiles.Register(profiles.AgentDef{
Names: []string{"codex"},
Overlay: func() *config.Config {
return &config.Config{
Filesystem: config.FilesystemConfig{
AllowRead: []string{"~/.codex", "~/.cache/codex"},
AllowWrite: []string{"~/.codex", "~/.cache/codex"},
},
}
},
})
}
20 changes: 20 additions & 0 deletions internal/profiles/agents/copilot.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package agents

import (
"github.com/GreyhavenHQ/greywall/internal/config"
"github.com/GreyhavenHQ/greywall/internal/profiles"
)

func init() {
profiles.Register(profiles.AgentDef{
Names: []string{"copilot"},
Overlay: func() *config.Config {
return &config.Config{
Filesystem: config.FilesystemConfig{
AllowRead: []string{"~/.copilot"},
AllowWrite: []string{"~/.copilot"},
},
}
},
})
}
20 changes: 20 additions & 0 deletions internal/profiles/agents/cursor.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package agents

import (
"github.com/GreyhavenHQ/greywall/internal/config"
"github.com/GreyhavenHQ/greywall/internal/profiles"
)

func init() {
profiles.Register(profiles.AgentDef{
Names: []string{"cursor", "cursor-agent"},
Overlay: func() *config.Config {
return &config.Config{
Filesystem: config.FilesystemConfig{
AllowRead: []string{"~/.cursor", "~/.config/cursor", "~/.local/share/cursor-agent"},
AllowWrite: []string{"~/.cursor", "~/.config/cursor", "~/.cache/cursor-compile-cache", "~/.local/share/cursor-agent"},
},
}
},
})
}
Loading
Loading