metergraph-cli 0.2.3 → 0.2.5

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 (60) hide show
  1. package/README.md +78 -14
  2. package/assets/skill/SKILL.md +124 -31
  3. package/assets/skill/manifest.json +3 -3
  4. package/assets/skills/manifest.json +37 -31
  5. package/assets/skills/metergraph-analyze/SKILL.md +161 -58
  6. package/assets/skills/metergraph-candidates/SKILL.md +50 -8
  7. package/assets/skills/metergraph-evals/SKILL.md +83 -16
  8. package/assets/skills/metergraph-investigate/SKILL.md +90 -34
  9. package/assets/skills/metergraph-iterate/SKILL.md +82 -12
  10. package/assets/skills/metergraph-local-bundle/SKILL.md +146 -128
  11. package/assets/skills/metergraph-model-swap/SKILL.md +142 -20
  12. package/assets/skills/metergraph-onboarding/SKILL.md +93 -49
  13. package/assets/skills/metergraph-price-check/SKILL.md +219 -0
  14. package/assets/skills/metergraph-report/SKILL.md +60 -15
  15. package/assets/skills/metergraph-workloads/SKILL.md +97 -13
  16. package/native/README.md +85 -0
  17. package/native/anchored-fs.c +243 -0
  18. package/native/bin/anchored-fs-darwin-arm64 +0 -0
  19. package/native/bin/anchored-fs-darwin-x64 +0 -0
  20. package/native/bin/anchored-fs-linux-arm64 +0 -0
  21. package/native/bin/anchored-fs-linux-x64 +0 -0
  22. package/native/build.mjs +55 -0
  23. package/native/manifest.json +9 -0
  24. package/native/targets.mjs +24 -0
  25. package/package.json +10 -3
  26. package/src/action-help.js +130 -0
  27. package/src/agent-key.js +21 -14
  28. package/src/anchored-fs.js +163 -0
  29. package/src/args.js +125 -17
  30. package/src/auth-binding.js +12 -7
  31. package/src/auth-callback.js +10 -2
  32. package/src/auth-login.js +111 -28
  33. package/src/auth-oauth.js +89 -45
  34. package/src/auth-session.js +19 -12
  35. package/src/auth-store.js +28 -7
  36. package/src/cli.js +52 -8
  37. package/src/constants.js +37 -9
  38. package/src/experiments-output.js +7 -6
  39. package/src/experiments.js +26 -17
  40. package/src/output.js +274 -79
  41. package/src/read-contract.js +42 -8
  42. package/src/read-output.js +66 -13
  43. package/src/read.js +90 -15
  44. package/src/repository-identity.js +2 -2
  45. package/src/setup-env-git.js +1 -1
  46. package/src/setup-env.js +16 -9
  47. package/src/setup-interactive.js +19 -4
  48. package/src/setup-refusal.js +2 -2
  49. package/src/setup-state.js +9 -4
  50. package/src/setup.js +15 -3
  51. package/src/skill-bundle.js +2 -2
  52. package/src/skill-trusted.js +56 -0
  53. package/src/skill.js +28 -9
  54. package/src/style.js +33 -7
  55. package/src/tools-output.js +73 -0
  56. package/src/tools.js +249 -0
  57. package/src/trace-contract.js +1 -1
  58. package/src/transport.js +21 -0
  59. package/src/verify-output.js +2 -2
  60. package/src/verify.js +5 -2
package/src/auth-oauth.js CHANGED
@@ -3,9 +3,11 @@ import { createHash, randomBytes } from "node:crypto";
3
3
  import {
4
4
  AGENT_CONTRACT_VERSION,
5
5
  AUTH_PATHS,
6
+ DEBUG_SCOPE,
6
7
  EXPERIMENT_START_KEY_SCOPES,
7
8
  EXPERIMENT_START_SCOPE,
8
9
  METADATA_SCOPE,
10
+ WORKFLOW_SCOPES,
9
11
  } from "./constants.js";
10
12
  import { parseJsonObject } from "./http.js";
11
13
  import { failureOf, send } from "./transport.js";
@@ -34,6 +36,7 @@ const MAX_EXPIRES_IN = 366 * 24 * 60 * 60;
34
36
  // metadata and in the setup discovery document. Without it the CLI registers
35
37
  // a new public client for each sign in, as servers without the field expect.
36
38
  export const CLI_CLIENT_ID_FIELD = "metergraph_cli_client_id";
39
+ export const WORKSPACE_HINT_FIELD = "metergraph_workspace_hint_supported";
37
40
 
38
41
  // The access token claim that names the workspace the grant is for. The
39
42
  // claim is a sanity check only; the CLI does not verify token signatures.
@@ -60,7 +63,7 @@ const stop = (outcome, reason) => ({ ok: false, outcome, reason });
60
63
  // fixed path on this origin. A server that does not advertise the Metadata
