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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
166 changes: 166 additions & 0 deletions docs/adr/0021-scoped-command-token.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
# 0021 — Scoped command token: a credential that can run one command on one device

Date: 2026-11-06
Status: proposed (merged with the next regular release; set to accepted when that release ships)

## Context

Every credential wanctl issues today is all-or-nothing. A full token
(`wanctl_<40 hex>`) carries its owner's whole namespace: every device, every
verb (exec, push, pull, logs, shell), plus account management through the
`/u/*` endpoints. The only narrower credential is the WebFetch delegation
(`wfd_`), which is derived from a browser ticket and approved per request by a
human in the portal; it limits the *device* and the *time*, not the *action*
("grants add no action matrix", ADR 0007 and `internal/delegation`).

That gap is a real obstacle. A phone agent, a cron job on a laptop, or a
webhook receiver often needs exactly one thing — "run the deploy script on box
A" — and today it must hold a token that can run anything on every device the
owner has. If that token leaks, or the program holding it is prompt-injected,
the blast radius is the owner's whole fleet. This is why Iris does not use
wanctl for its "ask the project" feature.

The owner asked for it directly: *a restricted-rights token*.

## Decision

**A new credential kind, the scoped command token.** It is bound at issue time
to exactly one thing: *one device* and *one literal command string*. It can
run that command on that device, byte for byte, and nothing else. Version one
has this one scope and no others (no wildcards, no argument templates, no file
verbs); more scopes can be added later behind the same field.

### Format and storage

- Token: `wcs_<40 hex>`. The distinct prefix, like `wfd_`, lets every legacy
code path refuse it by prefix without a database lookup.
- Stored as a SHA-256 hash in the existing `tokens` table, so expiry,
revocation, listing and the "disabled account" predicate come from the
machinery that already exists and is already tested. Its `kind` is
`delegated`, the kind earlier releases already refuse on every
namespace-only lookup (`Resolve`, `TokenAccountDisabled`); a row in
`scoped_tokens` is what marks it scoped. A new kind of its own would have
been accepted as a full account token by the old queries (`kind <>
'delegated'`) the moment a relay was rolled back.
- Scope lives in a new table (migration 016):
`scoped_tokens(token_id PK → tokens.id, namespace, device_id, device_fp, command)`.
`device_fp` is the device's fingerprint at issue time; if the device is later
re-enrolled under a new identity the token stops resolving, exactly like a
delegation grant (`delegation_devices`).
- The raw token is returned once at issue and never stored.

### Issue, list, revoke

- CLI, authenticated with the owner's full token:
`wanctl scoped-token create --device D --command 'CMD' [--days N] [--label L]`,
`wanctl scoped-token list`, `wanctl scoped-token revoke ID`.
Relay endpoints: `POST /u/scoped-tokens`, `GET /u/scoped-tokens`,
`POST /u/scoped-tokens/revoke`. They use the legacy `r.auth`, which refuses
scoped and delegated tokens, so a scoped token cannot mint or revoke tokens.
- Only the owner's own devices (not devices shared *to* them) can be scoped.
- Expiry is mandatory: default 90 days, maximum 365. A forever-token is the
thing this feature exists to avoid; owners rotate instead.
- At most 50 live scoped tokens per namespace.
- Revocation reuses `tokens.revoked_at` (the same `RevokeToken` the portal's
token list already calls), so a scoped token also shows up and can be revoked
from the portal's existing token page, where it appears as "one command
only" with its command and device.
The portal gets no new issue form in this version.

### Enforcement: two places, and why the relay cannot be the only one

Sessions are end-to-end TLS between the controller and the device. The relay
sees ciphertext and by design cannot read the command; putting a command filter
in the relay would mean breaking E2E. So the permission is decided in two
places that each hold part of it:

