@ory/argus 0.14.0 → 1.0.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 (150) hide show
  1. package/README.md +31 -46
  2. package/assets/commands/temporal-up.md +1 -1
  3. package/assets/skills/auth-setup/SKILL.md +1 -1
  4. package/assets/skills/local-dev/SKILL.md +17 -7
  5. package/assets/skills/ory-build-agent/SKILL.md +39 -91
  6. package/assets/skills/ory-e2b-sandbox/SKILL.md +18 -17
  7. package/assets/skills/ory-temporal-worker/SKILL.md +31 -33
  8. package/assets/skills/permissions-onboarding/SKILL.md +131 -104
  9. package/dist/adapters.d.ts +88 -32
  10. package/dist/adapters.js +443 -164
  11. package/dist/agent-auth.d.ts +226 -67
  12. package/dist/agent-auth.js +951 -205
  13. package/dist/auth-store.d.ts +37 -2
  14. package/dist/auth-store.js +37 -3
  15. package/dist/auth.d.ts +33 -4
  16. package/dist/auth.js +163 -20
  17. package/dist/bash-parser.d.ts +98 -0
  18. package/dist/bash-parser.js +396 -0
  19. package/dist/branding.d.ts +77 -16
  20. package/dist/branding.js +93 -23
  21. package/dist/build-info.json +4 -4
  22. package/dist/cli-invocation.d.ts +1 -1
  23. package/dist/cli-invocation.js +2 -1
  24. package/dist/cli.d.ts +20 -29
  25. package/dist/cli.js +271 -239
  26. package/dist/client.d.ts +175 -138
  27. package/dist/client.js +672 -391
  28. package/dist/config.d.ts +217 -54
  29. package/dist/config.js +461 -42
  30. package/dist/context.d.ts +10 -0
  31. package/dist/context.js +21 -0
  32. package/dist/contract-suite.d.ts +3 -5
  33. package/dist/contract-suite.js +75 -47
  34. package/dist/denial.d.ts +36 -3
  35. package/dist/denial.js +79 -10
  36. package/dist/event-reporter.d.ts +77 -0
  37. package/dist/event-reporter.js +776 -0
  38. package/dist/external-registrations-main.d.ts +10 -0
  39. package/dist/external-registrations-main.js +38 -0
  40. package/dist/external-registrations.d.ts +79 -0
  41. package/dist/external-registrations.js +188 -0
  42. package/dist/help-cli.d.ts +39 -0
  43. package/dist/help-cli.js +55 -0
  44. package/dist/hook-timeout.d.ts +64 -0
  45. package/dist/hook-timeout.js +88 -0
  46. package/dist/index.d.ts +28 -23
  47. package/dist/index.js +173 -58
  48. package/dist/lifecycle.d.ts +3 -3
  49. package/dist/lifecycle.js +38 -6
  50. package/dist/local/cli.js +11 -6
  51. package/dist/local/configs.d.ts +74 -18
  52. package/dist/local/configs.js +291 -84
  53. package/dist/local/health.js +7 -7
  54. package/dist/local/index.d.ts +2 -2
  55. package/dist/local/index.js +24 -10
  56. package/dist/local/manager.d.ts +20 -1
  57. package/dist/local/manager.js +159 -36
  58. package/dist/local/ports.d.ts +158 -0
  59. package/dist/local/ports.js +443 -0
  60. package/dist/local/seed.d.ts +13 -5
  61. package/dist/local/seed.js +62 -38
  62. package/dist/logger.d.ts +54 -25
  63. package/dist/logger.js +329 -63
  64. package/dist/mcp.d.ts +2 -2
  65. package/dist/mcp.js +10 -5
  66. package/dist/mirror-bootstrap.d.ts +48 -0
  67. package/dist/mirror-bootstrap.js +254 -0
  68. package/dist/opl.d.ts +289 -0
  69. package/dist/opl.js +446 -0
  70. package/dist/permission-mode.d.ts +87 -0
  71. package/dist/permission-mode.js +307 -0
  72. package/dist/permissions-cli.d.ts +13 -49
  73. package/dist/permissions-cli.js +154 -348
  74. package/dist/permissions.d.ts +148 -38
  75. package/dist/permissions.js +591 -45
  76. package/dist/post-install.d.ts +33 -0
  77. package/dist/post-install.js +127 -0
  78. package/dist/read-credential.d.ts +65 -0
  79. package/dist/read-credential.js +86 -0
  80. package/dist/registry/cli.js +5 -2
  81. package/dist/registry/config.d.ts +0 -17
  82. package/dist/registry/config.js +0 -23
  83. package/dist/registry/index.d.ts +1 -1
  84. package/dist/registry/index.js +2 -2
  85. package/dist/registry/manager.d.ts +4 -21
  86. package/dist/registry/manager.js +21 -37
  87. package/dist/runtime-credential.d.ts +140 -0
  88. package/dist/runtime-credential.js +572 -0
  89. package/dist/runtime.d.ts +408 -0
  90. package/dist/runtime.js +748 -0
  91. package/dist/setup.d.ts +23 -47
  92. package/dist/setup.js +59 -116
  93. package/dist/skills.js +0 -7
  94. package/dist/status-cli.d.ts +27 -11
  95. package/dist/status-cli.js +121 -115
  96. package/dist/status-data.d.ts +116 -17
  97. package/dist/status-data.js +121 -38
  98. package/dist/subject.d.ts +126 -20
  99. package/dist/subject.js +215 -30
  100. package/dist/testing.d.ts +74 -38
  101. package/dist/testing.js +185 -68
  102. package/dist/tool-catalog.d.ts +53 -11
  103. package/dist/tool-catalog.js +164 -13
  104. package/dist/tool-metadata.d.ts +7 -6
  105. package/dist/tool-metadata.js +6 -5
  106. package/dist/types.d.ts +11 -1
  107. package/dist/uninstall.d.ts +71 -34
  108. package/dist/uninstall.js +217 -85
  109. package/dist/user-login.d.ts +9 -10
  110. package/dist/user-login.js +56 -75
  111. package/dist/watch-cli.d.ts +6 -0
  112. package/dist/watch-cli.js +217 -0
  113. package/package.json +4 -27
  114. package/assets/commands/dashboard.md +0 -34
  115. package/dist/dashboard-cli.d.ts +0 -8
  116. package/dist/dashboard-cli.js +0 -70
  117. package/dist/dev.d.ts +0 -103
  118. package/dist/dev.js +0 -583
  119. package/dist/interactive-setup.d.ts +0 -286
  120. package/dist/interactive-setup.js +0 -1734
  121. package/dist/local/jaeger-main.d.ts +0 -13
  122. package/dist/local/jaeger-main.js +0 -85
  123. package/dist/local/jaeger.d.ts +0 -50
  124. package/dist/local/jaeger.js +0 -162
  125. package/dist/otel/exporter.d.ts +0 -17
  126. package/dist/otel/exporter.js +0 -12
  127. package/dist/otel/index.d.ts +0 -2
  128. package/dist/otel/index.js +0 -8
  129. package/dist/otel/otlp.d.ts +0 -103
  130. package/dist/otel/otlp.js +0 -385
  131. package/dist/project-api-key.d.ts +0 -69
  132. package/dist/project-api-key.js +0 -147
  133. package/dist/setup-actions.d.ts +0 -232
  134. package/dist/setup-actions.js +0 -507
  135. package/dist/tracer.d.ts +0 -190
  136. package/dist/tracer.js +0 -481
  137. package/dist/watch-sandbox.d.ts +0 -9
  138. package/dist/watch-sandbox.js +0 -81
  139. package/dist/web/api.d.ts +0 -33
  140. package/dist/web/api.js +0 -294
  141. package/dist/web/launch.d.ts +0 -11
  142. package/dist/web/launch.js +0 -96
  143. package/dist/web/server.d.ts +0 -20
  144. package/dist/web/server.js +0 -233
  145. package/dist/web/types.d.ts +0 -65
  146. package/dist/web/types.js +0 -2
  147. package/dist/webapp/assets/index-Wucl4SZs.css +0 -1
  148. package/dist/webapp/assets/index-m-GtEdq0.js +0 -49
  149. package/dist/webapp/favicon.ico +0 -0
  150. package/dist/webapp/index.html +0 -15
