@naswerks/periscope 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 (230) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/LICENSE +21 -0
  3. package/README.md +146 -0
  4. package/SECURITY.md +315 -0
  5. package/dist/bin/command.d.ts +97 -0
  6. package/dist/bin/command.js +141 -0
  7. package/dist/bin/config.d.ts +7 -0
  8. package/dist/bin/config.js +90 -0
  9. package/dist/bin/login.d.ts +48 -0
  10. package/dist/bin/login.js +82 -0
  11. package/dist/bin/main.d.ts +33 -0
  12. package/dist/bin/main.js +173 -0
  13. package/dist/bin/pair.d.ts +28 -0
  14. package/dist/bin/pair.js +198 -0
  15. package/dist/bin/periscope.d.ts +3 -0
  16. package/dist/bin/periscope.js +4 -0
  17. package/dist/bin/posture.d.ts +64 -0
  18. package/dist/bin/posture.js +157 -0
  19. package/dist/bin/reconfigure.d.ts +64 -0
  20. package/dist/bin/reconfigure.js +195 -0
  21. package/dist/bin/serve.d.ts +103 -0
  22. package/dist/bin/serve.js +441 -0
  23. package/dist/bin/status.d.ts +11 -0
  24. package/dist/bin/status.js +49 -0
  25. package/dist/bin/workspaces.d.ts +109 -0
  26. package/dist/bin/workspaces.js +215 -0
  27. package/dist/control/backoff.d.ts +25 -0
  28. package/dist/control/backoff.js +20 -0
  29. package/dist/control/codec.d.ts +15 -0
  30. package/dist/control/codec.js +501 -0
  31. package/dist/control/credential.d.ts +23 -0
  32. package/dist/control/credential.js +8 -0
  33. package/dist/control/frames.d.ts +1180 -0
  34. package/dist/control/frames.js +441 -0
  35. package/dist/control/link-state.d.ts +32 -0
  36. package/dist/control/link-state.js +39 -0
  37. package/dist/control/link.d.ts +109 -0
  38. package/dist/control/link.js +736 -0
  39. package/dist/control/queue.d.ts +110 -0
  40. package/dist/control/queue.js +174 -0
  41. package/dist/control/seq.d.ts +49 -0
  42. package/dist/control/seq.js +52 -0
  43. package/dist/control/stream-routing.d.ts +200 -0
  44. package/dist/control/stream-routing.js +167 -0
  45. package/dist/control/stream.d.ts +43 -0
  46. package/dist/control/stream.js +64 -0
  47. package/dist/core/async-queue.d.ts +26 -0
  48. package/dist/core/async-queue.js +63 -0
  49. package/dist/core/failure.d.ts +22 -0
  50. package/dist/core/failure.js +53 -0
  51. package/dist/core/index.d.ts +18 -0
  52. package/dist/core/index.js +7 -0
  53. package/dist/core/keyed-turns.d.ts +28 -0
  54. package/dist/core/keyed-turns.js +44 -0
  55. package/dist/core/paths.d.ts +25 -0
  56. package/dist/core/paths.js +77 -0
  57. package/dist/core/refusal.d.ts +43 -0
  58. package/dist/core/refusal.js +399 -0
  59. package/dist/core/result.d.ts +22 -0
  60. package/dist/core/result.js +16 -0
  61. package/dist/core/time.d.ts +19 -0
  62. package/dist/core/time.js +25 -0
  63. package/dist/core/vocab.d.ts +23 -0
  64. package/dist/core/vocab.js +37 -0
  65. package/dist/core/workspace-id.d.ts +12 -0
  66. package/dist/core/workspace-id.js +12 -0
  67. package/dist/gate/command.d.ts +136 -0
  68. package/dist/gate/command.js +551 -0
  69. package/dist/gate/decision.d.ts +111 -0
  70. package/dist/gate/decision.js +97 -0
  71. package/dist/gate/escalate.d.ts +82 -0
  72. package/dist/gate/escalate.js +92 -0
  73. package/dist/gate/gate.d.ts +173 -0
  74. package/dist/gate/gate.js +417 -0
  75. package/dist/gate/index.d.ts +39 -0
  76. package/dist/gate/index.js +31 -0
  77. package/dist/gate/jail.d.ts +92 -0
  78. package/dist/gate/jail.js +131 -0
  79. package/dist/gate/local.d.ts +69 -0
  80. package/dist/gate/local.js +51 -0
  81. package/dist/gate/outcome.d.ts +72 -0
  82. package/dist/gate/outcome.js +91 -0
  83. package/dist/gate/shell.d.ts +36 -0
  84. package/dist/gate/shell.js +226 -0
  85. package/dist/host/agent-process.d.ts +452 -0
  86. package/dist/host/agent-process.js +382 -0
  87. package/dist/host/bulk-post.d.ts +44 -0
  88. package/dist/host/bulk-post.js +114 -0
  89. package/dist/host/claude-transcripts.d.ts +84 -0
  90. package/dist/host/claude-transcripts.js +339 -0
  91. package/dist/host/config-file.d.ts +60 -0
  92. package/dist/host/config-file.js +216 -0
  93. package/dist/host/git-facts.d.ts +7 -0
  94. package/dist/host/git-facts.js +106 -0
  95. package/dist/host/hooks.d.ts +56 -0
  96. package/dist/host/hooks.js +75 -0
  97. package/dist/host/host.d.ts +356 -0
  98. package/dist/host/host.js +1294 -0
  99. package/dist/host/index.d.ts +35 -0
  100. package/dist/host/index.js +22 -0
  101. package/dist/host/link-state-file.d.ts +18 -0
  102. package/dist/host/link-state-file.js +66 -0
  103. package/dist/host/loopback.d.ts +38 -0
  104. package/dist/host/loopback.js +122 -0
  105. package/dist/host/machine.d.ts +9 -0
  106. package/dist/host/machine.js +19 -0
  107. package/dist/host/mcp-server.d.ts +11 -0
  108. package/dist/host/mcp-server.js +48 -0
  109. package/dist/host/package-facts.d.ts +3 -0
  110. package/dist/host/package-facts.js +26 -0
  111. package/dist/host/paired-credential-store.d.ts +32 -0
  112. package/dist/host/paired-credential-store.js +112 -0
  113. package/dist/host/paths.d.ts +80 -0
  114. package/dist/host/paths.js +165 -0
  115. package/dist/host/repository-read.d.ts +33 -0
  116. package/dist/host/repository-read.js +201 -0
  117. package/dist/host/session-store.d.ts +53 -0
  118. package/dist/host/session-store.js +100 -0
  119. package/dist/host/sign-in.d.ts +90 -0
  120. package/dist/host/sign-in.js +239 -0
  121. package/dist/host/telemetry.d.ts +42 -0
  122. package/dist/host/telemetry.js +74 -0
  123. package/dist/host/token-cache.d.ts +62 -0
  124. package/dist/host/token-cache.js +185 -0
  125. package/dist/host/transcript-fs.d.ts +4 -0
  126. package/dist/host/transcript-fs.js +104 -0
  127. package/dist/host/wire-request.d.ts +80 -0
  128. package/dist/host/wire-request.js +196 -0
  129. package/dist/host/workspace-fs.d.ts +14 -0
  130. package/dist/host/workspace-fs.js +84 -0
  131. package/dist/host/workspace-trust.d.ts +23 -0
  132. package/dist/host/workspace-trust.js +80 -0
  133. package/dist/identity/authorize.d.ts +59 -0
  134. package/dist/identity/authorize.js +72 -0
  135. package/dist/identity/config.d.ts +98 -0
  136. package/dist/identity/config.js +130 -0
  137. package/dist/identity/credential.d.ts +91 -0
  138. package/dist/identity/credential.js +76 -0
  139. package/dist/identity/device-code.d.ts +115 -0
  140. package/dist/identity/device-code.js +134 -0
  141. package/dist/identity/index.d.ts +33 -0
  142. package/dist/identity/index.js +10 -0
  143. package/dist/identity/mode.d.ts +106 -0
  144. package/dist/identity/mode.js +69 -0
  145. package/dist/identity/paired-credential.d.ts +52 -0
  146. package/dist/identity/paired-credential.js +43 -0
  147. package/dist/identity/pkce.d.ts +50 -0
  148. package/dist/identity/pkce.js +94 -0
  149. package/dist/identity/store.d.ts +43 -0
  150. package/dist/identity/store.js +43 -0
  151. package/dist/identity/token.d.ts +58 -0
  152. package/dist/identity/token.js +149 -0
  153. package/dist/index.d.ts +45 -0
  154. package/dist/index.js +53 -0
  155. package/dist/mcp/descriptor.d.ts +59 -0
  156. package/dist/mcp/descriptor.js +14 -0
  157. package/dist/mcp/index.d.ts +14 -0
  158. package/dist/mcp/index.js +4 -0
  159. package/dist/mcp/schema.d.ts +37 -0
  160. package/dist/mcp/schema.js +175 -0
  161. package/dist/mcp/server.d.ts +107 -0
  162. package/dist/mcp/server.js +66 -0
  163. package/dist/persistence/entry.d.ts +74 -0
  164. package/dist/persistence/entry.js +105 -0
  165. package/dist/persistence/index.d.ts +22 -0
  166. package/dist/persistence/index.js +8 -0
  167. package/dist/persistence/key.d.ts +46 -0
  168. package/dist/persistence/key.js +33 -0
  169. package/dist/persistence/mirror.d.ts +71 -0
  170. package/dist/persistence/mirror.js +57 -0
  171. package/dist/persistence/receipt.d.ts +111 -0
  172. package/dist/persistence/receipt.js +85 -0
  173. package/dist/persistence/retention.d.ts +68 -0
  174. package/dist/persistence/retention.js +68 -0
  175. package/dist/persistence/store.d.ts +106 -0
  176. package/dist/persistence/store.js +86 -0
  177. package/dist/persistence/transition-log.d.ts +73 -0
  178. package/dist/persistence/transition-log.js +133 -0
  179. package/dist/protocol.d.ts +27 -0
  180. package/dist/protocol.js +12 -0
  181. package/dist/sessions/index.d.ts +8 -0
  182. package/dist/sessions/index.js +4 -0
  183. package/dist/sessions/registry.d.ts +186 -0
  184. package/dist/sessions/registry.js +190 -0
  185. package/dist/sessions/session.d.ts +178 -0
  186. package/dist/sessions/session.js +288 -0
  187. package/dist/sessions/spawn-env.d.ts +88 -0
  188. package/dist/sessions/spawn-env.js +277 -0
  189. package/dist/state/coverage.d.ts +308 -0
  190. package/dist/state/coverage.js +315 -0
  191. package/dist/state/index.d.ts +12 -0
  192. package/dist/state/index.js +7 -0
  193. package/dist/state/machine.d.ts +112 -0
  194. package/dist/state/machine.js +237 -0
  195. package/dist/state/model.d.ts +233 -0
  196. package/dist/state/model.js +214 -0
  197. package/dist/state/observer.d.ts +55 -0
  198. package/dist/state/observer.js +413 -0
  199. package/dist/state/reporter.d.ts +45 -0
  200. package/dist/state/reporter.js +35 -0
  201. package/dist/state/store.d.ts +56 -0
  202. package/dist/state/store.js +120 -0
  203. package/dist/telemetry/index.d.ts +11 -0
  204. package/dist/telemetry/index.js +2 -0
  205. package/dist/telemetry/usage.d.ts +78 -0
  206. package/dist/telemetry/usage.js +69 -0
  207. package/dist/workspace/git-worktree.d.ts +150 -0
  208. package/dist/workspace/git-worktree.js +417 -0
  209. package/dist/workspace/index.d.ts +9 -0
  210. package/dist/workspace/index.js +4 -0
  211. package/dist/workspace/plain-dir.d.ts +34 -0
  212. package/dist/workspace/plain-dir.js +90 -0
  213. package/dist/workspace/provider.d.ts +152 -0
  214. package/dist/workspace/provider.js +2 -0
  215. package/dist/workspace/worktree-porcelain.d.ts +29 -0
  216. package/dist/workspace/worktree-porcelain.js +100 -0
  217. package/docs/architecture.md +277 -0
  218. package/docs/configuration.md +187 -0
  219. package/docs/gate.md +219 -0
  220. package/docs/identity.md +107 -0
  221. package/docs/protocol.md +501 -0
  222. package/docs/state-machine.md +160 -0
  223. package/examples/README.md +141 -0
  224. package/examples/minimal-controller/controller.ts +38 -0
  225. package/examples/parallel-run-proof/permission-mode-probe.ts +129 -0
  226. package/examples/parallel-run-proof/run.ts +850 -0
  227. package/examples/test-controller/controller.ts +655 -0
  228. package/examples/test-controller/serve.ts +29 -0
  229. package/examples/tsconfig.json +30 -0
  230. package/package.json +98 -0