1. **Relay (server).** Resolves the token on every dial and on every
revalidation. It admits the token only to its one pinned device
(`Access.Allows`), stamps the session with exec-only capabilities
(`sessionauth.Exec`, not `UseCapabilities`), and puts the allowed command
into the `sessionauth.Open` message it sends the *agent* over the
authenticated agent control channel. The controller cannot declare or alter
it. Any other device, `/peers` entry, account endpoint, enrollment, or
agent endpoint is refused the same way a delegated credential is
(`Resolve` rejects kind `delegated`; `wcs_` is refused by prefix on every
namespace-only path, including satellite relays).
2. **Agent (device).** Reads the command from the relay-stamped `Open`
message and, before any other handling of each request, requires the
request to be *exactly* the canonical form of "exec this command": kind
`exec`, `oneshot`, command equal byte for byte, and every other field zero
except `spill_after` (output handling). It is an allow-list over the whole
decoded message, not a deny-list of known-bad fields, so a field added to
`protocol.Message` later is rejected until someone decides otherwise. The
wire decoder drops unknown keys and lets the last duplicate or any key
spelling win; that is sound because the decoded struct that is compared is
the struct that would run (pinned by `TestScopedRequestJSONSpelling…`). Everything else
— file put/get/read/edit/write, logs, status, desktop, workspace, async
exec, elevation, a different `cwd`, a different command, a pipe or `;`
appended — gets a reject and is never executed. A changed client cannot get
around this because the agent, not the client, holds the rule.

Each request on a scoped session also re-asks the relay `/agent/delegation-check`
(the existing per-request revalidation), so a revocation or expiry stops the
*next* request on an already-open session, and the relay's one-second lease
check closes the session itself.

### The token does not widen the device's own policy

The token narrows; it never grants. The command still goes through the device's
normal gate (mode, rules, approval, `bypass`), and the controller still has to
be one the device has paired. A leaked token used from an unknown machine
therefore also hits the device's pairing prompt, which is a second line.
The practical consequence for a headless use: the owner either runs the device
in a mode that allows that command, or adds an allow rule for it
(`wanctl rules`), once.

We considered making the token itself pre-approve its command on the device.
Rejected for v1: it would let the relay, which is not trusted with device
authority (architecture, trust model layer 3), create execute permission on a
device, which is the reverse of what a *limited* token should do.

### Old agents

An agent that does not know about scoped tokens must never serve one, because
it would ignore the extra field and run whatever the controller asks. Two
independent fail-closed guards:

