@naswerks/periscope 1.0.0 → 1.0.1

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 (187) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +2 -2
  3. package/contracts/wire-vectors/bulk_delivered.full.json +21 -0
  4. package/contracts/wire-vectors/bulk_delivered.minimal.json +21 -0
  5. package/contracts/wire-vectors/bulk_delivered.without-stat-pair.json +22 -0
  6. package/contracts/wire-vectors/bulk_failed.declared-reason.json +22 -0
  7. package/contracts/wire-vectors/bulk_failed.unknown-reason.json +23 -0
  8. package/contracts/wire-vectors/bulk_request.full.json +21 -0
  9. package/contracts/wire-vectors/host_configure.set-and-remove.json +28 -0
  10. package/contracts/wire-vectors/host_configure_result.applied.json +34 -0
  11. package/contracts/wire-vectors/host_configure_result.refused.json +33 -0
  12. package/contracts/wire-vectors/link_ack.cursors.json +25 -0
  13. package/contracts/wire-vectors/link_ack.empty.json +16 -0
  14. package/contracts/wire-vectors/link_bye.cause.json +16 -0
  15. package/contracts/wire-vectors/link_hello.empty.json +33 -0
  16. package/contracts/wire-vectors/link_hello.full.json +44 -0
  17. package/contracts/wire-vectors/link_ping.nonce.json +16 -0
  18. package/contracts/wire-vectors/link_ping.unknown-payload-key.json +18 -0
  19. package/contracts/wire-vectors/link_pong.nonce.json +16 -0
  20. package/contracts/wire-vectors/link_welcome.empty.json +18 -0
  21. package/contracts/wire-vectors/link_welcome.full.json +25 -0
  22. package/contracts/wire-vectors/refused.empty.json +10 -0
  23. package/contracts/wire-vectors/refused.encode.empty-at.json +18 -0
  24. package/contracts/wire-vectors/refused.encode.empty-session-id.json +18 -0
  25. package/contracts/wire-vectors/refused.encode.missing-member.json +22 -0
  26. package/contracts/wire-vectors/refused.encode.seq-zero.json +18 -0
  27. package/contracts/wire-vectors/refused.encode.undeclared-configure-refusal.json +34 -0
  28. package/contracts/wire-vectors/refused.encode.undeclared-wire-refusal.json +27 -0
  29. package/contracts/wire-vectors/refused.missing-member.json +10 -0
  30. package/contracts/wire-vectors/refused.not-json.json +10 -0
  31. package/contracts/wire-vectors/refused.over-limit.json +10 -0
  32. package/contracts/wire-vectors/refused.seq-zero.json +10 -0
  33. package/contracts/wire-vectors/refused.unknown-frame.json +10 -0
  34. package/contracts/wire-vectors/refused.unknown-kind.json +10 -0
  35. package/contracts/wire-vectors/repository_list.root.json +19 -0
  36. package/contracts/wire-vectors/repository_list.subdirectory.json +19 -0
  37. package/contracts/wire-vectors/repository_list_result.entries.json +34 -0
  38. package/contracts/wire-vectors/repository_list_result.refused.json +24 -0
  39. package/contracts/wire-vectors/repository_read.head.json +20 -0
  40. package/contracts/wire-vectors/repository_read_result.refused.json +25 -0
  41. package/contracts/wire-vectors/repository_read_result.truncated.json +22 -0
  42. package/contracts/wire-vectors/session_cancel.bare.json +17 -0
  43. package/contracts/wire-vectors/session_configure.full.json +22 -0
  44. package/contracts/wire-vectors/session_configure.minimal.json +20 -0
  45. package/contracts/wire-vectors/session_delta.agent-message.json +27 -0
  46. package/contracts/wire-vectors/session_list.request.json +18 -0
  47. package/contracts/wire-vectors/session_list_result.empty.json +21 -0
  48. package/contracts/wire-vectors/session_list_result.full.json +36 -0
  49. package/contracts/wire-vectors/session_new.full.json +72 -0
  50. package/contracts/wire-vectors/session_new.minimal.json +22 -0
  51. package/contracts/wire-vectors/session_new.request-all-null.json +41 -0
  52. package/contracts/wire-vectors/session_prompt.empty.json +18 -0
  53. package/contracts/wire-vectors/session_prompt.multibyte.json +18 -0
  54. package/contracts/wire-vectors/session_prompt.text.json +18 -0
  55. package/contracts/wire-vectors/session_prompt.unknown-frame-key.json +20 -0
  56. package/contracts/wire-vectors/session_prompt.unknown-payload-key.json +20 -0
  57. package/contracts/wire-vectors/session_update.agent-message.json +26 -0
  58. package/contracts/wire-vectors/session_update.state-transition-with-activity.json +44 -0
  59. package/contracts/wire-vectors/session_update.state-transition.json +41 -0
  60. package/contracts/wire-vectors/session_update.wire-refusal.json +26 -0
  61. package/contracts/wire-vectors/transcript_failed.declared-reason.json +22 -0
  62. package/contracts/wire-vectors/transcript_list.from-start.json +19 -0
  63. package/contracts/wire-vectors/transcript_list.paged.json +19 -0
  64. package/contracts/wire-vectors/transcript_list_result.entry-without-cwd.json +30 -0
  65. package/contracts/wire-vectors/transcript_list_result.full.json +29 -0
  66. package/contracts/wire-vectors/transcript_list_result.last-page.json +29 -0
  67. package/contracts/wire-vectors/transcript_tail.any-user-entry.json +22 -0
  68. package/contracts/wire-vectors/transcript_tail.with-needle.json +22 -0
  69. package/contracts/wire-vectors/transcript_tail_result.absent.json +23 -0
  70. package/contracts/wire-vectors/transcript_tail_result.found.json +23 -0
  71. package/contracts/wire-vectors/workspace_list.from-start.json +19 -0
  72. package/contracts/wire-vectors/workspace_list_result.page.json +48 -0
  73. package/contracts/wire-vectors/workspace_list_result.refused.json +26 -0
  74. package/contracts/wire-vectors/workspace_release.by-path-delete-branch.json +22 -0
  75. package/contracts/wire-vectors/workspace_release.request.json +22 -0
  76. package/contracts/wire-vectors/workspace_release_bulk.two-entries.json +32 -0
  77. package/contracts/wire-vectors/workspace_release_bulk_result.mixed.json +37 -0
  78. package/contracts/wire-vectors/workspace_release_result.partial.json +26 -0
  79. package/contracts/wire-vectors/workspace_release_result.refused.json +26 -0
  80. package/contracts/wire-vectors/workspace_release_result.released.json +23 -0
  81. package/package.json +6 -1
  82. package/src/bin/command.ts +198 -0
  83. package/src/bin/config.ts +104 -0
  84. package/src/bin/login.ts +149 -0
  85. package/src/bin/main.ts +204 -0
  86. package/src/bin/pair.ts +246 -0
  87. package/src/bin/periscope.ts +3 -0
  88. package/src/bin/posture.ts +232 -0
  89. package/src/bin/reconfigure.ts +266 -0
  90. package/src/bin/serve.ts +615 -0
  91. package/src/bin/status.ts +61 -0
  92. package/src/bin/workspaces.ts +280 -0
  93. package/src/control/backoff.ts +40 -0
  94. package/src/control/codec.ts +557 -0
  95. package/src/control/credential.ts +33 -0
  96. package/src/control/frames.ts +1586 -0
  97. package/src/control/link-state.ts +67 -0
  98. package/src/control/link.ts +894 -0
  99. package/src/control/queue.ts +248 -0
  100. package/src/control/seq.ts +76 -0
  101. package/src/control/stream-routing.ts +216 -0
  102. package/src/control/stream.ts +149 -0
  103. package/src/core/async-queue.ts +63 -0
  104. package/src/core/failure.ts +61 -0
  105. package/src/core/index.ts +22 -0
  106. package/src/core/keyed-turns.ts +47 -0
  107. package/src/core/paths.ts +84 -0
  108. package/src/core/refusal.ts +430 -0
  109. package/src/core/result.ts +27 -0
  110. package/src/core/time.ts +34 -0
  111. package/src/core/vocab.ts +44 -0
  112. package/src/core/workspace-id.ts +11 -0
  113. package/src/gate/command.ts +617 -0
  114. package/src/gate/decision.ts +173 -0
  115. package/src/gate/escalate.ts +164 -0
  116. package/src/gate/gate.ts +635 -0
  117. package/src/gate/index.ts +53 -0
  118. package/src/gate/jail.ts +210 -0
  119. package/src/gate/local.ts +115 -0
  120. package/src/gate/outcome.ts +164 -0
  121. package/src/gate/shell.ts +295 -0
  122. package/src/host/agent-process.ts +694 -0
  123. package/src/host/bulk-post.ts +166 -0
  124. package/src/host/claude-transcripts.ts +404 -0
  125. package/src/host/config-file.ts +246 -0
  126. package/src/host/git-facts.ts +119 -0
  127. package/src/host/hooks.ts +102 -0
  128. package/src/host/host.ts +1901 -0
  129. package/src/host/index.ts +92 -0
  130. package/src/host/link-state-file.ts +79 -0
  131. package/src/host/loopback.ts +177 -0
  132. package/src/host/machine.ts +27 -0
  133. package/src/host/mcp-server.ts +66 -0
  134. package/src/host/package-facts.ts +25 -0
  135. package/src/host/paired-credential-store.ts +140 -0
  136. package/src/host/paths.ts +175 -0
  137. package/src/host/repository-read.ts +245 -0
  138. package/src/host/session-store.ts +133 -0
  139. package/src/host/sign-in.ts +352 -0
  140. package/src/host/telemetry.ts +105 -0
  141. package/src/host/token-cache.ts +214 -0
  142. package/src/host/transcript-fs.ts +110 -0
  143. package/src/host/wire-request.ts +285 -0
  144. package/src/host/workspace-fs.ts +90 -0
  145. package/src/host/workspace-trust.ts +92 -0
  146. package/src/identity/authorize.ts +127 -0
  147. package/src/identity/config.ts +227 -0
  148. package/src/identity/credential.ts +176 -0
  149. package/src/identity/device-code.ts +217 -0
  150. package/src/identity/index.ts +84 -0
  151. package/src/identity/mode.ts +161 -0
  152. package/src/identity/paired-credential.ts +89 -0
  153. package/src/identity/pkce.ts +113 -0
  154. package/src/identity/store.ts +98 -0
  155. package/src/identity/token.ts +203 -0
  156. package/src/index.ts +140 -0
  157. package/src/mcp/descriptor.ts +75 -0
  158. package/src/mcp/index.ts +23 -0
  159. package/src/mcp/schema.ts +214 -0
  160. package/src/mcp/server.ts +180 -0
  161. package/src/persistence/entry.ts +142 -0
  162. package/src/persistence/index.ts +47 -0
  163. package/src/persistence/key.ts +77 -0
  164. package/src/persistence/mirror.ts +118 -0
  165. package/src/persistence/receipt.ts +182 -0
  166. package/src/persistence/retention.ts +118 -0
  167. package/src/persistence/store.ts +183 -0
  168. package/src/persistence/transition-log.ts +176 -0
  169. package/src/protocol.ts +215 -0
  170. package/src/sessions/index.ts +27 -0
  171. package/src/sessions/registry.ts +392 -0
  172. package/src/sessions/session.ts +440 -0
  173. package/src/sessions/spawn-env.ts +318 -0
  174. package/src/state/coverage.ts +360 -0
  175. package/src/state/index.ts +58 -0
  176. package/src/state/machine.ts +359 -0
  177. package/src/state/model.ts +417 -0
  178. package/src/state/observer.ts +499 -0
  179. package/src/state/reporter.ts +60 -0
  180. package/src/state/store.ts +157 -0
  181. package/src/telemetry/index.ts +10 -0
  182. package/src/telemetry/usage.ts +117 -0
  183. package/src/workspace/git-worktree.ts +551 -0
  184. package/src/workspace/index.ts +17 -0
  185. package/src/workspace/plain-dir.ts +132 -0
  186. package/src/workspace/provider.ts +159 -0
  187. package/src/workspace/worktree-porcelain.ts +118 -0
