Skip to content

fix(machine): enforce allowPageAgents + visibleToGlobalAssistant machine access toggles#2044

Merged
2witstudios merged 5 commits into
masterfrom
pu/fix-machine-access-enforcement
Jul 13, 2026
Merged

fix(machine): enforce allowPageAgents + visibleToGlobalAssistant machine access toggles#2044
2witstudios merged 5 commits into
masterfrom
pu/fix-machine-access-enforcement

Conversation

@2witstudios

@2witstudios 2witstudios commented Jul 13, 2026

Copy link
Copy Markdown
Owner

Summary

GA-blocker remediation for deferred items 1 and 2 of the Machine/Terminal arc (page wm76dkca6cd2jwcldumys5c5): the two Machine access toggles were persisted but never enforced — the Settings switches wrote allowPageAgents / visibleToGlobalAssistant to the page row, and nothing on the machine-access path read them back.

What's enforced now

Policy (pure, machine-readable codes): decideMachineToggleAccess in packages/lib/src/services/machines/machine-access.ts — the module the codebase designates for shared machine access checks. allowPageAgents gates page-scoped agents (own agentPageId or a sub-agent's parentAgentId — the same discriminator resolveConfiguredMachines uses to pick whose machine list applies); visibleToGlobalAssistant gates the global assistant. Each toggle applies only to its own actor kind.

Exemption — user-scoped page agents: a page agent with userScopedAccess=true (owner-toggled) already acts with the invoking user's own reach for view permissions (canActorViewPageresolveActingAgentId). isMachineAccessible mirrors that fallthrough via a new isUserScopedAgent dep: such an agent bypasses allowPageAgents entirely (it isn't reclassified as the global assistant either — visibleToGlobalAssistant doesn't apply to it, since its machine list comes from its own agent config, not the global config repository). Matches the task's original scope ("a user-scoped/global agent is unaffected by this flag").

Enforcement (single site, every call): isMachineAccessible in createMachineDirectory (sandbox-tools-runtime.ts) consumes the policy and renders an LLM-facing denial reason naming the machine and the exact Settings toggle. Every bash/file/git tool call, switch_machine, and list_machines goes through it. Trashed machine pages are also denied. Toggle checks run after canViewPage, so the reason never leaks to an actor who can't view the page.

Active-machine resolution (resolveActiveMachine, sandbox-tools.ts) performs the access check itself and returns {machine, access}:

  • The default falls back past blocked machines to the first accessible configured one — a blocked machines[0] doesn't dead-end the whole tool group while a usable machine sits one slot over (both toggles, both actor kinds).
  • An explicitly switched machine that becomes blocked is denied with its specific reason — never silently rerouted.
  • When every machine is blocked, the first one's real reason surfaces instead of the misleading "Terminal access is not enabled".
  • list_machines computes each machine's access decision once and derives both the active-machine selection (selectActiveFromDecisions, no extra I/O) and its accessible-only display filter from that single pass — avoiding a double isMachineAccessible call per configured machine.

switch_machine returns the denial code (page_agents_disabled / hidden_from_global) as its machine-readable reason instead of miscategorizing policy denials as inaccessible. Denial rendering is shared (machineAccessDeniedError) so the bash/file and git execution boundaries cannot drift. Git tools reuse the same machineDirectory singleton — no extra wiring.

The flag's existing AI_CHAT/page-agent consumer (agent-awareness.ts) is untouched. Whole surface remains behind CODE_EXECUTION_ENABLED (OFF).

Tests

  • packages/lib machine-access.test.ts: pure policy matrix (each toggle gates only its own actor kind, denial codes).
  • sandbox-tools-runtime.test.ts: page agent denied (code + reason) / allowed; sub-agent treated as page-scoped; global assistant denied (code + reason) / allowed; cross-flag non-interference; no-title-leak; trashed machine denied; user-scoped agent exempt from allowPageAgents (but still subject to view-permission checks, and the exemption keys off its own chatSource.agentPageId only — not a sub-agent's parentAgentId).
  • sandbox-tools.test.ts: fallback past a blocked machines[0] routes bash to the usable machine; all-blocked surfaces the first machine's specific reason; an explicitly switched blocked machine denies without silent rerouting; list_machines marks the fallback target active AND calls isMachineAccessible exactly once per configured machine (asserted directly); switch_machine returns the toggle code.

vitest: 236/236 (web sandbox tool files, includes the new call-count assertion) + 22/22 (lib machines). tsc --noEmit green in packages/lib and apps/web.

🤖 Generated with Claude Code

https://claude.ai/code/session_01DrdKH5t1RYZrVo9CnE8Jm3

…ine access toggles

Both toggles were persisted by the Machine Settings route but never read on
the machine-access path, so flipping either did nothing (GA blocker, deferred
items 1-2 of the Machine/Terminal arc).

- isMachineAccessible (createMachineDirectory) now returns a
  MachineAccessDecision ({ allowed } with an optional LLM-facing denial
  reason) instead of a bare boolean, and enforces both toggles after the
  existing page-view check: a page-scoped agent (agentPageId, own or
  inherited via parentAgentId) is denied when allowPageAgents=false; the
  global assistant (no agentPageId) is denied when
  visibleToGlobalAssistant=false. Toggle reasons surface only to actors who
  can already view the page (no title leak).
- resolveGlobalConfiguredMachines additionally EXCLUDES hidden machines at
  resolution, so the global assistant never lists them and the default-active
  fallback (machines[0]) lands on the next visible machine instead of
  dead-ending on a hidden one. Applies to the resolved own-machine page too.
- bash/file/git tool gates and switch_machine surface the denial reason to
  the model; list_machines filters on the decision. Git tools reuse the same
  machineDirectory singleton, so they are covered with no extra wiring.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DrdKH5t1RYZrVo9CnE8Jm3
@coderabbitai

coderabbitai Bot commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@2witstudios, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 17 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 00e012da-1d2e-406b-b3e4-0afd5a2b7c78

📥 Commits

Reviewing files that changed from the base of the PR and between d6a4cde and 71433ed.

📒 Files selected for processing (10)
  • apps/web/src/components/layout/middle-content/page-views/machine/tabs/SettingsTab.tsx
  • apps/web/src/lib/ai/tools/__tests__/sandbox-git-tools.test.ts
  • apps/web/src/lib/ai/tools/__tests__/sandbox-tools-runtime.test.ts
  • apps/web/src/lib/ai/tools/__tests__/sandbox-tools.test.ts
  • apps/web/src/lib/ai/tools/sandbox-git-tools.ts
  • apps/web/src/lib/ai/tools/sandbox-tools-runtime.ts
  • apps/web/src/lib/ai/tools/sandbox-tools.ts
  • packages/lib/src/services/machines/__tests__/machine-access.test.ts
  • packages/lib/src/services/machines/machine-access.ts
  • packages/lib/src/services/machines/machine-settings.ts
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch pu/fix-machine-access-enforcement

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2fd1e95642

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

// canViewPage so a toggle reason (which names the machine) is never
// surfaced to an actor who can't view the page in the first place.
const agentPageId = activeMachineAgentPageId(rawContext);
if (agentPageId && !page.allowPageAgents) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Respect user-scoped agents before applying page-agent toggle

When a page agent has userScopedAccess=true, the permission layer treats it like the invoking user (canActorViewPage falls through via resolveActingAgentId), but this new check only tests for the presence of an agent page id. In that setup, a personal/user-scoped agent configured with an existing Machine the user can view is still blocked whenever that Machine has "Allow page agents" off, even though user-scoped/global actors should not be subject to the page-agent toggle. Use the same acting-agent resolution, or include userScopedAccess in the directory deps, before applying allowPageAgents.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in 8f0b5e2. canActorViewPage already resolves through resolveActingAgentId, which treats a userScopedAccess=true page agent as the invoking user for view permissions — the toggle classification did not know about this and blocked such an agent whenever allowPageAgents was off, contradicting the task's own stated scope.

Fix: isMachineAccessible now resolves the agent's own chatSource.agentPageId (getAgentPageId — the same seam resolveActingAgentId uses) and checks a new isUserScopedAgent dep (wired to hasAgentUserScopedAccess) before applying either toggle. A user-scoped agent bypasses the toggle decision entirely — exempt from BOTH toggles, not reclassified as the global assistant (visibleToGlobalAssistant would apply an unrelated gate, since its machine list comes from its own agent config, not the global config repository).

Added 4 tests: user-scoped agent allowed despite allowPageAgents=false; the exemption does not bypass the view-permission check; a non-user-scoped agent is still gated; the exemption keys off chatSource.agentPageId only, not a sub-agent's parentAgentId. vitest/tsc --noEmit green.

Leaving this thread open for your verification per policy.

2witstudios and others added 2 commits July 12, 2026 23:28
…active machine fallback

Review-driven hardening of the toggle enforcement (8-angle self-review):

- resolveActiveMachine now performs the access check itself on every call and
  returns {machine, access}: the default falls back past blocked machines to
  the first ACCESSIBLE configured one (a blocked machines[0] no longer
  dead-ends bash/git/file while a usable machine sits one slot over — for
  BOTH toggles and both actor kinds), an explicitly switched machine is
  denied with its specific reason rather than silently rerouted, and when
  every machine is blocked the first one's real reason surfaces instead of
  the misleading "Terminal access is not enabled".
- Dropped the resolution-time visibleToGlobalAssistant filter:
  isMachineAccessible is the single policy site, so switch_machine to a
  hidden machine now explains the toggle instead of claiming "unconfigured",
  and the N-findPage-per-listMachines fan-out is gone.
- Toggle policy extracted to @pagespace/lib machines/machine-access.ts
  (decideMachineToggleAccess, pure + machine-readable denial codes) beside
  canViewMachine/canEditMachine; the directory renders the LLM-facing prose.
  switch_machine returns the code (page_agents_disabled/hidden_from_global)
  instead of miscategorizing policy denials as 'inaccessible'.
- Denial rendering shared via machineAccessDeniedError (bash/file + git
  boundaries can no longer drift); findPage row type reuses
  Pick<MachineSettings, ...>; trashed machine pages now denied at the gate;
  stale SettingsTab doc comment ("not yet enforced anywhere") corrected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DrdKH5t1RYZrVo9CnE8Jm3
… toggle

canActorViewPage already resolves through resolveActingAgentId, which treats
a page agent with userScopedAccess=true (owner-toggled via update_agent_config)
as the INVOKING USER for view permissions — it falls through to the user's
own access rather than the agent's narrower membership. isMachineAccessible's
toggle-actor classification didn't know about this: it only checked
agentPageId presence, so a user-scoped agent (which the machine owner can
already see via canViewPage as themselves) was still misclassified as a
narrow page-scoped actor and blocked whenever allowPageAgents was off —
contradicting the task's own stated scope ("a user-scoped/global agent is
unaffected by this flag").

Fix: isMachineAccessible now resolves the agent's OWN chatSource.agentPageId
(getAgentPageId — the same seam resolveActingAgentId/canActorViewPage use,
deliberately NOT the parentAgentId sub-agent fallback) and checks the new
isUserScopedAgent dep (wired to hasAgentUserScopedAccess) before applying
either toggle. A user-scoped agent bypasses the toggle decision entirely —
it is exempt from BOTH toggles, not reclassified as the global assistant
(visibleToGlobalAssistant would apply an unrelated gate: its machine list
comes from its own agent config, not globalMachineConfigRepository).

Colocated tests: user-scoped agent allowed despite allowPageAgents=false;
exemption doesn't bypass the view-permission check; a non-user-scoped agent
is still gated; the exemption keys off chatSource.agentPageId only, not a
sub-agent's parentAgentId.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DrdKH5t1RYZrVo9CnE8Jm3
2witstudios and others added 2 commits July 12, 2026 23:45
Self-review finding: list_machines called resolveActiveMachine (its own
listMachines + a short-circuited isMachineAccessible loop) AND separately
mapped isMachineAccessible over every configured machine to build the
accessible-only display list — up to 2x the isMachineAccessible calls (each
now a findPage + canViewPage + isUserScopedAgent DB round trip) per
list_machines invocation versus before this PR's active-machine fallback
was added.

Fix: compute the isMachineAccessible decision for every configured machine
ONCE, then derive both the active machine (selectActiveFromDecisions — no
I/O, mirrors resolveActiveMachine's switched/first-accessible/first-denied
policy) and the accessible-only filter from that single decision set.
open() (bash/file/git boundary) is untouched — it keeps resolveActiveMachine's
short-circuited lookup, which only needs ONE accessible machine, not all of
them.

Test asserts the call count directly: 2 configured machines -> exactly 2
isMachineAccessible calls (was up to 4 in the worst case where machines[0]
is blocked).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DrdKH5t1RYZrVo9CnE8Jm3
…ified fine locally)

No code change. gh run rerun on the previous CI run stalled identically
across 4 attempts (ESLint step / Build packages step never completing,
independent of fleet queue depth). Pushing a fresh commit to get a brand-new
workflow run rather than reusing the existing run's possibly-corrupted state.
Local bun run lint / vitest / tsc all pass — see PR #2044 description.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DrdKH5t1RYZrVo9CnE8Jm3
@2witstudios 2witstudios merged commit 53800c9 into master Jul 13, 2026
15 of 18 checks passed
@2witstudios 2witstudios deleted the pu/fix-machine-access-enforcement branch July 13, 2026 13:57
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