Introduce built-in templates for enhanced configuration options, support JSONC format
This commit is contained in:
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/Use-Tusk/fence/internal/config"
|
||||
"github.com/Use-Tusk/fence/internal/platform"
|
||||
"github.com/Use-Tusk/fence/internal/sandbox"
|
||||
"github.com/Use-Tusk/fence/internal/templates"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -28,6 +29,8 @@ var (
|
||||
debug bool
|
||||
monitor bool
|
||||
settingsPath string
|
||||
templateName string
|
||||
listTemplates bool
|
||||
cmdString string
|
||||
exposePorts []string
|
||||
exitCode int
|
||||
@@ -50,15 +53,18 @@ func main() {
|
||||
with network and filesystem restrictions.
|
||||
|
||||
By default, all network access is blocked. Configure allowed domains in
|
||||
~/.fence.json or pass a settings file with --settings.
|
||||
~/.fence.json or pass a settings file with --settings, or use a built-in
|
||||
template with --template.
|
||||
|
||||
Examples:
|
||||
fence curl https://example.com # Will be blocked (no domains allowed)
|
||||
fence -- curl -s https://example.com # Use -- to separate fence flags from command
|
||||
fence -c "echo hello && ls" # Run with shell expansion
|
||||
fence --settings config.json npm install
|
||||
fence -t npm-install npm install # Use built-in npm-install template
|
||||
fence -t ai-coding-agents -- agent-cmd # Use AI coding agents template
|
||||
fence -p 3000 -c "npm run dev" # Expose port 3000 for inbound connections
|
||||
fence -p 3000 -p 8080 -c "npm start" # Expose multiple ports
|
||||
fence --list-templates # Show available built-in templates
|
||||
|
||||
Configuration file format (~/.fence.json):
|
||||
{
|
||||
@@ -84,6 +90,8 @@ Configuration file format (~/.fence.json):
|
||||
rootCmd.Flags().BoolVarP(&debug, "debug", "d", false, "Enable debug logging")
|
||||
rootCmd.Flags().BoolVarP(&monitor, "monitor", "m", false, "Monitor and log sandbox violations (macOS: log stream, all: proxy denials)")
|
||||
rootCmd.Flags().StringVarP(&settingsPath, "settings", "s", "", "Path to settings file (default: ~/.fence.json)")
|
||||
rootCmd.Flags().StringVarP(&templateName, "template", "t", "", "Use built-in template (e.g., ai-coding-agents, npm-install)")
|
||||
rootCmd.Flags().BoolVar(&listTemplates, "list-templates", false, "List available templates")
|
||||
rootCmd.Flags().StringVarP(&cmdString, "c", "c", "", "Run command string directly (like sh -c)")
|
||||
rootCmd.Flags().StringArrayVarP(&exposePorts, "port", "p", nil, "Expose port for inbound connections (can be used multiple times)")
|
||||
rootCmd.Flags().BoolVarP(&showVersion, "version", "v", false, "Show version information")
|
||||
@@ -112,6 +120,11 @@ func runCommand(cmd *cobra.Command, args []string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
if listTemplates {
|
||||
printTemplates()
|
||||
return nil
|
||||
}
|
||||
|
||||
var command string
|
||||
switch {
|
||||
case cmdString != "":
|
||||
@@ -139,21 +152,36 @@ func runCommand(cmd *cobra.Command, args []string) error {
|
||||
fmt.Fprintf(os.Stderr, "[fence] Exposing ports: %v\n", ports)
|
||||
}
|
||||
|
||||
configPath := settingsPath
|
||||
if configPath == "" {
|
||||
configPath = config.DefaultConfigPath()
|
||||
}
|
||||
// Load config: template > settings file > default path
|
||||
var cfg *config.Config
|
||||
var err error
|
||||
|
||||
cfg, err := config.Load(configPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load config: %w", err)
|
||||
}
|
||||
|
||||
if cfg == nil {
|
||||
if debug {
|
||||
fmt.Fprintf(os.Stderr, "[fence] No config found at %s, using default (block all network)\n", configPath)
|
||||
switch {
|
||||
case templateName != "":
|
||||
cfg, err = templates.Load(templateName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load template: %w\nUse --list-templates to see available templates", err)
|
||||
}
|
||||
if debug {
|
||||
fmt.Fprintf(os.Stderr, "[fence] Using template: %s\n", templateName)
|
||||
}
|
||||
case settingsPath != "":
|
||||
cfg, err = config.Load(settingsPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load config: %w", err)
|
||||
}
|
||||
default:
|
||||
configPath := config.DefaultConfigPath()
|
||||
cfg, err = config.Load(configPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load config: %w", err)
|
||||
}
|
||||
if cfg == nil {
|
||||
if debug {
|
||||
fmt.Fprintf(os.Stderr, "[fence] No config found at %s, using default (block all network)\n", configPath)
|
||||
}
|
||||
cfg = config.Default()
|
||||
}
|
||||
cfg = config.Default()
|
||||
}
|
||||
|
||||
manager := sandbox.NewManager(cfg, debug, monitor)
|
||||
@@ -226,11 +254,19 @@ func runCommand(cmd *cobra.Command, args []string) error {
|
||||
// Landlock code exists for future integration (e.g., via a wrapper binary).
|
||||
|
||||
go func() {
|
||||
sig := <-sigChan
|
||||
if execCmd.Process != nil {
|
||||
_ = execCmd.Process.Signal(sig)
|
||||
sigCount := 0
|
||||
for sig := range sigChan {
|
||||
sigCount++
|
||||
if execCmd.Process == nil {
|
||||
continue
|
||||
}
|
||||
// First signal: graceful termination; second signal: force kill
|
||||
if sigCount >= 2 {
|
||||
_ = execCmd.Process.Kill()
|
||||
} else {
|
||||
_ = execCmd.Process.Signal(sig)
|
||||
}
|
||||
}
|
||||
// Give child time to exit, then cleanup will happen via defer
|
||||
}()
|
||||
|
||||
// Wait for command to finish
|
||||
@@ -246,6 +282,18 @@ func runCommand(cmd *cobra.Command, args []string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// printTemplates prints all available templates to stdout.
|
||||
func printTemplates() {
|
||||
fmt.Println("Available templates:")
|
||||
fmt.Println()
|
||||
for _, t := range templates.List() {
|
||||
fmt.Printf(" %-20s %s\n", t.Name, t.Description)
|
||||
}
|
||||
fmt.Println()
|
||||
fmt.Println("Usage: fence -t <template> <command>")
|
||||
fmt.Println("Example: fence -t code -- code")
|
||||
}
|
||||
|
||||
// runLandlockWrapper runs in "wrapper mode" inside the sandbox.
|
||||
// It applies Landlock restrictions and then execs the user command.
|
||||
// Usage: fence --landlock-apply [--debug] -- <command...>
|
||||
|
||||
@@ -13,7 +13,7 @@ Fence is a sandboxing tool that restricts network and filesystem access for arbi
|
||||
- [Troubleshooting](troubleshooting.md) - Common failure modes and fixes
|
||||
- [Using Fence with AI agents](agents.md) - Defense-in-depth and policy standardization
|
||||
- [Recipes](recipes/README.md) - Common workflows (npm/pip/git/CI)
|
||||
- [Config Templates](templates/) - Copy/paste templates you can start from
|
||||
- [Templates](./templates.md) - Copy/paste templates you can start from
|
||||
|
||||
## Reference
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Using Fence with AI Agents
|
||||
|
||||
Many popular coding agents already include sandboxing. Fence can still be useful when you want a **tool-agnostic** policy layer that works the same way across:
|
||||
Many popular coding agents already include sandboxing. Fence can still be useful when you want a tool-agnostic policy layer that works the same way across:
|
||||
|
||||
- local developer machines
|
||||
- CI jobs
|
||||
@@ -11,11 +11,11 @@ Many popular coding agents already include sandboxing. Fence can still be useful
|
||||
|
||||
Treat an agent as "semi-trusted automation":
|
||||
|
||||
- **Restrict writes** to the workspace (and maybe `/tmp`)
|
||||
- **Allowlist only the network destinations** you actually need
|
||||
- Restrict writes to the workspace (and maybe `/tmp`)
|
||||
- Allowlist only the network destinations you actually need
|
||||
- Use `-m` (monitor mode) to audit blocked attempts and tighten policy
|
||||
|
||||
Fence can also reduce the risk of running agents with fewer interactive permission prompts (e.g. "skip permissions"), **as long as your Fence config tightly scopes writes and outbound destinations**. It's defense-in-depth, not a substitute for the agent's own safeguards.
|
||||
Fence can also reduce the risk of running agents with fewer interactive permission prompts (e.g. "skip permissions"), as long as your Fence config tightly scopes writes and outbound destinations. It's defense-in-depth, not a substitute for the agent's own safeguards.
|
||||
|
||||
## Example: API-only agent
|
||||
|
||||
@@ -36,6 +36,18 @@ Run:
|
||||
fence --settings ./fence.json <agent-command>
|
||||
```
|
||||
|
||||
## Real-world usage
|
||||
|
||||
Currently, we provide the `code.json` template. You can use it by running `fence -t code -- claude`.
|
||||
|
||||
However, not all coding agent CLIs work with Fence yet. We're actively investigating these issues.
|
||||
|
||||
| Agent | Works? | Notes |
|
||||
|-------|--------| ----- |
|
||||
| Claude Code | ✅ | Fully working with `code` template |
|
||||
| Codex | ❌ | Missing unidentified sandbox permission for interactive mode |
|
||||
| OpenCode | ❌ | Ignores proxy env vars; makes direct network connections |
|
||||
|
||||
## Protecting your environment
|
||||
|
||||
Fence includes additional "dangerous file protection (writes blocked regardless of config) to reduce persistence and environment-tampering vectors like:
|
||||
@@ -44,4 +56,4 @@ Fence includes additional "dangerous file protection (writes blocked regardless
|
||||
- shell startup files (`.zshrc`, `.bashrc`, etc.)
|
||||
- some editor/tool config directories
|
||||
|
||||
See `ARCHITECTURE.md` for the full list and rationale.
|
||||
See [`ARCHITECTURE.md`](/ARCHITECTURE.md) for the full list and rationale.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Configuration
|
||||
|
||||
Fence reads settings from `~/.fence.json` by default (or pass `--settings ./fence.json`).
|
||||
Fence reads settings from `~/.fence.json` by default (or pass `--settings ./fence.json`). Config files support JSONC.
|
||||
|
||||
Example config:
|
||||
|
||||
|
||||
28
docs/templates.md
Normal file
28
docs/templates.md
Normal file
@@ -0,0 +1,28 @@
|
||||
# Config Templates
|
||||
|
||||
Fence includes built-in config templates for common use cases. Templates are embedded in the binary, so you can use them directly without copying files.
|
||||
|
||||
## Using templates
|
||||
|
||||
Use the `-t` / `--template` flag to apply a template:
|
||||
|
||||
```bash
|
||||
# Use a built-in template
|
||||
fence -t npm-install npm install
|
||||
|
||||
# Wraps Claude Code
|
||||
fence -t code -- claude
|
||||
|
||||
# List available templates
|
||||
fence --list-templates
|
||||
```
|
||||
|
||||
You can also copy and customize templates from [`internal/templates/`](/internal/templates/).
|
||||
|
||||
## Available Templates
|
||||
|
||||
| Template | Description |
|
||||
|----------|-------------|
|
||||
| `code` | Production-ready config for AI coding agents (Claude Code, Codex, Copilot, etc.) |
|
||||
| `git-readonly` | Blocks destructive commands like `git push`, `rm -rf`, etc. |
|
||||
| `local-dev-server` | Allow binding and localhost outbound; allow writes to workspace/tmp |
|
||||
19
docs/templates/README.md
vendored
19
docs/templates/README.md
vendored
@@ -1,19 +0,0 @@
|
||||
# Config Templates
|
||||
|
||||
This directory contains Fence config templates. They are small and meant to be copied and customized.
|
||||
|
||||
## Templates
|
||||
|
||||
- `default-deny.json`: no network allowlist; no write access (most restrictive)
|
||||
- `workspace-write.json`: allow writes in the current directory
|
||||
- `npm-install.json`: allow npm registry; allow writes to workspace/node_modules/tmp
|
||||
- `pip-install.json`: allow PyPI; allow writes to workspace/tmp
|
||||
- `local-dev-server.json`: allow binding and localhost outbound; allow writes to workspace/tmp
|
||||
- `agent-api-only.json`: allow common LLM API domains; allow writes to workspace
|
||||
- `git-readonly.json`: blocks destructive commands like `git push`, `rm -rf`, etc.
|
||||
|
||||
## Using a template
|
||||
|
||||
```bash
|
||||
fence --settings ./docs/templates/npm-install.json npm install
|
||||
```
|
||||
8
docs/templates/agent-api-only.json
vendored
8
docs/templates/agent-api-only.json
vendored
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"network": {
|
||||
"allowedDomains": ["api.openai.com", "api.anthropic.com"]
|
||||
},
|
||||
"filesystem": {
|
||||
"allowWrite": ["."]
|
||||
}
|
||||
}
|
||||
8
docs/templates/default-deny.json
vendored
8
docs/templates/default-deny.json
vendored
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"network": {
|
||||
"allowedDomains": []
|
||||
},
|
||||
"filesystem": {
|
||||
"allowWrite": []
|
||||
}
|
||||
}
|
||||
8
docs/templates/npm-install.json
vendored
8
docs/templates/npm-install.json
vendored
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"network": {
|
||||
"allowedDomains": ["registry.npmjs.org", "*.npmjs.org"]
|
||||
},
|
||||
"filesystem": {
|
||||
"allowWrite": [".", "node_modules", "/tmp"]
|
||||
}
|
||||
}
|
||||
8
docs/templates/pip-install.json
vendored
8
docs/templates/pip-install.json
vendored
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"network": {
|
||||
"allowedDomains": ["pypi.org", "files.pythonhosted.org"]
|
||||
},
|
||||
"filesystem": {
|
||||
"allowWrite": [".", "/tmp"]
|
||||
}
|
||||
}
|
||||
5
docs/templates/workspace-write.json
vendored
5
docs/templates/workspace-write.json
vendored
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"filesystem": {
|
||||
"allowWrite": ["."]
|
||||
}
|
||||
}
|
||||
1
go.mod
1
go.mod
@@ -6,6 +6,7 @@ require (
|
||||
github.com/bmatcuk/doublestar/v4 v4.9.1
|
||||
github.com/spf13/cobra v1.8.1
|
||||
github.com/things-go/go-socks5 v0.0.5
|
||||
github.com/tidwall/jsonc v0.3.2
|
||||
golang.org/x/sys v0.39.0
|
||||
)
|
||||
|
||||
|
||||
2
go.sum
2
go.sum
@@ -16,6 +16,8 @@ github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcU
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/things-go/go-socks5 v0.0.5 h1:qvKaGcBkfDrUL33SchHN93srAmYGzb4CxSM2DPYufe8=
|
||||
github.com/things-go/go-socks5 v0.0.5/go.mod h1:mtzInf8v5xmsBpHZVbIw2YQYhc4K0jRwzfsH64Uh0IQ=
|
||||
github.com/tidwall/jsonc v0.3.2 h1:ZTKrmejRlAJYdn0kcaFqRAKlxxFIC21pYq8vLa4p2Wc=
|
||||
github.com/tidwall/jsonc v0.3.2/go.mod h1:dw+3CIxqHi+t8eFSpzzMlcVYxKp08UP5CD8/uSFCyJE=
|
||||
golang.org/x/net v0.20.0 h1:aCL9BSgETF1k+blQaYUBx9hJ9LOGP3gAVemcZlf1Kpo=
|
||||
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
|
||||
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
|
||||
|
||||
@@ -9,6 +9,8 @@ import (
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/tidwall/jsonc"
|
||||
)
|
||||
|
||||
// Config is the main configuration for fence.
|
||||
@@ -134,7 +136,7 @@ func Load(path string) (*Config, error) {
|
||||
}
|
||||
|
||||
var cfg Config
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
if err := json.Unmarshal(jsonc.ToJSON(data), &cfg); err != nil {
|
||||
return nil, fmt.Errorf("invalid JSON in config file: %w", err)
|
||||
}
|
||||
|
||||
@@ -231,6 +233,11 @@ func MatchesDomain(hostname, pattern string) bool {
|
||||
hostname = strings.ToLower(hostname)
|
||||
pattern = strings.ToLower(pattern)
|
||||
|
||||
// "*" matches all domains
|
||||
if pattern == "*" {
|
||||
return true
|
||||
}
|
||||
|
||||
// Wildcard pattern like *.example.com
|
||||
if strings.HasPrefix(pattern, "*.") {
|
||||
baseDomain := pattern[2:]
|
||||
|
||||
@@ -170,6 +170,41 @@ func TestCreateDomainFilter(t *testing.T) {
|
||||
port: 443,
|
||||
allowed: false,
|
||||
},
|
||||
{
|
||||
name: "star wildcard allows all",
|
||||
cfg: &config.Config{
|
||||
Network: config.NetworkConfig{
|
||||
AllowedDomains: []string{"*"},
|
||||
},
|
||||
},
|
||||
host: "any-domain.example.com",
|
||||
port: 443,
|
||||
allowed: true,
|
||||
},
|
||||
{
|
||||
name: "star wildcard with deny list",
|
||||
cfg: &config.Config{
|
||||
Network: config.NetworkConfig{
|
||||
AllowedDomains: []string{"*"},
|
||||
DeniedDomains: []string{"blocked.com"},
|
||||
},
|
||||
},
|
||||
host: "blocked.com",
|
||||
port: 443,
|
||||
allowed: false,
|
||||
},
|
||||
{
|
||||
name: "star wildcard allows non-denied",
|
||||
cfg: &config.Config{
|
||||
Network: config.NetworkConfig{
|
||||
AllowedDomains: []string{"*"},
|
||||
DeniedDomains: []string{"blocked.com"},
|
||||
},
|
||||
},
|
||||
host: "allowed.com",
|
||||
port: 443,
|
||||
allowed: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
||||
@@ -307,6 +307,9 @@ func GenerateSandboxProfile(params MacOSSandboxParams) string {
|
||||
(global-name "com.apple.bsd.dirhelper")
|
||||
(global-name "com.apple.securityd.xpc")
|
||||
(global-name "com.apple.coreservices.launchservicesd")
|
||||
(global-name "com.apple.FSEvents")
|
||||
(global-name "com.apple.fseventsd")
|
||||
(global-name "com.apple.SystemConfiguration.configd")
|
||||
)
|
||||
|
||||
; POSIX IPC
|
||||
|
||||
157
internal/templates/code.json
Normal file
157
internal/templates/code.json
Normal file
@@ -0,0 +1,157 @@
|
||||
{
|
||||
"allowPty": true,
|
||||
"network": {
|
||||
"allowLocalBinding": true,
|
||||
"allowLocalOutbound": true,
|
||||
"allowedDomains": [
|
||||
// LLM API providers
|
||||
"api.openai.com",
|
||||
"*.anthropic.com",
|
||||
"api.githubcopilot.com",
|
||||
"generativelanguage.googleapis.com",
|
||||
"api.mistral.ai",
|
||||
"api.cohere.ai",
|
||||
"api.together.xyz",
|
||||
"openrouter.ai",
|
||||
|
||||
// Git hosting
|
||||
"github.com",
|
||||
"api.github.com",
|
||||
"raw.githubusercontent.com",
|
||||
"codeload.github.com",
|
||||
"objects.githubusercontent.com",
|
||||
"gitlab.com",
|
||||
|
||||
// Package registries
|
||||
"registry.npmjs.org",
|
||||
"*.npmjs.org",
|
||||
"registry.yarnpkg.com",
|
||||
"pypi.org",
|
||||
"files.pythonhosted.org",
|
||||
"crates.io",
|
||||
"static.crates.io",
|
||||
"index.crates.io",
|
||||
"proxy.golang.org",
|
||||
"sum.golang.org",
|
||||
|
||||
// Model registry
|
||||
"models.dev"
|
||||
],
|
||||
|
||||
"deniedDomains": [
|
||||
// Cloud metadata APIs (prevent credential theft)
|
||||
"169.254.169.254",
|
||||
"metadata.google.internal",
|
||||
"instance-data.ec2.internal",
|
||||
|
||||
// Telemetry (optional, can be removed if needed)
|
||||
"statsig.anthropic.com",
|
||||
"*.sentry.io"
|
||||
]
|
||||
},
|
||||
|
||||
"filesystem": {
|
||||
"allowWrite": [
|
||||
".",
|
||||
// Temp files
|
||||
"/tmp",
|
||||
|
||||
// Claude Code state/config
|
||||
"~/.claude*",
|
||||
"~/.claude/**",
|
||||
|
||||
// Codex state/config
|
||||
"~/.codex/**",
|
||||
|
||||
// Package manager caches
|
||||
"~/.npm/_cacache",
|
||||
"~/.cache",
|
||||
"~/.bun/**",
|
||||
|
||||
// Cargo cache (Rust, used by Codex)
|
||||
"~/.cargo/registry/**",
|
||||
"~/.cargo/git/**",
|
||||
"~/.cargo/.package-cache",
|
||||
|
||||
// Shell completion cache
|
||||
"~/.zcompdump*",
|
||||
|
||||
// XDG directories for app configs/data
|
||||
"~/.local/share/**",
|
||||
"~/.config/**",
|
||||
|
||||
// OpenCode state
|
||||
"~/.opencode/**"
|
||||
],
|
||||
|
||||
"denyWrite": [
|
||||
// Protect environment files with secrets
|
||||
".env",
|
||||
".env.*",
|
||||
"**/.env",
|
||||
"**/.env.*",
|
||||
|
||||
// Protect key/certificate files
|
||||
"*.key",
|
||||
"*.pem",
|
||||
"*.p12",
|
||||
"*.pfx",
|
||||
"**/*.key",
|
||||
"**/*.pem",
|
||||
"**/*.p12",
|
||||
"**/*.pfx"
|
||||
],
|
||||
|
||||
"denyRead": [
|
||||
// SSH private keys and config
|
||||
"~/.ssh/id_*",
|
||||
"~/.ssh/config",
|
||||
"~/.ssh/*.pem",
|
||||
|
||||
// GPG keys
|
||||
"~/.gnupg/**",
|
||||
|
||||
// Cloud provider credentials
|
||||
"~/.aws/**",
|
||||
"~/.config/gcloud/**",
|
||||
"~/.kube/**",
|
||||
|
||||
// Docker config (may contain registry auth)
|
||||
"~/.docker/**",
|
||||
|
||||
// GitHub CLI auth
|
||||
"~/.config/gh/**",
|
||||
|
||||
// Package manager auth tokens
|
||||
"~/.pypirc",
|
||||
"~/.netrc",
|
||||
"~/.git-credentials",
|
||||
"~/.cargo/credentials",
|
||||
"~/.cargo/credentials.toml"
|
||||
]
|
||||
},
|
||||
|
||||
"command": {
|
||||
"useDefaults": true,
|
||||
"deny": [
|
||||
// Git commands that modify remote state
|
||||
"git push",
|
||||
"git reset",
|
||||
"git clean",
|
||||
"git checkout --",
|
||||
"git rebase",
|
||||
"git merge",
|
||||
|
||||
// Package publishing commands
|
||||
"npm publish",
|
||||
"pnpm publish",
|
||||
"yarn publish",
|
||||
"cargo publish",
|
||||
"twine upload",
|
||||
"gem push",
|
||||
|
||||
// Privilege escalation
|
||||
"sudo"
|
||||
]
|
||||
}
|
||||
}
|
||||
116
internal/templates/disable-telemetry.json
Normal file
116
internal/templates/disable-telemetry.json
Normal file
@@ -0,0 +1,116 @@
|
||||
{
|
||||
"allowPty": true,
|
||||
"network": {
|
||||
"allowLocalBinding": true,
|
||||
"allowLocalOutbound": true,
|
||||
"allowedDomains": ["*"],
|
||||
|
||||
// Block common analytics, telemetry, and error reporting services
|
||||
"deniedDomains": [
|
||||
// Error reporting
|
||||
"*.sentry.io",
|
||||
"*.ingest.sentry.io",
|
||||
"sentry.io",
|
||||
|
||||
// Product analytics
|
||||
"*.posthog.com",
|
||||
"app.posthog.com",
|
||||
"us.posthog.com",
|
||||
"eu.posthog.com",
|
||||
|
||||
// Feature flags / experimentation
|
||||
"*.statsig.com",
|
||||
"statsig.com",
|
||||
"statsig.anthropic.com",
|
||||
|
||||
// Customer data platforms
|
||||
"*.segment.io",
|
||||
"*.segment.com",
|
||||
"api.segment.io",
|
||||
"cdn.segment.com",
|
||||
|
||||
// Analytics
|
||||
"*.amplitude.com",
|
||||
"api.amplitude.com",
|
||||
"api2.amplitude.com",
|
||||
"*.mixpanel.com",
|
||||
"api.mixpanel.com",
|
||||
|
||||
"*.heap.io",
|
||||
"*.heapanalytics.com",
|
||||
|
||||
// Session recording
|
||||
"*.fullstory.com",
|
||||
"*.hotjar.com",
|
||||
"*.hotjar.io",
|
||||
"*.logrocket.io",
|
||||
"*.logrocket.com",
|
||||
|
||||
// Error tracking
|
||||
"*.bugsnag.com",
|
||||
"notify.bugsnag.com",
|
||||
"*.rollbar.com",
|
||||
"api.rollbar.com",
|
||||
|
||||
// APM / Monitoring
|
||||
"*.datadog.com",
|
||||
"*.datadoghq.com",
|
||||
|
||||
"*.newrelic.com",
|
||||
"*.nr-data.net",
|
||||
|
||||
// Feature flags
|
||||
"*.launchdarkly.com",
|
||||
"*.split.io",
|
||||
|
||||
// Product analytics / user engagement
|
||||
"*.pendo.io",
|
||||
"*.intercom.io",
|
||||
"*.intercom.com",
|
||||
|
||||
// Mobile attribution
|
||||
"*.appsflyer.com",
|
||||
"*.adjust.com",
|
||||
"*.branch.io",
|
||||
|
||||
// Crash reporting
|
||||
"crashlytics.com",
|
||||
"*.crashlytics.com",
|
||||
"firebase-settings.crashlytics.com"
|
||||
]
|
||||
},
|
||||
|
||||
"filesystem": {
|
||||
"allowWrite": [
|
||||
".",
|
||||
"/tmp",
|
||||
|
||||
// Claude Code state/config
|
||||
"~/.claude*",
|
||||
"~/.claude/**",
|
||||
|
||||
// Codex state/config
|
||||
"~/.codex/**",
|
||||
|
||||
// Package manager caches
|
||||
"~/.npm/_cacache",
|
||||
"~/.cache",
|
||||
"~/.bun/**",
|
||||
|
||||
// Cargo cache (Rust, used by Codex)
|
||||
"~/.cargo/registry/**",
|
||||
"~/.cargo/git/**",
|
||||
"~/.cargo/.package-cache",
|
||||
|
||||
// Shell completion cache
|
||||
"~/.zcompdump*",
|
||||
|
||||
// XDG directories for app configs/data
|
||||
"~/.local/share/**",
|
||||
"~/.config/**",
|
||||
|
||||
// OpenCode state
|
||||
"~/.opencode/**"
|
||||
]
|
||||
}
|
||||
}
|
||||
96
internal/templates/templates.go
Normal file
96
internal/templates/templates.go
Normal file
@@ -0,0 +1,96 @@
|
||||
// Package templates provides embedded configuration templates for fence.
|
||||
package templates
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/Use-Tusk/fence/internal/config"
|
||||
"github.com/tidwall/jsonc"
|
||||
)
|
||||
|
||||
//go:embed *.json
|
||||
var templatesFS embed.FS
|
||||
|
||||
// Template represents a named configuration template.
|
||||
type Template struct {
|
||||
Name string
|
||||
Description string
|
||||
}
|
||||
|
||||
// AvailableTemplates lists all embedded templates with descriptions.
|
||||
var templateDescriptions = map[string]string{
|
||||
"default-deny": "No network allowlist; no write access (most restrictive)",
|
||||
"disable-telemetry": "Block analytics/error reporting (Sentry, Posthog, Statsig, etc.)",
|
||||
"workspace-write": "Allow writes in the current directory",
|
||||
"npm-install": "Allow npm registry; allow writes to workspace/node_modules/tmp",
|
||||
"pip-install": "Allow PyPI; allow writes to workspace/tmp",
|
||||
"local-dev-server": "Allow binding and localhost outbound; allow writes to workspace/tmp",
|
||||
"git-readonly": "Blocks destructive commands like git push, rm -rf, etc.",
|
||||
"code": "Production-ready config for AI coding agents (Claude Code, Codex, Copilot, etc.)",
|
||||
}
|
||||
|
||||
// List returns all available template names sorted alphabetically.
|
||||
func List() []Template {
|
||||
entries, err := templatesFS.ReadDir(".")
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var templates []Template
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") {
|
||||
continue
|
||||
}
|
||||
name := strings.TrimSuffix(entry.Name(), ".json")
|
||||
desc := templateDescriptions[name]
|
||||
if desc == "" {
|
||||
desc = "No description available"
|
||||
}
|
||||
templates = append(templates, Template{Name: name, Description: desc})
|
||||
}
|
||||
|
||||
sort.Slice(templates, func(i, j int) bool {
|
||||
return templates[i].Name < templates[j].Name
|
||||
})
|
||||
|
||||
return templates
|
||||
}
|
||||
|
||||
// Load loads a template by name and returns the parsed config.
|
||||
func Load(name string) (*config.Config, error) {
|
||||
// Normalize name (remove .json if present)
|
||||
name = strings.TrimSuffix(name, ".json")
|
||||
filename := name + ".json"
|
||||
|
||||
data, err := templatesFS.ReadFile(filename)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("template %q not found", name)
|
||||
}
|
||||
|
||||
var cfg config.Config
|
||||
if err := json.Unmarshal(jsonc.ToJSON(data), &cfg); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse template %q: %w", name, err)
|
||||
}
|
||||
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
// Exists checks if a template with the given name exists.
|
||||
func Exists(name string) bool {
|
||||
name = strings.TrimSuffix(name, ".json")
|
||||
filename := name + ".json"
|
||||
|
||||
_, err := templatesFS.ReadFile(filename)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// GetPath returns the embedded path for a template (for display purposes).
|
||||
func GetPath(name string) string {
|
||||
name = strings.TrimSuffix(name, ".json")
|
||||
return filepath.Join("internal/templates", name+".json")
|
||||
}
|
||||
123
internal/templates/templates_test.go
Normal file
123
internal/templates/templates_test.go
Normal file
@@ -0,0 +1,123 @@
|
||||
package templates
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestList(t *testing.T) {
|
||||
templates := List()
|
||||
if len(templates) == 0 {
|
||||
t.Fatal("expected at least one template")
|
||||
}
|
||||
|
||||
// Check that code template exists
|
||||
found := false
|
||||
for _, tmpl := range templates {
|
||||
if tmpl.Name == "code" {
|
||||
found = true
|
||||
if tmpl.Description == "" {
|
||||
t.Error("code template should have a description")
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("code template not found")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
wantErr bool
|
||||
}{
|
||||
{"code", false},
|
||||
{"disable-telemetry", false},
|
||||
{"git-readonly", false},
|
||||
{"local-dev-server", false},
|
||||
{"nonexistent", true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cfg, err := Load(tt.name)
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Error("expected error, got nil")
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
if cfg == nil {
|
||||
t.Error("expected config, got nil")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadWithJsonExtension(t *testing.T) {
|
||||
// Should work with or without .json extension
|
||||
cfg1, err := Load("disable-telemetry")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to load disable-telemetry: %v", err)
|
||||
}
|
||||
|
||||
cfg2, err := Load("disable-telemetry.json")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to load disable-telemetry.json: %v", err)
|
||||
}
|
||||
|
||||
// Both should return valid configs
|
||||
if cfg1 == nil || cfg2 == nil {
|
||||
t.Error("expected both configs to be non-nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExists(t *testing.T) {
|
||||
if !Exists("code") {
|
||||
t.Error("code template should exist")
|
||||
}
|
||||
if Exists("nonexistent") {
|
||||
t.Error("nonexistent should not exist")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodeTemplate(t *testing.T) {
|
||||
cfg, err := Load("code")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to load code template: %v", err)
|
||||
}
|
||||
|
||||
// Verify key settings
|
||||
if !cfg.AllowPty {
|
||||
t.Error("code template should have AllowPty=true")
|
||||
}
|
||||
|
||||
if len(cfg.Network.AllowedDomains) == 0 {
|
||||
t.Error("code template should have allowed domains")
|
||||
}
|
||||
|
||||
// Check that *.anthropic.com is in allowed domains
|
||||
found := false
|
||||
for _, domain := range cfg.Network.AllowedDomains {
|
||||
if domain == "*.anthropic.com" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("*.anthropic.com should be in allowed domains")
|
||||
}
|
||||
|
||||
// Check that cloud metadata domains are denied
|
||||
if len(cfg.Network.DeniedDomains) == 0 {
|
||||
t.Error("code template should have denied domains")
|
||||
}
|
||||
|
||||
// Check command deny list
|
||||
if len(cfg.Command.Deny) == 0 {
|
||||
t.Error("code template should have denied commands")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user