Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 25 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ Configured checks are **AND**-combined: a request must pass every check that is
- `/healthz` is unauthenticated and says only `ok`.

Trust model: `exec` is a full shell as the logged-in user. `/mcp` is "my CLI on my Mac". A
sandboxed agent never gets `/mcp`; it gets a **reverse attach** with the `sandbox` profile (below).
sandboxed agent never gets `/mcp`; it gets a **reverse attach** with the `desktop` profile (below).

## Lending this Mac to a sandboxed agent (reverse attach)

Expand All @@ -161,20 +161,33 @@ An agent in an `openab-pty` session cannot reach this Mac — the pod has no egr
```

```sh
# "lend my Mac to session laptop for an hour, sandbox profile"; the Mac mints the attach
# "lend my Mac to session laptop for an hour, desktop profile"; the Mac mints the attach
# secret at the runtime with its admin credential (used once, not stored) and dials in.
curl -s -X POST -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"runtime":"ws://100.111.174.31:8090","session":"laptop","profile":"sandbox",
-d '{"runtime":"ws://100.111.174.31:8090","session":"laptop","profile":"desktop",
"ttl_secs":3600,"admin_credential":"<openab-pty admin credential>"}' \
https://macmini.<tailnet>.ts.net:8444/attach
# → 202 {"id":"…","state":"idle"|"attached",…} GET /attach lists · DELETE /attach/{id} revokes
```

- **`/attach` uses the same `AuthPolicy` as `/mcp`** — the human's tailnet login + bearer. The grant
lives on this Mac; the phone can be put away after the tap.
- **Profiles** are per grant and fixed for its life: `owner` = every tool; `sandbox` = no `exec*`
(the agent already has a shell in its sandbox). Under `sandbox`, `tools/list` omits them and a
forced `tools/call exec` is an *unknown tool* error. Widening is a new grant.
- **Profiles** are per grant and fixed for its life: `owner` = every tool; `desktop` = every tool
except `exec*`; `observe` = `sys_info` + `screenshot` only. Any other value is a 400, including
the removed name `sandbox`. Hidden tools are absent from `tools/list`, and a forced
`tools/call` is an *unknown tool* error. Widening is a new grant. Full per-profile tool lists
and diffs: [`docs/tool-profiles.md`](docs/tool-profiles.md).
- ⚠️ **Neither profile is a security boundary** ([#45](https://github.com/openabdev/instance-mcp/issues/45)).
GUI control is a shell: `osascript` runs `do shell script`, `key` types into a terminal, `mouse`
opens one. Removing `exec*` removes a convenient entry point, **not a privilege** — lending a
computer under `desktop` hands the agent the desktop user's shell for the whole lease, and the
browser tools run with this computer's logged-in browser profile and network (localhost and
tailnet included). Filtering AppleScript text cannot fix this (JXA `doShellScript`,
`ObjC.import` → `NSTask`, Terminal `do script`, System Events keystrokes). When an agent needs
full control, **lend a dedicated computer** — a Linux hands node or a throwaway machine/VM —
not the one you work on. Restricted, genuinely narrower tiers (`observe`, `browser` with a
throwaway profile) are planned in #45; `ProfileBoundaryTests` fails any profile that claims to
be narrower while still allowing a shell-capable tool.
- Either `secret` (already minted by the operator at the runtime) or `admin_credential` (the Mac
mints, TTL is the runtime's) — exactly one. A new grant for the same runtime+session replaces the
old one; that is renewal. `ttl_secs` is forwarded all the way to the runtime (not just held by
Expand All @@ -193,17 +206,18 @@ The sandbox has no path to any browser, so browser control is served **from this
LaunchAgent from [`poc/pw-mcp`](poc/pw-mcp/README.md) is installed) the daemon re-serves
Playwright's tools under its own `tools/list`, filtered by the connection's profile:

- `owner` sees all 32 `browser_*` tools; `sandbox` sees the navigate / read / interact subset
(`ToolProfile.sandboxBrowserTools`) and **not** `browser_run_code_unsafe`, file upload / PDF,
network inspection, raw mouse-by-coordinate, dialogs, `browser_close`. New Playwright tools are
denied under sandbox until listed.
- `owner` sees all 32 `browser_*` tools; `desktop` sees the navigate / read / interact subset
(`ToolProfile.desktopBrowserTools`) and **not** `browser_evaluate` / `browser_run_code_unsafe`
(arbitrary JavaScript), file upload / PDF, network inspection, raw mouse-by-coordinate, dialogs,
`browser_close`. New Playwright tools are denied under `desktop` until listed. This narrows the
browser *tools*, not the browser: it is this computer's persistent profile, with its cookies.
- `browser_navigate` + `browser_snapshot` returns the page as an accessibility tree — the first
video title on a channel page is one text line, no screenshot, no OCR. The browser is a real
window on this Mac's desktop, so Connect's Screens pane shows what the agent is doing.
- Upstream down → its tools are absent from `tools/list`; everything else works. The upstream's
`Mcp-Session-Id` is re-established automatically. Local tool names win on collision.

Verified 2026-09-26 from a lent pod session (sandbox): 16 `browser_*` tools listed, `run_code_unsafe`
Verified 2026-09-26 from a lent pod session (then `sandbox`, now `desktop`; 15 since `browser_evaluate` was removed in #45): 16 `browser_*` tools listed, `run_code_unsafe`
unknown, navigate → snapshot on a YouTube channel returned the first video's title as text.

Verified 2026-09-26 end to end on macmini against the openab-pty runtime (PR #38): mint via
Expand Down
19 changes: 14 additions & 5 deletions Sources/InstanceMCPCore/AttachManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,7 @@ public actor AttachManager {
@discardableResult
private func startClient(_ grant: Grant, secret: String) async -> Grant {
let id = grant.id
let instructions = Self.sandboxInstructions(profile: grant.profile, base: baseServer.instructions)
let instructions = Self.desktopInstructions(profile: grant.profile, base: baseServer.instructions)
let scoped = baseServer.scoped(to: grant.profile, instructions: instructions)
let config = ReverseAttachClient.Config(runtime: grant.runtime, session: grant.session, secret: secret,
profile: grant.profile, deadline: grant.expiresAt)
Expand Down Expand Up @@ -220,13 +220,22 @@ public actor AttachManager {

// MARK: helpers

static func sandboxInstructions(profile: ToolProfile, base: String?) -> String? {
guard profile == .sandbox else { return base }
static func desktopInstructions(profile: ToolProfile, base: String?) -> String? {
if profile == .observe {
let head = base.map { $0 + "\n\n" } ?? ""
return head + """
You reached this computer through OpenAB Connect under the `observe` profile: a human lets \
you look, not act. Only `sys_info` and `screenshot` are available — you cannot click, type \
or run anything here. If the task needs input on this computer, ask the human to grant \
the `desktop` profile instead.
"""
}
guard profile == .desktop else { return base }
let head = base.map { $0 + "\n\n" } ?? ""
return head + """
You reached this Mac through OpenAB Connect: a human lent it to your sandbox session for a \
limited time and is likely watching the screen. This is the `sandbox` profile — there is no \
`exec` tool here (you already have a shell in your own session); drive the Mac through \
limited time and is likely watching the screen. This is the `desktop` profile — there is no \
`exec` tool here (prefer the shell in your own session for local work); drive the Mac through \
`screenshot`, `mouse`, `key` and `osascript`, and — when `browser_*` tools are listed — through \
the browser directly: `browser_navigate` then `browser_snapshot` gives you the page as text, \
no screenshot needed. If a tool starts failing with "not attached", the grant ended; ask the \
Expand Down
2 changes: 1 addition & 1 deletion Sources/InstanceMCPCore/MCPHTTPEndpoint.swift
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ extension MCPHTTPEndpoint {
guard let session = body["session"]?.stringValue else {
return .json(400, JSONValue.object(["error": "session is required"]))
}
let profileStr = body["profile"]?.stringValue ?? ToolProfile.sandbox.rawValue
let profileStr = body["profile"]?.stringValue ?? ToolProfile.desktop.rawValue
guard let profile = ToolProfile(rawValue: profileStr) else {
return .json(400, JSONValue.object(["error": .string("profile must be one of \(ToolProfile.allCases.map(\.rawValue))")]))
}
Expand Down
67 changes: 52 additions & 15 deletions Sources/InstanceMCPCore/ToolProfile.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,36 +4,73 @@ import Foundation
/// the MCP server, so scoping is a per-connection tool list here, never MCP
/// parsing in a proxy (instance-mcp `docs/adr/reverse-attach.md`).
///
/// `owner` is the logged-in human's own CLI: everything. `sandbox` is an agent in
/// an `openab-pty` session that a human lent this Mac to: the see→act→see loop
/// (screenshot / mouse / key / osascript / sys_info) but **no `exec*`**, because
/// `exec` is a full shell as the desktop user and the agent already has a shell
/// of its own in the sandbox. Widening later (an approval hop in Connect) is a
/// new profile, not an edit to this one.
/// `owner` is the logged-in human's own CLI: everything. `desktop` is an agent in
/// an `openab-pty` session that a human lent this computer to: the see→act→see
/// loop (screenshot / mouse / key / osascript / sys_info) without the `exec*`
/// tools.
///
/// **`desktop` is not a security boundary** (instance-mcp#45). GUI control is a
/// shell: `osascript` runs `do shell script`, `key` can type into a terminal, and
/// `mouse` can open one. Removing `exec*` removes a convenient entry point, not a
/// privilege; lending under `desktop` hands the agent the desktop user's shell for
/// the lease. A profile that is meant to be narrower must be built from tools that
/// cannot reach a shell — `ToolProfileTests` enforces that for every profile that
/// does not declare itself `isShellEquivalent`.
///
/// `observe` is the first profile that *is* a boundary: `sys_info` and
/// `screenshot` only. The agent can see the screen (which still discloses what is
/// on it) but cannot change anything, so it is not shell-equivalent.
///
/// The old name `sandbox` is **not** accepted: it described a boundary that does not
/// exist, and a client still sending it has not been updated to say so to its user.
/// It is refused (400) like any unknown profile, and a grant stored under it is dropped.
/// Full per-profile tool lists: `docs/tool-profiles.md`.
public enum ToolProfile: String, Codable, Sendable, CaseIterable {
case owner
case sandbox
case desktop
case observe

/// Whether this profile grants (directly or through GUI control) the desktop
/// user's shell. `observe` is not, and `ProfileBoundaryTests` holds it to that:
/// it must allow no shell-capable tool.
public var isShellEquivalent: Bool {
switch self {
case .owner, .desktop: return true
case .observe: return false
}
}

/// Everything `observe` may call. An allowlist, so a new tool — local or
/// upstream — is denied under `observe` until it is listed here.
public static let observeTools: Set<String> = ["sys_info", "screenshot"]

/// Local tools that reach a shell as the desktop user, directly or by driving
/// the GUI. Any profile with `isShellEquivalent == false` must allow none.
public static let shellCapableTools: Set<String> = ["osascript", "key", "mouse"]

/// Match is on the tool name.
public func allows(_ toolName: String) -> Bool {
switch self {
case .owner: return true
case .sandbox:
case .desktop:
if toolName.hasPrefix("exec") { return false }
if toolName.hasPrefix("browser_") { return Self.sandboxBrowserTools.contains(toolName) }
if toolName.hasPrefix("browser_") { return Self.desktopBrowserTools.contains(toolName) }
return true
case .observe:
return Self.observeTools.contains(toolName)
}
}

/// Playwright MCP tools a lent sandbox may use: navigate, read, interact.
/// Not: arbitrary code in the browser process, the filesystem (upload / PDF),
/// raw network inspection, dialogs, media emulation, closing the browser.
/// Anything Playwright adds later is denied until listed here.
public static let sandboxBrowserTools: Set<String> = [
/// Playwright MCP tools a lent `desktop` grant may use: navigate, read, interact.
/// Not: arbitrary JavaScript (`browser_evaluate`, `browser_run_code_unsafe`), the
/// filesystem (upload / PDF), raw network inspection, dialogs, media emulation,
/// closing the browser. Anything Playwright adds later is denied until listed here.
/// The browser still uses this computer's persistent profile and network.
public static let desktopBrowserTools: Set<String> = [
"browser_navigate", "browser_navigate_back", "browser_snapshot", "browser_find",
"browser_click", "browser_type", "browser_fill_form", "browser_press_key", "browser_hover",
"browser_select_option", "browser_wait_for", "browser_tabs", "browser_take_screenshot",
"browser_console_messages", "browser_resize", "browser_evaluate",
"browser_console_messages", "browser_resize",
]
}

Expand Down
2 changes: 1 addition & 1 deletion Sources/oab-instance-mcp/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ func usage() -> Never {
--upstream <name=url> Re-serve the tools of a loopback MCP server (e.g. the Playwright MCP
at browser=http://127.0.0.1:8794/mcp) under this daemon, subject to the
connection's tool profile. Repeatable. Tools appear with the upstream's
own names; sandbox sees an allowlisted subset of browser_*.
own names; desktop sees an allowlisted subset of browser_*, observe none.
--no-attach Disable the reverse-attach plane (POST/GET /attach, DELETE /attach/{id}):
the human-credentialed endpoint through which Connect / Remote lends this
Mac to one openab-pty session (this Mac dials the pod; see the ADR).
Expand Down
2 changes: 1 addition & 1 deletion Tests/InstanceMCPCoreTests/ConformanceVectorTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ final class ConformanceVectorTests: XCTestCase {
n += 1
let config = ReverseAttachClient.Config(
runtime: URL(string: c["runtime"]!)!, session: c["session"]!, secret: "x",
profile: .sandbox, deadline: Date())
profile: .desktop, deadline: Date())
XCTAssertEqual(config.attachURL.absoluteString, c["expect"])
}
XCTAssertGreaterThanOrEqual(n, 23)
Expand Down
21 changes: 18 additions & 3 deletions Tests/InstanceMCPCoreTests/GrantPersistenceTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ final class GrantPersistenceTests: XCTestCase {
}

private func grant(_ id: String, expiresIn: TimeInterval) -> PersistedGrant {
PersistedGrant(id: id, runtime: URL(string: "ws://127.0.0.1:9")!, session: "s", profile: "sandbox",
PersistedGrant(id: id, runtime: URL(string: "ws://127.0.0.1:9")!, session: "s", profile: "desktop",
principal: "a@b", createdAt: Date(), expiresAt: Date().addingTimeInterval(expiresIn),
secret: "secret-\(id)")
}
Expand Down Expand Up @@ -86,7 +86,7 @@ final class GrantPersistenceTests: XCTestCase {

private func request(_ session: String, secret: String = "abc", ttl: TimeInterval = 60) -> AttachManager.Request {
// Unreachable runtime: the grant exists and its client keeps redialling.
AttachManager.Request(runtime: URL(string: "ws://127.0.0.1:9")!, session: session, profile: .sandbox,
AttachManager.Request(runtime: URL(string: "ws://127.0.0.1:9")!, session: session, profile: .desktop,
ttl: ttl, secret: secret, adminCredential: nil)
}

Expand All @@ -104,7 +104,7 @@ final class GrantPersistenceTests: XCTestCase {
let grants = await second.list()
XCTAssertEqual(grants.map(\.id), [created.id])
XCTAssertEqual(grants.first?.session, "laptop")
XCTAssertEqual(grants.first?.profile, .sandbox)
XCTAssertEqual(grants.first?.profile, .desktop)
XCTAssertEqual(grants.first?.principal, "a@b")
XCTAssertEqual(grants.first?.expiresAt.timeIntervalSince1970 ?? 0,
created.expiresAt.timeIntervalSince1970, accuracy: 1)
Expand Down Expand Up @@ -136,6 +136,21 @@ final class GrantPersistenceTests: XCTestCase {
await mgr.revoke(new.id)
}

/// instance-mcp#45: `sandbox` is refused, not aliased. A grant stored under it by an
/// older build ends at resume instead of coming back under any profile.
func testAGrantStoredUnderTheOldSandboxNameIsDropped() async throws {
let secrets = InMemorySecretStore()
let old = PersistedGrant(id: "old-sandbox", runtime: URL(string: "ws://127.0.0.1:9")!, session: "s",
profile: "sandbox", principal: "a@b", createdAt: Date(),
expiresAt: Date().addingTimeInterval(600), secret: "secret-old")
store(secrets).save([old])
let mgr = AttachManager(server: fullServer(), store: store(secrets))
let resumed = await mgr.resume()
XCTAssertEqual(resumed, 0)
let remaining = await mgr.list()
XCTAssertTrue(remaining.isEmpty)
}

func testWithoutAStoreNothingIsPersisted() async throws {
let mgr = AttachManager(server: fullServer())
let g = try await mgr.create(request("laptop"), principal: "a@b")
Expand Down
Loading
Loading