Skip to content

Permission System

The permission system in Keen Code uses a Guard to enforce filesystem access policies, with user approval for sensitive operations.

Guard

The Guard type in internal/filesystem/guard.go is the central component:

type Guard struct {
    workingDir   string
    blockedPaths []string
    gitignore    *GitAwareness
}

func NewGuard(workingDir string, gitignore *GitAwareness) *Guard

Permission States

type Permission int

const (
    PermissionDenied  Permission = iota  // Blocked by policy
    PermissionGranted                     // Allowed without prompting
    PermissionPending                     // Needs user approval
)

Path Resolution

Paths are resolved relative to the working directory:

func (g *Guard) ResolvePath(path string) (string, error) {
    if filepath.IsAbs(path) {
        return filepath.Clean(path), nil
    }
    return filepath.Join(g.workingDir, path), nil
}

Policy Checks

CheckPath evaluates an operation against the policy:

func (g *Guard) CheckPath(path string, operation string) Permission

Operation: "read"

  • Granted: Path is in working directory OR in skills directory
  • Pending: Path is outside working directory but not blocked
  • Denied: Path is blocked by policy or doesn't resolve

Operation: "write" or "edit"

  • Pending: Always requires user approval (no auto-grant for writes)
  • Denied: Path is blocked by policy

Other Operations

  • Denied: Any unrecognized operation is blocked

Blocked Paths

System directories are blocked by default:

func defaultBlockedPaths() []string {
    return []string{
        "/etc", "/usr", "/bin", "/sbin", "/lib", "/lib64",
        "/proc", "/sys", "/dev", "/root",
    }
}

GitAwareness

The GitAwareness type (internal/filesystem/gitawareness.go) loads .gitignore files to excludeIgnored paths from tool access:

type GitAwareness struct {
    patternSets []PatternSet
}

func (g *GitAwareness) LoadGitignoreRecursive(root string) error
func (g *GitAwareness) IsIgnored(filePath string) bool
func (g *GitAwareness) FilterPaths(paths []string) []string

It uses the go-git library's gitignore parser to handle .gitignore patterns correctly.

Blocked Path Checks

A path is blocked if: 1. It cannot be resolved 2. It matches a .gitignore pattern 3. It is in a hidden directory under home (~/.something) 4. It has a prefix in blockedPaths (system directories) 5. It is in a skill directory (exception - always allowed)

func (g *Guard) IsBlocked(path string) bool {
    resolved, err := g.ResolvePath(path)
    if err != nil {
        return true
    }
    if g.gitignore != nil && g.gitignore.IsIgnored(path) {
        return true
    }
    if g.IsInSkillDir(resolved) {
        return false
    }
    // ... home and system path checks
}

Skills Directory Exception

Skill directories are explicitly allowed for read access: - ~/.agents/skills - ~/.keen/skills

func (g *Guard) IsInSkillDir(path string) bool {
    home, _ := os.UserHomeDir()
    for _, dir := range []string{
        filepath.Join(home, ".agents", "skills"),
        filepath.Join(home, ".keen", "skills"),
    } {
        if strings.HasPrefix(path, dir) {
            return true
        }
    }
    return false
}

PermissionRequester Interface

Tools use PermissionRequester to request user approval:

// internal/tools/permission.go
type PermissionRequester interface {
    RequestPermission(ctx context.Context, toolName, path, resolvedPath string, isDangerous bool) (bool, error)
}

Parameters:

  • toolName: Name of the tool (e.g., "bash", "read_file")
  • path: Original path as provided
  • resolvedPath: Absolute resolved path
  • isDangerous: True for operations that modify files/system

The requester implementation (typically in the CLI/repl layer) prompts the user and returns their decision.

Tool Integration

All tools follow the same permission pattern:

func (t *SomeTool) Execute(ctx context.Context, input any) (any, error) {
    // 1. Parse parameters
    // 2. Resolve path
    // 3. Check permission
    permission := t.guard.CheckPath(path, operation)

    switch permission {
    case PermissionDenied:
        return nil, fmt.Errorf("permission denied by policy")
    case PermissionPending:
        allowed, err := t.permissionRequester.RequestPermission(...)
        if !allowed {
            return nil, fmt.Errorf("permission denied by user")
        }
    }

    // 4. Execute operation
}

Bash Tool Special Case

The bash tool has special handling for dangerous commands:

// If command is marked dangerous, always prompt
if isDangerous {
    allowed, err := t.permissionRequester.RequestPermission(
        ctx, t.Name(), command, "", true,
    )
    // ...
}

Dangerous commands include: - File removal (rm, rm -rf) - Git operations that modify repository state - Process termination - System modifications

Non-dangerous bash commands are auto-granted when the working directory check passes. The only interactive prompt for bash is the dangerous-command prompt.

Project-Level Allow List

Users can pre-allow specific tools for the current project via the /allow-permission command. Settings are stored in .keen/permissions.json:

{
  "allow": ["bash"]
}
  • Tools in allow skip the interactive prompt entirely (including the dangerous-command prompt for bash). The filesystem guard still applies — system directories, .gitignored files, and dotfiles under $HOME remain blocked.
  • Tools absent from allow follow the normal mechanism described above.

/reset-permission <tool_names...> removes tools from the allow list, restoring default behavior.

The lookup order inside RequestPermission is:

  1. autoApprove (headless mode) → grant
  2. project allow list → grant
  3. session-allowed tools (non-dangerous only) → grant
  4. prompt the user