@@ -0,0 +1,217 @@
1
+ /**
2
+ * The device-code fallback — for a box with no browser on it, which for this package is not an edge
3
+ * case but a deployment target.
4
+ *
5
+ * It is off unless explicitly configured, and it is never reached by falling back. Microsoft's
6
+ * guidance classes device code as a high-risk flow that phishing campaigns exploit and recommends
7
+ * that organisations block it (the Conditional Access authentication-flows documentation,
8
+ * https://learn.microsoft.com/entra/identity/conditional-access/concept-authentication-flows#device-code-flow);
9
+ * it is also exactly what workplace Conditional Access
10
+ * policies block, so a tenant refusing it outright is an expected outcome rather than a bug. A flow
11
+ * with that standing must be asked for by name — silently falling back to it when the loopback
12
+ * listener cannot bind would be the host quietly choosing the riskier option on the operator's
13
+ * behalf, at the moment they are least able to notice.
14
+ *
15
+ * And the trap that is invisible for weeks. A provider that tracks which protocol issued a token
16
+ * marks a refresh token obtained through device code as such, and a later non-device-code refresh
17
+ * against it fails with `AADSTS530036`; the provider's guidance is that such a token is unusable
18
+ * for good and should be deleted.
19
+ * The failure does not appear at sign-in; it appears the first time the token is refreshed, which
20
+ * for an unattended host is hours or weeks later and nowhere near the cause. So a device-code
21
+ * sign-in poisons the cache for any other flow: if this
22
+ * host ever switches a deployment back to loopback, the cached material must be discarded, not
23
+ * refreshed. `discardOnProtocolMismatch` is how that is decided rather than discovered.
24
+ */
25
+ import type { Refusal } from '../core/refusal.js';
26
+ import { refusal } from '../core/refusal.js';
27
+ import type { Result } from '../core/result.js';
28
+ import { ok, refuse } from '../core/result.js';
29
+ import type { IdentityConfig } from './config.js';
30
+ import { redactSecrets, secretsIn } from './token.js';
31
+
32
+ /** Which flow minted the material this host holds. Recorded so the trap above is decidable. */
33
+ export type AuthProtocol = 'loopback' | 'device-code';
34
+
35
+ /**
36
+ * The error code the provider returns when a Conditional Access authentication-flows policy
37
+ * rejects a refresh. Named so a reader can search for it.
38
+ *
39
+ * It is not a cross-protocol error, and naming it as one names the wrong cause. `AADSTS530036` is
40
+ * not "you refreshed device-code material with a different flow". Microsoft's documentation
41
+ * describes it as a Conditional Access authentication-flows check refusing the refresh token, and
42
+ * because such a policy applies to every application the token is unusable for good and should be
43
+ * deleted. The mechanism is protocol tracking: a session established with device code stays marked
44
+ * through every later refresh, and the tenant's policy then rejects it, so the trigger is an
45
+ * administrator enabling a policy, not this host being reconfigured. Source:
46
+ * https://learn.microsoft.com/entra/identity/conditional-access/concept-authentication-flows#device-code-flow
47
+ *
48
+ * A cross-protocol name would be dangerous rather than merely inaccurate: it would imply the fix
49
+ * is to sign in again, which re-runs the same flow into the same policy, forever. And it is the
50
+ * policy Microsoft actively recommends: allow device code only where a documented case needs it
51
+ * and block it everywhere else
52
+ * (https://learn.microsoft.com/entra/identity/conditional-access/policy-block-authentication-flows).
53
+ * See `host/sign-in.ts` for the recovery
54
+ * this routes to.
55
+ */
56
+ export const AUTH_FLOW_BLOCKED_ERROR = 'AADSTS530036';
57
+
58
+ /**
59
+ * Every provider code known to mean "a policy has blocked the flow this material was minted by".
60
+ * One today; a provider that reports the same condition under another code is added here, and
61
+ * only here: this file is the one place in the package that may know a provider's error codes.
62
+ */
63
+ export const AUTH_FLOW_BLOCKED_CODES: readonly string[] = [AUTH_FLOW_BLOCKED_ERROR];
64
+
65
+ export interface DeviceAuthorization {
66
+ readonly deviceCode: string;
67
+ readonly userCode: string;
68
+ readonly verificationUri: string;
69
+ readonly expiresAt: number;
70
+ readonly intervalMs: number;
71
+ }
72
+
73
+ /** The provider's answer to one poll. */
74
+ export type DevicePollOutcome =
75
+ | { readonly kind: 'pending' }
76
+ /** The provider asked the caller to back off; the caller widens its interval by this much. */
77
+ | { readonly kind: 'slow-down'; readonly intervalMs: number }
78
+ | { readonly kind: 'ready' }
79
+ | { readonly kind: 'declined'; readonly refusal: Refusal };
80
+
81
+ /**
82
+ * Refuse the device-code flow unless it was turned on.
83
+ *
84
+ * Exported and total so the refusal is reachable in a test rather than being a branch nobody drives.
85
+ */
86
+ export function requireDeviceCodeEnabled(config: IdentityConfig): Result<true> {
87
+ if (!config.deviceCodeEnabled) {
88
+ return refuse(
89
+ 'device-code-not-enabled',
90
+ 'the device-code flow is not enabled on this host; set PERISCOPE_IDENTITY_DEVICE_CODE=1 to allow it, and expect some tenants to refuse it by policy',
91
+ );
92
+ }
93
+ return ok(true);
94
+ }
95
+
96
+ export function deviceAuthorizationBody(config: IdentityConfig): string {
97
+ const body = new URLSearchParams();
98
+ body.set('client_id', config.clientId);
99
+ body.set('scope', config.scopes.join(' '));
100
+ return body.toString();
101
+ }
102
+
103
+ export function devicePollBody(config: IdentityConfig, deviceCode: string): string {
104
+ const body = new URLSearchParams();
105
+ // RFC 8628's grant type, urn-namespaced.
106
+ body.set('grant_type', 'urn:ietf:params:oauth:grant-type:device_code');
107
+ body.set('client_id', config.clientId);
108
+ body.set('device_code', deviceCode);
109
+ return body.toString();
110
+ }
111
+
112
+ export function readDeviceAuthorization(
113
+ status: number,
114
+ body: unknown,
115
+ nowMs: number,
116
+ ): Result<DeviceAuthorization> {
117
+ const secrets = secretsIn(body);
118
+ if (typeof body !== 'object' || body === null) {
119
+ return refuse(
120
+ 'device-code-declined',
121
+ `the device authorization endpoint returned HTTP ${status} with no JSON body`,
122
+ );
123
+ }
124
+ const record = body as Record<string, unknown>;
125
+
126
+ if (status < 200 || status >= 300) {
127
+ const error = typeof record['error'] === 'string' ? record['error'] : `HTTP ${status}`;
128
+ return refuse(
129
+ 'device-code-declined',
130
+ redactSecrets(
131
+ `the device authorization endpoint refused: ${error} — some tenants block this flow by policy`,
132
+ secrets,
133
+ ),
134
+ );
135
+ }
136
+
137
+ const deviceCode = record['device_code'];
138
+ const userCode = record['user_code'];
139
+ const verificationUri = record['verification_uri'] ?? record['verification_url'];
140
+ const expiresIn = Number(record['expires_in']);
141
+ const interval = Number(record['interval']);
142
+
143
+ if (typeof deviceCode !== 'string' || typeof userCode !== 'string' || typeof verificationUri !== 'string') {
144
+ return refuse(
145
+ 'device-code-declined',
146
+ 'the device authorization response is missing device_code, user_code or verification_uri',
147
+ );
148
+ }
149
+ if (!Number.isFinite(expiresIn) || expiresIn <= 0) {
150
+ return refuse('device-code-declined', 'the device authorization response carries no usable expires_in');
151
+ }
152
+
153
+ return ok({
154
+ deviceCode,
155
+ userCode,
156
+ verificationUri,
157
+ expiresAt: nowMs + expiresIn * 1000,
158
+ // RFC 8628 says default to 5 seconds when the provider does not state an interval.
159
+ intervalMs: (Number.isFinite(interval) && interval > 0 ? interval : 5) * 1000,
160
+ });
161
+ }
162
+
163
+ /**
164
+ * Read one poll response.
165
+ *
166
+ * `authorization_pending` and `slow_down` are not failures — they are the protocol working. Mapping
167
+ * them onto a refusal would end a sign-in that was proceeding normally.
168
+ */
169
+ export function readDevicePoll(status: number, body: unknown, currentIntervalMs: number): DevicePollOutcome {
170
+ if (status >= 200 && status < 300) return { kind: 'ready' };
171
+
172
+ const record = typeof body === 'object' && body !== null ? (body as Record<string, unknown>) : {};
173
+ const error = typeof record['error'] === 'string' ? record['error'] : `http_${status}`;
174
+
175
+ if (error === 'authorization_pending') return { kind: 'pending' };
176
+ if (error === 'slow_down') return { kind: 'slow-down', intervalMs: currentIntervalMs + 5_000 };
177
+
178
+ const description = typeof record['error_description'] === 'string' ? record['error_description'] : null;
179
+ return {
180
+ kind: 'declined',
181
+ refusal: refusal(
182
+ 'device-code-declined',
183
+ redactSecrets(
184
+ description === null
185
+ ? `the device-code sign-in ended: ${error}`
186
+ : `the device-code sign-in ended: ${error} — ${description}`,
187
+ secretsIn(body),
188
+ ),
189
+ ),
190
+ };
191
+ }
192
+
193
+ /**
194
+ * Must cached material be discarded rather than refreshed, because this host was reconfigured?
195
+ *
196
+ * This is not the `AADSTS530036` guard. It detects one real but narrow
197
+ * case: the cache was minted by one flow and the host is now configured for the other, so a refresh
198
+ * would fail for a reason nobody would connect to a sign-in that happened weeks ago.
199
+ *
200
+ * It cannot fire on the policy case, which is the common one. When a tenant enables the
201
+ * authentication-flows policy, the host is still configured for device code and the cache was still
202
+ * minted by device code — `cached === configured`, so this returns `false` and nothing is discarded.
203
+ * A guard named for an error it is structurally unable to catch is worse than no guard, because it
204
+ * reads as coverage. The reactive path in `host/sign-in.ts` is what handles the policy case.
205
+ */
206
+ export function discardOnProtocolMismatch(cached: AuthProtocol, configured: AuthProtocol): boolean {
207
+ return cached !== configured;
208
+ }
209
+
210
+ /**
211
+ * Does this provider error mean a Conditional Access authentication-flows policy has blocked this
212
+ * material permanently? Microsoft's documentation: "the token will never be usable and should be
213
+ * deleted."
214
+ */
215
+ export function isAuthFlowBlocked(errorText: string): boolean {
216
+ return AUTH_FLOW_BLOCKED_CODES.some((code) => errorText.includes(code));
217
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Identity — signing a real user in, holding what that produced, and presenting it.
3
+ *
4
+ * Every module behind this barrel is pure. The two impure halves live in `host/`
5
+ * (`token-cache.ts` writes the file, `loopback.ts` opens the port), which is where this package
6
+ * confines anything that touches the machine. That split is the same one the gate uses, and it is
7
+ * what makes the awkward branches — a malformed cache, a callback with the wrong `state`, a
8
+ * filesystem that ignores modes — drivable without a machine in that state.
9
+ *
10
+ * Nothing here names a provider. The authority, client and endpoints are configuration, and the
11
+ * flows are RFC 6749 / 7636 / 8628 as written. Any one commercial provider is a configuration of
12
+ * this, not the shape of it — which is what lets somebody point this at their own provider and
13
+ * have it work.
14
+ */
15
+ export type { IdentityConfig, IdentityEndpoints, IdentityPosture } from './config.js';
16
+ export {
17
+ DEFAULT_SCOPES,
18
+ discoveryUrl,
19
+ identityPosture,
20
+ readDiscoveryDocument,
21
+ readIdentityConfig,
22
+ } from './config.js';
23
+
24
+ export type { Pkce } from './pkce.js';
25
+ export {
26
+ CODE_CHALLENGE_METHOD,
27
+ challengeFor,
28
+ createPkce,
29
+ createState,
30
+ randomUrlSafe,
31
+ requireS256,
32
+ stateMatches,
33
+ } from './pkce.js';
34
+
35
+ export type { AuthorizationCallback, AuthorizationRequest } from './authorize.js';
36
+ export {
37
+ CALLBACK_PAGE,
38
+ buildAuthorizationUrl,
39
+ readAuthorizationCallback,
40
+ redirectUriFor,
41
+ } from './authorize.js';
42
+
43
+ export type { TokenSet } from './token.js';
44
+ export {
45
+ EXPIRY_SKEW_MS,
46
+ authorizationValue,
47
+ codeExchangeBody,
48
+ isFresh,
49
+ readTokenResponse,
50
+ redactSecrets,
51
+ refreshBody,
52
+ secretsIn,
53
+ } from './token.js';
54
+
55
+ export type { CredentialModeOutcome, ModeEnforcement } from './mode.js';
56
+ export {
57
+ CREDENTIAL_MODE,
58
+ classifyCredentialMode,
59
+ classifyProbeReadings,
60
+ isWiderThan,
61
+ toOctal,
62
+ } from './mode.js';
63
+
64
+ export type { AuthProtocol, DeviceAuthorization, DevicePollOutcome } from './device-code.js';
65
+ export {
66
+ AUTH_FLOW_BLOCKED_CODES,
67
+ AUTH_FLOW_BLOCKED_ERROR,
68
+ deviceAuthorizationBody,
69
+ devicePollBody,
70
+ discardOnProtocolMismatch,
71
+ isAuthFlowBlocked,
72
+ readDeviceAuthorization,
73
+ readDevicePoll,
74
+ requireDeviceCodeEnabled,
75
+ } from './device-code.js';
76
+
77
+ export type { CachedTokens, TokenStore } from './store.js';
78
+ export { readCachedTokens } from './store.js';
79
+
80
+ export type { TokenCredentialOptions, TokenRefresher } from './credential.js';
81
+ export { AUTHORIZATION_HEADER, TokenCredential } from './credential.js';
82
+
83
+ export type { PairedCredentialFile, PairedCredentialStore } from './paired-credential.js';
84
+ export { PairedHostCredential, readPairedCredentialFile } from './paired-credential.js';
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Whether the token file on disk is actually as private as requested — decided here, purely, so
3
+ * every branch can be driven on any platform.
4
+ *
5
+ * The defect, observed on win32 rather than reasoned. On win32, `node` accepts a `0o600` mode and
6
+ * does nothing with it: writing with `{mode: 0o600}` and then `chmod`ing to `0o600` both leave
7
+ * `statSync().mode & 0o777` reading `0o666`, and a deliberately world-readable `0o666` file reads
8
+ * exactly the same. So on Windows a restricted token file and an unrestricted one are
9
+ * indistinguishable through the only API available.
10
+ *
11
+ * That makes the obvious implementation wrong in both directions, which is why this is a module and
12
+ * not an `if`:
13
+ *
14
+ * - Verify literally (`observed === 0o600`) and the host refuses every write on Windows — it
15
+ * would be unusable there.
16
+ * - Relax it to "not world-writable" and the check passes vacuously on Windows while proving
17
+ * nothing at all — a green that reads as evidence.
18
+ *
19
+ * So the third answer is the only honest one: where modes are enforced, verify and refuse if the
20
+ * file came out wider than asked. Where they are not, say so by name as a degrade. A degrade is
21
+ * a named outcome, never a silent pass — and "this could not be confirmed" is a fact an operator
22
+ * can act on, while a checkmark that means nothing is not.
23
+ *
24
+ * Enforcement is measured, not assumed from `process.platform`. The caller probes it (see
25
+ * `host/token-cache.ts`) and passes the answer in. A platform list would be a guess that ages;
26
+ * WSL, a POSIX filesystem mounted on Windows, or a future runtime that maps modes onto ACLs would
27
+ * each make the guess wrong in the direction that matters.
28
+ *
29
+ * And the measurement has an inconclusive state, which is why this is a three-way enum and not a
30
+ * boolean. A probe that reports "not enforced" is only worth anything if it could have reported
31
+ * something else. On win32, `chmod 0444` does read back as `444`, so the write bit is real even
32
+ * though the read/group/other bits are fabricated as `6`. That gives the probe a positive control
33
+ * — it can demonstrate it observes something before it claims it cannot observe privacy.
34
+ * Without that, "unenforced" and "my probe is broken" are the same answer, and an instrument with
35
+ * no inconclusive state reports confidence it has not earned.
36
+ */
37
+ import type { Refusal } from '../core/refusal.js';
38
+ import { refusal } from '../core/refusal.js';
39
+
40
+ /** The mode credential material is written with, everywhere. */
41
+ export const CREDENTIAL_MODE = 0o600;
42
+
43
+ const PERMISSION_BITS = 0o777;
44
+
45
+ /** What the filesystem was measured to do with a mode. */
46
+ export type ModeEnforcement =
47
+ /** POSIX modes are honoured: what was asked for is what came back, and wide differs from narrow. */
48
+ | 'enforced'
49
+ /**
50
+ * Measured, and the answer is that only the write bit survives — the win32 shape. `0600` and
51
+ * `0666` are indistinguishable, so privacy cannot be confirmed; but `0444` reads back as `444`,
52
+ * so the instrument is live and this is a real finding rather than a dead probe.
53
+ */
54
+ | 'write-bit-only'
55
+ /**
56
+ * Inconclusive. No mode change was observable at all, not even clearing the write bit. The
57
+ * probe cannot distinguish "this filesystem records nothing" from "this probe is broken", and
58
+ * saying so is the only honest option.
59
+ */
60
+ | 'unobservable';
61
+
62
+ export type CredentialModeOutcome =
63
+ /** Modes are enforced here and the file is exactly as private as requested. */
64
+ | { readonly kind: 'verified'; readonly observed: number }
65
+ /** Modes are enforced here and the file is readable or writable by more than its owner. */
66
+ | { readonly kind: 'too-wide'; readonly observed: number; readonly refusal: Refusal }
67
+ /** Not a failure: privacy is unconfirmable here, and that was measured rather than assumed. */
68
+ | { readonly kind: 'unenforced'; readonly observed: number; readonly refusal: Refusal }
69
+ /** Not even a measurement: the instrument could observe nothing. Worth investigating. */
70
+ | { readonly kind: 'unobservable'; readonly observed: number; readonly refusal: Refusal };
71
+
72
+ /** Is any permission bit set that was not requested? */
73
+ export function isWiderThan(observed: number, requested: number): boolean {
74
+ return (observed & ~requested & PERMISSION_BITS) !== 0;
75
+ }
76
+
77
+ /**
78
+ * Decide what three probe readings mean. Pure, so the shapes this machine cannot produce are still
79
+ * drivable.
80
+ *
81
+ * This function exists because the obvious control is vacuous. Without the `0444` reading, on
82
+ * win32 a probe that runs the control and a probe that merely assumes its answer both report
83
+ * `write-bit-only`, so nothing on that machine can tell them apart. What is uniquely lost is the
84
+ * ability to distinguish a filesystem that records only the write bit from one that records
85
+ * nothing — and that difference only shows up on a filesystem a win32 machine cannot produce on
86
+ * demand. Stating the readings as data is what makes it testable at all.
87
+ *
88
+ * narrow what `stat` reported after asking for `requested` (0600)
89
+ * wide what it reported after asking for 0666
90
+ * readOnly what it reported after clearing the write bit (0444) — the positive control
91
+ */
92
+ export function classifyProbeReadings(
93
+ narrow: number,
94
+ wide: number,
95
+ readOnly: number,
96
+ requested: number = CREDENTIAL_MODE,
97
+ ): ModeEnforcement {
98
+ const n = narrow & PERMISSION_BITS;
99
+ const w = wide & PERMISSION_BITS;
100
+ const r = readOnly & PERMISSION_BITS;
101
+
102
+ // Full POSIX: what was asked for came back, and asking for something wider changed the answer.
103
+ if (n === (requested & PERMISSION_BITS) && n !== w) return 'enforced';
104
+
105
+ // Privacy is unconfirmable. Before saying so, the instrument must have shown it observes
106
+ // something — otherwise "unenforced" is indistinguishable from a dead probe.
107
+ return r !== w ? 'write-bit-only' : 'unobservable';
108
+ }
109
+
110
+ /**
111
+ * Classify what a verify-after-write actually found.
112
+ *
113
+ * `enforcement` is the measured answer to "what does this filesystem do with a mode at all".
114
+ */
115
+ export function classifyCredentialMode(
116
+ observed: number,
117
+ enforcement: ModeEnforcement,
118
+ requested: number = CREDENTIAL_MODE,
119
+ ): CredentialModeOutcome {
120
+ const bits = observed & PERMISSION_BITS;
121
+
122
+ if (enforcement === 'unobservable') {
123
+ return {
124
+ kind: 'unobservable',
125
+ observed: bits,
126
+ refusal: refusal(
127
+ 'credential-mode-unobservable',
128
+ `mode ${toOctal(requested)} was requested and this filesystem reported no observable mode change of any kind — not even clearing the write bit — so nothing at all can be said about the token file's permissions here; this is an inconclusive instrument rather than a finding about privacy`,
129
+ ),
130
+ };
131
+ }
132
+
133
+ if (enforcement === 'write-bit-only') {
134
+ return {
135
+ kind: 'unenforced',
136
+ observed: bits,
137
+ refusal: refusal(
138
+ 'credential-mode-unenforced',
139
+ `mode ${toOctal(requested)} was requested and this filesystem records only the write bit (it reports ${toOctal(bits)} here, and cannot distinguish owner-only from world-readable), so the token file's privacy cannot be confirmed — it is protected by the gate's credential-path denial and by the OS account, not by this mode. The probe did observe a mode change when the write bit was cleared, so this is a measurement rather than a dead check`,
140
+ ),
141
+ };
142
+ }
143
+
144
+ if (isWiderThan(bits, requested)) {
145
+ return {
146
+ kind: 'too-wide',
147
+ observed: bits,
148
+ refusal: refusal(
149
+ 'credential-mode-too-wide',
150
+ `the token file was written with mode ${toOctal(bits)} after ${toOctal(requested)} was requested, so it is readable or writable by more than its owner`,
151
+ ),
152
+ };
153
+ }
154
+
155
+ return { kind: 'verified', observed: bits };
156
+ }
157
+
158
+ /** `0o600`, for a message a human reads. */
159
+ export function toOctal(mode: number): string {
160
+ return `0${(mode & PERMISSION_BITS).toString(8)}`;
161
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * The paired-host credential — the durable identity a machine holds after `periscope pair`.
3
+ *
4
+ * The shape and its validation live here, not with the filesystem — the same split `store.ts`
5
+ * states for the token cache, for the same reason: `host/` may import this, and this may never
6
+ * import `host/`.
7
+ *
8
+ * This is not the OIDC token and not the RFC 8628 `device-code` flow, and the noun is chosen to
9
+ * keep the three apart. A paired credential is an opaque controller-minted bearer
10
+ * (`p1.<hostId>.<secret>`): it never expires on a clock, never refreshes, and dies only when the
11
+ * controller revokes it — at which point the upgrade answers 401 and the host's terminal path
12
+ * names it. That is what makes it survive the idle window after which a provider may let an OIDC
13
+ * refresh token lapse.
14
+ *
15
+ * It carries no user, and cannot. The controller derives the acting user from its own registry
16
+ * row; a host presenting this credential asserts nothing about who it acts for, which is the whole
17
+ * design — nothing in this file could smuggle an identity claim even by mistake, because there is
18
+ * no field to put one in.
19
+ */
20
+ import type { Authorization, ControllerCredential } from '../control/credential.js';
21
+ import type { Result } from '../core/result.js';
22
+ import { ok, refuse } from '../core/result.js';
23
+ import { AUTHORIZATION_HEADER } from './credential.js';
24
+
25
+ /** The persisted record — what `periscope pair` writes and the daemon reads. */
26
+ export interface PairedCredentialFile {
27
+ /** The host id the credential names. The daemon announces this id in `link_hello` — the
28
+ * controller refuses a hello naming anything else, so the file's copy is load-bearing. */
29
+ readonly hostId: string;
30
+ /** The full opaque credential, exactly as the controller minted it. */
31
+ readonly credential: string;
32
+ }
33
+
34
+ /** The port the pair verb writes through and the daemon reads through — an interface for the same
35
+ * reason `TokenStore` is one: every malformed-file branch is drivable without a disk. */
36
+ export interface PairedCredentialStore {
37
+ read(): Result<PairedCredentialFile>;
38
+ write(file: PairedCredentialFile): Result<unknown>;
39
+ }
40
+
41
+ /** Validate a parsed file into the shape the daemon relies on. */
42
+ export function readPairedCredentialFile(parsed: unknown): Result<PairedCredentialFile> {
43
+ if (typeof parsed !== 'object' || parsed === null) {
44
+ return refuse('credential-cache-unreadable', 'the paired credential file is not a JSON object');
45
+ }
46
+ const record = parsed as Record<string, unknown>;
47
+ const hostId = record['hostId'];
48
+ const credential = record['credential'];
49
+
50
+ if (typeof hostId !== 'string' || hostId === '') {
51
+ return refuse('credential-cache-unreadable', 'the paired credential file names no hostId');
52
+ }
53
+ if (typeof credential !== 'string' || credential === '') {
54
+ return refuse('credential-cache-unreadable', 'the paired credential file carries no credential');
55
+ }
56
+
57
+ // The credential embeds the host id it speaks for; a file whose two copies disagree would make
58
+ // this host announce one identity and authenticate as another — the exact mismatch the
59
+ // controller's hello binding exists to refuse. Caught here, where the fix (re-pair) is printable.
60
+ if (!credential.startsWith(`p1.${hostId}.`)) {
61
+ return refuse(
62
+ 'credential-cache-unreadable',
63
+ 'the paired credential does not match the hostId beside it — the file is corrupt; run: periscope pair <code>',
64
+ );
65
+ }
66
+
67
+ return ok({ hostId, credential });
68
+ }
69
+
70
+ /**
71
+ * The `ControllerCredential` a paired host presents — on the link, the decision POST and the bulk
72
+ * POST alike, through the one spread point in the composition root.
73
+ *
74
+ * No refresh, no expiry, no reporter, and each absence is the design: the credential is a stable
75
+ * secret whose validity lives server-side, so there is exactly one outcome here and nothing to
76
+ * distinguish. Revocation surfaces as the controller's 401, which the link already classifies as
77
+ * terminal `link-unauthorized` — this class cannot see it coming and must not pretend to.
78
+ */
79
+ export class PairedHostCredential implements ControllerCredential {
80
+ readonly #value: string;
81
+
82
+ constructor(file: PairedCredentialFile) {
83
+ this.#value = `Bearer ${file.credential}`;
84
+ }
85
+
86
+ authorize(): Promise<Result<Authorization>> {
87
+ return Promise.resolve(ok({ header: AUTHORIZATION_HEADER, value: this.#value }));
88
+ }
89
+ }
@@ -0,0 +1,113 @@
1
+ /**
2
+ * PKCE (RFC 7636) and the callback's `state` — the two random values that make an authorization-code
3
+ * exchange safe to run on a machine somebody else is also running code on.
4
+ *
5
+ * They defend different things and neither substitutes for the other. This is the sentence worth
6
+ * reading twice, because "it does PKCE" is routinely taken to mean the whole callback is covered:
7
+ *
8
+ * - The code verifier proves that whoever redeems the authorization code is the same party
9
+ * that requested it. It protects the code exchange.
10
+ * - The state proves that the callback this host received is the answer to the request this
11
+ * host made. It protects the callback itself.
12
+ *
13
+ * Why that matters acutely here, rather than as boilerplate. This host's whole premise is that
14
+ * the agent runs as the same OS user as the host — it is why an 0600 token file is not a boundary
15
+ * and why the gate has a credential-path denial. That same fact means any local process can
16
+ * connect to the loopback listener this flow opens. Without `state`, such a process could hand this
17
+ * host an authorization code of its own obtaining and have the host redeem it and cache the
18
+ * resulting token. `state` is what makes that a named refusal instead of a silent substitution.
19
+ */
20
+ import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
21
+
22
+ import type { Result } from '../core/result.js';
23
+ import { ok, refuse } from '../core/result.js';
24
+
25
+ /**
26
+ * The only challenge method this host will use.
27
+ *
28
+ * RFC 7636 also defines `plain`, where the challenge is the verifier. This host refuses it rather
29
+ * than merely not offering it — an unused branch is one a later reader restores on a compatibility
30
+ * report, and `plain` gives away the whole property to anyone who can observe the authorization
31
+ * request.
32
+ */
33
+ export const CODE_CHALLENGE_METHOD = 'S256';
34
+
35
+ /** 32 bytes, which is 43 base64url characters — the RFC's floor is 43 and its ceiling is 128. */
36
+ const ENTROPY_BYTES = 32;
37
+
38
+ export interface Pkce {
39
+ readonly verifier: string;
40
+ readonly challenge: string;
41
+ readonly method: typeof CODE_CHALLENGE_METHOD;
42
+ }
43
+
44
+ /**
45
+ * base64url, per RFC 4648 §5 — no padding, URL-safe alphabet.
46
+ *
47
+ * Hand-rolled from base64 rather than reached for by name: `Buffer`'s `base64url` encoding is
48
+ * equivalent, but the transform is one line and stating it keeps this readable next to the RFC.
49
+ */
50
+ function base64Url(bytes: Buffer): string {
51
+ return bytes.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
52
+ }
53
+
54
+ /**
55
+ * A cryptographically random, URL-safe string.
56
+ *
57
+ * `randomBytes`, never `Math.random`. `Math.random` is seeded, predictable and explicitly not for
58
+ * this; a predictable verifier or state defeats the property entirely while looking identical in
59
+ * every test.
60
+ */
61
+ export function randomUrlSafe(bytes: number = ENTROPY_BYTES): string {
62
+ return base64Url(randomBytes(bytes));
63
+ }
64
+
65
+ /** A fresh verifier and its S256 challenge. */
66
+ export function createPkce(): Pkce {
67
+ const verifier = randomUrlSafe();
68
+ return { verifier, challenge: challengeFor(verifier), method: CODE_CHALLENGE_METHOD };
69
+ }
70
+
71
+ /** The S256 challenge for a verifier: base64url(SHA-256(ascii(verifier))). */
72
+ export function challengeFor(verifier: string): string {
73
+ return base64Url(createHash('sha256').update(verifier, 'ascii').digest());
74
+ }
75
+
76
+ /**
77
+ * The `state` value for one authorization request.
78
+ *
79
+ * Same entropy source as the verifier and deliberately a separate value — reusing the verifier as
80
+ * state would put it in a URL the browser and the provider both log, and the verifier is the one
81
+ * that must never leave this process until the token exchange.
82
+ */
83
+ export function createState(): string {
84
+ return randomUrlSafe();
85
+ }
86
+
87
+ /**
88
+ * Refuse a challenge method this host will not use.
89
+ *
90
+ * Exported so the refusal is reachable and testable, rather than being an `if` nobody can drive.
91
+ */
92
+ export function requireS256(method: string): Result<typeof CODE_CHALLENGE_METHOD> {
93
+ if (method !== CODE_CHALLENGE_METHOD) {
94
+ return refuse(
95
+ 'pkce-method-unsupported',
96
+ `code challenge method ${method} is refused; this host uses ${CODE_CHALLENGE_METHOD} only, and "plain" would put the verifier itself in the authorization request`,
97
+ );
98
+ }
99
+ return ok(CODE_CHALLENGE_METHOD);
100
+ }
101
+
102
+ /**
103
+ * Compare two `state` values without leaking their contents through timing.
104
+ *
105
+ * The length check is separate and deliberate: `timingSafeEqual` throws on unequal lengths, so
106
+ * comparing lengths first is required rather than an optimisation. It leaks the length and nothing
107
+ * else, which is the standard accepted residual.
108
+ */
109
+ export function stateMatches(expected: string, received: string): boolean {
110
+ const a = Buffer.from(expected, 'utf8');
111
+ const b = Buffer.from(received, 'utf8');
112
+ return a.length === b.length && a.length > 0 && timingSafeEqual(a, b);
113
+ }