@ref0/cli 0.10.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +144 -2
  2. package/dist/main.mjs +1623 -965
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -11,7 +11,7 @@ ref0 connect
11
11
 
12
12
  It opens your browser at its request on ref0.dev, or on the origin `REF0_API` selects, signing you in first if needed. An agent is who the sessions in a folder act as on ref0. The page shows each folder with its agent chosen already: Claude Code's folders act as `claude` and Codex's as `codex`: your agent of that handle, or a new one if you have none. Change a folder to another of your agents or a new handle, or leave it out, then approve once with your passkey. The yes comes back to the terminal by itself. ref0 keeps a grant for each folder it connected, and installs ref0's plugin into each from the public [ref-0/plugins marketplace](https://github.com/ref-0/plugins). For a Codex folder, the terminal first asks whether to trust ref0's hooks and whether ref0's sandbox profile may be the folder's default. Then it says which agent each folder acts as, and which organizations are still authorizing it. A folder whose install fails stays connected, and ref0 prints the `ref0 install` command that repairs it. Start a new session in each folder.
13
13
 
14
- When no browser opens, such as over SSH, open the printed link on any device, or type the code it prints in Find under Waiting on you. After your yes, the page shows an answer code: paste it only into the terminal where you ran `ref0 connect`. The page also shows it if the yes doesn't reach the terminal on its own.
14
+ When no browser opens, such as over SSH, open the printed link on any device, or type the code it prints in Find under Waiting on you. After your yes, the page shows an answer code: paste it only into the terminal where you ran `ref0 connect`. The page also shows it if the yes doesn't reach the terminal on its own. A folder connected before keeps its connection until this terminal keeps and confirms the new one, so a yes whose code never arrives leaves it working.
15
15
 
16
16
  Pip (`pip-ref0`) has two Claude Code config folders and one Codex config folder on their laptop. They keep all three, approve once and leave each folder's agent as the page chose it:
17
17
 
@@ -48,4 +48,146 @@ Set `REF0_API=https://staging.ref0.dev` to use staging. ref0 keeps each API orig
48
48
 
49
49
  Connecting writes a launcher under `~/.ref0/origins/<encoded origin>/bin/ref0` with the absolute Node, CLI and Git paths and the API origin. The installed plugin pins that origin for its hooks and MCP server. Each origin has its own plugin and marketplace name: `ref0` for ref0.dev, so its status command is `/ref0:status` in Claude Code and `$ref0:status` in Codex, and `ref0-` and a hash of the origin for any other. A clone records `ref0.api`, and its Git helper pins the same origin. Commands use `REF0_API`, then the clone’s `ref0.api`, then ref0.dev. Hooks select the clone’s origin before the ambient variable. If Git cannot read the origin, they stop without selecting an API. If you move or remove Node, run install again with the Node you use now.
50
50
 
51
- After upgrading from shared credentials, run connect again for each API origin, then start new sessions and clone again. Connecting to ref0.dev replaces the `ref0` marketplace and plugin an earlier release installed in each folder, and each folder's grant from 0.5.0. Once every folder that used an old grant keeps its own, it deletes the old keychain entry and `~/.ref0/plugin-agents.json`. Clones are not migrated.
51
+ After upgrading from shared credentials, run connect again for each API origin, then start new sessions and clone again. Connecting to ref0.dev replaces the `ref0` marketplace and plugin an earlier release installed in each folder, and each folder's grant from 0.5.0. Once every folder that used an old grant keeps its own, and ref0 takes the confirm, it deletes the old keychain entry and `~/.ref0/plugin-agents.json`. Clones are not migrated.
52
+
53
+ ## Prove which agent is asking, to another service
54
+
55
+ An agent in a ref0 session can prove to a service outside ref0 which agent it is. `ref0 identity-token --audience <origin>` prints an identity token: a JWT that ref0 signs for that one service, lasting at most 10 minutes. The service checks it against the keys ref0 publishes, with a stock JWT library, and never calls ref0.
56
+
57
+ Mo runs a service at `https://verifier.example` that allows Pip's agent. In Pip's Claude Code session, the agent runs:
58
+
59
+ ```sh
60
+ ref0 identity-token --audience https://verifier.example | node verify.mjs
61
+ ```
62
+
63
+ ```text
64
+ {"agent":"agent_522kh8r0nff9ratkw2n35hgpc7","session":"psession_4rdpxsbffp27g01mwc9j7cyexv","accountable":"pperson_7p2fdqsjyef8svnkrey2ds63zv"}
65
+ ```
66
+
67
+ `verify.mjs` is the [example verifier](#the-example-verifier), with Mo's settings in its environment.
68
+
69
+ ### Get a token
70
+
71
+ Run `ref0 identity-token` inside an agent's session: in Claude Code or Codex, in a config folder that `ref0 connect` connected. It sends one request with the session's own token, and prints the token and a newline on stdout. With `--json`, it prints `{"token":"…","expiresAt":…}`, the expiry in epoch milliseconds.
72
+
73
+ Name `--audience` once, as the service's HTTPS origin: a host and an optional port, with no path. ref0 keeps the audience exactly as written, so `https://verifier.example:443` is another audience than `https://verifier.example`. Put no private names in it: the token and ref0's log both hold it.
74
+
75
+ The command never starts or renews a session. Outside one, or when ref0 refuses, it says why on stderr, prints nothing on stdout and exits 1. Each run makes a new token. ref0 refuses a session that asks more than about six times a minute. ref0 records each token it makes in the project's log by its `jti`, never the token itself. If ref0's answer is lost, ref0 may have made a token that never reached you. Ask again for a fresh one.
76
+
77
+ A token expires 10 minutes after it's made, or sooner if the session's lease or its own token expires first.
78
+
79
+ ### What a token says
80
+
81
+ The header is `{"alg":"ES256","kid":"<key ID>","typ":"JWT"}`. The payload holds these claims and no others:
82
+
83
+ | Claim | Value |
84
+ | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
85
+ | `iss` | ref0's origin: `https://ref0.dev`, or `https://staging.ref0.dev` |
86
+ | `sub` | The agent's ID, `agent_…`, the same at every service |
87
+ | `aud` | The audience the agent named, as one string |
88
+ | `iat`, `exp` | When ref0 made the token and when it expires, in whole epoch seconds, at most 600 apart |
89
+ | `jti` | A random UUID for this token |
90
+ | `act` | The session that asked, then up to three sessions it was started from, then the accountable person, nested as `{"sub":…,"act":…}`. Each `sub` is a pairwise ID |
91
+
92
+ The accountable person is the one whose attachment authorized the sessions. Anyone holding a token can read every claim. A token names no thread, project, repo, owner name or email, and no harness or model. It doesn't prove which model or harness ran the agent, and it grants nothing: your service decides what an agent may do.
93
+
94
+ ### Find ref0's keys
95
+
96
+ | Environment | Issuer | Keys | Discovery |
97
+ | ----------- | -------------------------- | ------------------------------------------------ | ----------------------------------------------------------- |
98
+ | Production | `https://ref0.dev` | `https://ref0.dev/.well-known/jwks.json` | `https://ref0.dev/.well-known/openid-configuration` |
99
+ | Staging | `https://staging.ref0.dev` | `https://staging.ref0.dev/.well-known/jwks.json` | `https://staging.ref0.dev/.well-known/openid-configuration` |
100
+
101
+ Each environment signs with its own keys, so trust the one issuer you mean. The keys document holds at most three public P-256 keys, each named by its `kid`. The discovery document names the issuer, the keys URL, ES256 and the claims, for a JWT verifier. It isn't a full OpenID provider's: ref0 has no authorization, token or userinfo endpoint there. Both answer with `Cache-Control: public, max-age=300, must-revalidate`.
102
+
103
+ ### Verify a token
104
+
105
+ Your verifier checks all of these:
106
+
107
+ - The issuer is the one you configured. The keys come from its keys URL, never from the token's `iss`, `jku` or `x5u`.
108
+ - The signature is ES256, by a key whose `kid` the keys document lists. Refuse `none` and HMAC.
109
+ - `typ` is `JWT`, and every claim in the table is present.
110
+ - `iat` and `exp` are whole numbers, and `exp` is 1 to 600 seconds after `iat`. `iat` isn't in the future and `exp` hasn't passed, with at most 30 seconds of clock tolerance.
111
+ - `aud` is one string, exactly your audience. A library's audience check alone accepts a list that holds yours.
112
+ - `sub` is an agent you allow, and `act` is one to four sessions, then a person.
113
+
114
+ Your policy is the agents you allow, each a pair of issuer and `sub`. The sessions and the person in `act` say how the agent came to ask, and grant nothing more. A valid token isn't permission to act. ref0 doesn't stop replays: anyone holding a token can present it to your audience until it expires. If you need each token used once, record each `jti` you accept until its `exp` plus your clock tolerance, in your own store.
115
+
116
+ ### The example verifier
117
+
118
+ This verifier checks a token as the list above says, with `jose` 6 on Node.js 22 or later. It reads the token from stdin and prints the agent, the session and the accountable person. Set `AUDIENCE` to your origin and `EXPECTED_AGENT` to the agent you allow. For staging, also set `REF0_ISSUER=https://staging.ref0.dev`. To find the agent's ID, ask the agent to run `ref0 whoami` in its session.
119
+
120
+ ```js
121
+ import { createRemoteJWKSet, jwtVerify } from 'jose';
122
+ import { readFileSync } from 'node:fs';
123
+
124
+ // Your service's own settings: the issuer it trusts, its own origin, and the agent it allows.
125
+ const issuer = process.env.REF0_ISSUER ?? 'https://ref0.dev';
126
+ const audience = process.env.AUDIENCE;
127
+ const agent = process.env.EXPECTED_AGENT;
128
+ const refuse = (why) => {
129
+ console.error(`Refused: ${why}`);
130
+ process.exit(1);
131
+ };
132
+ const id = (kind, value) =>
133
+ typeof value === 'string' && new RegExp(`^${kind}_[0-7][0-9a-hjkmnp-tv-z]{25}$`).test(value);
134
+ if (!audience) refuse('Set AUDIENCE to your service’s HTTPS origin.');
135
+ if (!id('agent', agent)) refuse('Set EXPECTED_AGENT to the agent ID you allow.');
136
+
137
+ // Keys only from the issuer you trust, kept 5 minutes at most, as ref0's key rotation expects.
138
+ const keys = createRemoteJWKSet(new URL(`${issuer}/.well-known/jwks.json`), {
139
+ timeoutDuration: 3000,
140
+ cacheMaxAge: 300_000,
141
+ });
142
+ const token = readFileSync(0, 'utf8').trim();
143
+ const { payload: p, protectedHeader: h } = await jwtVerify(token, keys, {
144
+ issuer,
145
+ audience,
146
+ subject: agent,
147
+ algorithms: ['ES256'],
148
+ typ: 'JWT',
149
+ clockTolerance: 30,
150
+ maxTokenAge: '10m',
151
+ requiredClaims: ['iss', 'sub', 'aud', 'exp', 'iat', 'jti', 'act'],
152
+ }).catch((error) => refuse(error.message));
153
+
154
+ // What jose leaves to you: one exact audience, a key ID, at most 10 minutes, a token ID, and a
155
+ // chain of one to four sessions, the current one first, then the accountable person.
156
+ const lifetime = Number.isInteger(p.iat) && Number.isInteger(p.exp) ? p.exp - p.iat : 0;
157
+ const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
158
+ if (p.aud !== audience || typeof h.kid !== 'string' || !h.kid) refuse('Wrong audience or no key.');
159
+ if (lifetime < 1 || lifetime > 600) refuse('Invalid lifetime.');
160
+ if (typeof p.jti !== 'string' || !uuid.test(p.jti)) refuse('Invalid token ID.');
161
+ const sessions = [];
162
+ let actor = p.act;
163
+ while (id('psession', actor?.sub) && sessions.length < 5) {
164
+ sessions.push(actor.sub);
165
+ actor = actor.act;
166
+ }
167
+ if (sessions.length < 1 || sessions.length > 4) refuse('Invalid chain.');
168
+ if (!id('pperson', actor?.sub) || actor.act !== undefined) refuse('Invalid chain.');
169
+ console.log(JSON.stringify({ agent: p.sub, session: sessions[0], accountable: actor.sub }));
170
+ ```
171
+
172
+ Save it as `verify.mjs`, run `npm i jose`, then pipe a token in:
173
+
174
+ ```sh
175
+ export AUDIENCE=https://verifier.example EXPECTED_AGENT=agent_522kh8r0nff9ratkw2n35hgpc7
176
+ ref0 identity-token --audience https://verifier.example | node verify.mjs
177
+ ```
178
+
179
+ It exits 1 and says why on stderr, with nothing on stdout, for a token made for another audience (`Refused: unexpected "aud" claim value`) or another agent (`Refused: unexpected "sub" claim value`). Without `EXPECTED_AGENT`, it refuses to start.
180
+
181
+ ### Pairwise IDs hide ref0's IDs, not the person
182
+
183
+ Each `sub` in `act` is a pairwise ID, `pperson_…` or `psession_…`, made from ref0's own ID, the issuer and the audience with a key only ref0 holds. A person or a session has the same pairwise ID at your audience every time, so you can allow a person's tokens or follow one session across its tokens. It has a different one at every other audience, and no service learns ref0's own ID for either. A pairwise ID holds no creation time.
184
+
185
+ Pairwise IDs don't make a person's agents unlinkable. The agent's `sub` is its real agent ID, the same at every service, and public in the commits it pushes. Two services that both see one agent can match their pairwise IDs for its person. Each can then tell the other which of that person's other agents it has seen.
186
+
187
+ ### Revoking stops new tokens, not issued ones
188
+
189
+ Once a session ends, its attachment is revoked or its agent is turned off, ref0 refuses that session's next token. A token already made still verifies until its `exp`, plus your clock tolerance: up to 10 minutes and 30 seconds. ref0 can't recall it, since your verifier never asks ref0 about a token.
190
+
191
+ ### Keep ref0's keys 5 minutes at most
192
+
193
+ ref0 rotates its signing key monthly. It publishes the next key at least 15 minutes before signing with it. It keeps a retired key published for at least 20 minutes after its last token: 10 minutes of a token's life, 30 seconds of clock tolerance and 5 minutes of cache. So keep fetched keys for 5 minutes at most. `jose`'s `createRemoteJWKSet` keeps them for 10 minutes by default, so the example verifier sets `cacheMaxAge: 300_000`. A verifier that keeps keys longer still trusts a removed key until its copy expires. That matters most when ref0 removes a compromised key at once. When a token names a `kid` you don't have, fetch the keys again, once.