- The new agent registers with `scoped_exec=1` (control register message and
the HTTP carrier's query). The relay refuses to dial a scoped token to an
agent that did not, with the same shape of refusal as delegation today
(`access.Delegated && !a.delegation`). The caller sees a plain refusal.
- The `Open` message for a scoped session carries `Capabilities = exec` with a
grant id. Agents from before this change validate a grant session as
`Capabilities == UseCapabilities` (`Open.ValidFor`) and silently drop
anything else, so they would not serve the session even if the relay check
were bypassed.

An older *relay* talking to a newer agent is not a case: the token only exists
on a relay that has migration 016 and knows the kind.

### Considered and not done in v1

- Binding the token to a controller key (so a leaked token alone is useless):
the schema leaves room (`Access.ControllerFingerprint` already exists), but
it needs a way to learn the holder's fingerprint before issue, which is a
second step for every use. The device's pairing gate covers the same threat
for now.
- Argument templates, wildcards, multiple commands per token, file scopes,
read-only scopes: not asked for; each would be its own design.
- A portal issue form, and switching Iris onto the token: next steps, not part
of this change.
- Relay-side command filtering: impossible under E2E, see above.

## Consequences

- Leaking a scoped token exposes one command on one device, for a bounded
time, behind that device's own policy and pairing.
- Full tokens and the WebFetch delegation behave exactly as before. The new
`ExecOnly` field is empty for them and every new branch is guarded on it.
- A device must run a version of wanctl that includes this change to be
reachable with a scoped token; older devices refuse. The public docs say so.
- Rollback: a relay binary without this code cannot resolve a scoped token
(kind `delegated` with no delegation request) and refuses it on the
namespace-only paths, so the tokens go dead rather than gaining account
rights; the table can stay in place. A regression test runs the old
`Resolve` query against a scoped token.
27 changes: 27 additions & 0 deletions docs/contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ REFUSALS — none of these mean retry as-is:
| `mcp` | — | Run wanctl as an MCP server |
| `docs` | — | Read and write the portal's documentation articles |
| `friends` | — | List and manage friend relationships between namespaces |
| `scoped-token` | — | Issue a token that can run one command on one device |
| `share` | — | Grant another namespace the use of one of your devices |
| `screenshot` | `wanctl_screenshot` | Capture a device's screen and desktop coordinates |
| `act` | `wanctl_act` | Operate a Windows desktop or Android device |
Expand Down Expand Up @@ -928,6 +929,32 @@ wanctl friends
wanctl friends accept other-ns
```

## `wanctl scoped-token`

*Issue a token that can run one command on one device*

A scoped token is for a program that needs exactly one thing from a device. It
runs one literal command on one device of yours and is refused everything
else: any other command or argument, a pipe or `;`, files, logs, other
devices, your account. It expires (default 90 days, at most 365) and `revoke`
stops it on its next request. It narrows access and never widens it: the
device's own mode, rules and pairing still apply, and the device must run a
wanctl version that knows scoped tokens. Hold it in WANCTL_TOKEN and run the
command with `wanctl exec --oneshot`.

| Parameter | CLI | Type | Required | Meaning |
|---|---|---|---|---|
| — | `--device DEV` | string | no | The device of yours the token is bound to (create). |
| — | `--command CMD` | string | no | The one command it may run, one printable line, matched byte for byte (create). |
| — | `--days N` | string | no | Lifetime in days, 1-365; default 90 (create). |
| — | `--label TEXT` | string | no | A note about who holds it (create). |

```
wanctl scoped-token create --device home-pc --command 'uptime' --days 30
wanctl scoped-token list
wanctl scoped-token revoke 12
```

## `wanctl share`

*Grant another namespace the use of one of your devices*
Expand Down
83 changes: 83 additions & 0 deletions docs/scoped-token.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
# Scoped command tokens

A scoped token is a credential for a program that needs exactly one thing from
a device — "restart the app on box A", "run the nightly backup" — and should
not be able to do anything else if it leaks or is tricked.

It can run **one literal command on one device**. Everything else is refused:
another command, the same command with a different argument, a pipe or `;`
appended, another device, files, logs, a shell, your account, other tokens.
It expires, and you can revoke it at any time.

> **Not available yet.** Scoped tokens need the next relay release (after
> v0.29.1), and devices updated to it. Until the relay you use is upgraded,
> `wanctl scoped-token` and the tokens it issues are refused.

## Issue one

With your normal login (`wanctl login`), on any machine:

```bash
wanctl scoped-token create --device home-pc --command 'systemctl restart app' --days 30 --label ci
```

The token (`wcs_…`) is printed once on stdout; the details go to stderr, so
`T=$(wanctl scoped-token create …)` captures just the token. It cannot be
shown again.

- `--device` must be one of your own devices, not a device shared to you.
- `--command` is matched byte for byte. One printable line, up to 4096 bytes.
For anything longer, put it in a script on the device and scope the token to
running the script.
- `--days` is 1–365, default 90. There is no non-expiring scoped token.
- You can hold at most 50 live scoped tokens.

```bash
wanctl scoped-token list # id, state, command, device, label
wanctl scoped-token revoke 12 # its next request is refused
```

## Use one

Give the token to the program, set it as `WANCTL_TOKEN`, and run the command
with `--oneshot`:

```bash
WANCTL_TOKEN=wcs_… wanctl exec --target home-pc --oneshot -- 'systemctl restart app'
```

The machine using the token must trust the device's identity once, like any
controller: `wanctl trust server --target home-pc --fingerprint SHA256:…` with
the fingerprint the device prints when its agent starts.

Anything but that exact request is refused by the relay or by the device, and
nothing is executed. A revoked or expired token fails on the next request,
including on a session that was already open.

## What it does not do

A scoped token **narrows** access; it never adds any.

- The device's own mode, rules and approvals still decide whether the command
runs. On a device that asks before running things, either approve the
command once with a rule (`wanctl rules`) or the call waits for you like any
other.
- The machine using the token still has to be paired with the device, the
same as any controller. A stolen token used from a machine the device has
never seen gets the device's pairing prompt, not a command.
- The device must run a wanctl version that supports scoped tokens. An older
device refuses the token instead of ignoring the limit.
- It is not a secret-hiding tool: whoever holds the token can run that command.
Pick commands that are safe to run on demand.

## How it is enforced

Sessions are end-to-end encrypted, so the relay cannot read the command. The
permission is therefore split. The relay admits the token only to its pinned
device, with exec-only rights, and tells the device which command was
authorised over the device's own authenticated channel. The device then
accepts only a request that is exactly "run this command once" and refuses
everything else, including any field it does not recognise. Changing the
client does not help: the rule is held by the device, and the relay re-checks
the token on every request. The design and the alternatives are in
ADR 0021 (`docs/adr/0021-scoped-command-token.md` in the repository).
65 changes: 65 additions & 0 deletions docs/scoped-token.zh.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# 限权令牌(scoped token)

限权令牌是给「只需要从设备上拿一件事」的程序用的凭据,比如「在 A 机上重启应用」
「跑夜间备份」。令牌泄露或程序被诱导时,它也做不了别的。

它只能在**一台设备上运行一条固定命令**。其余一律拒绝:换命令、同一条命令改参数、
末尾加管道或 `;`、换设备、文件、日志、shell、你的账号、其他令牌。它会过期,你随时可以撤销。

> **暂时还不能用。** 限权令牌要等下一版 relay(v0.29.1 之后的版本)才支持,设备也要更新到
> 那一版。在你用的 relay 升级之前,`wanctl scoped-token` 和它发出的令牌都会被拒绝。

## 发一把

用平常的登录(`wanctl login`),在任意一台机器上:

```bash
wanctl scoped-token create --device home-pc --command 'systemctl restart app' --days 30 --label ci
```

令牌(`wcs_…`)只在 stdout 打印一次,说明写在 stderr,所以
`T=$(wanctl scoped-token create …)` 拿到的只有令牌。丢了不能再看,只能重发。

- `--device` 必须是你自己的设备,不能是别人共享给你的。
- `--command` 按字节逐一比对。一行可见字符,最长 4096 字节。更长的写成设备上的脚本,
让令牌只允许运行这个脚本。
- `--days` 取 1 到 365,默认 90。没有永不过期的限权令牌。
- 同时有效的限权令牌最多 50 把。

```bash
wanctl scoped-token list # id, state, command, device, label
wanctl scoped-token revoke 12 # its next request is refused
```

## 使用

把令牌交给程序,设为 `WANCTL_TOKEN`,加 `--oneshot` 运行这条命令:

```bash
WANCTL_TOKEN=wcs_… wanctl exec --target home-pc --oneshot -- 'systemctl restart app'
```

用令牌的那台机器和任何控制端一样,要先确认一次设备身份:
`wanctl trust server --target home-pc --fingerprint SHA256:…`,指纹是设备上 agent 启动时打印的那一个。

除了这一条请求,别的都会被 relay 或设备拒绝,什么都不会执行。撤销或过期的令牌在下一次请求时失效,
已经打开的会话也一样。

## 它不做什么

限权令牌只会**收窄**权限,不会增加权限。

- 设备自己的模式、规则和审批仍然决定命令跑不跑。设备在「每次询问」模式时,要么用规则
(`wanctl rules`)一次性放行这条命令,要么调用会像别的请求一样等你批准。
- 用令牌的那台机器仍然要和设备配对,和任何控制端一样。令牌被偷、从设备没见过的机器上使用,
碰到的是设备的配对询问,不是命令执行。
- 设备上的 wanctl 必须是支持限权令牌的版本。旧版设备会拒绝这把令牌,不会忽略限制。
- 它不是保密工具:拿到令牌的人能随时运行那条命令,所以要选可以随时跑的命令。

## 怎么执行的

会话是端到端加密的,relay 看不到命令,所以权限分两处判断。relay 只把令牌放行到它绑定的
那台设备,且只给执行权限,并通过设备自己的已认证通道告诉设备授权的是哪条命令。设备只接受
恰好是「运行这条命令一次」的请求,其余全部拒绝,包括它不认识的任何字段。改客户端没用:
规则在设备手里,relay 也在每次请求时重新检查令牌。设计取舍见
ADR 0021(仓库里的 `docs/adr/0021-scoped-command-token.md`)。
Loading
Loading