@@ -0,0 +1,187 @@
1
+ # Configuration
2
+
3
+ Everything the host reads at start, where it comes from, and what it prints. The short version: a
4
+ paired host needs nothing set by hand. `periscope pair <code> --controller <origin>` writes the two
5
+ addresses the controller answers with, and `periscope` reads them. Everything below is for a host
6
+ set up by a supervisor's environment, for sessions that need a repository and a worktree each, and
7
+ for the questions a running host answers.
8
+
9
+ A setting arrives one of three ways, and the order is the rule:
10
+
11
+ 1. **The environment** always wins, per key.
12
+ 2. **The config file** (`<config dir>/config.json`) fills absences. `periscope config <key> <value>`
13
+ writes one key, `periscope config --unset <key>` removes it, `periscope config` lists the file and
14
+ marks every value the environment is currently overriding. On Windows this is the way to set a
15
+ value that should outlive the shell. Only the eight keys marked "config-file key" below may live
16
+ in the file; a value the host would refuse at start (an `https:` controller address, a `ws:`
17
+ decision address, a relative root) is refused by `periscope config` before it is written.
18
+ 3. **The controller, over the link** (`host_configure`) writes six of those keys to the file; see
19
+ [what a controller can set](#what-a-controller-can-set-over-the-link).
20
+
21
+ ## The two addresses
22
+
23
+ `PERISCOPE_CONTROLLER_URL` is the WebSocket the host dials (`ws:` or `wss:`).
24
+ `PERISCOPE_DECISION_URL` is the HTTP endpoint it POSTs every permission decision to (`http:` or
25
+ `https:`). Both are required by `serve`; a host with nowhere to send a decision would be an open
26
+ door or a session where nothing runs, so it refuses to start instead. `pair` writes both when the
27
+ controller's answer names them, which every controller built on the reference does.
28
+
29
+ A controller on a development certificate (a local build serving `https://localhost:…`) is refused by
30
+ Node's certificate check like any other self-signed server: `pair` reports the certificate by its
31
+ code (`DEPTH_ZERO_SELF_SIGNED_CERT`), and `serve` reports it on the link. Export the certificate
32
+ as PEM and point Node at the file with `NODE_EXTRA_CA_CERTS`, set for the user so every new terminal
33
+ carries it. For the ASP.NET Core development certificate:
34
+
35
+ ```powershell
36
+ dotnet dev-certs https --export-path "$env:USERPROFILE\.periscope\aspnet-dev.pem" --format PEM --no-password
37
+ [Environment]::SetEnvironmentVariable('NODE_EXTRA_CA_CERTS', "$env:USERPROFILE\.periscope\aspnet-dev.pem", 'User')
38
+ ```
39
+
40
+ ```sh
41
+ dotnet dev-certs https --export-path ~/.periscope/aspnet-dev.pem --format PEM --no-password
42
+ export NODE_EXTRA_CA_CERTS=~/.periscope/aspnet-dev.pem # in the shell profile
43
+ ```
44
+
45
+ The export writes the private key beside the certificate (`aspnet-dev.key`); delete it, only the
46
+ certificate is read. Then run `pair` and `serve` in a new terminal. Node's `--use-system-ca` does not accept the
47
+ development certificate even when the operating system trusts it. `NODE_TLS_REJECT_UNAUTHORIZED=0`
48
+ also works and trusts every certificate the host meets; prefer the file.
49
+
50
+ ## Where sessions run
51
+
52
+ Which directory a session runs in is the one choice worth making deliberately:
53
+
54
+ | Roots set | Where a session runs |
55
+ | -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
56
+ | neither | the `cwd` the controller names, verbatim; a `session_new` with `cwd: null` is refused |
57
+ | `PERISCOPE_WORKSPACE_ROOT` | a plain directory beneath it, one per workspace key (the session key unless the controller names one) |
58
+ | `PERISCOPE_WORKSPACE_ROOT` + `PERISCOPE_REPOSITORY_ROOT` | a linked git worktree of the repository on its own branch (`PERISCOPE_BRANCH_SCHEME`, default `{repo}/{key}`); needs git |
59
+
60
+ From a terminal on the host:
61
+
62
+ ```sh
63
+ periscope config PERISCOPE_REPOSITORY_ROOT /srv/checkouts/my-repo
64
+ periscope config PERISCOPE_WORKSPACE_ROOT /srv/workspaces
65
+ ```
66
+
67
+ Restart the host; `periscope status` reports `workspace git-worktree` and the branch scheme. A
68
+ controller can set the same two keys over the link (`host_configure`) and the host rebuilds its
69
+ workspace provider live, except while a session is open or opening.
70
+
71
+ ## Environment
72
+
73
+ | Variable | Meaning |
74
+ | ------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
75
+ | `PERISCOPE_CONTROLLER_URL` | Required by `serve`. The WebSocket URL the host dials (`ws:`/`wss:`). Config-file key; settable over the link, in effect at the next start. |
76
+ | `PERISCOPE_DECISION_URL` | Required by `serve`. The HTTP endpoint the host POSTs each permission escalation to (`http:`/`https:`; settable over the link, in effect at the next start). Its origin is also where `pair` derives the redemption URL when `PERISCOPE_PAIR_URL` is unset. Config-file key. |
77
+ | `PERISCOPE_HOST_ID` | The id announced in `link_hello`. Defaults to the machine hostname (an empty value counts as unset); a paired credential's host id overrides it, and the override is logged when the two differ. Config-file key. |
78
+ | `PERISCOPE_WORKSPACE_ROOT` | When set, every session gets a directory beneath it instead of the `cwd` the controller named. Config-file key. |
79
+ | `PERISCOPE_REPOSITORY_ROOT` | With `PERISCOPE_WORKSPACE_ROOT`, sessions get a linked git worktree of this repository on their own branch instead of a plain directory. Config-file key. |
80
+ | `PERISCOPE_BRANCH_SCHEME` | The branch-name template those worktrees use; placeholders are `{key}` and `{repo}` (the repository directory's name). Default `{repo}/{key}`. Needs both roots. Screened at start-up. Config-file key. |
81
+ | `PERISCOPE_WORKSPACE_KEY` | The workspace key an unkeyed `session_new` provisions at. Needs `PERISCOPE_WORKSPACE_ROOT`. Screened at start-up. Config-file key. |
82
+ | `PERISCOPE_AGENT_HOME` | The agent's home: the folder the agent CLI keeps its state in. Transcripts are read from `<home>/projects` (derived, reported in the hello, never set on its own). Default: the CLI's own, `<user home>/.claude`. Must be absolute. Config-file key. |
83
+ | `PERISCOPE_CONFIG_DIR` | The directory holding the token cache, the paired credential and the config file. Default `<home>/.periscope`, where home is `USERPROFILE` or `HOME`. The whole directory is in the gate's protected set. Deliberately not a config-file key. |
84
+ | `PERISCOPE_PAIR_URL` | Where `periscope pair` redeems a code when no `--controller` is given. Default: the origin of `PERISCOPE_DECISION_URL` plus the controller's pair route. |
85
+ | `PERISCOPE_MACHINE_LABEL` | The label sent with a pair request when no `--label` is given, shown in the controller's listings. Default: the hostname. |
86
+ | `PERISCOPE_IDENTITY_AUTHORITY` | The OIDC issuer URL; https is required, `localhost` included. With `PERISCOPE_IDENTITY_CLIENT_ID` it enables identity; one without the other refuses start. |
87
+ | `PERISCOPE_IDENTITY_CLIENT_ID` | The public-client id registered with the provider. |
88
+ | `PERISCOPE_IDENTITY_SCOPES` | Space- or comma-separated scopes. Default `openid profile offline_access`. Set to nothing, it refuses start. |
89
+ | `PERISCOPE_IDENTITY_AUTHORIZE_URL` | The authorization endpoint. Set together with `PERISCOPE_IDENTITY_TOKEN_URL` or not at all; when both are unset they are discovered from the authority. |
90
+ | `PERISCOPE_IDENTITY_TOKEN_URL` | The token endpoint. Same rule as above. |
91
+ | `PERISCOPE_IDENTITY_DEVICE_CODE_URL` | The device-authorization endpoint. Read only when the two endpoints above are set; on its own it is ignored. |
92
+ | `PERISCOPE_IDENTITY_REDIRECT_PORT` | The loopback listener's port, an integer 0-65535. Default 0 (an ephemeral port). |
93
+ | `PERISCOPE_IDENTITY_DEVICE_CODE` | `1` enables the device-code fallback. Off otherwise, and never reached by falling back. |
94
+ | `PERISCOPE_LIVE` | Tests only. `1` runs the probes that need a real agent (`npm run test:live`). |
95
+ | `PERISCOPE_PROOF_OUT` | Examples only. A file path the parallel-run proof writes its full report to. |
96
+ | `PERISCOPE_FC_SEED` | Tests only. The property-test seed: a number reproduces a reported failure, `random` explores. |
97
+ | `PERISCOPE_UPDATE_CONTRACTS` | Tests only. Set by `npm run contracts:update` while it re-approves the snapshots under `contracts/`. |
98
+
99
+ `CLAUDE_CONFIG_DIR`, when set, and `PERISCOPE_AGENT_HOME`, when it is not the default, are added to
100
+ the gate's protected paths; the full set is listed in [the gate](gate.md).
101
+
102
+ ## What a controller can set over the link
103
+
104
+ `host_configure` accepts six of the config-file keys: `PERISCOPE_WORKSPACE_ROOT`,
105
+ `PERISCOPE_REPOSITORY_ROOT`, `PERISCOPE_BRANCH_SCHEME`, `PERISCOPE_AGENT_HOME`,
106
+ `PERISCOPE_CONTROLLER_URL`, `PERISCOPE_DECISION_URL`. The host screens the whole set before writing,
107
+ refuses a change to a workspace root while any session is live or opening, and answers with the
108
+ effective values plus the keys its environment shadows. The two addresses are written but never
109
+ applied to the live link: the host keeps dialling what it dialled, names them as pending in its
110
+ answer and in every hello, and the next start reads the file. The host id is never settable over the
111
+ link. A controller can also list one directory or read the head of one text file under the
112
+ repository root (`repository_list` / `repository_read`), jailed to that root and to the protected
113
+ set, bounded, and text-only. [The wire protocol](protocol.md) states both doors.
114
+
115
+ ## What the host prints
116
+
117
+ Every line of output is `<ISO timestamp> [channel] message`, with a detail after a dash when there
118
+ is one. The first lines of a host with no identity configured look like this:
119
+
120
+ ```
121
+ 2026-09-08T12:00:00.000Z [host] periscope 1.0.0 · host build-box · credential absent · workspace none · config file /home/agent/.periscope/config.json
122
+ 2026-09-08T12:00:00.000Z [credential] absent - no identity is configured, so this host will dial without authentication
123
+ 2026-09-08T12:00:00.010Z [link] idle -> connecting (start_requested)
124
+ 2026-09-08T12:00:00.250Z [link] connecting -> open (socket_connected)
125
+ 2026-09-08T12:00:00.310Z [link] open -> accepted (hello_completed)
126
+ ```
127
+
128
+ A paired host prints `[credential] paired as <hostId> - the paired credential is presented on every
129
+ dial` instead; a host on a signed-in token prints its first `[credential]` line (`cache-hit`,
130
+ `refreshed` or `refused`) when the first dial presents it. `open` is the socket; `accepted` is the
131
+ controller's welcome, and the link stays there. A rejected handshake, a dropped socket or a missed
132
+ heartbeat moves it to `backoff` with the cause named.
133
+
134
+ `periscope status` prints the same posture whole, from the record `serve` keeps beside the
135
+ credentials, so it answers even when the link is down and never dials: the version, the link's state
136
+ and last transition, the credential kind and its expiry or none, the host id and whether the paired
137
+ credential overrides the configured one, the workspace mode and branch scheme, the config file's
138
+ path, and every setting with the source it came from (environment, config file, default, or unset).
139
+
140
+ ## Why the host refuses to start
141
+
142
+ With the reason on stderr and a non-zero exit, when:
143
+
144
+ - the effective uid is 0 (an unattended agent as root has the whole machine on every tool call, and
145
+ a container built the obvious way runs as root; refused by policy, before anything else is read);
146
+ - the config file exists but is not usable (not JSON, not one object of strings, or carrying a key
147
+ outside the closed set);
148
+ - `PERISCOPE_CONTROLLER_URL` is unset, or `PERISCOPE_DECISION_URL` is unset, or either carries a
149
+ scheme it cannot use;
150
+ - the workspace posture is inconsistent: `PERISCOPE_WORKSPACE_KEY` without `PERISCOPE_WORKSPACE_ROOT`,
151
+ `PERISCOPE_BRANCH_SCHEME` without both roots, a scheme with an unknown placeholder or an unmatched
152
+ brace, a scheme whose literal text renders an illegal branch name, a relative root or agent home,
153
+ or a default key that fails the same screen a wire-supplied key must pass;
154
+ - a paired-credential file exists but cannot be read;
155
+ - the identity configuration is partial or wrong: one of the authority/client-id pair without the
156
+ other, a non-https authority, one of the authorize/token endpoints without the other, a redirect
157
+ port outside 0-65535, or a scopes variable set to nothing;
158
+ - identity is configured but there is nowhere to keep the token cache (no home directory and no
159
+ `PERISCOPE_CONFIG_DIR`).
160
+
161
+ After start-up, one link event is fatal: `credential_rejected`. The controller or its identity
162
+ provider has refused the host's material, so the process stops its sessions, prints the remedy
163
+ (`periscope pair <code>` for a paired host, `periscope login` otherwise) and exits non-zero.
164
+
165
+ ## Running unattended
166
+
167
+ Run the host under a supervisor (systemd, a Windows service, a container's init) and let it restart.
168
+ It exits 0 on SIGTERM or SIGINT after ending its sessions, and 1 when its credential is refused
169
+ (`credential_rejected`, above) or when its configuration refuses at start; every other failure is
170
+ retried with backoff, forever, and `periscope status` says where it is. In a container, do not run
171
+ as root, `exec` the process so it receives SIGTERM, and keep the shebang LF (`SECURITY.md` says why
172
+ each fails confusingly otherwise).
173
+
174
+ ## Two constraints, before you deploy
175
+
176
+ **One controller, one replica.** The sequence and retention model assumes one controller process on
177
+ the other end of the link: sequence numbers are dense per session per direction, a frame is held
178
+ until that controller acks it, and the host-scoped channel is numbered per link. Two replicas behind
179
+ one address would each see the other's frames as gaps. Run one controller instance per host.
180
+
181
+ **The handshake is a protocol version window.** The host's `link_hello` carries `protocolRange`
182
+ beside `protocolVersion`; the controller answers with the version it chose inside the overlap, and
183
+ the host accepts any version in its own window. From the next protocol bump on, the version before
184
+ the current one stays supported for one release, so a controller and a host one release apart still
185
+ connect and either can upgrade first. A controller outside the window is refused by name
186
+ (`protocol_version_rejected`, naming both windows) and the host retries with backoff, forever, until
187
+ one side moves.
package/docs/gate.md ADDED
@@ -0,0 +1,219 @@
1
+ # The gate
2
+
3
+ The permission mechanism: `options.hooks.PreToolUse`, fail-closed on every path, an offline local
4
+ refusal ahead of it, and a hold that makes "who is waiting on a decision" answerable. How the gate
5
+ is composed onto a session is in [architecture.md](architecture.md); how a decision travels is in
6
+ [protocol.md](protocol.md).
7
+
8
+ The invariant is absolute: no answer, any error, any timeout, any outage, any unrecognised decision
9
+ means the tool does not run. Everything downstream is designed around trusting that sentence, and
10
+ the SDK's default falsifies it: a hook handler that throws is treated by the CLI as absent, not as
11
+ a denial, so under `bypassPermissions` a bug in the gate's own code would be an open door.
12
+
13
+ ## Why `PreToolUse`, never `canUseTool`
14
+
15
+ The hook fires on every tool call, MCP tools and subagent-internal calls included, carrying
16
+ `agent_id` and `agent_type`. `canUseTool` is skipped whenever a settings-file allow rule,
17
+ `options.allowedTools` or `bypassPermissions` covers the call, and those are embedder-chosen
18
+ configurations, so a hold built on `canUseTool` silently does not fire on somebody else's setup.
19
+ `Decision` is shaped as the SDK's `PermissionResult` field for field and the decider is an injected
20
+ async function, so wiring the `canUseTool` durable-hold lane later is a wiring job, not a redesign.
21
+ The SDK ships no `PermissionResultDeny` type; the deny shape is
22
+ `{ behavior: 'deny'; message: string; interrupt?: boolean }`.
23
+
24
+ ## Fail-closed, in layers
25
+
26
+ - Every handler path returns an explicit `permissionDecision: 'deny'` on error. That is the
27
+ difference between fail-closed and fail-open, not defensive style.
28
+ - A recovery path must not use the thing that broke. `readRequest` is total and returns `null`
29
+ rather than a filled-in default: an unnameable call is not an authorizable one, so it blocks. A
30
+ catch that re-read the hook input would throw inside the catch when reading the input is what
31
+ threw, and the handler would escape as absent.
32
+ - A negative control against this invariant must remove every defence, or write a case only the
33
+ outer one can satisfy. Removing one redundant defence leaves the suite green and measures the
34
+ redundancy, not the invariant. Validate such a control by which tests go red, never by how many.
35
+ - An allow returns no opinion unless `grantOnAllow` is set (below). Without it the gate only ever
36
+ adds a refusal, and allow output exists solely to carry `updatedInput`.
37
+
38
+ ## What an explicit allow skips
39
+
40
+ The Claude Code documentation states that deny and ask rules are evaluated regardless of a hook's
41
+ allow, and that an explicit allow skips only the permission mode, the allow rules and `canUseTool`
42
+ (<https://code.claude.com/docs/en/permissions#extend-permissions-with-hooks>). The operator's deny
43
+ and ask rules survive it.
44
+
45
+ That claim is documented, not measured, and the package's standing rule is that the runtime beats
46
+ the documentation. The probe that would settle it is written in `gate.live.test.ts` (does a hook
47
+ allow override an operator deny rule) and is not exercised: run from inside an agent session it is
48
+ contaminated by the enclosing harness's tool surface, so it must be run on a machine that is not
49
+ itself an agent session. Do not upgrade the claim without that receipt.
50
+
51
+ ## `grantOnAllow`
52
+
53
+ `GateTimings.grantOnAllow` defaults to false, and leaving it there gives a gate that cannot say
54
+ yes. Saying nothing leaves the agent's own permission mode as the decider, and an embedder loading
55
+ no settings files (the package default, and the only posture under which this host can state what
56
+ an agent's permissions are) has left nobody who can grant. On a real session the gate allowed a
57
+ `Write`, the tool did not run, and the model was told it had requested a permission nobody had
58
+ granted, in a host with no user to grant anything. The flag does not weaken the gate; it makes the
59
+ decision the gate already took take effect, one call at a time.
60
+
61
+ - `PeriscopeHost` sets it to true. By its own default it loads no settings files, so nothing sits
62
+ behind the gate a grant could override.
63
+ - Setting it with `settingSources` non-empty is refused by name, `permission-grant-shadows-settings`,
64
+ before any process exists. Not because a grant is a bypass (deny and ask survive it), but because
65
+ two mechanisms then answer the same question from different places with no stated precedence.
66
+ The exception is `permissionMode: 'bypassPermissions'` (`isBypassMode`): under bypass the mode
67
+ already allows everything the grant skips, so the grant changes nothing and there is no second
68
+ authority to shadow. A hook deny survives every mode. Every other mode keeps the refusal, and the
69
+ refusal reaches the controller as a `spawning` to `ended` transition ([protocol.md](protocol.md),
70
+ the refused open).
71
+ - That refusal is incomplete on its own logic, and says so: managed policy settings and
72
+ `~/.claude.json` load regardless of `settingSources`. An empty list is not proof that no operator
73
+ rule is live.
74
+ - The residual an embedder hits: composing by hand and leaving the flag off yields a gate whose
75
+ every approval silently fails to happen. The first such allow raises a `gate-cannot-grant` degrade
76
+ if `onDegrade` is passed (`composeSession` wires it from `onRefusal`, so a `PeriscopeHost` embedder
77
+ gets it); a hand-composer who passes neither gets silence. The doc comment on the field is the
78
+ primary defence.
79
+
80
+ ## The host's own gate
81
+
82
+ `localGate` (`src/gate/local.ts`) is consulted before the decider, and that ordering is the offline
83
+ property: with the controller unreachable a locally refused call is refused immediately instead of
84
+ after `DEFAULT_DECISION_TIMEOUT_MS` reported as an outage. It is synchronous and total by contract;
85
+ an asynchronous local policy would be a second place a decision can hang.
86
+
87
+ What refuses locally, and only locally: a path that escapes the session's workspace
88
+ (`path-escapes-root`), a read of the protected set (`credential-path-denied`), and a git invocation
89
+ whose verb is not on the allow-list. A boundary-crossing shell shape (`shell-boundary-command`: a
90
+ push, a force, a remote change, a branch deletion, a merge) is classified locally and then
91
+ escalated like any other call, because a controller may hold it for a person to answer; refusing it
92
+ in-process would make that answer impossible to give. With the controller unreachable it refuses at
93
+ the decision deadline.
94
+
95
+ - It adds refusals and never removes one. No opinion returns `null` and the surrounding gate asks
96
+ whoever it was going to ask. A locally-decided refusal is `refusal/<reason>`, and the fix for one
97
+ lives with the embedder's policy.
98
+ - A throwing local gate refuses; it must not fall through (`askLocalGate` in `src/gate/gate.ts`).
99
+ - The vocabulary is owned locally, never received. The wire carries no policy in either direction;
100
+ the embedder chooses tool families and protected paths at construction.
101
+ - Optional in the type, supplied by default by `PeriscopeHost`: jailed to the session's own
102
+ workspace, with `credentialPaths(env, { agentHome })` as the protected set. That set, stated once
103
+ (`src/host/paths.ts`): the host's own config directory (`PERISCOPE_CONFIG_DIR` or
104
+ `~/.periscope`, which holds the token cache, the paired credential and the config file);
105
+ `~/.claude` and `~/.claude.json`; `~/.aws`, `~/.config/gcloud`, `~/.azure`, `~/.ssh`;
106
+ `CLAUDE_CONFIG_DIR` when set; and the effective agent home when it is not the default. The
107
+ host's own cache location derives from the same function, so the two cannot disagree about what
108
+ is protected. The repository read doors honour the same set.
109
+ - Shell commands are parsed before they are judged (`src/gate/command.ts`, `src/gate/shell.ts`):
110
+ the classifier sees a parsed shape, never text. An interpreter payload (`bash -c "..."`) is
111
+ scanned as the nested command it is, with the interpreter's own delimiter closing it, so a nested
112
+ quote of the other kind and a `#` inside the payload are the nested command's own syntax.
113
+ `src/gate/shell.property.test.ts` holds totality, determinism and monotone refusal under quoting
114
+ and composition.
115
+
116
+ ## The five outcomes that must never blur
117
+
118
+ | Outcome | Cause on the transition | Fix lives with |
119
+ | -------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ | ----------------------------- |
120
+ | A deliberate denial | `control/permission_denied` | nobody; it worked |
121
+ | A locally-decided refusal, taken before anyone was asked | `refusal/<reason>` (for example `path-escapes-root`, `credential-path-denied`) | the embedder's policy |
122
+ | An outage (the decider threw, the transport failed, a non-2xx status, a non-JSON body) | `refusal/permission-decision-unavailable` | the controller's availability |
123
+ | A decision this build does not know (valid JSON, unknown `behavior`) | `refusal/permission-decision-unrecognised` | the controller's version |
124
+ | The host's own deadline passed | `timeout/hook_timed_out` | the configuration |
125
+
126
+ Beside them is one degrade, which reports on the gate's own configuration rather than on a request:
127
+ `gate-cannot-grant`, raised at most once per gate on the first allow that does not take effect. It
128
+ is a `RefusalReason` declared in `src/core/refusal.ts`, not a free string, so a degrade cannot be
129
+ invented at a call site.
130
+
131
+ The unknown decision is never an allow. Its raw payload is preserved into the emitted transition
132
+ (bounded, marked when truncated) and it is refused by the gate, not the transport:
133
+ `escalatingDecider` returns well-formed nonsense intact so the tool's fate is a policy act with a
134
+ receipt, not a parse error. Malformed JSON is an outage because version skew produces a different
135
+ decision, never a broken one. `cause.event` is `permission_denied` in `CONTROL_EVENTS`; naming the
136
+ SDK's `PermissionDenied` hook would write into the trace the name of an event that does not fire.
137
+
138
+ ## Two deadlines, the inner one the host's
139
+
140
+ - The matcher's `timeout` (per matcher, in seconds, fail-closed; `DEFAULT_MATCHER_TIMEOUT_SECONDS`)
141
+ is CLI-enforced; the handler never learns it fired, so a block would read as a hang.
142
+ - The host's `decisionTimeoutMs` (`DEFAULT_DECISION_TIMEOUT_MS`) fires first, blocks, and names the
143
+ expiry; the matcher remains the backstop for the case where this code is what hung. A decision
144
+ still open after `holdAfterMs` (`DEFAULT_HOLD_AFTER_MS`) opens the hold described below. `permissionHooks()` throws at construction
145
+ when `decisionTimeoutMs >= matcherTimeoutSeconds * 1000`; equal is refused too, because two timers
146
+ racing at the same instant is a coin flip, not an ordering.
147
+ - A controller that holds a decision for a person keeps its own budget strictly inside
148
+ `decisionTimeoutMs` and answers with a non-2xx status when the budget passes. An unanswered hold
149
+ that runs into the host's deadline reports as a timeout, and one that runs past the matcher's
150
+ reads as a hang.
151
+
152
+ A per-session `gate` in `session_new` (`decisionTimeoutMs`, `holdAfterMs`, `matcherTimeoutSeconds`)
153
+ overrides the host defaults; an inverted pair answers the named refusal `gate-deadlines-inverted`
154
+ rather than a throw, enforced in two places from one declaration (`deadlineOrderRefusal`).
155
+
156
+ ## The hold
157
+
158
+ A decision taking longer than `holdAfterMs` opens a `permission:<toolUseId>` entry through the
159
+ state machine and closes it at resolution; a synchronous allow or deny opens nothing, because an
160
+ entry opened and closed in one tick is noise in the one signal it carries. It is keyed by
161
+ `tool_use_id`, so a second `PreToolUse` for the same tool name cannot close a live hold; the
162
+ observer's `permission:<toolName>` lane is single-writer and effectively dormant (only the
163
+ never-firing `PermissionRequest` opens it). Emission rides outside the deny path's `try/catch`: a
164
+ gate that fails closed still says it did.
165
+
166
+ The hold reaches the wire. `forwardSession` subscribes to `machine.onTransition` and emits every
167
+ recorded transition whatever caused it, so a hold, a deny, an outage and an expiry are all visible
168
+ off-box. That is also why `system/hook_started` and `system/hook_response` are declined in
169
+ `MESSAGE_ROUTING`: the decision's transition already rides the wire, and forwarding the hook
170
+ messages too would invite a consumer to count one decision twice.
171
+
172
+ Register the gate after the observer: `mergeHooks(observationHooks(...), permissionHooks(...))`.
173
+ Matchers are dispatched in array order but awaited concurrently, and a gate registered first would
174
+ open a hold the observer closes in the same event. No test pins the order; it lives at both sites.
175
+
176
+ ## Escalation
177
+
178
+ `escalatingDecider()` asks the controller over HTTP and discriminates the status before reading the
179
+ body: a 500 with a parseable body must never impersonate a human "no". An `EscalationUnavailable`
180
+ keeps its own detail so an outage says which lane failed.
181
+
182
+ `DecisionRequest` carries `sessionKey`, the controller's handle for the session, so a decision can
183
+ be correlated without re-deriving it from transport state. The POST presents a credential per
184
+ request (`EscalationOptions.credential`, resolved at call time), and a refusing credential is an
185
+ outage, the deliberate opposite of the link's posture: a prompt that cannot be delivered is
186
+ retried; a gate that cannot authenticate must not proceed. Resolving per request makes this lane a
187
+ second caller of `authorize()`, so a busy session reports one `cache-hit` per decision; read a
188
+ stream of them as the gate working, never as a credential problem.
189
+
190
+ ## What is deliberately not covered
191
+
192
+ - `bypassPermissions` is composable, as the SDK's own `permissionMode` vocabulary. It travels wire
193
+ to `readSessionRequest` to `src/host/agent-process.ts` and nowhere else, and
194
+ `pins/permission-config.test.ts` holds three things: no settings-file or rule-list option
195
+ anywhere, `permissionMode` only on that path, and `setPermissionMode` called from one module (by
196
+ `session_configure`) while the other mid-session mutators stay uncalled. The gate keeps its
197
+ authority through `PreToolUse` in every mode; the package's own measurement of a hook deny
198
+ surviving bypass is the unexercised probe above.
199
+ - `permissionDecision: 'defer'` was evaluated and not adopted: it ends the query for a later
200
+ resume, a different lifecycle from "hold, then continue with everything you knew".
201
+ - `outputFor` cannot render a hold: `holding` is excluded by the type, not by a branch. A branch
202
+ that returned `{}` for it would be no opinion, which the CLI reads as allow, so the one state that
203
+ could open the door is made unrepresentable rather than handled.
204
+
205
+ ## Where things live
206
+
207
+ | Path | What |
208
+ | ------------------------------------------ | ----------------------------------------------------------------------------------------------------------------- |
209
+ | `src/gate/gate.ts` | `permissionHooks()`: registration, the fail-closed wrappers, both deadlines, the hold |
210
+ | `src/gate/decision.ts` | `Decision` (the SDK's `PermissionResult`) and `readDecision`, the total parser |
211
+ | `src/gate/outcome.ts` | `GateOutcome` (`holding`, `allow`, `deny`, `refused`, `expired`) to transitions, pure; `recordGateOutcome` |
212
+ | `src/gate/escalate.ts` | `escalatingDecider()`: status before body, `EscalationUnavailable`; the HTTP lane the link does not carry |
213
+ | `src/gate/local.ts` | `localGate()`: the host's own offline refusal; pure, synchronous, total |
214
+ | `src/gate/jail.ts` | The path jail and the protected-path check; `pathFromToolInput`, `commandFromToolInput` |
215
+ | `src/gate/shell.ts`, `src/gate/command.ts` | `classifyShellCommand` over `parseCommand`'s parsed shape |
216
+ | `src/host/paths.ts` | `credentialPaths(env)` and `nodePathResolver`: the one source of the protected set and the token cache's location |
217
+ | `src/host/host.ts` | Where the gate is composed onto a session, and the `grantOnAllow` and `settingSources` refusal |
218
+ | `src/pins/permission-config.test.ts` | The shadowing-lane pin, compile and scan halves |
219
+ | `src/gate/gate.live.test.ts` | The live probes, skipped unless `PERISCOPE_LIVE=1` |
@@ -0,0 +1,107 @@
1
+ # Identity
2
+
3
+ How a host proves who it is to a controller, and what protects the material it holds. The wire
4
+ side of this (the credential on the three transports, pairing as a contract) is in
5
+ [protocol.md](protocol.md); what the gate keeps the agent away from is in [gate.md](gate.md).
6
+
7
+ A host presents one credential on every transport it uses, and there are three postures:
8
+
9
+ | Posture | What is presented | Choose it when |
10
+ | ------------- | ------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- |
11
+ | **paired** | a machine credential the controller minted (`p1.<hostId>.<secret>`) | the host runs unattended; the credential has no clock and dies only when the controller revokes it |
12
+ | **signed-in** | a real user's own access token, refreshed by the host | a person is at the machine and the controller wants a user identity, not a machine one |
13
+ | **none** | nothing; the host connects without a header and says so at start-up | the controller is reachable only from inside a network that is itself the boundary |
14
+
15
+ The daemon never signs anyone in. `periscope login` writes the token cache; `periscope pair <code>`
16
+ writes the paired credential; `serve` presents what is already there and refuses by name when it is
17
+ not. When both exist the paired credential wins: a refresh token can lapse after a period of
18
+ inactivity, and a paired credential cannot.
19
+
20
+ ## The agent's own identity is separate
21
+
22
+ The credential above authenticates the host to the controller. The agent the host spawns
23
+ authenticates to its own provider separately, through the Claude Code CLI's ambient credential
24
+ under the agent home (`~/.claude` by default). The spawn environment is an allow-list, so
25
+ `ANTHROPIC_API_KEY` and `CLAUDE_CODE_OAUTH_TOKEN` set on the host are not inherited by a session;
26
+ sign the CLI in once, as the OS user that runs the host (`claude auth login`; `claude auth status`
27
+ confirms), or pass a key to one session through `session_new.request.env.extraEnv`.
28
+
29
+ ## Pairing: the durable machine credential
30
+
31
+ A signed-in user mints a short-lived single-use code from the controller, and
32
+ `periscope pair <code> --controller <origin> --label <name>` trades it for this machine's own
33
+ credential, written beside the token cache under the config directory, presented on every dial,
34
+ preferred over the token cache when both exist.
35
+
36
+ | | |
37
+ | -------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
38
+ | **What it is** | A controller-minted bearer shaped `p1.<hostId>.<secret>`, scoped to the user who minted the code. The shape is the contract: the host reads its own id out of it, and `pair` refuses an answer outside the shape before writing anything. A controller keeps only a hash of the secret; this machine holds the one copy |
39
+ | **Where the redemption door is** | `--controller <origin>` when given (its origin plus the controller's pair route, `/api/periscope/pair`); else `PERISCOPE_PAIR_URL` when set; else the origin of `PERISCOPE_DECISION_URL` plus that route. A 404 at the door is named as a wrong door, not as a refused code |
40
+ | **What else the answer carries** | The controller names the link and decision URLs this host should dial (`controllerUrl`, `decisionUrl`). When both arrive, `pair` writes `PERISCOPE_CONTROLLER_URL` and `PERISCOPE_DECISION_URL` to the config file beside the credential, so `serve` needs nothing else; the environment still wins per key, and the write says so when it is shadowed. A controller that names neither leaves both to you |
41
+ | **Revocation** | The controller's, per machine: it refuses the bearer at the upgrade (401 or 403) and closes the link. The host reads that as `link-unauthorized`, which is terminal: it exits non-zero naming the remedy, a fresh `periscope pair <code>` |
42
+ | **The label** | `--label <name>` names this machine in the controller's listings; `PERISCOPE_MACHINE_LABEL` when the flag is absent; the hostname otherwise |
43
+
44
+ ## Signing in as a user
45
+
46
+ The host signs in as a real user and presents that user's own access token. No provider hostname or
47
+ tenant is baked in: the authority, client id, scopes and endpoints are configuration, and a test
48
+ fails the build if a provider hostname appears in shipped code. One provider-specific error code is
49
+ recognised (`AUTH_FLOW_BLOCKED_CODES` in `src/identity/device-code.ts`), so that material a
50
+ provider's policy has blocked is discarded and named rather than refreshed forever.
51
+
52
+ | | |
53
+ | ------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
54
+ | **Primary flow** | Loopback authorization code + PKCE (`S256` only; `plain` is refused, not merely unused), the same shape `az`, `gh` and `kubectl` use. The listener binds `127.0.0.1` explicitly, accepts exactly one callback, and times out |
55
+ | **`state` is verified, always** | PKCE protects the code exchange; `state` protects the callback. They are not substitutes. The agent shares this host's OS user, so any local process can reach the loopback listener, and an unverified callback would let one hand this host a code it obtained itself |
56
+ | **Fallback** | Device code, off unless `PERISCOPE_IDENTITY_DEVICE_CODE=1`. Never reached by falling back. Some providers class it as a high-risk flow and let organisations block it by policy, and a device-code sign-in can poison the cache for any other flow, so switching flows discards rather than refreshes |
57
+ | **Cache** | One JSON file, written `0600`, verified after write. See the limit below |
58
+ | **Configuration** | `PERISCOPE_IDENTITY_AUTHORITY` + `PERISCOPE_IDENTITY_CLIENT_ID` are required together. Set neither and the host starts without identity. Set one wrongly and the host refuses to start: a typo must not degrade into "authenticating as nobody" |
59
+
60
+ ## What `0600` is, and what it is not
61
+
62
+ It is not a boundary against the agent. The agent runs as the same OS user as this host, so an
63
+ `0600` token file is readable by it exactly as it is by the host. What keeps the agent out is the
64
+ gate's credential-path denial, and that denial covers this file because `credentialPaths()` and
65
+ `tokenCachePath()` are derived from one function, not kept equal by hand.
66
+
67
+ The improvement is still real, and it is a change of order rather than degree: what this replaces
68
+ is a shared secret granting access as _every_ user. What it stores is _one user's own_ token,
69
+ expiring by itself and revocable from the provider without touching this machine.
70
+
71
+ On Windows the mode cannot be confirmed at all. Measured: a file written `0o600`, a file `chmod`ed
72
+ to `0o600`, and a deliberately world-readable one all report `0o666`. So the host probes what the
73
+ filesystem actually records and reports `credential-mode-unenforced` as a named degrade where
74
+ privacy is unconfirmable. It does not claim a privacy it cannot verify.
75
+
76
+ The probe proves it is alive before it reports a negative. `chmod 0444` _does_ read back as `444`
77
+ on Windows (the write bit is the one real bit there), so the probe takes that reading as a positive
78
+ control. Three outcomes, never two:
79
+
80
+ | | |
81
+ | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
82
+ | `enforced` | POSIX modes are honoured; the file is verified, and one wider than requested is refused |
83
+ | `write-bit-only` | Only writability is recorded, so privacy is unconfirmable. A finding |
84
+ | `unobservable` | Not even clearing the write bit changed anything: inconclusive, and reported as a different reason. A probe that cannot fail is not a measurement |
85
+
86
+ `0444` is the control, never the target: the cache stays owner-writable because refreshes are
87
+ written to it.
88
+
89
+ ## Deployment: where the app registration lives
90
+
91
+ One public-client registration with a loopback redirect, at any OIDC provider that supports the
92
+ authorization-code flow with PKCE. The host consumes it; it never creates it.
93
+
94
+ A tenant or organisation gets its own registration, and the host is pointed at it through
95
+ `PERISCOPE_IDENTITY_AUTHORITY`. That is a deployment step, not a code path, which is the reason the
96
+ authority is configuration. Some provider tenant types are single-tenant by construction, in which
97
+ case one registration cannot be shared across tenants and each deployment registers its own.
98
+
99
+ ## Where things live
100
+
101
+ | Path | What |
102
+ | ------------------------------------- | ----------------------------------------------------------------------------------------------- |
103
+ | `src/identity/` | The generic OIDC client: config, PKCE, the authorization and device-code flows, the token store |
104
+ | `src/identity/paired-credential.ts` | The paired credential's shape and the reader that refuses a file whose two copies disagree |
105
+ | `src/host/sign-in.ts` | The interactive flows the `login` verb runs; the refresher the daemon presents |
106
+ | `src/host/paths.ts` | Where the cache, the paired credential and the config file live, and the protected set |
107
+ | `src/bin/login.ts`, `src/bin/pair.ts` | The two verbs |