Skip to content

refactor(modules): extract permissions as optional module#1847

Merged
gavrielc merged 2 commits intov2from
refactor/pr5-permissions
Apr 18, 2026
Merged

refactor(modules): extract permissions as optional module#1847
gavrielc merged 2 commits intov2from
refactor/pr5-permissions

Conversation

@gavrielc
Copy link
Copy Markdown
Collaborator

Summary

Extracts the permissions module per REFACTOR_PLAN.md Step 5 / REFACTOR_EXECUTION.md Phase 3.

Moves user identity, roles, membership, DM resolution, and access-decision code out of core into `src/modules/permissions/`. The module registers a single `setInboundGate` hook that owns sender resolution + access decision + unknown-sender policy + drop-audit recording.

Module taxonomy: permissions is an optional module (registry-based, installed via `/add-permissions` skill post-Phase-4). Without it, core defaults to allow-all (every message routes through, userId=null); container formatter falls back to permissionless mode (every sender treated as admin).

Changes

  • Moved (`git mv`): `src/db/{users,user-roles,agent-group-members,user-dms,dropped-messages}.ts` → `src/modules/permissions/db/`; `src/user-dm.ts` → `src/modules/permissions/user-dm.ts`.
  • Split: `src/access.ts` — `canAccessAgentGroup` + `AccessDecision` moved to `src/modules/permissions/access.ts`; dead code (`canAdminAgentGroup`, `agentGroupIdForSession`) deleted. Approver-picking helpers (`pickApprover`, `pickApprovalDelivery`) stay in core `src/access.ts` — they relocate in PR Fix hardcoded home directory fallback in container runner #7 alongside the approvals re-tier.
  • New: `src/modules/permissions/index.ts` registers the inbound gate, consolidating the four inline fallback helpers from router.ts.
  • Slimmed: router.ts 357 → 179 lines (inline fallback chain gone).
  • Inlined in `container-runner.ts`: admin-ID query is now raw SQL guarded by `hasTable(db, 'user_roles')` — keeps core free of any import from the permissions module.
  • Container: `poll-loop.ts` treats empty `NANOCLAW_ADMIN_USER_IDS` as permissionless mode (every sender with a senderId is admin).
  • Module contract doc formalizes the tier model + DAG dependency rule and flags the one transitional violation.

Known transitional violation

`src/access.ts` (core) imports from `src/modules/permissions/` (optional) for its remaining approver-picking helpers. Documented in the file header, the module contract doc, and this PR. Resolves in PR #7 when access.ts moves into the approvals re-tier.

Test plan

  • `pnpm run build` — clean
  • `pnpm test` — 137/137 host tests pass
  • `bun test` (container/agent-runner) — 17/17 pass
  • `tsc --noEmit` (container) — clean
  • Service start: `gtimeout 10 node dist/index.js` boots to "NanoClaw running", permissions module's gate registers, clean SIGTERM shutdown
  • Manual: route a real message through with a real DM channel, confirm access gate enforced

🤖 Generated with Claude Code

Moves user-roles / users / agent-group-members / user-dms /
dropped-messages / user-dm / canAccessAgentGroup into
src/modules/permissions/. Module registers a single inbound-gate that
owns sender resolution, access decision, unknown-sender policy, and
drop-audit recording.

Router slimmed from 357 → 179 lines; the inline fallback chain
(extractAndUpsertUser / enforceAccess / handleUnknownSender /
recordDroppedMessage) is gone — without the permissions module core
defaults to allow-all with userId=null.

container-runner's admin-ID query is now inline SQL guarded by
sqlite_master on user_roles, keeping core free of any import from the
permissions module. The container-side formatter falls back to
permissionless mode when NANOCLAW_ADMIN_USER_IDS is empty: every sender
with an identifiable senderId is treated as admin.

Module contract doc formalizes the tier model and the dependency rule
(core ← default modules ← optional modules). One transitional violation
flagged: src/access.ts (core) imports from the permissions module for
its remaining approver-picking helpers; resolves in the planned PR #7
re-tier.

Validation: host build clean, 137/137 host tests, 17/17 container
tests, typecheck clean, service boots to "NanoClaw running" with
permissions module registering its gate and clean SIGTERM shutdown.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@gavrielc gavrielc requested a review from gabi-simons as a code owner April 18, 2026 14:45
PR #5 review flagged three behavior changes that shouldn't have slipped
in. This commit reverts each to match the pre-refactor behavior exactly.

1. User upsert ordering. Split the router hook into two setters:
   setSenderResolver (runs before agent resolution) and setAccessGate
   (runs after). Restores the pre-PR sequence where the users row is
   upserted even if the message is dropped by wiring or trigger rules.

2. dropped_messages audit. Moved src/modules/permissions/db/dropped-messages.ts
   back to src/db/dropped-messages.ts. The table is core audit infra, not
   permissions-specific. Router re-writes rows for no_agent_wired and
   no_trigger_match; the access gate writes rows for policy refusals.

3. Permissionless container fallback. Dropped. poll-loop restores the
   original deny-all check when NANOCLAW_ADMIN_USER_IDS is empty.

Module contract doc updated with the two-hook shape.

Validation: host build clean, 137/137 host tests, 17/17 container
tests, typecheck clean, service boots to "NanoClaw running" with
permissions module registering both hooks and clean SIGTERM shutdown.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@gavrielc gavrielc merged commit c80a23e into v2 Apr 18, 2026
@gavrielc gavrielc deleted the refactor/pr5-permissions branch April 18, 2026 15:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant