docs(security): clarify computer act authorization

This commit is contained in:
joshavant
2026-07-10 13:06:53 -05:00
parent b91e117dcd
commit 5b20013399
5 changed files with 60 additions and 0 deletions
+1
View File
@@ -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.
+7
View File
@@ -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.
+8
View File
@@ -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`.
@@ -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,
+7
View File
@@ -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"]);