Plan: /permission Command with Project-Level Config¶
Overview¶
Introduce a /permission [allow|deny|default] <tool_names...> REPL command that lets users grant or revoke specific tool permissions, persisted to .keen/permissions.json in the working directory. Overrides apply before the normal permission mechanism but after filesystem guard safety checks.
Config File¶
Path: <working_dir>/.keen/permissions.json
{
"allow": ["bash", "write_file"],
"deny": ["edit_file"]
}
- Tool present in
allow→ auto-grant (skip prompt, including dangerous), filesystem guard still applies - Tool present in
deny→ always block, regardless of path - Tool absent from both → normal Keen Code mechanism
New Files¶
internal/config/project_permissions.go¶
type ToolSet map[string]struct{}
// MarshalJSON serializes as a JSON array: ["bash", "write_file"]
// UnmarshalJSON parses from a JSON array
type ProjectPermissions struct {
Allow ToolSet `json:"allow"`
Deny ToolSet `json:"deny"`
}
ToolSet provides O(1) lookups directly and serializes cleanly to/from JSON arrays.
Functions:
- LoadProjectPermissions(workingDir string) (*ProjectPermissions, error) — reads .keen/permissions.json; returns empty struct (both sets initialized) if file not found
- SaveProjectPermissions(workingDir string, perms *ProjectPermissions) error — creates .keen/ dir if needed, writes JSON
Modified Files¶
internal/cli/repl/permissions/requester.go¶
Add projectPerms *config.ProjectPermissions field to Requester.
At the top of RequestPermission(), before the existing autoApprove and sessionAllowedTools checks, insert:
if projectPerms.IsInDeny(toolName) → return false
if projectPerms.IsInAllow(toolName) → return true // guard already ran upstream; bypasses dangerous prompt too
Update NewRequester(...) to accept *config.ProjectPermissions.
internal/cli/repl/commands/commands.go¶
Add:
const Permission = "/permission"
Add to All slice:
{Name: Permission, Description: "Set tool permissions: allow|deny|default <tool_names>"}
internal/cli/repl/command_handlers.go¶
Add a case for replcommands.Permission in dispatchCommand. Handler logic:
- Parse:
/permission <subcommand> <tool1> [tool2...] - If no args or invalid subcommand → print usage + list available tool names (from
appStateregistry) - Validate each tool name against the registry; warn on unknown names
allow→ delete from deny set, add to allow setdeny→ delete from allow set, add to deny setdefault→ delete from both sets- Persist updated
projectPermsviaconfig.SaveProjectPermissions - Print confirmation
Autocomplete hint: when user types /permission (with trailing space), suggest allow, deny, default. After a subcommand is typed, suggest registered tool names.
internal/cli/repl/repl.go / internal/cli/repl/tooling/tool_registry.go¶
In SetupToolRegistry (or just before it in initialModel):
- Call config.LoadProjectPermissions(workingDir) to get *ProjectPermissions
- Pass it through AppState or directly to permissions.NewRequester(...)
- Store reference on replModel so dispatchCommand can mutate and persist it
Permission Check Flow (Updated)¶
tool.Execute -> guard.CheckPath(path, op)
├── PermissionDenied → error (hard block, unchanged)
├── PermissionGranted → proceed (unchanged)
└── PermissionPending → permissionRequester.RequestPermission(toolName, ...)
├── projectPerms.IsInDeny(toolName) → false (new)
├── projectPerms.IsInAllow(toolName) → true (new; bypasses dangerous prompt)
├── autoApprove → true (existing)
├── sessionAllowedTools[toolName]→ true (existing)
└── prompt user (existing)
Note: PermissionDenied from the guard (system paths, gitignore, dotfiles) is never reached by RequestPermission — it short-circuits before that call. So /permission allow cannot bypass those hard blocks.
Key Decisions¶
| Decision | Choice | Reason |
|---|---|---|
| Config scope | Project-level .keen/permissions.json |
Per-repo trust model |
| Default key | Absent from both maps | Simpler; no sentinel value needed |
| Load strategy | Once at startup, sync in-memory on /permission calls |
Avoids per-call file I/O |
| Filesystem guard | Always runs | Safety boundary cannot be overridden |
| Dangerous commands | /permission allow bash bypasses dangerous prompt |
Bash non-dangerous is already auto-allowed; the only effect of allowing bash is skipping dangerous prompts |
Files Touched Summary¶
| File | Change |
|---|---|
internal/config/project_permissions.go |
New — struct + load/save |
internal/cli/repl/permissions/requester.go |
Add project perms check + new constructor param |
internal/cli/repl/commands/commands.go |
Add Permission constant and All entry |
internal/cli/repl/command_handlers.go |
Add /permission dispatch + handler |
internal/cli/repl/repl.go |
Load project perms at startup, store on model |
internal/cli/repl/tooling/tool_registry.go |
Pass project perms to requester |
Out of Scope¶
- Persisting session-level allows (
ChoiceAllowSession) to the project config — that remains in-memory only - Global (cross-project) permission overrides
- UI for listing current permission state (follow-up:
/permission list)