From 57769bfc761f5994130f3778b3a427adf3605b2b Mon Sep 17 00:00:00 2001 From: chaodu-agent <274062505+chaodu-agent@users.noreply.github.com> Date: Wed, 30 Sep 2026 12:20:23 -0400 Subject: [PATCH 1/4] fix(profile): desktop (was sandbox) is shell-equivalent; say so and drop browser_evaluate Refs #45. GUI control is a shell: osascript (do shell script), key and mouse all reach the desktop user's shell, so hiding exec* removed a convenience, not a privilege. Rename the profile to desktop (sandbox still accepted on the wire and in stored grants, on macOS and Linux), declare it isShellEquivalent, and add ProfileBoundaryTests: any profile that claims to be narrower must allow no shell-capable tool. Remove browser_evaluate from the allowed browser tools (arbitrary JS; contradicted the README). README, reverse-attach ADR and Linux docs state the limit and recommend lending a dedicated computer. --- README.md | 34 +++++--- Sources/InstanceMCPCore/AttachManager.swift | 10 +-- Sources/InstanceMCPCore/MCPHTTPEndpoint.swift | 4 +- Sources/InstanceMCPCore/ToolProfile.swift | 68 ++++++++++++---- Sources/oab-instance-mcp/main.swift | 2 +- .../ConformanceVectorTests.swift | 2 +- .../GrantPersistenceTests.swift | 4 +- .../ProfileBoundaryTests.swift | 80 +++++++++++++++++++ .../ReverseAttachTests.swift | 22 ++--- .../UpstreamMCPTests.swift | 15 ++-- docs/adr/reverse-attach.md | 17 +++- docs/linux-setup.md | 2 +- poc/reverse-attach-linux/README.md | 14 ++-- poc/reverse-attach-linux/smoke.sh | 3 +- poc/reverse-attach-linux/src/attach/store.rs | 25 +++++- poc/reverse-attach-linux/src/http.rs | 13 ++- poc/reverse-attach-linux/src/mcp.rs | 51 ++++++++++-- 17 files changed, 285 insertions(+), 81 deletions(-) create mode 100644 Tests/InstanceMCPCoreTests/ProfileBoundaryTests.swift diff --git a/README.md b/README.md index 7cc5116..3ad7020 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,20 @@ 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*` (`sandbox` is its old name and still accepted). Under `desktop`, `tools/list` + omits `exec*` and a forced `tools/call exec` is an *unknown tool* error. Widening is a new grant. +- ⚠️ **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 +204,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..811d8dc 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,13 @@ 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? { + 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..6175c09 100644 --- a/Sources/InstanceMCPCore/MCPHTTPEndpoint.swift +++ b/Sources/InstanceMCPCore/MCPHTTPEndpoint.swift @@ -112,9 +112,9 @@ 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))")])) + return .json(400, JSONValue.object(["error": .string("profile must be one of \(ToolProfile.allCases.map(\.rawValue)) (sandbox = desktop)")])) } let ttl = TimeInterval(body["ttl_secs"]?.intValue ?? Int(AttachManager.defaultTTL)) let request = AttachManager.Request(runtime: runtime, session: session, profile: profile, ttl: ttl, diff --git a/Sources/InstanceMCPCore/ToolProfile.swift b/Sources/InstanceMCPCore/ToolProfile.swift index 2843bb3..5440071 100644 --- a/Sources/InstanceMCPCore/ToolProfile.swift +++ b/Sources/InstanceMCPCore/ToolProfile.swift @@ -4,36 +4,74 @@ 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`. +/// +/// Wire and persisted value was `sandbox`; it is still accepted and means `desktop`. public enum ToolProfile: String, Codable, Sendable, CaseIterable { case owner - case sandbox + case desktop + + /// Accepts the pre-rename `sandbox` so existing clients and stored grants work. + public init?(rawValue: String) { + switch rawValue { + case "owner": self = .owner + case "desktop", "sandbox": self = .desktop + default: return nil + } + } + + public var rawValue: String { + switch self { + case .owner: return "owner" + case .desktop: return "desktop" + } + } + + /// Whether this profile grants (directly or through GUI control) the desktop + /// user's shell. True for both profiles today; a future `observe` / `browser` + /// profile must be false and is tested to contain no shell-capable tool. + public var isShellEquivalent: Bool { + switch self { + case .owner, .desktop: return true + } + } + + /// 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 } } - /// 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..8d992c0 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 (was sandbox) sees an allowlisted subset of browser_*. --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..5f6bc69 100644 --- a/Tests/InstanceMCPCoreTests/GrantPersistenceTests.swift +++ b/Tests/InstanceMCPCoreTests/GrantPersistenceTests.swift @@ -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) diff --git a/Tests/InstanceMCPCoreTests/ProfileBoundaryTests.swift b/Tests/InstanceMCPCoreTests/ProfileBoundaryTests.swift new file mode 100644 index 0000000..2402770 --- /dev/null +++ b/Tests/InstanceMCPCoreTests/ProfileBoundaryTests.swift @@ -0,0 +1,80 @@ +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")) + } + + func testNoAllowedBrowserToolRunsArbitraryCode() { + for tool in ["browser_evaluate", "browser_run_code_unsafe"] { + XCTAssertFalse(ToolProfile.desktopBrowserTools.contains(tool)) + XCTAssertFalse(ToolProfile.desktop.allows(tool), tool) + } + } + + /// Renamed on the wire from `sandbox`; old clients and persisted grants must keep working. + func testSandboxIsAcceptedAsTheOldNameOfDesktop() throws { + XCTAssertEqual(ToolProfile(rawValue: "sandbox"), .desktop) + XCTAssertEqual(ToolProfile(rawValue: "desktop"), .desktop) + XCTAssertEqual(ToolProfile(rawValue: "owner"), .owner) + XCTAssertNil(ToolProfile(rawValue: "observe"), "unknown profiles must not widen to anything") + XCTAssertEqual(ToolProfile.desktop.rawValue, "desktop") + let decoded = try JSONDecoder().decode([ToolProfile].self, from: Data(#"["sandbox","desktop","owner"]"#.utf8)) + XCTAssertEqual(decoded, [.desktop, .desktop, .owner]) + 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..78864fa 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` — formerly + `sandbox` — = no `exec*`). 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/poc/reverse-attach-linux/README.md b/poc/reverse-attach-linux/README.md index 3c4f61e..050583e 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 (was sandbox) | 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,8 +93,9 @@ 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` (old name `sandbox`, still accepted) 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 diff --git a/poc/reverse-attach-linux/smoke.sh b/poc/reverse-attach-linux/smoke.sh index bfdbf3e..59fab83 100755 --- a/poc/reverse-attach-linux/smoke.sh +++ b/poc/reverse-attach-linux/smoke.sh @@ -40,7 +40,7 @@ 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 or desktop'* ]]" 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}') @@ -109,6 +109,7 @@ echo "== pre-minted secret path + sandbox 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"}') echo "$r" +check "old profile name sandbox is accepted as 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 ]]" diff --git a/poc/reverse-attach-linux/src/attach/store.rs b/poc/reverse-attach-linux/src/attach/store.rs index f9ee0b9..ba81ed8 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,25 @@ mod tests { let store = GrantStore::new(scratch("missing")); assert!(store.read().unwrap().is_empty()); } + + #[test] + fn grants_stored_under_the_old_profile_name_resume_as_desktop() { + 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_eq!( + ids, + vec!["old"], + "an unknown profile is dropped, never widened" + ); + assert_eq!(grants[0].profile, "desktop"); + } } diff --git a/poc/reverse-attach-linux/src/http.rs b/poc/reverse-attach-linux/src/http.rs index ca215f7..92054bf 100644 --- a/poc/reverse-attach-linux/src/http.rs +++ b/poc/reverse-attach-linux/src/http.rs @@ -573,10 +573,15 @@ 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 (`sandbox` = old name of `desktop`). Anything else must + // not silently widen to owner. + let Some(profile) = crate::mcp::normalize_profile(&profile) else { + return bad_request( + stream, + "profile must be owner or desktop (sandbox = desktop)", + ); + }; + 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..a45934b 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,17 @@ 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) + normalize_profile(profile) != Some("desktop") || DESKTOP_BROWSER_TOOLS.contains(&name) +} + +/// `owner` | `desktop`; `sandbox` is the pre-rename name of `desktop` (instance-mcp#45) +/// and is still accepted from clients and stored grants. Anything else is `None`. +pub(crate) fn normalize_profile(profile: &str) -> Option<&'static str> { + match profile { + "owner" => Some("owner"), + "desktop" | "sandbox" => Some("desktop"), + _ => None, + } } pub(crate) static UPSTREAMS: Mutex>> = Mutex::new(Vec::new()); @@ -332,8 +342,9 @@ 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`. + // Both profiles 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. Only the browser subset differs. let _ = profile; let mut tools = vec![sys_info, screenshot, bash, mouse, key]; for (_, t) in upstream_tools_for(profile, LOCAL_TOOL_NAMES) { @@ -371,3 +382,27 @@ pub(crate) fn handle_tool_call(params: &Value, profile: &str) -> Result Date: Wed, 30 Sep 2026 12:36:46 -0400 Subject: [PATCH 2/4] feat(profile): observe (sys_info + screenshot); document every profile observe is the first profile that is a boundary: an allowlist of sys_info and screenshot, so any new tool (local or upstream) is denied until listed; isShellEquivalent = false, enforced by ProfileBoundaryTests. Same on the Linux node (local_tool_allowed / OBSERVE_TOOLS; hidden tools are unknown on call too). docs/tool-profiles.md records the profiles, their complete tool lists on macOS and Linux (from live tools/list on macmini and black), and what each loses against the previous one. --- README.md | 3 +- Sources/InstanceMCPCore/AttachManager.swift | 9 ++ Sources/InstanceMCPCore/ToolProfile.swift | 19 ++- .../ProfileBoundaryTests.swift | 14 +- docs/tool-profiles.md | 142 ++++++++++++++++++ poc/reverse-attach-linux/src/mcp.rs | 68 +++++++-- 6 files changed, 240 insertions(+), 15 deletions(-) create mode 100644 docs/tool-profiles.md diff --git a/README.md b/README.md index 3ad7020..2933da7 100644 --- a/README.md +++ b/README.md @@ -173,7 +173,8 @@ 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; `desktop` = every tool - except `exec*` (`sandbox` is its old name and still accepted). Under `desktop`, `tools/list` + except `exec*` (`sandbox` is its old name and still accepted); `observe` = `sys_info` + + `screenshot` only. Full per-profile tool lists and diffs: [`docs/tool-profiles.md`](docs/tool-profiles.md). Under `desktop`, `tools/list` omits `exec*` and a forced `tools/call exec` is an *unknown tool* error. Widening is a new grant. - ⚠️ **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` diff --git a/Sources/InstanceMCPCore/AttachManager.swift b/Sources/InstanceMCPCore/AttachManager.swift index 811d8dc..9ba7a5b 100644 --- a/Sources/InstanceMCPCore/AttachManager.swift +++ b/Sources/InstanceMCPCore/AttachManager.swift @@ -221,6 +221,15 @@ public actor AttachManager { // MARK: helpers 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 + """ diff --git a/Sources/InstanceMCPCore/ToolProfile.swift b/Sources/InstanceMCPCore/ToolProfile.swift index 5440071..dd65d5d 100644 --- a/Sources/InstanceMCPCore/ToolProfile.swift +++ b/Sources/InstanceMCPCore/ToolProfile.swift @@ -17,16 +17,23 @@ import Foundation /// 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. +/// /// Wire and persisted value was `sandbox`; it is still accepted and means `desktop`. +/// Full per-profile tool lists: `docs/tool-profiles.md`. public enum ToolProfile: String, Codable, Sendable, CaseIterable { case owner case desktop + case observe /// Accepts the pre-rename `sandbox` so existing clients and stored grants work. public init?(rawValue: String) { switch rawValue { case "owner": self = .owner case "desktop", "sandbox": self = .desktop + case "observe": self = .observe default: return nil } } @@ -35,18 +42,24 @@ public enum ToolProfile: String, Codable, Sendable, CaseIterable { switch self { case .owner: return "owner" case .desktop: return "desktop" + case .observe: return "observe" } } /// Whether this profile grants (directly or through GUI control) the desktop - /// user's shell. True for both profiles today; a future `observe` / `browser` - /// profile must be false and is tested to contain no shell-capable tool. + /// 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"] @@ -59,6 +72,8 @@ public enum ToolProfile: String, Codable, Sendable, CaseIterable { if toolName.hasPrefix("exec") { return false } if toolName.hasPrefix("browser_") { return Self.desktopBrowserTools.contains(toolName) } return true + case .observe: + return Self.observeTools.contains(toolName) } } diff --git a/Tests/InstanceMCPCoreTests/ProfileBoundaryTests.swift b/Tests/InstanceMCPCoreTests/ProfileBoundaryTests.swift index 2402770..f2fd9b4 100644 --- a/Tests/InstanceMCPCoreTests/ProfileBoundaryTests.swift +++ b/Tests/InstanceMCPCoreTests/ProfileBoundaryTests.swift @@ -59,6 +59,16 @@ final class ProfileBoundaryTests: XCTestCase { 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)) @@ -71,7 +81,9 @@ final class ProfileBoundaryTests: XCTestCase { XCTAssertEqual(ToolProfile(rawValue: "sandbox"), .desktop) XCTAssertEqual(ToolProfile(rawValue: "desktop"), .desktop) XCTAssertEqual(ToolProfile(rawValue: "owner"), .owner) - XCTAssertNil(ToolProfile(rawValue: "observe"), "unknown profiles must not widen to anything") + 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(#"["sandbox","desktop","owner"]"#.utf8)) XCTAssertEqual(decoded, [.desktop, .desktop, .owner]) diff --git a/docs/tool-profiles.md b/docs/tool-profiles.md new file mode 100644 index 0000000..0e856c7 --- /dev/null +++ b/docs/tool-profiles.md @@ -0,0 +1,142 @@ +# Tool profiles + +把一台電腦借給 `openab-pty` session 時(reverse attach,`POST /attach {profile}`), +**profile 決定那個 session 裡的 agent 能看到、能呼叫哪些工具**。用 IAM 來類比:profile +就像 managed policy,grant 就是把 policy attach 到一個 principal(session)上,lease +(1–24 小時)則相當於 STS session duration。 + +- 過濾在**被借出的電腦端**執行(macOS:`ToolProfile.swift`;Linux:`poc/reverse-attach-linux/src/mcp.rs`), + pod 裡的 session 改不了。 +- 不在 profile 裡的工具,`tools/list` 不會列出;硬呼叫 `tools/call` 會得到 *unknown tool*, + 和這個工具根本不存在時的回應一樣。 +- 未知的 profile 名稱一律拒絕(HTTP 400;存在磁碟上的 grant 則在重新載入時丟棄), + 不會被當成 `owner` 放寬。 + +> ⚠️ **只有 `observe` 是安全邊界。** GUI 控制等同 shell:`osascript` 能跑 +> `do shell script`,`key` 能在終端機裡打字,`mouse` 能開終端機。所以 `desktop` 雖然拿掉了 +> `exec*`,權限上並沒有變小,只是少了一個方便的入口(見 +> [#45](https://github.com/openabdev/instance-mcp/issues/45))。要給完整控制權時,請借一台 +> **專用的電腦**,例如 Linux hands node 或拋棄式的機器/VM,不要借你正在用的那台。 +> `ProfileBoundaryTests` 會擋下任何宣稱比 shell 窄、卻允許能拿到 shell 的工具的 profile。 + +## 1. 目前可用的 profiles + +| Profile | 舊名 / 別名 | 等同 shell? | macOS 工具數 | Linux 工具數 | 用途 | +|---|---|---|---|---|---| +| `owner` | — | 是(直接) | 42 | 37 | 電腦主人自己的 CLI;全部工具 | +| `desktop` | `sandbox`(仍接受,一律視為 `desktop`) | **是**(透過 GUI) | 20 | 20 | 讓 agent 操作桌面:看、點、打字、AppleScript、瀏覽器互動 | +| `observe` | — | **否** | 2 | 2 | 只能看不能動:系統資訊與截圖 | + +工具數包含瀏覽器工具(`browser_*`),前提是那台電腦有設定 Playwright upstream +(`--upstream browser=…`;macmini、rpi1、black 都有)。沒有 upstream 時,`owner` 在 macOS +上是 10 個工具、在 Linux 上是 5 個;`desktop` 在兩邊都是 5 個;`observe` 不受影響。 + +清單取自 2026-09-30 macmini(instance-mcp,macOS)與 black(Linux hands node)的實際 +`tools/list`,再依本 repo 的 profile 規則過濾。 + +## 2. 各 profile 的完整工具清單 + +### `owner` + +**macOS — 42 個** + +| 類別 | 工具 | +|---|---| +| 系統 | `sys_info` | +| Shell | `exec`, `exec_start`, `exec_poll`, `exec_list`, `exec_cancel` | +| 畫面 / 輸入 | `screenshot`, `mouse`, `key`, `osascript` | +| 瀏覽器(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 個** + +| 類別 | 工具 | +|---|---| +| 系統 | `sys_info` | +| Shell | `bash` | +| 畫面 / 輸入 | `screenshot`, `mouse`, `key` | +| 瀏覽器(32) | 與 macOS 相同的 32 個 | + +Linux 沒有 `osascript`;`bash` 對應 macOS 的 `exec*`。 + +### `desktop`(舊名 `sandbox`) + +**macOS — 20 個** + +| 類別 | 工具 | +|---|---| +| 系統 | `sys_info` | +| 畫面 / 輸入 | `screenshot`, `mouse`, `key`, `osascript` | +| 瀏覽器(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 個** + +| 類別 | 工具 | +|---|---| +| 系統 | `sys_info` | +| Shell | `bash` | +| 畫面 / 輸入 | `screenshot`, `mouse`, `key` | +| 瀏覽器(15) | 與 macOS `desktop` 相同的 15 個 | + +瀏覽器工具的過濾只限制「工具」,不限制瀏覽器本身:瀏覽器用的是那台電腦**持久化的 +profile**,帶著已登入網站的 cookie,而且能連到那台電腦能連到的網路,包括 localhost 和 +tailnet。 + +### `observe`(新) + +**macOS 與 Linux — 2 個** + +| 類別 | 工具 | +|---|---| +| 系統 | `sys_info` | +| 畫面 | `screenshot` | + +這是 allowlist:之後新增的任何工具(包括 local 工具與 upstream 瀏覽器工具),在 +`observe` 下預設都不給,除非明確加進 `ToolProfile.observeTools`(Linux:`OBSERVE_TOOLS`)。 +截圖仍會洩漏螢幕上顯示的內容,但 agent 沒辦法改變任何東西。 + +## 3. 和上一個 profile 比起來少了什麼 + +### `owner` → `desktop` + +| | macOS | Linux | +|---|---|---| +| 少了 local 工具 | `exec`, `exec_start`, `exec_poll`, `exec_list`, `exec_cancel`(5) | 無(`bash` 保留,理由見下) | +| 少了瀏覽器工具(17) | `browser_evaluate`, `browser_run_code_unsafe`(任意 JavaScript);`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`(以座標操作的原始滑鼠) | 同左 17 個 | +| 總數 | 42 → 20 | 37 → 20 | +| **權限上是否變小** | **否**:`osascript` / `key` / `mouse` 仍能取得桌面使用者的 shell | **否**:`bash` 本身就是 shell | + +Linux 的 `desktop` 保留 `bash`,因為 `mouse` 和 `key` 本來就能打開終端機。拿掉它只會讓 +agent 比較不方便,不會降低權限,就不假裝它是限制。 + +### `desktop` → `observe` + +| | macOS | Linux | +|---|---|---| +| 少了 local 工具 | `mouse`, `key`, `osascript`(3) | `bash`, `mouse`, `key`(3) | +| 少了瀏覽器工具 | 全部 15 個 | 全部 15 個 | +| 總數 | 20 → 2 | 20 → 2 | +| **權限上是否變小** | **是**:沒有任何能輸入、執行或改變狀態的工具 | **是** | + +## 相容性與現況 + +- **線上值**:`owner`、`desktop`、`observe`。`sandbox` 仍然接受,視為 `desktop`;grant + 一律以新名稱回報。 +- **OpenAB Connect / Remote**:目前 UI 只提供 Desktop 與 Owner 兩個選項,送出的值是 + `sandbox`/`owner`,這樣舊版的 instance-mcp 才不會回 400。等所有電腦都更新到支援 + `desktop`/`observe` 的版本後,client 才會改送新名稱,並加上 Observe 選項 + (oablab/oab-pty-mac#76)。 +- **降版**:新版存下的 grant 會寫 `desktop` 或 `observe`。舊版 daemon 載入時會把它當成 + 未知 profile 丟棄,grant 因此結束,不會被放寬。 + +## 計畫中(尚未提供) + +- **`browser`**:只有 Playwright 工具,每個 grant 使用**用完即丟**的瀏覽器 profile,不含 + `browser_evaluate`。它的邊界來自瀏覽器本身,但仍能連到那台電腦的網路,文件要照實寫。 +- **有型別的 App 操作**:用「開啟某個 App」「點某個選單項目」「列出視窗」這類小工具,搭配 + bundle ID 白名單,取代受限層級裡通用的 `osascript`。白名單要排除 Terminal、iTerm、 + Script Editor、系統設定。 +- **Custom policy**:grant 時直接帶 allow/deny 清單。它一樣要經過 `ProfileBoundaryTests` + 的提權檢查,只要含有能拿到 shell 的工具,就自動標示為「等同 shell」。 +- **VM**:借出拋棄式的 macOS VM,而不是宿主機本身。 + +以上都在 [#45](https://github.com/openabdev/instance-mcp/issues/45) 追蹤。 diff --git a/poc/reverse-attach-linux/src/mcp.rs b/poc/reverse-attach-linux/src/mcp.rs index a45934b..53adbe2 100644 --- a/poc/reverse-attach-linux/src/mcp.rs +++ b/poc/reverse-attach-linux/src/mcp.rs @@ -174,7 +174,24 @@ pub(crate) const DESKTOP_BROWSER_TOOLS: &[&str] = &[ ]; pub(crate) fn upstream_tool_allowed(name: &str, profile: &str) -> bool { - normalize_profile(profile) != Some("desktop") || DESKTOP_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`; `sandbox` is the pre-rename name of `desktop` (instance-mcp#45) @@ -183,6 +200,7 @@ pub(crate) fn normalize_profile(profile: &str) -> Option<&'static str> { match profile { "owner" => Some("owner"), "desktop" | "sandbox" => Some("desktop"), + "observe" => Some("observe"), _ => None, } } @@ -342,11 +360,14 @@ pub(crate) fn tool_list(profile: &str) -> Value { } }); - // Both profiles 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. Only the browser subset differs. - 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); } @@ -360,13 +381,15 @@ pub(crate) fn handle_tool_call(params: &Value, profile: &str) -> Result