Scope Enforcement
A Portuni session always has two boundaries: what files the agent can write to, and what nodes it can read from. Without bounds, the agent in a “Goldea Presale” project session can edit files in a sibling process mirror, or list every project across every organization the user can see. Neither is the intended behavior.
The scope model adds two enforceable, complementary mechanisms – one for filesystem writes, one for graph reads – both anchored on the same idea: the session home node.
Session home node
Section titled “Session home node”When a session starts, Portuni picks a single anchor:
- It looks at the current working directory.
- It walks upward through registered local mirrors and finds the nearest ancestor that is a Portuni mirror.
- The node owning that mirror becomes the session home node.
If cwd isn’t inside any mirror, the session has no home node. Scope enforcement degrades to “warn only” – the agent is working outside Portuni territory and Portuni can’t meaningfully bound it.
Everything else in this page is built on top of this single anchor.
Filesystem write scope – three tiers
Section titled “Filesystem write scope – three tiers”Writes divide into three concentric zones, each with different default behavior:
+---------------------------------------------------------------+| Tier 3 – Outside PORTUNI_ROOT || e.g. ~/Desktop, ~/.ssh, /tmp, unrelated repos || -> HARD FLOOR: always ask, no exceptions |+---------------------------------------------------------------+ ^ |+------+--------------------------------------------------------+| PORTUNI_ROOT (e.g. ~/Dev/projekty/) || +----------------------------------------------------------+ || | Tier 2 – Inside PORTUNI_ROOT, outside current mirror | || | e.g. session home is workflow/projects/goldea-presale/, | || | target is workflow/processes/partner-account-mgmt/ | || | -> DENY by default; bypass with explicit confirmation | || +----------------------------------------------------------+ || +----------------------------------------------------------+ || | Tier 1 – Current mirror | || | e.g. workflow/projects/goldea-presale/** | || | -> FREE WRITE | || +----------------------------------------------------------+ |+---------------------------------------------------------------+PORTUNI_ROOT is a single environment variable that names the directory containing every Portuni mirror on the machine. It defaults to the nearest common ancestor of all registered mirrors.
How it’s enforced
Section titled “How it’s enforced”Portuni does not intercept filesystem operations at runtime – cross-harness interception is fragile and easy to bypass. Instead, when a mirror is created or renamed, Portuni writes per-harness configuration into local_path, layering on top of user-owned files (never replacing them):
-
.claude/settings.local.json– an overlay file Claude Code merges on top ofsettings.json. Portuni owns this file completely, so it can be regenerated safely on every call. Three things in one file:permissions.allowfor the current mirror andpermissions.denyfor every other mirror in the registry. No synthetic tier-3 negation – Claude Code’s permission grammar is plain glob, so tier-3 enforcement is delegated to theportuni-guardPreToolUse hook (next bullet).hooks.PreToolUseauto-wired toscripts/portuni-guard.sh(matcher:Edit|Write|NotebookEdit|MultiEdit). Resolved fromPORTUNI_GUARD_SCRIPTenv or relative to the Portuni install. The hook block is omitted when the script can’t be located.portuni_managedmarker so the file is recognisable as auto-generated.
-
.mcp.json– Claude Code project-scoped MCP server registration (mcpServers.portuni). The user is prompted once on first session whether to trust the server; afterwards every session inside this mirror connects to the local Portuni server automatically. Bearer auth header is embedded only whenPORTUNI_AUTH_TOKENis set on the Portuni server’s environment at the timeportuni_mirrorran. Gitignore this file if you embed an auth token. -
.codex/config.toml– two blocks under one Portuni-managed marker:[sandbox_workspace_write]withwritable_roots = [<this mirror>]. Codex’s Seatbelt / Landlock enforces this at the kernel level.[mcp_servers.portuni]withtype = "http",url = <PORTUNI_URL>/mcp, optional bearer headers. Codex CLI auto-connects.
Portuni writes this file only when it is missing or already carries the Portuni marker comment; a hand-edited Codex config is preserved.
-
PORTUNI_SCOPE.md,.cursor/rules– plain-text rules so the agent has the same picture even if the harness config is missing. -
CLAUDE.md/AGENTS.md– refreshed only if they already exist, between BEGIN/ENDportuni-scopemarkers. User content outside the markers is preserved.
When the registry changes (mirror added, removed, or renamed), every affected mirror’s config is regenerated. Result of every regen:
- write-scope deny lists pick up the new sibling mirrors,
- guard hook + MCP server config stay aligned with the running Portuni instance,
- soft hints reflect the up-to-date mirror layout.
Configuration that drives the generated files
Section titled “Configuration that drives the generated files”portuni_mirror reads these at the moment it materialises a mirror’s config; nothing is hard-coded. Set them on the Portuni server’s environment.
| Variable | What it controls | Default |
|---|---|---|
PORTUNI_ROOT | Tier 1/2 boundary. The directory containing every Portuni mirror on this machine. | Nearest common ancestor of every registered mirror |
PORTUNI_GUARD_SCRIPT | Absolute path of portuni-guard.sh written into .claude/settings.local.json as the PreToolUse hook command. | Resolved relative to the Portuni install (scripts/portuni-guard.sh) |
PORTUNI_URL | MCP server base URL written into .mcp.json and .codex/config.toml. The /mcp suffix is appended if missing. | http://${HOST}:${PORT}/mcp, defaulting to http://127.0.0.1:4011/mcp |
PORTUNI_AUTH_TOKEN | Bearer token embedded in MCP Authorization headers. When set, .mcp.json should be gitignored. | unset (no headers emitted) |
PORTUNI_SCOPE_MODE | Read-scope elicitation strictness (strict / balanced / permissive). | strict |
Backstop hook
Section titled “Backstop hook”A PreToolUse hook (portuni-guard.sh) is shipped optionally. When installed, it queries Portuni’s /scope endpoint before every Edit, Write, NotebookEdit, or MultiEdit and returns one of:
- Tier 1 -> exit 0 (allow silently)
- Tier 2 -> exit 2 with “target is in sibling mirror
<name>; run from that mirror or confirm the cross-mirror write” - Tier 3 -> exit 2 with “target is outside
PORTUNI_ROOT; confirm the write is intended”
Behaviour at the edges is deliberate:
- A write tool with no recoverable target path fails closed (exit 2). The hook would rather block than allow a write it cannot classify.
- A non-write tool always allows.
- A malformed JSON payload allows (we cannot tell what the harness wanted).
- An unreachable Portuni server allows. The guard is a soft fallback, not the primary defense; the harness’s own permission system is.
This catches drift in the declarative config, harness bugs, and cases where the config was never written.
What this doesn’t cover
Section titled “What this doesn’t cover”- Writes inside the current mirror that aren’t part of the node’s artifacts (the user’s own scratch files that happen to live there). Scope is directory-based, not artifact-based.
- Writes via
Bashcommands that bypass the harness’s file tools. Some shell tricks slip past Seatbelt and Landlock; neither sandbox is complete. - Hard isolation. For high-stakes work (contractor access, sensitive client data), the recommended path is Dagger Container Use – one container per node, no path escape by construction. Portuni doesn’t ship that; it points at it.
Read scope – session scope set
Section titled “Read scope – session scope set”Graph reads are bounded by a session scope set – the set of node IDs the agent is allowed to fetch in this session. Initially narrow, expanded only by explicit, audited actions.
Initial scope set
Section titled “Initial scope set”At session start the scope set contains:
- The session home node.
- Every node directly connected to it by an edge (depth 1, both directions).
Without a home node (cwd outside any mirror), the scope set starts empty. Every read requires expansion.
Three ways to expand
Section titled “Three ways to expand”| Path | Trigger | User confirmation |
|---|---|---|
| User-initiated pull | User names a node in the prompt (“look at project Evoluce”) | Not needed – the user already asked |
| Agent-initiated expansion | Agent calls a read tool with an out-of-scope node | Required – server elicits confirmation via MCP |
| Connection-following | Agent walks an edge from an in-scope node | Allowed if the neighbor is within depth 1 of any in-scope node |
Every expansion is logged to the audit trail with the reason (the user’s quoted phrase, or the agent’s stated rationale).
Why this is its own page (and not a permission system)
Section titled “Why this is its own page (and not a permission system)”Scope is orthogonal to permissions. Permissions (visibility, Google Groups membership) decide what a user is allowed to see at all. Scope decides what an in-progress session is currently focused on – a second, intentionality-shaped filter applied on top of permissions.
A user with read access to every node in their org still gets a narrow scope set when they start a session in one project. The agent isn’t omniscient by default; it’s focused, and expansion is auditable.
Scope modes
Section titled “Scope modes”PORTUNI_SCOPE_MODE controls how aggressively scope expansion is gated:
| Mode | Behavior |
|---|---|
strict (default) | Every agent-initiated reach for an out-of-scope node elicits user confirmation. Safe default. |
balanced | First reach for a given node per session elicits; subsequent reads of the same node pass silently. Reduces prompt fatigue while still surfacing each new node once. |
permissive | No elicitation. Expansions auto-approved, audited, surfaced in portuni_session_log. Pairs well with harness auto mode. |
Hard floors override mode. A node with meta.scope_sensitive: true, or a visibility: private node owned by another user, always elicits – even in permissive.
MCP tools
Section titled “MCP tools”| Tool | Purpose |
|---|---|
portuni_session_init(home_node_id) | Called by the SessionStart hook. Seeds the scope set with the home node + its depth-1 neighbors. |
portuni_expand_scope(node_ids, reason, triggered_by, confirmed_hard_floor?) | Add nodes to scope. Always audited. Hard-floor nodes (private-other, meta.scope_sensitive) require both confirmed_hard_floor=true AND a real user confirmation; a refusal entry is logged otherwise. |
portuni_session_log() | Returns the current scope set, mode, expansion history. |
portuni_get_node | Out-of-scope target returns {"error":"scope_expansion_required",...}. Name lookups are filtered to in-scope candidates first, so name probing cannot leak metadata. |
portuni_get_context | Start node must be in scope. Depth ≤ 1 is the natural read; depth ≥ 2 is treated as breadth expansion and refused in strict/balanced. |
portuni_list_nodes / portuni_list_events / portuni_list_files | Default to session scope. Global form (scope: "global" on list_nodes, no node_id on the others) is mode-gated: strict refuses, balanced refuses on first call, permissive auto-allows + audits. |
REST surface (out of scope)
Section titled “REST surface (out of scope)”The HTTP REST endpoints (/graph, /context, /nodes/:id/sync-status, /users, /actors, etc.) are intended for the local desktop UI, not for agent-driven access. They are NOT subject to the read-scope set – the UI runs as the same human user the scope model is meant to assist, and gating it would defeat its purpose. Agent-facing access goes through the MCP tools listed above; that’s the surface scope enforcement covers.
Implementation status
Section titled “Implementation status”| Piece | Status |
|---|---|
| Spec | Written – docs/superpowers/specs/2026-04-24-scope-model.md |
Session home node detection (portuni_session_init) | Implemented |
| Read-scope set + per-MCP-connection state | Implemented |
portuni_expand_scope, portuni_session_log | Implemented |
Hard-floor enforcement in expand_scope (refuses without confirmed_hard_floor) | Implemented |
Read tools gated by scope: get_node, get_context, list_nodes, list_events, list_files | Implemented |
get_node(name) ambiguity filtered to in-scope candidates | Implemented |
get_context(depth ≥ 2) treated as breadth expansion | Implemented |
| Mode-gated global queries (strict refuses, balanced first-time refuses, permissive auto-allow + audit) | Implemented |
PORTUNI_SCOPE_MODE (strict / balanced / permissive) | Implemented |
Per-harness write-scope config on portuni_mirror | Implemented |
Settings overlay strategy (.claude/settings.local.json, codex marker-aware) | Implemented |
Auto-wire portuni-guard as PreToolUse hook in generated Claude settings | Implemented |
.mcp.json for Claude Code project-scoped MCP registration | Implemented |
[mcp_servers.portuni] block in generated Codex config.toml | Implemented |
| Sibling regen on mirror add | Implemented |
/scope endpoint + portuni-guard PreToolUse hook (fail-closed on missing target) | Implemented |
portuni-context.sh SessionStart hook honours PORTUNI_AUTH_TOKEN | Implemented |
Audit entries: expand_scope, scope_global_query, scope_hard_floor_refusal, session_init | Implemented |
| Session-close summary | Pending |
| Other harnesses (Gemini CLI, Cline, Continue, Aider, Windsurf, Roo) | Out of scope until requested |
| Harness-mode -> scope-mode auto-alignment | Pending (intentionally fragile, may not ship) |