61
64
  // scope, S256 or public clients is unsupported; there is no fallback to
62
65
  // another scope.
63
- export async function discover(origin, signal) {
66
+ export async function discover(origin, signal, baseScope = METADATA_SCOPE) {
64
67
  const expected = endpointsFor(origin);
65
68
 
66
69
  const prm = await fetchMetadata(origin, [
@@ -80,6 +83,9 @@ export async function discover(origin, signal) {
80
83
  if (resource.scopes_supported !== undefined && !includes(resource.scopes_supported, METADATA_SCOPE)) {
81
84
  return stop("unsupported", "metadata_scope_unsupported");
82
85
  }
86
+ if (resource.scopes_supported !== undefined && !includes(resource.scopes_supported, baseScope)) {
87
+ return stop("unsupported", "debug_scope_unsupported");
88
+ }
83
89
  if (resource.bearer_methods_supported !== undefined && !includes(resource.bearer_methods_supported, "header")) {
84
90
  return stop("unsupported", "oauth_metadata_invalid");
85
91
  }
@@ -103,6 +109,7 @@ export async function discover(origin, signal) {
103
109
  if (!includes(server.scopes_supported, METADATA_SCOPE)) {
104
110
  return stop("unsupported", "metadata_scope_unsupported");
105
111
  }
112
+ if (!includes(server.scopes_supported, baseScope)) return stop("unsupported", "debug_scope_unsupported");
106
113
  if (!includes(server.code_challenge_methods_supported, "S256")) return stop("unsupported", "pkce_unsupported");
107
114
  if (!includes(server.token_endpoint_auth_methods_supported, "none")) {
108
115
  return stop("unsupported", "public_client_unsupported");
@@ -128,6 +135,9 @@ export async function discover(origin, signal) {
128
135
  endpoints: expected,
129
136
  requireIss: server.authorization_response_iss_parameter_supported === true,
130
137
  offeredClientId: offered,
138
+ // The consent page can match a workspace the person named by slug or
139
+ // name, and offers approval for that workspace alone.
140
+ workspaceHint: server[WORKSPACE_HINT_FIELD] === true,
131
141
  };
132
142
  }
133
143
 
@@ -143,9 +153,9 @@ export function offeredClientId(doc) {
143
153
  // The client for one sign in: the pre-registered client the service offered,
144
154
  // or a newly registered one when it offered none. The offered client accepts
145
155
  // the loopback redirect on any port, so it needs no registration request.
146
- export async function clientFor(endpoints, offered, redirectUri, signal) {
156
+ export async function clientFor(endpoints, offered, redirectUri, signal, scopes = [METADATA_SCOPE]) {
147
157
  if (offered !== null) return { ok: true, clientId: offered };
148
- return register(endpoints, redirectUri, signal);
158
+ return register(endpoints, redirectUri, signal, scopes);
149
159
  }
150
160
 
151
161
  async function fetchMetadata(origin, paths, signal) {
@@ -170,7 +180,7 @@ function includes(list, value) {
170
180
  // Dynamic client registration for a public client bound to one exact
171
181
  // loopback redirect. A response that carries a client secret, a different
172
182
  // redirect or another authentication method is refused.
173
- export async function register(endpoints, redirectUri, signal) {
183
+ export async function register(endpoints, redirectUri, signal, scopes = [METADATA_SCOPE]) {
174
184
  const response = await send(endpoints.origin, AUTH_PATHS.registration, {
175
185
  method: "POST",
176
186
  signal,
@@ -181,7 +191,7 @@ export async function register(endpoints, redirectUri, signal) {
181
191
  grant_types: ["authorization_code", "refresh_token"],
182
192
  response_types: ["code"],
183
193
  token_endpoint_auth_method: "none",
184
- scope: METADATA_SCOPE,
194
+ scope: scopes.join(" "),
185
195
  },
186
196
  });
187
197
  const failure = failureOf(response);
@@ -235,13 +245,18 @@ export function newPkce() {
235
245
  // also approve the first ingest key for a new setup family. The service
236
246
  // returns that receipt next to the code; it is redeemed separately with its
237
247
  // own PKCE verifier.
238
- export function authorizationUrl(endpoints, { clientId, redirectUri, state, challenge, signup, ingestSetup = null }) {
248
+ // scopes is the access asked for: Metadata, or Debug with the chosen workflow
249
+ // permissions. workspaceHint, when given, is the workspace the person named,
250
+ // sent only to a service that advertises it.
251
+ export function authorizationUrl(endpoints, {
252
+ clientId, redirectUri, state, challenge, signup, ingestSetup = null, scopes = [METADATA_SCOPE], workspaceHint = null,
253
+ }) {
239
254
  const url = new URL(endpoints.authorization);
240
255
  url.search = new URLSearchParams({
241
256
  response_type: "code",
242
257
  client_id: clientId,
243
258
  redirect_uri: redirectUri,
244
- scope: METADATA_SCOPE,
259
+ scope: scopes.join(" "),
245
260
  state,
246
261
  code_challenge: challenge,
247
262
  code_challenge_method: "S256",
@@ -251,6 +266,7 @@ export function authorizationUrl(endpoints, { clientId, redirectUri, state, chal
251
266
  ingest_setup_family_id: ingestSetup.familyId,
252
267
  ingest_setup_code_challenge: ingestSetup.challenge,
253
268
  }),
269
+ ...(workspaceHint === null ? {} : { workspace_hint: workspaceHint }),
254
270
  }).toString();
255
271
  if (!signup) return url.href;
256
272
  const entry = new URL(`${endpoints.origin}${AUTH_PATHS.signup}`);
@@ -260,7 +276,7 @@ export function authorizationUrl(endpoints, { clientId, redirectUri, state, chal
260
276
 
261
277
  // Exchanges an authorization code. Codes are single use, so there is never
262
278
  // a retry.
263
- export async function exchangeCode(endpoints, { clientId, redirectUri, code, verifier }, signal) {
279
+ export async function exchangeCode(endpoints, { clientId, redirectUri, code, verifier, scopes = [METADATA_SCOPE] }, signal) {
264
280
  const response = await send(endpoints.origin, AUTH_PATHS.token, {
265
281
  method: "POST",
266
282
  signal,
@@ -278,13 +294,13 @@ export async function exchangeCode(endpoints, { clientId, redirectUri, code, ver
278
294
  if (failure !== null) return { ok: false, ...failure };
279
295
  if (response.status === 400 || response.status === 401) return stop("authorization_failed", "code_rejected");
280
296
  if (response.status !== 200) return stop("unsupported", "unexpected_status");
281
- return validateGrant(parseJsonObject(response), endpoints, clientId, { requireRefresh: true });
297
+ return validateGrant(parseJsonObject(response), endpoints, clientId, { requireRefresh: true, scopes });
282
298
  }
283
299
 
284
300
  // Refreshes a grant once. The result says whether the refresh token may have
285
301
  // been consumed without the CLI learning its replacement (ambiguous), so the
286
302
  // caller never retries with it.
287
- export async function refreshGrant(endpoints, { clientId, refreshToken }, signal) {
303
+ export async function refreshGrant(endpoints, { clientId, refreshToken, scopes = [METADATA_SCOPE] }, signal) {
288
304
  const response = await send(endpoints.origin, AUTH_PATHS.token, {
289
305
  method: "POST",
290
306
  signal,
@@ -294,7 +310,7 @@ export async function refreshGrant(endpoints, { clientId, refreshToken }, signal
294
310
  refresh_token: refreshToken,
295
311
  client_id: clientId,
296
312
  resource: endpoints.resource,
297
- scope: METADATA_SCOPE,
313
+ scope: scopes.join(" "),
298
314
  },
299
315
  });
300
316
  if (response.kind === "error" && !response.sent) {
@@ -312,16 +328,17 @@ export async function refreshGrant(endpoints, { clientId, refreshToken }, signal
312
328
  if (response.kind !== "response" || response.status !== 200 || response.tooLarge) {
313
329
  return { ok: false, outcome: "login_required", reason: "refresh_interrupted", ambiguous: true };
314
330
  }
315
- const result = validateGrant(parseJsonObject(response), endpoints, clientId, { requireRefresh: false });
331
+ const result = validateGrant(parseJsonObject(response), endpoints, clientId, { requireRefresh: false, scopes });
316
332
  // A 200 that cannot be used may still have rotated the refresh token.
317
333
  return result.ok ? result : { ...result, ambiguous: true };
318
334
  }
319
335
 
320
- // Accepts only a Bearer grant for exactly the Metadata scope with bounded,
321
- // non-empty tokens and an expiry. The access token claims must name this
322
- // issuer, resource and client and one workspace. These are sanity checks on
323
- // what the server said, not a signature check.
324
- export function validateGrant(body, endpoints, clientId, { requireRefresh }) {
336
+ // Accepts only a Bearer grant for exactly the scopes asked for (Metadata, or
337
+ // Debug with the chosen workflow permissions) with bounded, non-empty tokens and an expiry. The
338
+ // access token claims must name this issuer, resource and client and one
339
+ // workspace. These are sanity checks on what the server said, not a
340
+ // signature check.
341
+ export function validateGrant(body, endpoints, clientId, { requireRefresh, scopes = [METADATA_SCOPE] }) {
325
342
  if (body === null) return stop("verification_failed", "token_response_invalid");
326
343
  if (typeof body.token_type !== "string" || body.token_type.toLowerCase() !== "bearer") {
327
344
  return stop("verification_failed", "token_type_invalid");
@@ -340,9 +357,7 @@ export function validateGrant(body, endpoints, clientId, { requireRefresh }) {
340
357
  if (!Number.isInteger(body.expires_in) || body.expires_in < 1 || body.expires_in > MAX_EXPIRES_IN) {
341
358
  return stop("verification_failed", "token_response_invalid");
342
359
  }
343
- if (typeof body.scope !== "string" || body.scope !== METADATA_SCOPE) {
344
- return stop("verification_failed", "scope_mismatch");
345
- }
360
+ if (!sameScopes(body.scope, scopes)) return stop("verification_failed", "scope_mismatch");
346
361
 
347
362
  const claims = decodeClaims(body.access_token);
348
363
  if (claims === null) return stop("verification_failed", "token_claims_invalid");
@@ -350,7 +365,7 @@ export function validateGrant(body, endpoints, clientId, { requireRefresh }) {
350
365
  const audience = Array.isArray(claims.aud) ? claims.aud : [claims.aud];
351
366
  if (!audience.includes(endpoints.resource)) return stop("verification_failed", "resource_mismatch");
352
367
  if (claims.client_id !== clientId) return stop("verification_failed", "client_mismatch");
353
- if (claims.scope !== undefined && claims.scope !== METADATA_SCOPE) {
368
+ if (claims.scope !== undefined && !sameScopes(claims.scope, scopes)) {
354
369
  return stop("verification_failed", "scope_mismatch");
355
370
  }
356
371
  const tenant = normalizeUuid(claims[TENANT_CLAIM]);
@@ -371,6 +386,13 @@ export function validateGrant(body, endpoints, clientId, { requireRefresh }) {
371
386
  };
372
387
  }
373
388
 
389
+ // Whether a space-separated scope string names exactly these scopes.
390
+ export function sameScopes(value, scopes) {
391
+ if (typeof value !== "string") return false;
392
+ const given = value.split(" ");
393
+ return given.length === scopes.length && new Set(given).size === given.length && scopes.every((scope) => given.includes(scope));
394
+ }
395
+
374
396
  function decodeClaims(token) {
375
397
  const parts = token.split(".");
376
398
  if (parts.length !== 3 || !/^[A-Za-z0-9_-]+$/.test(parts[1])) return null;
@@ -384,27 +406,43 @@ function decodeClaims(token) {
384
406
  }
385
407
  }
386
408
 
409
+ // What a credential's scopes amount to: "metadata" for exactly the Metadata
410
+ // scope, "debug" for Debug with any of the workflow permissions, or null for
411
+ // anything else, including Replay.
412
+ export function credentialAccess(scopes) {
413
+ if (!Array.isArray(scopes) || !scopes.every((scope) => typeof scope === "string")) return null;
414
+ if (new Set(scopes).size !== scopes.length) return null;
415
+ if (scopes.length === 1 && scopes[0] === METADATA_SCOPE) return "metadata";
416
+ if (!scopes.includes(DEBUG_SCOPE)) return null;
417
+ return scopes.every((scope) => scope === DEBUG_SCOPE || WORKFLOW_SCOPES.includes(scope)) ? "debug" : null;
418
+ }
419
+
387
420
  // Asks the service, with the access token, which workspace and profile the
388
421
  // grant is for and what it may do. The answers are server-authoritative:
389
422
  // GET /v1/agent/workspace: workspace.id, provenance.workspace_id and the
390
423
  // expected workspace must agree, provenance.deployment_profile must be
391
- // the profile seen before sign in, access.scopes must be exactly the
392
- // Metadata scope and content.included must be false. Capture being
424
+ // the profile seen before sign in, access.scopes must be what this use
425
+ // accepts (below) and content.included must be false. Capture being
393
426
  // enabled for the workspace (content.captured) is not a reason to refuse.
394
427
  // GET /v1/agent/capabilities: provenance.workspace_id and both
395
- // provenance.deployment_profile and deployment_profile must match, and
396
- // every capability that reads content or replays, or calls a configured
397
- // provider, must be unavailable to this grant.
428
+ // provenance.deployment_profile and deployment_profile must match. For a
429
+ // Metadata credential, every capability that reads content or replays,
430
+ // or calls a configured provider, must be unavailable.
398
431
  // Nothing else is read: no telemetry, content or provider requests.
399
- // Returns { ok: true, workspaceId, documents } or { ok: false, outcome, reason, denied }
400
- // where denied means the service refused the token (401 or 403).
432
+ // Returns { ok: true, workspaceId, access, scopes, documents } or
433
+ // { ok: false, outcome, reason, denied } where denied means the service
434
+ // refused the token (401 or 403).
401
435
  // workspaceId is the workspace the credential must be for. Only an agent key
402
436
  // with nothing to pin it to passes anyWorkspace: true with workspaceId null,
403
437
  // and then the workspace the service reports is checked for consistency and
404
438
  // returned as the verified one.
405
- // access is "metadata" (every read command and sign in) or
406
- // "experiment_start" (experiments start only: a coding-agent key with Debug
407
- // access and the start grant, checked for exactly that and used for nothing else).
439
+ // access says what this use accepts:
440
+ // "metadata": exactly the Metadata scope (sign in).
441
+ // "read": Metadata, or Debug with any workflow permissions, which a person
442
+ // grants for writing evaluations and starting analyses (the read
443
+ // commands). Replay is refused.
444
+ // A read command still sends only its own Metadata requests.
445
+ // "experiment_start": Debug with the start grant (experiments start).
408
446
  export async function verifyContext(origin, accessToken, { profile, workspaceId, anyWorkspace = false, access = "metadata" }, signal) {
409
447
  const workspace = await bearerJson(origin, AUTH_PATHS.workspace, accessToken, WORKSPACE_BYTES, signal, "workspace");
410
448
  if (!workspace.ok) return workspace;
@@ -414,7 +452,7 @@ export async function verifyContext(origin, accessToken, { profile, workspaceId,
414
452
  }
415
453
  const checked = access === "experiment_start"
416
454
  ? checkStartWorkspace(workspace.body, { profile, workspaceId })
417
- : checkWorkspace(workspace.body, { profile, workspaceId });
455
+ : checkWorkspace(workspace.body, { profile, workspaceId, debug: access === "read" });
418
456
  if (!checked.ok) return checked;
419
457
 
420
458
  const capabilities = await bearerJson(
@@ -428,14 +466,15 @@ export async function verifyContext(origin, accessToken, { profile, workspaceId,
428
466
  if (!capabilities.ok) return capabilities;
429
467
  const allowed = access === "experiment_start"
430
468
  ? checkStartCapabilities(capabilities.body, { profile, workspaceId })
431
- : checkCapabilities(capabilities.body, { profile, workspaceId });
469
+ : checkCapabilities(capabilities.body, { profile, workspaceId, access: checked.access });
432
470
  if (!allowed.ok) return allowed;
433
471
  // The verified documents stay in memory for read commands, which print
434
472
  // only fields they validate themselves.
435
473
  return {
436
474
  ok: true,
437
475
  workspaceId,
438
- scopes: checked.scopes ?? [METADATA_SCOPE],
476
+ access: checked.access,
477
+ scopes: checked.scopes,
439
478
  documents: { workspace: workspace.body, capabilities: capabilities.body },
440
479
  };
441
480
  }
@@ -443,7 +482,8 @@ export async function verifyContext(origin, accessToken, { profile, workspaceId,
443
482
  // Both documents carry the service's contract version string in
444
483
  // schema_version. Any other value, including a number, is a contract this
445
484
  // CLI does not know.
446
- export function checkWorkspace(body, { profile, workspaceId }) {
485
+ // debug: true accepts Debug with workflow permissions as well as Metadata.
486
+ export function checkWorkspace(body, { profile, workspaceId, debug = false }) {
447
487
  const id = normalizeUuid(body.workspace?.id);
448
488
  if (body.schema_version !== AGENT_CONTRACT_VERSION || id === null || !isObject(body.provenance)) {
449
489
  return stop("verification_failed", "workspace_response_invalid");
@@ -453,29 +493,27 @@ export function checkWorkspace(body, { profile, workspaceId }) {
453
493
  }
454
494
  if (body.provenance.deployment_profile !== profile) return stop("verification_failed", "profile_mismatch");
455
495
  const scopes = body.access?.scopes;
456
- if (!Array.isArray(scopes) || scopes.length !== 1 || scopes[0] !== METADATA_SCOPE) {
496
+ const access = credentialAccess(scopes);
497
+ if (access === null || (access === "debug" && !debug)) {
457
498
  return stop("verification_failed", "scope_mismatch");
458
499
  }
459
500
  if (!isObject(body.content) || body.content.included !== false) {
460
501
  return stop("verification_failed", "content_access_granted");
461
502
  }
462
- return { ok: true };
503
+ return { ok: true, access, scopes: [...scopes].sort() };
463
504
  }
464
505
 
465
506
  // A start key's workspace document: the same identity checks as
466
- // checkWorkspace, and exactly the scopes Debug and the start grant.
507
+ // checkWorkspace, and Debug with the start grant (other workflow permissions
508
+ // may come with it; Replay may not).
467
509
  export function checkStartWorkspace(body, { profile, workspaceId }) {
468
510
  const identity = checkWorkspace({ ...body, access: { scopes: [METADATA_SCOPE] } }, { profile, workspaceId });
469
511
  if (!identity.ok) return identity;
470
512
  const scopes = body.access?.scopes;
471
- if (
472
- !Array.isArray(scopes) ||
473
- scopes.length !== EXPERIMENT_START_KEY_SCOPES.length ||
474
- !EXPERIMENT_START_KEY_SCOPES.every((scope) => scopes.includes(scope))
475
- ) {
513
+ if (credentialAccess(scopes) !== "debug" || !EXPERIMENT_START_KEY_SCOPES.every((scope) => scopes.includes(scope))) {
476
514
  return stop("permission_denied", "experiment_start_not_granted");
477
515
  }
478
- return { ok: true, scopes: [...scopes].sort() };
516
+ return { ok: true, access: "debug", scopes: [...scopes].sort() };
479
517
  }
480
518
 
481
519
  // A start key's capabilities document: the same identity checks as
@@ -497,7 +535,9 @@ export function checkStartCapabilities(body, { profile, workspaceId }) {
497
535
  return { ok: true };
498
536
  }
499
537
 
500
- export function checkCapabilities(body, { profile, workspaceId }) {
538
+ // access is the credential's ("metadata" or "debug"). A Debug credential is
539
+ // offered content and starts by design; the read commands never use them.
540
+ export function checkCapabilities(body, { profile, workspaceId, access = "metadata" }) {
501
541
  if (body.schema_version !== AGENT_CONTRACT_VERSION || !isObject(body.provenance) || !isObject(body.agent)) {
502
542
  return stop("verification_failed", "capabilities_response_invalid");
503
543
  }
@@ -509,6 +549,10 @@ export function checkCapabilities(body, { profile, workspaceId }) {
509
549
  }
510
550
  for (const entry of Object.values(body.agent)) {
511
551
  if (!isObject(entry)) return stop("verification_failed", "capabilities_response_invalid");
552
+ if (access !== "metadata" && entry.required_scope === "agent:replay" && entry.available !== false) {
553
+ return stop("verification_failed", "scope_mismatch");
554
+ }
555
+ if (access !== "metadata") continue;
512
556
  const sensitive =
513
557
  entry.content !== false ||
514
558
  entry.privacy_class !== "metadata" ||
@@ -1,6 +1,6 @@
1
- import { endpointsFor, refreshGrant, verifyContext } from "./auth-oauth.js";
1
+ import { endpointsFor, refreshGrant, sameScopes, verifyContext } from "./auth-oauth.js";
2
2
  import { readBinding, resolveProject } from "./auth-binding.js";
3
- import { Stop, openStore, readCredential, resolveConfigDir, withSlotLock, writeCredential } from "./auth-store.js";
3
+ import { Stop, openStore, readProjectCredential, resolveConfigDir, withSlotLock, writeCredential } from "./auth-store.js";
4
4
  import { AUTH_HTTP_TIMEOUT_MS, METADATA_SCOPE } from "./constants.js";
5
5
  import { deadline } from "./transport.js";
6
6
 
@@ -14,7 +14,7 @@ const REFRESH_MARGIN_MS = 60 * 1000;
14
14
  export const LOCK_WAIT_MS = AUTH_HTTP_TIMEOUT_MS + 5000;
15
15
 
16
16
  // Resolves with
17
- // { ok: true, session: { origin, workspaceId, profile, scopes, accessToken }, documents, knownCredentials }
17
+ // { ok: true, session: { origin, workspaceId, profile, access, scopes, accessToken }, documents, knownCredentials }
18
18
  // { ok: false, outcome, reason }
19
19
  // documents holds the workspace and capabilities documents the service
20
20
  // returned during verification, already checked by verifyContext.
@@ -25,7 +25,7 @@ export async function verifiedSession({ project = null, configDir = null, cancel
25
25
  const store = openStore(resolveConfigDir(configDir), { create: false });
26
26
  const found = readBinding(root);
27
27
  if (found === null) return stop("login_required", "not_signed_in");
28
- return await sessionFor(found.binding, store, cancel);
28
+ return await sessionFor(found.binding, store, cancel, root);
29
29
  } catch (error) {
30
30
  if (error instanceof Stop) return stop(error.outcome, error.reason);
31
31
  throw error;
@@ -34,10 +34,10 @@ export async function verifiedSession({ project = null, configDir = null, cancel
34
34
 
35
35
  // The same, for a binding and store already read by the caller. store may be
36
36
  // null when the private store does not exist.
37
- export async function sessionFor(binding, store, cancel) {
37
+ export async function sessionFor(binding, store, cancel, root) {
38
38
  if (store === null) return stop("login_required", "credential_missing");
39
39
  const slot = binding.credential_slot;
40
- let record = readCredential(store, slot);
40
+ let record = readProjectCredential(store, slot, root);
41
41
  if (record === null) return stop("login_required", "credential_missing");
42
42
  const context = checkIdentity(record, binding);
43
43
  if (!context.ok) return context;
@@ -52,7 +52,7 @@ export async function sessionFor(binding, store, cancel) {
52
52
  if (record.refresh_pending || record.expires_at - Date.now() <= REFRESH_MARGIN_MS) {
53
53
  let refreshed;
54
54
  try {
55
- refreshed = await withSlotLock(store, slot, () => refreshLocked(store, slot, binding, cancel), {
55
+ refreshed = await withSlotLock(store, slot, () => refreshLocked(store, slot, binding, cancel, root), {
56
56
  waitMs: LOCK_WAIT_MS,
57
57
  signal: cancel,
58
58
  });
@@ -68,12 +68,18 @@ export async function sessionFor(binding, store, cancel) {
68
68
  }
69
69
 
70
70
  const limit = deadline(AUTH_HTTP_TIMEOUT_MS, cancel);
71
+ // A Debug sign in (login --role debug) is verified as Debug with workflow
72
+ // permissions, and must still hold exactly the scopes it was granted.
73
+ const debug = record.scope !== METADATA_SCOPE;
71
74
  const verified = await verifyContext(
72
75
  binding.origin,
73
76
  record.access_token,
74
- { profile: binding.deployment_profile, workspaceId: binding.workspace_id },
77
+ { profile: binding.deployment_profile, workspaceId: binding.workspace_id, access: debug ? "read" : "metadata" },
75
78
  limit.signal,
76
79
  );
80
+ if (verified.ok && debug && !sameScopes(record.scope, verified.scopes)) {
81
+ return stop("verification_failed", "scope_mismatch");
82
+ }
77
83
  if (!verified.ok) {
78
84
  // The service refused a token that was valid when issued: the grant was
79
85
  // revoked, or the user lost access to the workspace. Fail closed.
@@ -87,7 +93,8 @@ export async function sessionFor(binding, store, cancel) {
87
93
  origin: binding.origin,
88
94
  workspaceId: binding.workspace_id,
89
95
  profile: binding.deployment_profile,
90
- scopes: [METADATA_SCOPE],
96
+ access: verified.access,
97
+ scopes: verified.scopes,
91
98
  accessToken: record.access_token,
92
99
  },
93
100
  documents: verified.documents,
@@ -127,8 +134,8 @@ function checkContext(record, binding) {
127
134
  // unusable answer, a failed save or a killed process) leaves the mark, so the
128
135
  // possibly consumed refresh token is never sent again and the user is asked
129
136
  // to reconnect.
130
- async function refreshLocked(store, slot, binding, cancel) {
131
- const current = readCredential(store, slot);
137
+ async function refreshLocked(store, slot, binding, cancel, root) {
138
+ const current = readProjectCredential(store, slot, root);
132
139
  if (current === null) return stop("login_required", "credential_missing");
133
140
  const context = checkContext(current, binding);
134
141
  if (!context.ok) return context;
@@ -139,7 +146,7 @@ async function refreshLocked(store, slot, binding, cancel) {
139
146
  const limit = deadline(AUTH_HTTP_TIMEOUT_MS, cancel);
140
147
  const result = await refreshGrant(
141
148
  endpoints,
142
- { clientId: current.client_id, refreshToken: current.refresh_token },
149
+ { clientId: current.client_id, refreshToken: current.refresh_token, scopes: current.scope.split(" ") },
143
150
  limit.signal,
144
151
  );
145
152
  if (!result.ok) {
package/src/auth-store.js CHANGED
@@ -1,11 +1,11 @@
1
1
  import { spawnSync } from "node:child_process";
2
- import { randomBytes } from "node:crypto";
3
- import fs from "node:fs";
2
+ import { createHash, randomBytes } from "node:crypto";
3
+ import fs, { withRollback } from "./anchored-fs.js";
4
4
  import os from "node:os";
5
5
  import path from "node:path";
6
6
 
7
7
  import { systemRoot } from "./auth-browser.js";
8
- import { AUTH_PATHS, METADATA_SCOPE, SUPPORTED_PROFILES } from "./constants.js";
8
+ import { AUTH_PATHS, SUPPORTED_PROFILES, isLoginScope } from "./constants.js";
9
9
  import { parseOrigin } from "./origin.js";
10
10
 
11
11
  // Per-user credential storage for the delegated Metadata OAuth grant. Only
@@ -90,6 +90,7 @@ export function openStore(dir, { create }) {
90
90
  return { dir, credentials };
91
91
  } catch (error) {
92
92
  if (error instanceof Stop) throw error;
93
+ if (error.code === "ENOTSUP") throw new Stop("unsupported", "secure_writes_unavailable");
93
94
  throw new Stop("filesystem_error", "credential_store_unavailable");
94
95
  }
95
96
  }
@@ -111,6 +112,7 @@ export function readCredential(store, slot) {
111
112
  content = readPrivateFile(file);
112
113
  } catch (error) {
113
114
  if (error instanceof Stop) throw error;
115
+ if (error.code === "ENOTSUP") throw new Stop("unsupported", "secure_writes_unavailable");
114
116
  throw new Stop("filesystem_error", "credential_store_unavailable");
115
117
  }
116
118
  if (content === null) return null;
@@ -119,6 +121,20 @@ export function readCredential(store, slot) {
119
121
  return record;
120
122
  }
121
123
 
124
+
125
+ // Only a private record created for this canonical checkout authorizes use or
126
+ // retirement. Public bindings and copied slot names carry no such authority.
127
+ export function projectIdentity(root) {
128
+ const canonical = fs.realpathSync(root);
129
+ const identity = fs.statSync(canonical, { bigint: true });
130
+ return createHash("sha256").update(`${canonical}\0${identity.dev}\0${identity.ino}`).digest("hex");
131
+ }
132
+
133
+ export function readProjectCredential(store, slot, root) {
134
+ const record = readCredential(store, slot);
135
+ return record?.project_id === projectIdentity(root) ? record : null;
136
+ }
137
+
122
138
  // Atomically replaces the slot file with record.
123
139
  export function writeCredential(store, slot, record) {
124
140
  if (!validRecord(record)) throw new Error("credential record is not valid");
@@ -127,6 +143,7 @@ export function writeCredential(store, slot, record) {
127
143
  writePrivateFile(store.credentials, `${slot}.json`, content);
128
144
  } catch (error) {
129
145
  if (error instanceof Stop) throw error;
146
+ if (error.code === "ENOTSUP") throw new Stop("unsupported", "secure_writes_unavailable");
130
147
  throw new Stop("filesystem_error", "credential_write_failed");
131
148
  }
132
149
  }
@@ -143,6 +160,7 @@ export function deleteCredential(store, slot) {
143
160
  return true;
144
161
  } catch (error) {
145
162
  if (error instanceof Stop) throw error;
163
+ if (error.code === "ENOTSUP") throw new Stop("unsupported", "secure_writes_unavailable");
146
164
  throw new Stop("filesystem_error", "credential_write_failed");
147
165
  }
148
166
  }
@@ -165,6 +183,7 @@ export async function withSlotLock(store, slot, work, { waitMs, signal = null })
165
183
  fs.closeSync(fd);
166
184
  break;
167
185
  } catch (error) {
186
+ if (error.code === "ENOTSUP") throw new Stop("unsupported", "secure_writes_unavailable");
168
187
  if (error.code !== "EEXIST") throw new Stop("filesystem_error", "credential_store_unavailable");
169
188
  if (Date.now() >= until) throw new Stop("conflict", "credential_locked");
170
189
  await pause(50, signal);
@@ -175,7 +194,7 @@ export async function withSlotLock(store, slot, work, { waitMs, signal = null })
175
194
  return await work();
176
195
  } finally {
177
196
  try {
178
- fs.unlinkSync(lock);
197
+ withRollback(() => fs.unlinkSync(lock));
179
198
  } catch {
180
199
  // Reported by the next run as a held lock.
181
200
  }
@@ -197,7 +216,9 @@ function pause(ms, signal) {
197
216
 
198
217
  export function validRecord(record) {
199
218
  if (record === null || typeof record !== "object" || Array.isArray(record)) return false;
200
- if (Object.keys(record).sort().join() !== RECORD_KEYS) return false;
219
+ const keys = Object.keys(record).filter((key) => key !== "project_id").sort().join();
220
+ if (keys !== RECORD_KEYS) return false;
221
+ if (Object.hasOwn(record, "project_id") && !/^[0-9a-f]{64}$/.test(record.project_id)) return false;
201
222
  const origin = parseOrigin(record.origin);
202
223
  return (
203
224
  record.schema_version === 1 &&
@@ -207,7 +228,7 @@ export function validRecord(record) {
207
228
  SUPPORTED_PROFILES.includes(record.deployment_profile) &&
208
229
  typeof record.workspace_id === "string" &&
209
230
  /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(record.workspace_id) &&
210
- record.scope === METADATA_SCOPE &&
231
+ isLoginScope(record.scope) &&
211
232
  printable(record.client_id, 256) &&
212
233
  printable(record.access_token, 8192) &&
213
234
  printable(record.refresh_token, 4096) &&
@@ -373,7 +394,7 @@ function writePrivateFile(dir, name, content) {
373
394
  fs.renameSync(temp, dest);
374
395
  } catch (error) {
375
396
  try {
376
- fs.unlinkSync(temp);
397
+ withRollback(() => fs.unlinkSync(temp));
377
398
  } catch {
378
399
  // Already renamed or never created.
379
400
  }