diff --git a/SECURITY.md b/SECURITY.md index ec441ea94ca2..d67b6a833966 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -90,6 +90,7 @@ These are frequently reported but are typically closed with no code change: - Prompt-injection-only chains without a boundary bypass (prompt injection is out of scope). - Operator-intended local features (for example TUI local `!` shell) presented as remote injection. - Reports that treat explicit operator-control surfaces (for example `canvas.eval`, browser evaluate/script execution, or direct `node.invoke` execution primitives) as vulnerabilities without demonstrating an auth/policy/sandbox boundary bypass. These capabilities are intentional when enabled and are trusted-operator features, not standalone security bugs. +- Reports that treat an admin-gated enablement or arming step as requiring `operator.admin` for every subsequent action, when the documented contract delegates use of the enabled capability to `operator.write` and no auth, arming, allowlist, sandbox, or policy bypass is shown. This is an arm-then-use operator guardrail, not privilege escalation. - Authorized user-triggered local actions presented as privilege escalation. Example: an allowlisted/owner sender running `/export-session /absolute/path.html` to write on the host. In this trust model, authorized user actions are trusted host actions unless you demonstrate an auth/sandbox/boundary bypass. - Reports that only show a malicious plugin executing privileged actions after a trusted operator installs/enables it. - Reports that assume per-user multi-tenant authorization on a shared gateway host/config. diff --git a/docs/gateway/operator-scopes.md b/docs/gateway/operator-scopes.md index bdb83fdd2120..e00f01af980a 100644 --- a/docs/gateway/operator-scopes.md +++ b/docs/gateway/operator-scopes.md @@ -104,6 +104,13 @@ command list: | non-exec node commands | `operator.pairing` + `operator.write` | | `system.run`, `system.run.prepare`, or `system.which` | `operator.pairing` + `operator.admin` | +Approving a node declaration does not enable commands that have a separate +runtime allowlist gate. For example, approving a node that declares +`computer.act` requires pairing plus write scope, but only records the surface. +An administrator or owner must still arm `computer.act`. While it remains +armed, invoking it through the write-scoped `node.invoke` method does not +require admin scope for each action. + Node pairing establishes identity and trust; it does not replace a node's own `system.run` exec approval policy. diff --git a/docs/nodes/computer-use.md b/docs/nodes/computer-use.md index 4beb2b08dfab..8877143c55c5 100644 --- a/docs/nodes/computer-use.md +++ b/docs/nodes/computer-use.md @@ -71,6 +71,14 @@ Reads reuse `screen.snapshot`; there is no second capture path. See [Camera and For persistent authorization, add `computer.act` to `gateway.nodes.allowCommands` **and remove it from** `gateway.nodes.denyCommands`; the deny list wins. Persistent authorization does not auto-expire. Entries already present before `/phone arm` remain after `/phone disarm`; do not convert a temporary grant to persistent while it is armed. +Authorization is deliberately split between enabling and use. Arming or +persistently configuring `computer.act` requires administrative authority. +Once armed, an authenticated operator with `operator.write` can invoke +`computer.act` through `node.invoke` until the grant expires or is disarmed; +there is no per-action admin check. Approving a node that declares +`computer.act` only records the surface so it can be armed later and does not +enable invocation by itself. + ## Safety - Before authorization, every layer (tool policy, gateway command policy, macOS setting, Accessibility, and Screen Recording) must agree. Once armed, actions execute without a per-action confirmation until expiry or `/phone disarm`. diff --git a/src/gateway/server-methods/nodes.invoke-wake.test.ts b/src/gateway/server-methods/nodes.invoke-wake.test.ts index 1cddde7483ce..ec926ba9bce2 100644 --- a/src/gateway/server-methods/nodes.invoke-wake.test.ts +++ b/src/gateway/server-methods/nodes.invoke-wake.test.ts @@ -554,6 +554,43 @@ describe("node.invoke APNs wake path", () => { expect(nodeRegistry.invoke).not.toHaveBeenCalled(); }); + it("allows an armed computer.act command for write-scoped operators", async () => { + mocks.getRuntimeConfig.mockReturnValue({ + gateway: { nodes: { allowCommands: ["computer.act"] } }, + }); + mocks.resolveNodeCommandAllowlist.mockReturnValue(new Set(["computer.act"])); + const nodeRegistry = { + get: vi.fn(() => ({ + nodeId: "computer-node", + commands: ["computer.act"], + platform: "macOS 26.0.0", + })), + invoke: vi.fn().mockResolvedValue({ + ok: true, + payloadJSON: '{"ok":true}', + }), + }; + + const respond = await invokeNode({ + nodeRegistry, + client: createOperatorClient({ scopes: ["operator.write"] }), + requestParams: { + nodeId: "computer-node", + command: "computer.act", + params: { action: "type", text: "hello" }, + }, + }); + + const call = firstRespondCall(respond); + expect(call[0]).toBe(true); + expect(nodeRegistry.invoke).toHaveBeenCalledTimes(1); + expectRecordFields(mockArg(nodeRegistry.invoke, 0, 0), "node invoke payload", { + nodeId: "computer-node", + command: "computer.act", + params: { action: "type", text: "hello" }, + }); + }); + it("explains the explicit opt-in required for dangerous commands", async () => { mocks.isNodeCommandAllowed.mockReturnValue({ ok: false, diff --git a/src/infra/node-pairing-authz.test.ts b/src/infra/node-pairing-authz.test.ts index d7fb222b34ab..83eaebec435c 100644 --- a/src/infra/node-pairing-authz.test.ts +++ b/src/infra/node-pairing-authz.test.ts @@ -17,6 +17,13 @@ describe("resolveNodePairApprovalScopes", () => { ]); }); + it("treats computer.act pairing approval as non-exec surface approval", () => { + expect(resolveNodePairApprovalScopes(["computer.act"])).toEqual([ + "operator.pairing", + "operator.write", + ]); + }); + it("requires only operator.pairing without commands", () => { expect(resolveNodePairApprovalScopes(undefined)).toEqual(["operator.pairing"]); expect(resolveNodePairApprovalScopes([])).toEqual(["operator.pairing"]);