diff --git a/README.md b/README.md index 7cc5116..3948cac 100644 --- a/README.md +++ b/README.md @@ -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) @@ -161,10 +161,10 @@ 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":""}' \ https://macmini..ts.net:8444/attach # → 202 {"id":"…","state":"idle"|"attached",…} GET /attach lists · DELETE /attach/{id} revokes @@ -172,9 +172,22 @@ curl -s -X POST -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/ - **`/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 @@ -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 diff --git a/Sources/InstanceMCPCore/AttachManager.swift b/Sources/InstanceMCPCore/AttachManager.swift index bf0a99a..9ba7a5b 100644 --- a/Sources/InstanceMCPCore/AttachManager.swift +++ b/Sources/InstanceMCPCore/AttachManager.swift @@ -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) @@ -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 \ diff --git a/Sources/InstanceMCPCore/MCPHTTPEndpoint.swift b/Sources/InstanceMCPCore/MCPHTTPEndpoint.swift index 5949bb3..aaae431 100644 --- a/Sources/InstanceMCPCore/MCPHTTPEndpoint.swift +++ b/Sources/InstanceMCPCore/MCPHTTPEndpoint.swift @@ -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))")])) } diff --git a/Sources/InstanceMCPCore/ToolProfile.swift b/Sources/InstanceMCPCore/ToolProfile.swift index 2843bb3..3b96476 100644 --- a/Sources/InstanceMCPCore/ToolProfile.swift +++ b/Sources/InstanceMCPCore/ToolProfile.swift @@ -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 = ["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 = ["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 = [ + /// 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 = [ "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", ] } diff --git a/Sources/oab-instance-mcp/main.swift b/Sources/oab-instance-mcp/main.swift index 004551b..484cd1e 100644 --- a/Sources/oab-instance-mcp/main.swift +++ b/Sources/oab-instance-mcp/main.swift @@ -41,7 +41,7 @@ func usage() -> Never { --upstream 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). diff --git a/Tests/InstanceMCPCoreTests/ConformanceVectorTests.swift b/Tests/InstanceMCPCoreTests/ConformanceVectorTests.swift index 76e834c..f41d2a1 100644 --- a/Tests/InstanceMCPCoreTests/ConformanceVectorTests.swift +++ b/Tests/InstanceMCPCoreTests/ConformanceVectorTests.swift @@ -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) diff --git a/Tests/InstanceMCPCoreTests/GrantPersistenceTests.swift b/Tests/InstanceMCPCoreTests/GrantPersistenceTests.swift index c55ce3a..5fd693a 100644 --- a/Tests/InstanceMCPCoreTests/GrantPersistenceTests.swift +++ b/Tests/InstanceMCPCoreTests/GrantPersistenceTests.swift @@ -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)") } @@ -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) } @@ -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) @@ -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") diff --git a/Tests/InstanceMCPCoreTests/ProfileBoundaryTests.swift b/Tests/InstanceMCPCoreTests/ProfileBoundaryTests.swift new file mode 100644 index 0000000..bde1dbe --- /dev/null +++ b/Tests/InstanceMCPCoreTests/ProfileBoundaryTests.swift @@ -0,0 +1,93 @@ +import Foundation +import XCTest +@testable import InstanceMCPCore + +/// instance-mcp#45: a tool profile is only a security boundary if it cannot reach +/// the desktop user's shell. GUI control is a shell — `osascript` runs +/// `do shell script`, `key` types into a terminal, `mouse` opens one — so a profile +/// that hides `exec*` but keeps those is not narrower in privilege, only in +/// convenience. These tests make that impossible to claim by accident. +final class ProfileBoundaryTests: XCTestCase { + /// Every local tool this daemon ships, by name, straight from the source so a + /// new tool is covered without anyone remembering to list it here. + private func shippedLocalToolNames() throws -> Set { + let dir = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent() + .appendingPathComponent("Sources/InstanceMCPCore/Tools") + var names = Set() + for file in try FileManager.default.contentsOfDirectory(atPath: dir.path) where file.hasSuffix(".swift") { + let text = try String(contentsOf: dir.appendingPathComponent(file), encoding: .utf8) + let regex = try NSRegularExpression(pattern: #"let name = "([a-z_]+)""#) + for m in regex.matches(in: text, range: NSRange(text.startIndex..., in: text)) { + if let r = Range(m.range(at: 1), in: text) { names.insert(String(text[r])) } + } + } + return names + } + + func testTheToolScanSeesTheShellCapableTools() throws { + let shipped = try shippedLocalToolNames() + for tool in ["exec", "osascript", "key", "mouse", "screenshot", "sys_info"] { + XCTAssertTrue(shipped.contains(tool), "tool scan missed \(tool); shipped: \(shipped.sorted())") + } + } + + /// The adversary check: a profile that does not declare itself shell-equivalent + /// must allow no tool that reaches a shell. Today no such profile exists and + /// `desktop` must say so; a future `observe` / `browser` profile is held to it. + func testNoProfileClaimsToBeNarrowerThanItIs() throws { + let shellReaching = try shippedLocalToolNames().filter { + $0.hasPrefix("exec") || ToolProfile.shellCapableTools.contains($0) + } + XCTAssertFalse(shellReaching.isEmpty) + for profile in ToolProfile.allCases { + let reachable = shellReaching.filter(profile.allows) + if !profile.isShellEquivalent { + XCTAssertTrue(reachable.isEmpty, + "\(profile.rawValue) claims no shell but allows \(reachable.sorted())") + } else { + XCTAssertFalse(reachable.isEmpty, "\(profile.rawValue) is marked shell-equivalent; keep it honest") + } + } + } + + func testDesktopIsDeclaredShellEquivalent() { + XCTAssertTrue(ToolProfile.desktop.isShellEquivalent) + for tool in ["osascript", "key", "mouse"] { + XCTAssertTrue(ToolProfile.desktop.allows(tool), "\(tool) is part of desktop control") + } + XCTAssertFalse(ToolProfile.desktop.allows("exec")) + } + + /// `observe` is the one real boundary today: look, never act. + func testObserveCanOnlyLook() throws { + XCTAssertFalse(ToolProfile.observe.isShellEquivalent) + let shipped = try shippedLocalToolNames() + XCTAssertEqual(Set(shipped.filter(ToolProfile.observe.allows)), ["sys_info", "screenshot"]) + for tool in ["browser_navigate", "browser_snapshot", "browser_take_screenshot", "browser_something_new", "exec", "osascript"] { + XCTAssertFalse(ToolProfile.observe.allows(tool), "\(tool) is an action, or unknown") + } + } + + func testNoAllowedBrowserToolRunsArbitraryCode() { + for tool in ["browser_evaluate", "browser_run_code_unsafe"] { + XCTAssertFalse(ToolProfile.desktopBrowserTools.contains(tool)) + XCTAssertFalse(ToolProfile.desktop.allows(tool), tool) + } + } + + /// `sandbox` promised a boundary that does not exist; it is refused, not aliased. + func testTheOldSandboxNameIsRefused() throws { + XCTAssertNil(ToolProfile(rawValue: "sandbox")) + XCTAssertEqual(ToolProfile(rawValue: "desktop"), .desktop) + XCTAssertEqual(ToolProfile(rawValue: "owner"), .owner) + XCTAssertEqual(ToolProfile(rawValue: "observe"), .observe) + XCTAssertNil(ToolProfile(rawValue: "browser"), "unknown profiles must not widen to anything") + XCTAssertNil(ToolProfile(rawValue: "Observe")) + XCTAssertEqual(ToolProfile.desktop.rawValue, "desktop") + let decoded = try JSONDecoder().decode([ToolProfile].self, from: Data(#"["observe","desktop","owner"]"#.utf8)) + XCTAssertEqual(decoded, [.observe, .desktop, .owner]) + XCTAssertThrowsError(try JSONDecoder().decode([ToolProfile].self, from: Data(#"["sandbox"]"#.utf8))) + XCTAssertEqual(String(decoding: try JSONEncoder().encode(ToolProfile.desktop), as: UTF8.self), #""desktop""#) + } +} diff --git a/Tests/InstanceMCPCoreTests/ReverseAttachTests.swift b/Tests/InstanceMCPCoreTests/ReverseAttachTests.swift index 9ba6f0f..a374247 100644 --- a/Tests/InstanceMCPCoreTests/ReverseAttachTests.swift +++ b/Tests/InstanceMCPCoreTests/ReverseAttachTests.swift @@ -27,7 +27,7 @@ func fullServer() -> MCPServer { final class ToolProfileTests: XCTestCase { func testSandboxDropsEveryExecTool() async { - let scoped = fullServer().scoped(to: .sandbox) + let scoped = fullServer().scoped(to: .desktop) XCTAssertEqual(scoped.toolNames, ["echo", "screenshot"]) let list = await scoped.handle(try! JSONCoding.decoder.decode(JSONRPCRequest.self, from: rpc("tools/list"))) let names = list?.result?["tools"]?.arrayValue?.compactMap { $0["name"]?.stringValue } @@ -39,7 +39,7 @@ final class ToolProfileTests: XCTestCase { } func testCallingAnOmittedToolLooksLikeAnUnknownTool() async { - let scoped = fullServer().scoped(to: .sandbox) + let scoped = fullServer().scoped(to: .desktop) let call = await scoped.handle(try! JSONCoding.decoder.decode(JSONRPCRequest.self, from: rpc("tools/call", params: ["name": "exec", "arguments": ["command": "id"]]))) XCTAssertEqual(call?.error?.code, JSONRPCError.invalidParams) @@ -51,10 +51,10 @@ final class ToolProfileTests: XCTestCase { } func testScopedCanCarryItsOwnInstructions() async { - let scoped = fullServer().scoped(to: .sandbox, instructions: "sandboxed") + let scoped = fullServer().scoped(to: .desktop, instructions: "sandboxed") let init_ = await scoped.handle(try! JSONCoding.decoder.decode(JSONRPCRequest.self, from: rpc("initialize"))) XCTAssertEqual(init_?.result?["instructions"]?.stringValue, "sandboxed") - XCTAssertEqual(fullServer().scoped(to: .sandbox).instructions, "base") + XCTAssertEqual(fullServer().scoped(to: .desktop).instructions, "base") } } @@ -83,7 +83,7 @@ final class ReverseAttachPolicyTests: XCTestCase { func testAttachURLIsBuiltFromTheRuntimeBase() { let c = ReverseAttachClient.Config(runtime: URL(string: "ws://100.1.2.3:8090")!, session: "laptop", - secret: "s", profile: .sandbox, deadline: .distantFuture) + secret: "s", profile: .desktop, deadline: .distantFuture) XCTAssertEqual(c.attachURL.absoluteString, "ws://100.1.2.3:8090/tools/attach/laptop") let tls = ReverseAttachClient.Config(runtime: URL(string: "wss://pod.tail.ts.net/")!, session: "x", secret: "s", profile: .owner, deadline: .distantFuture) @@ -94,8 +94,8 @@ final class ReverseAttachPolicyTests: XCTestCase { /// and sessions: the grant *is* the auth, no header on the socket is trusted. func testFrameAnsweringMatchesDispatch() async { let c = ReverseAttachClient.Config(runtime: URL(string: "ws://h:1")!, session: "s", secret: "x", - profile: .sandbox, deadline: .distantFuture) - let client = ReverseAttachClient(config: c, server: fullServer().scoped(to: .sandbox)) + profile: .desktop, deadline: .distantFuture) + let client = ReverseAttachClient(config: c, server: fullServer().scoped(to: .desktop)) let list = await client.answer(String(decoding: rpc("tools/list", id: 7), as: UTF8.self)) let parsed = decodeJSON(Data(list!.utf8)) XCTAssertEqual(parsed["id"]?.intValue, 7) @@ -162,7 +162,7 @@ final class AttachEndpointTests: XCTestCase { XCTAssertEqual(r.status, 202, String(decoding: r.body, as: UTF8.self)) let g = decodeJSON(r.body) let id = g["id"]!.stringValue! - XCTAssertEqual(g["profile"]?.stringValue, "sandbox") + XCTAssertEqual(g["profile"]?.stringValue, "desktop", "the default profile, reported under its honest name") XCTAssertEqual(g["principal"]?.stringValue, "a@b") XCTAssertEqual(g["session"]?.stringValue, "laptop") @@ -337,8 +337,8 @@ final class ReverseAttachEndToEndTests: XCTestCase { defer { runtime.stop() } let states = StateSink() let cfg = ReverseAttachClient.Config(runtime: URL(string: "ws://127.0.0.1:\(runtime.port)")!, session: "laptop", - secret: "s3", profile: .sandbox, deadline: Date().addingTimeInterval(30)) - let client = ReverseAttachClient(config: cfg, server: fullServer().scoped(to: .sandbox), + secret: "s3", profile: .desktop, deadline: Date().addingTimeInterval(30)) + let client = ReverseAttachClient(config: cfg, server: fullServer().scoped(to: .desktop), onStateChange: { states.push($0) }) await client.start() @@ -383,7 +383,7 @@ final class ReverseAttachEndToEndTests: XCTestCase { // (A real 401 is exercised by openab-pty's own suite; here we prove the // client does not spin: it backs off and stops at the deadline.) var cfg = ReverseAttachClient.Config(runtime: URL(string: "ws://127.0.0.1:1")!, session: "x", - secret: "s", profile: .sandbox, deadline: Date().addingTimeInterval(1.2)) + secret: "s", profile: .desktop, deadline: Date().addingTimeInterval(1.2)) cfg.initialBackoff = 0.3 let states = StateSink() let client = ReverseAttachClient(config: cfg, server: fullServer(), onStateChange: { states.push($0) }) diff --git a/Tests/InstanceMCPCoreTests/UpstreamMCPTests.swift b/Tests/InstanceMCPCoreTests/UpstreamMCPTests.swift index 55acdbe..e394938 100644 --- a/Tests/InstanceMCPCoreTests/UpstreamMCPTests.swift +++ b/Tests/InstanceMCPCoreTests/UpstreamMCPTests.swift @@ -106,7 +106,7 @@ final class UpstreamMCPTests: XCTestCase { func testSandboxSeesOnlyAllowlistedBrowserToolsAndLocalNamesWin() async throws { let up = try FakeUpstream(); defer { up.stop() } - let s = makeServer(up, profile: .sandbox) + let s = makeServer(up, profile: .desktop) let list = await s.handle(try JSONCoding.decoder.decode(JSONRPCRequest.self, from: rpc("tools/list"))) XCTAssertEqual(names(list), ["echo", "screenshot", "browser_navigate", "browser_snapshot"], "no exec, no browser_run_code_unsafe, upstream 'screenshot' shadowed by the local one") @@ -122,7 +122,7 @@ final class UpstreamMCPTests: XCTestCase { func testCallIsForwardedAndResultPassedThroughVerbatim() async throws { let up = try FakeUpstream(); defer { up.stop() } - let s = makeServer(up, profile: .sandbox) + let s = makeServer(up, profile: .desktop) let nav = await s.handle(try JSONCoding.decoder.decode(JSONRPCRequest.self, from: rpc("tools/call", params: ["name": "browser_navigate", "arguments": ["url": "https://x"]]))) XCTAssertEqual(nav?.result?["content"]?.arrayValue?.first?["text"]?.stringValue, "did browser_navigate with https://x") @@ -138,7 +138,7 @@ final class UpstreamMCPTests: XCTestCase { func testDeniedUpstreamToolIsUnknownUnderSandbox() async throws { let up = try FakeUpstream(); defer { up.stop() } - let s = makeServer(up, profile: .sandbox) + let s = makeServer(up, profile: .desktop) let r = await s.handle(try JSONCoding.decoder.decode(JSONRPCRequest.self, from: rpc("tools/call", params: ["name": "browser_run_code_unsafe", "arguments": [:]]))) XCTAssertEqual(r?.error?.code, JSONRPCError.invalidParams) @@ -148,7 +148,7 @@ final class UpstreamMCPTests: XCTestCase { func testUpstreamDownMeansNoBrowserToolsAndLocalStillWorks() async throws { let up = try FakeUpstream() up.stop() - let s = makeServer(up, profile: .sandbox) + let s = makeServer(up, profile: .desktop) let list = await s.handle(try JSONCoding.decoder.decode(JSONRPCRequest.self, from: rpc("tools/list"))) XCTAssertEqual(names(list), ["echo", "screenshot"]) let echo = await s.handle(try JSONCoding.decoder.decode(JSONRPCRequest.self, @@ -180,11 +180,12 @@ final class UpstreamMCPTests: XCTestCase { final class SandboxBrowserAllowlistTests: XCTestCase { func testAllowlistShapesMatchTheThreatModel() { - let p = ToolProfile.sandbox - for ok in ["browser_navigate", "browser_snapshot", "browser_click", "browser_type", "browser_evaluate", "browser_take_screenshot", "browser_tabs"] { + let p = ToolProfile.desktop + for ok in ["browser_navigate", "browser_snapshot", "browser_click", "browser_type", "browser_take_screenshot", "browser_tabs"] { XCTAssertTrue(p.allows(ok), ok) } - for no in ["browser_run_code_unsafe", "browser_file_upload", "browser_pdf_save", "browser_network_requests", "browser_mouse_click_xy", "browser_close", "browser_handle_dialog", "browser_something_new"] { + // browser_evaluate is arbitrary JavaScript in the page, like run_code_unsafe (#45). + for no in ["browser_evaluate", "browser_run_code_unsafe", "browser_file_upload", "browser_pdf_save", "browser_network_requests", "browser_mouse_click_xy", "browser_close", "browser_handle_dialog", "browser_something_new"] { XCTAssertFalse(p.allows(no), no) } XCTAssertTrue(p.allows("screenshot")) diff --git a/docs/adr/reverse-attach.md b/docs/adr/reverse-attach.md index 0a6bfe3..ef80ed6 100644 --- a/docs/adr/reverse-attach.md +++ b/docs/adr/reverse-attach.md @@ -51,8 +51,17 @@ why we start from the opposite premise. and multiplexes requests over the reverse socket by JSON-RPC `id`. The CLI's `mcp.json` points at a plain local URL — no token, no TLS, no proxy; its HTTP stack is irrelevant. 4. **The Mac chooses the tool profile per attach.** The Mac *is* the MCP server, so scoping is - a per-connection tool list in `MCPServer` (`owner` = everything, `sandbox` = no `exec*`). - No MCP parsing in a proxy. + a per-connection tool list in `MCPServer` (`owner` = everything, `desktop` = no `exec*`; + originally named `sandbox`, a name since removed and refused). No MCP parsing in a proxy. + **Amended 2026-09-30 ([#45](https://github.com/openabdev/instance-mcp/issues/45)):** a tool + list over one desktop session is not a privilege boundary. `osascript` (`do shell script`), + `key` and `mouse` each reach the desktop user's shell, so `desktop` is shell-equivalent and the + earlier rationale — "no `exec` because the agent already has a shell" — described a + convenience, not a restriction. The profile was renamed to say so; the pod-side isolation (no + egress, credential-less shell) is unaffected and remains the boundary this ADR claims. A real + narrower tier must be built from tools that cannot reach a shell (`observe`: `sys_info` + + `screenshot`; `browser`: Playwright with a per-grant throwaway profile), and the hands + themselves are isolated only by lending a dedicated machine or VM. 5. **The human decides which pod the Mac dials**, from OpenAB Connect (Mac) **or OpenAB Remote (iPhone)**: pick a PTY session → "lend my Mac to this agent" with profile + TTL → the client calls `POST /attach {runtime, session, profile, ttl}` on `oab-instance-mcp` with the human's @@ -114,8 +123,8 @@ Verified on the real hop (Mac → pod inbound → loopback mux → CLI), never M - From inside the shell, with no `*_PROXY` set: `curl http://127.0.0.1:/healthz` succeeds while attached. - One `sys_info` and one streaming call round-trip end to end. -- `tools/list` under the `sandbox` profile contains no `exec*`; a forced `tools/call exec` is - rejected. +- `tools/list` under the `desktop` profile contains no `exec*`; a forced `tools/call exec` is + rejected. (This verifies the filter, not a boundary — see decision 4's amendment.) - Deleting the attach verifier (or TTL expiry) closes the socket; the CLI's next call fails cleanly and `tools/list` reports "not attached" rather than an error. - From inside the shell, `curl` to any other tailnet node **fails** — the property this diff --git a/docs/linux-setup.md b/docs/linux-setup.md index 4a681e8..906cc1d 100644 --- a/docs/linux-setup.md +++ b/docs/linux-setup.md @@ -268,7 +268,7 @@ sequenceDiagram ```sh curl -s -X POST -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' $U/attach \ - -d '{"runtime":"ws://:8090","session":"","profile":"sandbox", + -d '{"runtime":"ws://:8090","session":"","profile":"desktop", "ttl_secs":3600,"admin_credential":""}' # → 202 {"id":"grant-…","state":"idle",…}; GET $U/attach lists, DELETE $U/attach/ revokes ``` diff --git a/docs/requirements/authn.md b/docs/requirements/authn.md index 590c223..7004293 100644 --- a/docs/requirements/authn.md +++ b/docs/requirements/authn.md @@ -15,7 +15,7 @@ comment states "nothing here is a secret — auth is the caller's Tailscale iden ### Threat: a leaked tailnet credential is sufficient -`Tailscale-User-Login` reflects the **登入身份 of the connecting node**, authenticated by the +`Tailscale-User-Login` reflects the **login identity of the connecting node**, authenticated by the Tailscale control plane and un-spoofable by the HTTP client (`tailscale serve` overwrites any client-supplied header — verified). But that identity is only as strong as tailnet enrolment: diff --git a/docs/tool-profiles.md b/docs/tool-profiles.md new file mode 100644 index 0000000..2b72c78 --- /dev/null +++ b/docs/tool-profiles.md @@ -0,0 +1,153 @@ +# Tool profiles + +When a computer is lent to an `openab-pty` session (reverse attach, `POST /attach {profile}`), +the **profile decides which tools the agent in that session can see and call**. In IAM terms: +a profile is a managed policy, a grant attaches it to a principal (the session), and the lease +(1–24 hours) is the session duration. + +- The filter runs **on the lent computer** (macOS: `ToolProfile.swift`; Linux: + `poc/reverse-attach-linux/src/mcp.rs`). The session in the pod cannot change it. +- A tool outside the profile is absent from `tools/list`. A forced `tools/call` gets *unknown + tool*, the same answer as for a tool that does not exist. +- Unknown profile names are refused: HTTP 400 on `POST /attach`, and a stored grant with an + unknown profile is dropped when it is loaded. They are never widened to `owner`. + +> ⚠️ **Only `observe` is a security boundary.** GUI control is a shell: `osascript` runs +> `do shell script`, `key` can type into a terminal, and `mouse` can open one. `desktop` removes +> `exec*`, but that takes away a convenient entry point, not a privilege +> ([#45](https://github.com/openabdev/instance-mcp/issues/45)). When you grant full control, lend +> a **dedicated computer** (a Linux hands node, a throwaway machine or VM), not the one you work +> on. `ProfileBoundaryTests` fails any profile that claims to be narrower than a shell while +> allowing a shell-capable tool. + +## 1. Available profiles + +| Profile | Shell-equivalent? | Tools on macOS | Tools on Linux | Purpose | +|---|---|---|---|---| +| `owner` | yes (directly) | 42 | 37 | The owner's own CLI: every tool | +| `desktop` | **yes** (through the GUI) | 20 | 20 | Let an agent drive the desktop: look, click, type, AppleScript, browser interaction | +| `observe` | **no** | 2 | 2 | Look, never act: system info and screenshots | + +The former name `sandbox` has been **removed and is refused**, because it implied a boundary +that does not exist. Sending `sandbox` returns HTTP 400. A grant stored under `sandbox` is +dropped when it is loaded. + +The counts include the browser tools (`browser_*`), which exist only when the computer is +configured with the Playwright upstream (`--upstream browser=…`; macmini, rpi1 and black are). +Without it, `owner` has 10 tools on macOS and 5 on Linux, `desktop` has 5 on both, and `observe` +is unchanged. + +Lists taken on 2026-09-30 from the live `tools/list` of macmini (instance-mcp, macOS) and black +(Linux hands node), then filtered by this repository's profile rules. + +## 2. Complete tool lists per profile + +### `owner` + +#### macOS — 42 tools + +| Category | Tools | +|---|---| +| System | `sys_info` | +| Shell | `exec`, `exec_start`, `exec_poll`, `exec_list`, `exec_cancel` | +| Screen / input | `screenshot`, `mouse`, `key`, `osascript` | +| Browser (32) | `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_run_code_unsafe`, `browser_file_upload`, `browser_drop`, `browser_pdf_save`, `browser_network_requests`, `browser_network_request`, `browser_handle_dialog`, `browser_emulate_media`, `browser_close`, `browser_drag`, `browser_mouse_move_xy`, `browser_mouse_click_xy`, `browser_mouse_drag_xy`, `browser_mouse_down`, `browser_mouse_up`, `browser_mouse_wheel` | + +#### Linux — 37 tools + +| Category | Tools | +|---|---| +| System | `sys_info` | +| Shell | `bash` | +| Screen / input | `screenshot`, `mouse`, `key` | +| Browser (32) | the same 32 as on macOS | + +Linux has no `osascript`. `bash` is the counterpart of macOS `exec*`. + +### `desktop` + +#### macOS — 20 tools + +| Category | Tools | +|---|---| +| System | `sys_info` | +| Screen / input | `screenshot`, `mouse`, `key`, `osascript` | +| Browser (15) | `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` | + +#### Linux — 20 tools + +| Category | Tools | +|---|---| +| System | `sys_info` | +| Shell | `bash` | +| Screen / input | `screenshot`, `mouse`, `key` | +| Browser (15) | the same 15 as macOS `desktop` | + +The browser filter narrows the *tools*, not the browser. The browser runs with the computer's +**persistent profile**, including cookies for sites it is logged in to, and it reaches whatever +network the computer reaches, localhost and the tailnet included. + +### `observe` + +#### macOS and Linux — 2 tools + +| Category | Tools | +|---|---| +| System | `sys_info` | +| Screen | `screenshot` | + +This is an allowlist. Any tool added later, local or upstream (browser), is denied under +`observe` until it is added to `ToolProfile.observeTools` (Linux: `OBSERVE_TOOLS`). A screenshot +still discloses whatever is on screen, but the agent cannot change anything. + +## 3. What each profile loses against the previous one + +### `owner` → `desktop` + +| | macOS | Linux | +|---|---|---| +| Local tools removed | `exec`, `exec_start`, `exec_poll`, `exec_list`, `exec_cancel` (5) | none (`bash` stays; see below) | +| Browser tools removed (17) | `browser_evaluate`, `browser_run_code_unsafe` (arbitrary JavaScript); `browser_file_upload`, `browser_drop`, `browser_pdf_save` (filesystem); `browser_network_requests`, `browser_network_request` (network inspection); `browser_handle_dialog`, `browser_emulate_media`, `browser_close`; `browser_drag`, `browser_mouse_move_xy`, `browser_mouse_click_xy`, `browser_mouse_drag_xy`, `browser_mouse_down`, `browser_mouse_up`, `browser_mouse_wheel` (raw coordinate mouse) | the same 17 | +| Total | 42 → 20 | 37 → 20 | +| **Less privilege?** | **No**: `osascript`, `key` and `mouse` still reach the desktop user's shell | **No**: `bash` is a shell | + +Linux `desktop` keeps `bash` because `mouse` and `key` can open a terminal anyway. Hiding it would +only inconvenience the agent without removing any privilege, and the profile does not pretend +otherwise. + +### `desktop` → `observe` + +| | macOS | Linux | +|---|---|---| +| Local tools removed | `mouse`, `key`, `osascript` (3) | `bash`, `mouse`, `key` (3) | +| Browser tools removed | all 15 | all 15 | +| Total | 20 → 2 | 20 → 2 | +| **Less privilege?** | **Yes**: no tool can type, run or change state | **Yes** | + +## Compatibility and status + +- **Accepted wire values:** `owner`, `desktop`, `observe`. Anything else is a 400, including the + former `sandbox`. +- **Breaking change:** clients and computers must be updated together. An older Connect or Remote + that sends `sandbox` is refused by an updated computer. An updated client that sends `desktop` + or `observe` is refused by an older computer. The client side is oablab/oab-pty-mac#77, which + also makes `observe` the default choice. +- **Existing grants:** a grant stored as `sandbox` by an older build is dropped when the updated + daemon loads it, so the grant ends and must be made again. +- **Downgrade:** grants stored by this build carry `desktop` or `observe`. An older daemon does + not recognize them and drops them, so the grant ends instead of widening. + +## Planned (not yet available) + +- **`browser`**: Playwright tools only, with a **per-grant throwaway** browser profile and no + `browser_evaluate`. The browser provides the boundary. It still reaches the computer's network, + and the documentation must say so. +- **Typed app control** in place of generic `osascript` for restricted tiers: open an app, click a + menu item, list windows, all limited by a bundle-ID allowlist. The allowlist must exclude + Terminal, iTerm, Script Editor and System Settings. +- **Custom policies**: an allow/deny list supplied with the grant. It gets the same escalation + check as `ProfileBoundaryTests`: any list that contains a shell-capable tool is marked + shell-equivalent. +- **VMs**: lend a disposable macOS VM instead of the host. + +All tracked in [#45](https://github.com/openabdev/instance-mcp/issues/45). diff --git a/poc/reverse-attach-linux/README.md b/poc/reverse-attach-linux/README.md index 3c4f61e..7eae7ec 100644 --- a/poc/reverse-attach-linux/README.md +++ b/poc/reverse-attach-linux/README.md @@ -21,9 +21,10 @@ mirroring the Swift `ReverseAttachClient` / `POST /attach` contract: → **stop**; `1000` / `4006` / other / 2xx / 429 / 5xx → **redial**. Same table the `reverse-attach-conformance` suite (#24) checks 23/23 against the Swift oracle. - MCP over the socket: `initialize`, `tools/list`, `tools/call`; notifications get no reply; - ping → pong. Tools: `sys_info`, `screenshot`, `bash` — in **both** profiles (decision - 2026-09-28: a lent node is only useful if the agent can act on it, and the macOS sandbox - profile already hands out a shell through `osascript`'s `do shell script`). `bash` = `bash -c` + ping → pong. Tools: `sys_info`, `screenshot`, `bash`, `mouse`, `key` — in **both** profiles + (decision 2026-09-28: a lent node is only useful if the agent can act on it, and the macOS + `desktop` profile already hands out a shell through `osascript`'s `do shell script`). Neither + profile is a security boundary on Linux either (#45): lend a dedicated node. `bash` = `bash -c` as the daemon user with `cwd` (`~` expands), `timeout_secs` (default 60, max 600; the whole process group is killed → exit 137, `timed_out=true`), `max_output_bytes` per stream (≤1 MiB). @@ -46,7 +47,7 @@ bash smoke.sh # RESULT: 36 passed, 0 failed | control contract | `POST /attach` 202 with every Connect-required grant field; `GET /attach` wrapper; `GET /attach/{id}`; `DELETE` 204 + removal; principal and dynamic expiry | | redial / stop | close `1000` → redialed after 1 s; close `4010` → `state=ended, ended=revoked`; wrong secret → `ended=handshake_rejected_401` with exactly one attempt; unreachable runtime → `ended=deadline` | | MCP | `serverInfo.name=instance-mcp-rpi`; owner list = `sys_info,screenshot,exec`; `sys_info.hostname=rpi1`; `exec` ran on the node; unknown tool / method → `-32601`; notification silent; ping → pong | -| sandbox | list = `sys_info,screenshot`; forced `exec` → error, not executed | +| desktop | list = `sys_info,screenshot`; forced `exec` → error, not executed (2026-09-28 run, before `bash` joined both profiles) | | close handshake | client echoes the Close frame (was a bare EOF before the `socket.flush()` fix in `dial_loop`) | ## Direct `/mcp` for OpenAB Connect's Screens pane (added 2026-09-28) @@ -92,15 +93,16 @@ re-serves its tools under its own `tools/list`, filtered by the connection's pro 400/404 (the stale id must be dropped *before* re-`initialize`, or the upstream 404s that too), SSE or JSON replies, `tools/list` cached 30 s (failures not cached), upstream down → its tools absent. Local names win on collision. -- Profile filter is the verbatim Swift `ToolProfile.sandboxBrowserTools` list (16 tools). - `owner` sees all 32; `sandbox` never sees `run_code_unsafe`, upload, pdf, network, raw +- Profile filter is the verbatim Swift `ToolProfile.desktopBrowserTools` list (15 tools). + `owner` sees all 32; `desktop` never sees `evaluate`, + `run_code_unsafe`, upload, pdf, network, raw mouse-by-coordinate, dialogs, `browser_close`, or any new upstream tool. - Chromium managed policy `/etc/chromium/policies/managed/oab-instance-mcp.json` blocks camera/mic/notification/geolocation prompts: YouTube triggered the xdg-desktop-portal "Allow app to use the Camera?" dialog, which nothing but `mouse` could dismiss. Verified on rpi1: owner `/mcp` → 37 tools (5 local + 32 browser); after a pw-mcp restart the -next call re-initializes and still lists 37. Lent to `kiro-1040` session `mac` (sandbox): +next call re-initializes and still lists 37. Lent to `kiro-1040` session `mac` (then named `sandbox`, now `desktop`): 22 tools = 5 local + 16 browser + `instance_status`; forced `browser_run_code_unsafe` → `-32601 unknown tool`; `browser_navigate` example.com 13 s (Pi 4 class, first page), then `browser_snapshot` → `heading "Example Domain"`, `link "Learn more"`. The Chromium window is diff --git a/poc/reverse-attach-linux/smoke.sh b/poc/reverse-attach-linux/smoke.sh index bfdbf3e..590beac 100755 --- a/poc/reverse-attach-linux/smoke.sh +++ b/poc/reverse-attach-linux/smoke.sh @@ -40,7 +40,9 @@ check "reject both secret+admin" "[[ '$r' == *'exactly one'* ]]" r=$($C -X POST 127.0.0.1:8790/attach -d '{"runtime":"ws://127.0.0.1:18090","session":"a"}') check "reject neither secret nor admin" "[[ '$r' == *'exactly one'* ]]" r=$($C -X POST 127.0.0.1:8790/attach -d '{"runtime":"ws://127.0.0.1:18090","session":"a","secret":"s","profile":"Sandbox"}') -check "reject unknown profile (no silent widening)" "[[ '$r' == *'profile must be owner or sandbox'* ]]" +check "reject unknown profile (no silent widening)" "[[ '$r' == *'profile must be owner, desktop or observe'* ]]" +r=$($C -X POST 127.0.0.1:8790/attach -d '{"runtime":"ws://127.0.0.1:18090","session":"a","secret":"s","profile":"sandbox"}') +check "reject the old sandbox profile name (#45)" "[[ '$r' == *'profile must be owner, desktop or observe'* ]]" r=$($C -o /dev/null -w '%{http_code}' -X POST 127.0.0.1:8790/attach -H 'Content-Length: 99999999' -d '{}') check "oversized Content-Length refused before auth" "[[ '$r' == 000 || '$r' == 4* ]]" r=$($C -X POST 127.0.0.1:8790/attach -d '{"runtime":"ws://127.0.0.1:18090","session":"a","secret":"s","ttl_secs":0}') @@ -105,18 +107,19 @@ check "unknown tool → -32601" "[[ \$(py unk) == -32601* ]]" check "unknown method → -32601" "[[ \$(py bogus) == -32601 ]]" check "ping → pong" "[[ \$(py pong) == True ]]" -echo "== pre-minted secret path + sandbox profile (session pre) ==" +echo "== pre-minted secret path + desktop profile (session pre) ==" : > $LOG -r=$($C -X POST 127.0.0.1:8790/attach -d '{"runtime":"ws://127.0.0.1:18090","session":"pre","profile":"sandbox","ttl_secs":30,"secret":"preminted-xyz"}') +r=$($C -X POST 127.0.0.1:8790/attach -d '{"runtime":"ws://127.0.0.1:18090","session":"pre","profile":"desktop","ttl_secs":30,"secret":"preminted-xyz"}') echo "$r" +check "grant reports profile desktop" "[[ '$r' == *'\"profile\":\"desktop\"'* ]]" for i in $(seq 1 20); do grep -q '"ev": "closed"' $LOG 2>/dev/null && break; sleep 0.5; done check "no mint call for secret path" "! grep -q '\"ev\": \"mint\"' $LOG" -check "sandbox tools/list = sys_info,screenshot,bash" "[[ \$(py tools) == sys_info,screenshot,bash,mouse,key ]]" -check "sandbox bash ran on node" "[[ \$(py exec) == \"hands-node-$(hostname) $HOME\" ]]" +check "desktop tools/list = sys_info,screenshot,bash,mouse,key" "[[ \$(py tools) == sys_info,screenshot,bash,mouse,key ]]" +check "desktop bash ran on node" "[[ \$(py exec) == \"hands-node-$(hostname) $HOME\" ]]" check "no sleep leaked after timeout" "! pgrep -f 'sleep 30' >/dev/null" -check "close frame echoed (sandbox attach)" "grep -q '\"ev\": \"close_echo\"' $LOG" +check "close frame echoed (desktop attach)" "grep -q '\"ev\": \"close_echo\"' $LOG" st=$($C 127.0.0.1:8790/attach); echo "$st" -check "sandbox grant ended/revoked (CLOSES tail=4010)" "[[ \$(echo '$st' | state_of pre) == ended && \$(echo '$st' | ended_of pre) == revoked ]]" +check "desktop grant ended/revoked (CLOSES tail=4010)" "[[ \$(echo '$st' | state_of pre) == ended && \$(echo '$st' | ended_of pre) == revoked ]]" echo "== wrong secret → handshake 401 → stop ==" : > $LOG @@ -144,7 +147,7 @@ MOCK=$! start_ra() { BIND=127.0.0.1:8790 MCP_INSECURE_LOCAL=1 $BIN >> "$RA_LOG" 2>&1 & RA=$!; sleep 0.5; } attaches() { grep -c '"ev": "attach", "session": "keep", "status": 101' $LOG 2>/dev/null || echo 0; } start_ra -r=$($C -X POST 127.0.0.1:8790/attach -d '{"runtime":"ws://127.0.0.1:18090","session":"keep","profile":"sandbox","ttl_secs":120,"secret":"k1"}') +r=$($C -X POST 127.0.0.1:8790/attach -d '{"runtime":"ws://127.0.0.1:18090","session":"keep","profile":"desktop","ttl_secs":120,"secret":"k1"}') GID=$(echo "$r" | python3 -c 'import sys,json;print(json.load(sys.stdin)["id"])') for i in $(seq 1 20); do [[ $(attaches) -ge 1 ]] && break; sleep 0.3; done check "grants file written with mode 600" "[[ \$(stat -c %a \"$MCP_GRANTS_FILE\" 2>/dev/null || stat -f %Lp \"$MCP_GRANTS_FILE\") == 600 ]]" diff --git a/poc/reverse-attach-linux/src/attach/store.rs b/poc/reverse-attach-linux/src/attach/store.rs index f9ee0b9..2f34bf5 100644 --- a/poc/reverse-attach-linux/src/attach/store.rs +++ b/poc/reverse-attach-linux/src/attach/store.rs @@ -196,7 +196,7 @@ pub(crate) fn parse(text: &str, now: u64) -> Result, String> { id: s(g, "id")?, runtime: s(g, "runtime")?, session: s(g, "session")?, - profile: s(g, "profile")?, + profile: crate::mcp::normalize_profile(&s(g, "profile")?)?.to_string(), principal: s(g, "principal")?, expires_at_epoch_secs: g["expires_at_epoch_secs"].as_u64()?, secret: s(g, "secret")?, @@ -215,7 +215,7 @@ mod tests { id: id.into(), runtime: "ws://127.0.0.1:1".into(), session: "s".into(), - profile: "sandbox".into(), + profile: "desktop".into(), principal: "you@example.com".into(), expires_at_epoch_secs: expires, secret: "sec".into(), @@ -300,4 +300,23 @@ mod tests { let store = GrantStore::new(scratch("missing")); assert!(store.read().unwrap().is_empty()); } + + #[test] + fn grants_stored_under_the_old_sandbox_name_are_dropped() { + let now = 1_000; + let text = format!( + r#"{{"version":1,"grants":[ + {{"id":"old","runtime":"ws://x","session":"s","profile":"sandbox","principal":"p","expires_at_epoch_secs": {},"secret":"k"}}, + {{"id":"bogus","runtime":"ws://x","session":"s","profile":"admin","principal":"p","expires_at_epoch_secs": {},"secret":"k"}} + ]}}"#, + now + 5, + now + 5 + ); + let grants = parse(&text, now).unwrap(); + let ids: Vec<&str> = grants.iter().map(|g| g.id.as_str()).collect(); + assert!( + ids.is_empty(), + "sandbox and unknown profiles are dropped, never widened: {ids:?}" + ); + } } diff --git a/poc/reverse-attach-linux/src/http.rs b/poc/reverse-attach-linux/src/http.rs index ca215f7..40e2edd 100644 --- a/poc/reverse-attach-linux/src/http.rs +++ b/poc/reverse-attach-linux/src/http.rs @@ -573,10 +573,12 @@ fn handle_attach( if !valid_session(session) { return bad_request(stream, "session must match ^[a-z0-9-]{1,32}$"); } - // Only the two known profiles. Anything else must not silently widen to owner. - if profile != "owner" && profile != "sandbox" { - return bad_request(stream, "profile must be owner or sandbox"); - } + // Only the known profiles. Anything else — including the old `sandbox` — must not + // silently widen to owner. + let Some(profile) = crate::mcp::normalize_profile(&profile) else { + return bad_request(stream, "profile must be owner, desktop or observe"); + }; + let profile = profile.to_string(); if !(1..=86400).contains(&ttl_secs) { return bad_request(stream, "ttl_secs must be in 1..=86400"); } diff --git a/poc/reverse-attach-linux/src/mcp.rs b/poc/reverse-attach-linux/src/mcp.rs index 135427e..b68058b 100644 --- a/poc/reverse-attach-linux/src/mcp.rs +++ b/poc/reverse-attach-linux/src/mcp.rs @@ -151,13 +151,13 @@ impl Upstream { } } -/// Same list as Swift `ToolProfile.sandboxBrowserTools`: navigate / read / interact. -/// Everything else from the upstream (run_code_unsafe, upload, pdf, network, raw -/// mouse-by-coordinate, dialogs, close, and any new tool) is denied under sandbox. -pub(crate) const SANDBOX_BROWSER_TOOLS: &[&str] = &[ +/// Same list as Swift `ToolProfile.desktopBrowserTools`: navigate / read / interact. +/// Everything else from the upstream (evaluate / run_code_unsafe = arbitrary JS, upload, +/// pdf, network, raw mouse-by-coordinate, dialogs, close, and any new tool) is denied +/// under `desktop`. +pub(crate) const DESKTOP_BROWSER_TOOLS: &[&str] = &[ "browser_click", "browser_console_messages", - "browser_evaluate", "browser_fill_form", "browser_find", "browser_hover", @@ -174,7 +174,35 @@ pub(crate) const SANDBOX_BROWSER_TOOLS: &[&str] = &[ ]; pub(crate) fn upstream_tool_allowed(name: &str, profile: &str) -> bool { - profile != "sandbox" || SANDBOX_BROWSER_TOOLS.contains(&name) + match normalize_profile(profile) { + Some("owner") => true, + Some("desktop") => DESKTOP_BROWSER_TOOLS.contains(&name), + // observe: look only; no upstream (browser) tool is an observation of this node. + _ => false, + } +} + +/// Local tools `observe` may call: look, never act (instance-mcp#45). +pub(crate) const OBSERVE_TOOLS: &[&str] = &["sys_info", "screenshot"]; + +/// Whether a local tool is visible/callable under `profile`. +pub(crate) fn local_tool_allowed(name: &str, profile: &str) -> bool { + match normalize_profile(profile) { + Some("owner") | Some("desktop") => true, + Some("observe") => OBSERVE_TOOLS.contains(&name), + _ => false, + } +} + +/// `owner` | `desktop` | `observe`. The old name `sandbox` is refused like any unknown +/// profile (instance-mcp#45): it promised a boundary that does not exist. +pub(crate) fn normalize_profile(profile: &str) -> Option<&'static str> { + match profile { + "owner" => Some("owner"), + "desktop" => Some("desktop"), + "observe" => Some("observe"), + _ => None, + } } pub(crate) static UPSTREAMS: Mutex>> = Mutex::new(Vec::new()); @@ -332,10 +360,14 @@ pub(crate) fn tool_list(profile: &str) -> Value { } }); - // Both profiles get everything: a lent node is only useful if the agent can act on it, - // and the macOS sandbox profile already leaks a shell through `osascript`. - let _ = profile; - let mut tools = vec![sys_info, screenshot, bash, mouse, key]; + // owner and desktop get every local tool, `bash` included: `desktop` is not a boundary + // on any platform (instance-mcp#45) — mouse and keyboard reach a terminal, so hiding + // `bash` would remove a convenience, not a privilege. `observe` is the real boundary: + // sys_info + screenshot only. + let mut tools: Vec = vec![sys_info, screenshot, bash, mouse, key] + .into_iter() + .filter(|t| local_tool_allowed(t["name"].as_str().unwrap_or_default(), profile)) + .collect(); for (_, t) in upstream_tools_for(profile, LOCAL_TOOL_NAMES) { tools.push(t); } @@ -349,13 +381,15 @@ pub(crate) fn handle_tool_call(params: &Value, profile: &str) -> Result