@@ -18,6 +18,41 @@ export declare function loadTokens(): OryOAuth2Tokens | undefined;
18
18
  export declare function saveTokens(tokens: OryOAuth2Tokens): void;
19
19
  /** Remove the user's persisted OAuth2 tokens. */
20
20
  export declare function clearTokens(): void;
21
+ /**
22
+ * A user credential supplied out-of-band through the environment (CI, scripted
23
+ * runs, a harness launched by a wrapper that already authenticated).
24
+ */
25
+ export interface EnvUserCredential {
26
+ /** The bearer, if any of the four accepted variables is set. */
27
+ token?: string;
28
+ /** `ORY_USER_SUBJECT_ID`, when the operator pinned the subject. */
29
+ subject?: string;
30
+ }
31
+ /**
32
+ * Read the pre-supplied user credential from the environment.
33
+ *
34
+ * **`ORY_USER_OAUTH2_TOKEN` is the only accepted variable**, because an OAuth2
35
+ * access token is the only thing the credential is ever used as. All three
36
+ * consumers of `userPrincipal.token` demand one: broker delegation
37
+ * authentication and the agent + sub-agent DCR bootstraps (which send it as a
38
+ * bearer to `/oauth2/register`). Nothing calls `verifySession`, so a Kratos
39
+ * session token is never consumed as a session token.
40
+ *
41
+ * Three variables used to be accepted alongside it and have been removed:
42
+ * `ORY_USER_SESSION_TOKEN` (plus its legacy alias `ORY_SESSION_TOKEN`) named a
43
+ * Kratos session token and took *first* precedence, so supplying the credential
44
+ * its name asked for made every permission check fail `session_inactive` — and
45
+ * beat the variable that would have worked. `ORY_OAUTH2_TOKEN` was a redundant
46
+ * legacy alias. Pin the subject with `ORY_USER_SUBJECT_ID`.
47
+ *
48
+ * This lives here, and only here, because two call sites depend on agreeing:
49
+ * the login gate's `env_token` short-circuit (which runs on `SessionStart`) and
50
+ * the per-client rehydration in `OryAgentClient.fromEnv` (which runs on *every*
51
+ * lifecycle event, including the subprocess tool-call events where the gate
52
+ * never runs). If those disagreed, an env-supplied token would authenticate the
53
+ * session and then silently stop authenticating the permission checks.
54
+ */
55
+ export declare function readEnvUserCredential(env?: NodeJS.ProcessEnv): EnvUserCredential;
21
56
  /** Number of seconds before nominal expiry that we consider tokens stale. */
22
57
  export declare const TOKEN_EXPIRY_SKEW_SEC = 60;
23
58
  /** Returns true if the access token is missing or within the skew window of expiry. */
@@ -55,8 +90,8 @@ export interface PkceFlightLock {
55
90
  export declare function tryAcquirePkceFlightLock(): PkceFlightLock | null;
56
91
  /**
57
92
  * Unconditionally remove the pkce-flight lock, if any. Use this from
58
- * single-owner contexts (e.g. the dev launcher at startup) where you
59
- * know no peer is mid-flow. Returns true when a lock was removed.
93
+ * single-owner maintenance contexts where no peer is mid-flow. Returns true
94
+ * when a lock was removed.
60
95
  */
61
96
  export declare function clearPkceFlightLock(): boolean;
62
97
  /**
@@ -50,6 +50,7 @@ exports.TOKEN_EXPIRY_SKEW_SEC = void 0;
50
50
  exports.loadTokens = loadTokens;
51
51
  exports.saveTokens = saveTokens;
52
52
  exports.clearTokens = clearTokens;
53
+ exports.readEnvUserCredential = readEnvUserCredential;
53
54
  exports.isExpired = isExpired;
54
55
  exports.refreshAndSave = refreshAndSave;
55
56
  exports.tryAcquirePkceFlightLock = tryAcquirePkceFlightLock;
@@ -73,9 +74,10 @@ function loadTokens() {
73
74
  }
74
75
  /** Persist the user's OAuth2 tokens. Existing tokens are replaced atomically. */
75
76
  function saveTokens(tokens) {
77
+ const { idToken: _discardedIdToken, ...persistedTokens } = tokens;
76
78
  (0, config_js_1.mutateConfig)((current) => ({
77
79
  ...current,
78
- user: { ...(current.user ?? {}), oauth2: tokens },
80
+ user: { ...(current.user ?? {}), oauth2: persistedTokens },
79
81
  }));
80
82
  }
81
83
  /** Remove the user's persisted OAuth2 tokens. */
@@ -93,6 +95,38 @@ function clearTokens() {
93
95
  return next;
94
96
  });
95
97
  }
98
+ /**
99
+ * Read the pre-supplied user credential from the environment.
100
+ *
101
+ * **`ORY_USER_OAUTH2_TOKEN` is the only accepted variable**, because an OAuth2
102
+ * access token is the only thing the credential is ever used as. All three
103
+ * consumers of `userPrincipal.token` demand one: broker delegation
104
+ * authentication and the agent + sub-agent DCR bootstraps (which send it as a
105
+ * bearer to `/oauth2/register`). Nothing calls `verifySession`, so a Kratos
106
+ * session token is never consumed as a session token.
107
+ *
108
+ * Three variables used to be accepted alongside it and have been removed:
109
+ * `ORY_USER_SESSION_TOKEN` (plus its legacy alias `ORY_SESSION_TOKEN`) named a
110
+ * Kratos session token and took *first* precedence, so supplying the credential
111
+ * its name asked for made every permission check fail `session_inactive` — and
112
+ * beat the variable that would have worked. `ORY_OAUTH2_TOKEN` was a redundant
113
+ * legacy alias. Pin the subject with `ORY_USER_SUBJECT_ID`.
114
+ *
115
+ * This lives here, and only here, because two call sites depend on agreeing:
116
+ * the login gate's `env_token` short-circuit (which runs on `SessionStart`) and
117
+ * the per-client rehydration in `OryAgentClient.fromEnv` (which runs on *every*
118
+ * lifecycle event, including the subprocess tool-call events where the gate
119
+ * never runs). If those disagreed, an env-supplied token would authenticate the
120
+ * session and then silently stop authenticating the permission checks.
121
+ */
122
+ function readEnvUserCredential(env = process.env) {
123
+ const token = env.ORY_USER_OAUTH2_TOKEN || undefined;
124
+ const subject = env.ORY_USER_SUBJECT_ID || undefined;
125
+ return {
126
+ ...(token ? { token } : {}),
127
+ ...(subject ? { subject } : {}),
128
+ };
129
+ }
96
130
  /** Number of seconds before nominal expiry that we consider tokens stale. */
97
131
  exports.TOKEN_EXPIRY_SKEW_SEC = 60;
98
132
  /** Returns true if the access token is missing or within the skew window of expiry. */
@@ -203,8 +237,8 @@ function isLockStale(file) {
203
237
  }
204
238
  /**
205
239
  * Unconditionally remove the pkce-flight lock, if any. Use this from
206
- * single-owner contexts (e.g. the dev launcher at startup) where you
207
- * know no peer is mid-flow. Returns true when a lock was removed.
240
+ * single-owner maintenance contexts where no peer is mid-flow. Returns true
241
+ * when a lock was removed.
208
242
  */
209
243
  function clearPkceFlightLock() {
210
244
  try {
package/dist/auth.d.ts CHANGED
@@ -14,14 +14,31 @@
14
14
  import { OryOAuth2Tokens } from "./config.js";
15
15
  /** Loopback ports tried in order. All must be registered as redirect URIs on the Ory OAuth2 client. */
16
16
  export declare const LOOPBACK_PORTS: readonly [47823, 47824, 47825, 47826];
17
- /** Hard timeout if the user never returns to the browser. */
18
- export declare const DEFAULT_LOGIN_TIMEOUT_MS = 120000;
17
+ /**
18
+ * Hard timeout if the user never returns to the browser.
19
+ *
20
+ * Sized to fit **inside** the tightest session-start hook window any harness
21
+ * will accept, so the login always gives up on its own terms and records a
22
+ * `timeout` decline the user can act on. Being SIGKILLed by the harness
23
+ * instead surfaces nothing at all: the session proceeds with no user identity,
24
+ * and under `enforce` every subsequent tool is denied for a reason nothing
25
+ * explains (#220).
26
+ *
27
+ * The binding constraint is Antigravity, which validates its hook timeout to
28
+ * `5..120` seconds — so 120s is the ceiling, and this leaves headroom for the
29
+ * agent DCR registration and delegation record that run after the login inside
30
+ * the same hook invocation. See `hook-timeout.ts`, which derives every
31
+ * harness's declared window and asserts it outlasts this value.
32
+ */
33
+ export declare const DEFAULT_LOGIN_TIMEOUT_MS = 90000;
19
34
  export interface PkceLoginOptions {
20
35
  /** Ory project URL (e.g. https://your-project.projects.oryapis.com). */
21
36
  projectUrl: string;
22
37
  /** Public OAuth2 client id registered with the loopback redirect URIs. */
23
38
  clientId: string;
24
- /** Scopes to request. Defaults to `openid offline_access`. */
39
+ /** Scopes to request. Defaults to `openid offline_access profile email`
40
+ * — `profile` / `email` populate the id_token's name/username/email
41
+ * claims so the signed-in user can be shown by name, not just subject id. */
25
42
  scope?: string;
26
43
  /** Audience to request, if any. */
27
44
  audience?: string;
@@ -42,8 +59,20 @@ export type PkceLoginOutcome = {
42
59
  } | {
43
60
  kind: "declined";
44
61
  reason: PkceDeclineReason;
62
+ /**
63
+ * Concise, secret-free diagnostic for the failure, surfaced in the
64
+ * `user.auth` audit event and the operator message. Populated for
65
+ * `token_exchange_failed` (the underlying HTTP status / OAuth2 error,
66
+ * or a fetch cause code such as `UNABLE_TO_GET_ISSUER_CERT_LOCALLY` /
67
+ * `ECONNREFUSED`). Never contains the authorization code or a token.
68
+ */
69
+ detail?: string;
45
70
  };
46
- export type PkceDeclineReason = "headless" | "timeout" | "user_denied" | "state_mismatch" | "no_port" | "browser_launch_failed" | "aborted" | "token_exchange_failed";
71
+ export type PkceDeclineReason = "headless" | "timeout" | "user_denied" | "state_mismatch" | "no_port" | "browser_launch_failed" | "aborted" | "token_exchange_failed"
72
+ /** The authorization server rejected the requested scope (`invalid_scope`). */
73
+ | "invalid_scope"
74
+ /** The authorization server returned some other `error` on the redirect. */
75
+ | "oauth_error";
47
76
  /**
48
77
  * Default headless heuristic. Returns true only for unattended runs
49
78
  * (CI=true / CI=1) so {@link pkceLogin} can short-circuit instead of
package/dist/auth.js CHANGED
@@ -59,10 +59,34 @@ const node_http_1 = require("node:http");
59
59
  const node_url_1 = require("node:url");
60
60
  const fs = __importStar(require("node:fs"));
61
61
  const path = __importStar(require("node:path"));
62
+ const branding_js_1 = require("./branding.js");
62
63
  /** Loopback ports tried in order. All must be registered as redirect URIs on the Ory OAuth2 client. */
63
64
  exports.LOOPBACK_PORTS = [47823, 47824, 47825, 47826];
64
- /** Hard timeout if the user never returns to the browser. */
65
- exports.DEFAULT_LOGIN_TIMEOUT_MS = 120_000;
65
+ /**
66
+ * Hard timeout if the user never returns to the browser.
67
+ *
68
+ * Sized to fit **inside** the tightest session-start hook window any harness
69
+ * will accept, so the login always gives up on its own terms and records a
70
+ * `timeout` decline the user can act on. Being SIGKILLed by the harness
71
+ * instead surfaces nothing at all: the session proceeds with no user identity,
72
+ * and under `enforce` every subsequent tool is denied for a reason nothing
73
+ * explains (#220).
74
+ *
75
+ * The binding constraint is Antigravity, which validates its hook timeout to
76
+ * `5..120` seconds — so 120s is the ceiling, and this leaves headroom for the
77
+ * agent DCR registration and delegation record that run after the login inside
78
+ * the same hook invocation. See `hook-timeout.ts`, which derives every
79
+ * harness's declared window and asserts it outlasts this value.
80
+ */
81
+ exports.DEFAULT_LOGIN_TIMEOUT_MS = 90_000;
82
+ /**
83
+ * Login scopes, derived from the single source of truth in branding so the
84
+ * PKCE request can never drift from what the install registers the client
85
+ * with. `WIDE` (with profile/email) is requested by default and dropped to
86
+ * `BASE` if the client isn't allowed those scopes (see {@link pkceLogin}).
87
+ */
88
+ const BASE_LOGIN_SCOPE = branding_js_1.USER_LOGIN_BASE_SCOPES.join(" ");
89
+ const WIDE_LOGIN_SCOPE = branding_js_1.USER_LOGIN_SCOPE_OAUTH;
66
90
  /**
67
91
  * Default headless heuristic. Returns true only for unattended runs
68
92
  * (CI=true / CI=1) so {@link pkceLogin} can short-circuit instead of
@@ -87,12 +111,40 @@ function detectHeadless(env = process.env) {
87
111
  */
88
112
  async function pkceLogin(options) {
89
113
  const isHeadless = options.isHeadless ?? detectHeadless;
90
- const headless = isHeadless();
91
- if (headless) {
114
+ if (isHeadless()) {
92
115
  // Unattended environment (CI). No human to follow the URL, so we
93
116
  // skip the loopback listener entirely instead of timing out.
94
117
  return { kind: "declined", reason: "headless" };
95
118
  }
119
+ // An explicit scope is honored verbatim. The default asks for `profile` and
120
+ // `email` (so the id_token carries the signed-in user's name) but falls back
121
+ // to the base scope when the login client isn't allowed them — otherwise a
122
+ // client not configured for those scopes could never complete login
123
+ // (`invalid_scope`). The fallback costs one extra redirect, no user action.
124
+ const scopes = options.scope ? [options.scope] : [WIDE_LOGIN_SCOPE, BASE_LOGIN_SCOPE];
125
+ let outcome = { kind: "declined", reason: "user_denied" };
126
+ for (let i = 0; i < scopes.length; i++) {
127
+ const scopeFallbackPending = i < scopes.length - 1;
128
+ outcome = await runPkceAttempt(options, scopes[i], scopeFallbackPending);
129
+ if (outcome.kind === "ok")
130
+ return outcome;
131
+ if (outcome.reason === "invalid_scope" && scopeFallbackPending) {
132
+ process.stderr.write("The sign-in client isn't allowed the requested profile/email scopes; " +
133
+ "retrying with basic scopes (your name may not be shown in status).\n\n");
134
+ continue;
135
+ }
136
+ return outcome;
137
+ }
138
+ return outcome;
139
+ }
140
+ /**
141
+ * One PKCE attempt with a specific scope string: bind a loopback port, open the
142
+ * browser, wait for the callback, and exchange the code. `scopeFallbackPending`
143
+ * is true when {@link pkceLogin} will retry with a narrower scope if this
144
+ * attempt is rejected for `invalid_scope`, so the browser page can say
145
+ * "reconnecting" instead of showing a hard failure.
146
+ */
147
+ async function runPkceAttempt(options, scope, scopeFallbackPending) {
96
148
  const { server, port } = await listenOnFirstAvailablePort(options.ports ?? exports.LOOPBACK_PORTS);
97
149
  if (!server || port === null) {
98
150
  return { kind: "declined", reason: "no_port" };
@@ -101,7 +153,6 @@ async function pkceLogin(options) {
101
153
  const codeChallenge = sha256Base64Url(codeVerifier);
102
154
  const state = (0, node_crypto_1.randomBytes)(16).toString("base64url");
103
155
  const redirectUri = `http://127.0.0.1:${port}/callback`;
104
- const scope = options.scope ?? "openid offline_access";
105
156
  const authorizeUrl = buildAuthorizeUrl({
106
157
  projectUrl: options.projectUrl,
107
158
  clientId: options.clientId,
@@ -123,6 +174,7 @@ async function pkceLogin(options) {
123
174
  expectedState: state,
124
175
  timeoutMs,
125
176
  signal: options.signal,
177
+ scopeFallbackPending,
126
178
  });
127
179
  // Browser launch is best-effort. If `open` throws or the platform has
128
180
  // no usable browser, we keep the callback server alive and rely on the
@@ -153,10 +205,35 @@ async function pkceLogin(options) {
153
205
  });
154
206
  return { kind: "ok", tokens };
155
207
  }
156
- catch {
157
- return { kind: "declined", reason: "token_exchange_failed" };
208
+ catch (err) {
209
+ // Surface the underlying cause (HTTP status + OAuth2 error, or a fetch
210
+ // cause code like a TLS-trust failure) instead of swallowing it — this
211
+ // is the difference between a diagnosable failure and a bare
212
+ // "exchange failed" message. The detail is secret-free.
213
+ return {
214
+ kind: "declined",
215
+ reason: "token_exchange_failed",
216
+ detail: describeExchangeError(err),
217
+ };
158
218
  }
159
219
  }
220
+ /**
221
+ * Render an error thrown by {@link exchangeCodeForTokens} into a concise,
222
+ * secret-free detail string. For a failed `fetch` the useful signal is the
223
+ * low-level cause code (`UNABLE_TO_GET_ISSUER_CERT_LOCALLY`, `ECONNREFUSED`,
224
+ * …); for a non-2xx response the message already carries the HTTP status and
225
+ * the OAuth2 `error`. Neither includes the authorization code or any token.
226
+ */
227
+ function describeExchangeError(err) {
228
+ if (err instanceof Error) {
229
+ const cause = err.cause;
230
+ const code = cause && typeof cause === "object" && "code" in cause
231
+ ? String(cause.code)
232
+ : undefined;
233
+ return code ? `${err.message} (${code})` : err.message;
234
+ }
235
+ return String(err);
236
+ }
160
237
  // ─── PKCE primitives ──────────────────────────────────────────────────
161
238
  /** Generate a 64-byte (≈86-char base64url) code verifier per RFC 7636. */
162
239
  function generateCodeVerifier() {
@@ -247,8 +324,27 @@ function waitForCallback(server, opts) {
247
324
  }
248
325
  const error = url.searchParams.get("error");
249
326
  if (error) {
250
- respondHtml(res, 400, resultPage("cancelled"));
251
- settle({ kind: "declined", reason: "user_denied" });
327
+ const description = url.searchParams.get("error_description") ?? undefined;
328
+ // `access_denied` is the user clicking "cancel" — not an error to alarm
329
+ // them with. Everything else is a real authorization-server rejection
330
+ // (misconfigured client, unsupported scope, …); show the actual reason
331
+ // so it can be acted on rather than a generic "cancelled".
332
+ if (error === "access_denied") {
333
+ respondHtml(res, 400, resultPage("cancelled"));
334
+ settle({ kind: "declined", reason: "user_denied" });
335
+ }
336
+ else if (error === "invalid_scope") {
337
+ // A pending scope fallback will retry immediately with fewer scopes,
338
+ // so don't flash a hard failure — say we're reconnecting.
339
+ respondHtml(res, 400, opts.scopeFallbackPending
340
+ ? resultPage("reconnecting")
341
+ : resultPage("error", { code: error, description }));
342
+ settle({ kind: "declined", reason: "invalid_scope" });
343
+ }
344
+ else {
345
+ respondHtml(res, 400, resultPage("error", { code: error, description }));
346
+ settle({ kind: "declined", reason: "oauth_error" });
347
+ }
252
348
  return;
253
349
  }
254
350
  const state = url.searchParams.get("state");
@@ -280,14 +376,13 @@ function respondHtml(res, status, html) {
280
376
  }
281
377
  /**
282
378
  * Serve the shared Ory favicon (identical to console.ory.com's) so the sign-in
283
- * result page carries the same tab icon as the Console. It ships alongside the
284
- * built dashboard at `dist/webapp/favicon.ico`; if that file isn't present
285
- * (e.g. the webapp wasn't built), fall back to a 404 — the page still renders,
286
- * just without a favicon. Same-origin on the loopback server, so no external
287
- * request is made.
379
+ * result page carries the same tab icon as the Console, when a bundled
380
+ * `dist/webapp/favicon.ico` is present. It normally isn't, so this falls back
381
+ * to a 404 — the page still renders, just without a favicon. Same-origin on the
382
+ * loopback server, so no external request is made.
288
383
  */
289
384
  function respondFavicon(res) {
290
- // Compiled to dist/auth.js; the SPA (and its favicon) is built to dist/webapp.
385
+ // Compiled to dist/auth.js; look for an optional favicon under dist/webapp.
291
386
  const file = path.join(__dirname, "webapp", "favicon.ico");
292
387
  try {
293
388
  const buf = fs.readFileSync(file);
@@ -309,7 +404,7 @@ function respondFavicon(res) {
309
404
  * external requests — the only asset is the same-origin Ory favicon the
310
405
  * loopback server serves — so it renders identically offline.
311
406
  */
312
- function resultPage(kind) {
407
+ function resultPage(kind, detail) {
313
408
  const view = {
314
409
  success: {
315
410
  accent: "#22C55E", // green — a positive "done" check
@@ -329,7 +424,24 @@ function resultPage(kind) {
329
424
  title: "Sign-in could not be verified",
330
425
  body: "The login response failed a security check. <strong>Close this tab</strong> and re-run the login from your terminal.",
331
426
  },
427
+ error: {
428
+ accent: "#F43F5E",
429
+ icon: "!",
430
+ title: "Sign-in failed",
431
+ body: "Ory rejected the sign-in request. <strong>Close this tab</strong> and return to your terminal — the details below say why.",
432
+ },
433
+ reconnecting: {
434
+ accent: "#6366F1", // indigo — a neutral "in progress"
435
+ icon: "&#8635;", // ↻
436
+ title: "Reconnecting…",
437
+ body: "Re-opening sign-in with basic permissions. You can <strong>close this tab</strong>; a new one will open to continue.",
438
+ },
332
439
  }[kind];
440
+ // The authorization server's own `error` / `error_description`, shown verbatim
441
+ // (escaped) so a misconfiguration is actionable instead of opaque.
442
+ const detailBlock = detail && (detail.code || detail.description)
443
+ ? `\n <pre class="detail">${escapeHtml([detail.code, detail.description].filter(Boolean).join("\n"))}</pre>`
444
+ : "";
333
445
  return `<!doctype html>
334
446
  <html lang="en">
335
447
  <head>
@@ -355,18 +467,32 @@ function resultPage(kind) {
355
467
  h1 { font-size: 1.35rem; margin: 0 0 .5rem; color: #F8FAFC; }
356
468
  p { margin: 0; line-height: 1.55; color: #94A3B8; }
357
469
  strong { color: #E2E8F0; font-weight: 600; }
470
+ .detail {
471
+ margin: 1.25rem 0 0; padding: .75rem 1rem; text-align: left;
472
+ border-radius: .5rem; background: #1E293B; color: #CBD5E1;
473
+ font-size: .8rem; line-height: 1.5; white-space: pre-wrap; word-break: break-word;
474
+ }
358
475
  </style>
359
476
  </head>
360
477
  <body>
361
478
  <div class="card">
362
479
  <div class="badge">${view.icon}</div>
363
480
  <h1>${view.title}</h1>
364
- <p>${view.body}</p>
481
+ <p>${view.body}</p>${detailBlock}
365
482
  </div>
366
483
  </body>
367
484
  </html>
368
485
  `;
369
486
  }
487
+ /** Minimal HTML-entity escape for untrusted text interpolated into the page. */
488
+ function escapeHtml(s) {
489
+ return s
490
+ .replace(/&/g, "&amp;")
491
+ .replace(/</g, "&lt;")
492
+ .replace(/>/g, "&gt;")
493
+ .replace(/"/g, "&quot;")
494
+ .replace(/'/g, "&#39;");
495
+ }
370
496
  // ─── Browser launch ───────────────────────────────────────────────────
371
497
  async function launchBrowser(url, override) {
372
498
  if (override) {
@@ -396,7 +522,23 @@ async function exchangeCodeForTokens(args) {
396
522
  body: body.toString(),
397
523
  });
398
524
  if (!res.ok) {
399
- throw new Error(`Token exchange failed: HTTP ${res.status}`);
525
+ // Include the OAuth2 error from the body when present. `error` /
526
+ // `error_description` are standard OAuth2 diagnostics (e.g.
527
+ // `invalid_client`, `invalid_grant`), not secrets — they turn an opaque
528
+ // 400/401 into an actionable message.
529
+ let suffix = "";
530
+ try {
531
+ const body = (await res.json());
532
+ const error = typeof body.error === "string" ? body.error : undefined;
533
+ if (error) {
534
+ const desc = typeof body.error_description === "string" ? body.error_description : undefined;
535
+ suffix = desc ? `: ${error} — ${desc}` : `: ${error}`;
536
+ }
537
+ }
538
+ catch {
539
+ // Non-JSON body; the status alone is the diagnostic.
540
+ }
541
+ throw new Error(`Token exchange failed: HTTP ${res.status}${suffix}`);
400
542
  }
401
543
  const json = (await res.json());
402
544
  return tokensFromTokenResponse(json, args.clientId);
@@ -433,14 +575,15 @@ function tokensFromTokenResponse(json, clientId, fallbackRefreshToken) {
433
575
  throw new Error("Token response missing access_token");
434
576
  const expiresIn = typeof json.expires_in === "number" ? json.expires_in : 3600;
435
577
  const refreshToken = typeof json.refresh_token === "string" ? json.refresh_token : fallbackRefreshToken;
578
+ const idToken = typeof json.id_token === "string" ? json.id_token : undefined;
436
579
  return {
437
580
  accessToken,
438
581
  refreshToken,
439
582
  expiresAt: Math.floor(Date.now() / 1000) + expiresIn,
440
583
  clientId,
441
- idToken: typeof json.id_token === "string" ? json.id_token : undefined,
584
+ displayName: displayNameFromIdToken(idToken),
442
585
  scope: typeof json.scope === "string" ? json.scope : undefined,
443
- subject: subjectFromIdToken(typeof json.id_token === "string" ? json.id_token : undefined),
586
+ subject: subjectFromIdToken(idToken),
444
587
  };
445
588
  }
446
589
  /** Best-effort sub-claim extraction from a JWT id_token. Returns undefined on any failure. */
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Shell-command decomposition for fine-grained permission checks.
3
+ *
4
+ * The permission gate authorizes on *tool name* only, so a denied surface
5
+ * (`WebFetch`, `Read`, an MCP write) is trivially reached through
6
+ * `bash -c "curl …"`. This module parses a shell command with the
7
+ * `tree-sitter-bash` grammar (via `web-tree-sitter`) into a **structured
8
+ * command model** — the shared foundation for the permission work:
9
+ *
10
+ * - #76 (this): check each command word as `ShellTool:<word>#use`.
11
+ * Derived from `ParsedShell.commands.map(c => c.name)`.
12
+ * - #77 (argument scoping): per-binary extractors read `ShellCommand.args`.
13
+ * - #78 (combinatory dataflow): edges are built from `ShellCommand.pipeline`
14
+ * grouping and `ShellCommand.redirects`.
15
+ * - #79 (env escalation): `ShellCommand.assignments` and redirect targets.
16
+ *
17
+ * Building the model once (one parse, one AST walk) keeps every follow-on
18
+ * additive instead of re-walking the tree.
19
+ *
20
+ * **Why a grammar parser, not a regex/tokenizer.** A hand-rolled tokenizer is
21
+ * vulnerable to *parser-differential attacks* — where the checker reads a
22
+ * command differently than the shell that will run it. tree-sitter-bash gives
23
+ * us the shell's own view of the command.
24
+ *
25
+ * **Failure postures (two, deliberately opposite):**
26
+ * - `tooComplex` — parsed but unresolvable (dynamic command name, parse
27
+ * error, dynamic `sh -c` target). Security signal: enforce denies.
28
+ * - `parserUnavailable` — the runtime/grammar could not load. Availability
29
+ * signal: the gate falls open. A broken build must never block shell use.
30
+ *
31
+ * **Known static-analysis limits (checks for them are #77/#78/#79):** command
32
+ * names built at runtime, programs invoked as interpreter arguments
33
+ * (`python -c "…"`, `find -exec`), and dynamic dataflow. The backstop is that
34
+ * the decode/exec primitives themselves (`sh`, `bash`, `eval`, `base64`, …) are
35
+ * ordinary checked words.
36
+ */
37
+ /** A redirection attached to a command (`> file`, `< file`, `2>err`, `>/dev/tcp/…`). */
38
+ export interface ShellRedirect {
39
+ /** Raw operator text (`>`, `>>`, `<`, `2>`, `&>`, …). */
40
+ operator: string;
41
+ /** Literal redirect target (path / fd / device), or `null` when dynamic. */
42
+ target: string | null;
43
+ /** `read` for `<`/`<<`/`<<<`, `write` for `>`/`>>`/`&>`. */
44
+ direction: "read" | "write";
45
+ }
46
+ /** A NAME=value assignment (an `env`-style prefix on a command). */
47
+ export interface ShellAssignment {
48
+ name: string;
49
+ /** Literal value, or `null` when it contains an expansion. */
50
+ value: string | null;
51
+ }
52
+ /** One effective command invocation extracted from a shell command line. */
53
+ export interface ShellCommand {
54
+ /**
55
+ * Resolved program/builtin word, normalized to its basename. `null` when the
56
+ * command name is dynamic/unresolvable (`$X`, `$(…)`), which also flips
57
+ * {@link ParsedShell.tooComplex}.
58
+ */
59
+ name: string | null;
60
+ /** Raw text of each argument token, in order (for #77 argument scoping). */
61
+ args: string[];
62
+ /** `NAME=value` assignment prefixes on this command (for #79 env scoping). */
63
+ assignments: ShellAssignment[];
64
+ /** Redirects on this command / its enclosing statement (for #78/#79). */
65
+ redirects: ShellRedirect[];
66
+ /**
67
+ * Pipeline group id. Commands joined by `|` share an id, so #78 can form the
68
+ * pipe edges between them. Standalone commands each get a distinct id.
69
+ */
70
+ pipeline: number;
71
+ }
72
+ /** Structured result of decomposing one shell command line. */
73
+ export interface ParsedShell {
74
+ /** Every effective command invocation (including wrapper- and `sh -c`-nested). */
75
+ commands: ShellCommand[];
76
+ /** Something couldn't be resolved statically (dynamic name / parse error). */
77
+ tooComplex: boolean;
78
+ /** The tree-sitter runtime/grammar failed to load — fail OPEN, don't block. */
79
+ parserUnavailable?: boolean;
80
+ }
81
+ /** Back-compat flat view used by the #76 gate: the deduped command words. */
82
+ export interface BashParseResult {
83
+ words: string[];
84
+ tooComplex: boolean;
85
+ parserUnavailable?: boolean;
86
+ }
87
+ /**
88
+ * Decompose a shell command into a {@link ParsedShell} model. Never throws.
89
+ * This is the shared foundation; see {@link extractBashCommandWords} for the
90
+ * flat word list the #76 gate uses.
91
+ */
92
+ export declare function extractShellCommands(command: string): Promise<ParsedShell>;
93
+ /**
94
+ * Flat view of {@link extractShellCommands} for the #76 gate: the deduped set
95
+ * of command/builtin words the command will execute. Never throws. See
96
+ * {@link BashParseResult} for the two failure postures.
97
+ */
98
+ export declare function extractBashCommandWords(command: string): Promise<BashParseResult>;