@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.
- package/CHANGELOG.md +33 -0
- package/LICENSE +21 -0
- package/README.md +146 -0
- package/SECURITY.md +315 -0
- package/dist/bin/command.d.ts +97 -0
- package/dist/bin/command.js +141 -0
- package/dist/bin/config.d.ts +7 -0
- package/dist/bin/config.js +90 -0
- package/dist/bin/login.d.ts +48 -0
- package/dist/bin/login.js +82 -0
- package/dist/bin/main.d.ts +33 -0
- package/dist/bin/main.js +173 -0
- package/dist/bin/pair.d.ts +28 -0
- package/dist/bin/pair.js +198 -0
- package/dist/bin/periscope.d.ts +3 -0
- package/dist/bin/periscope.js +4 -0
- package/dist/bin/posture.d.ts +64 -0
- package/dist/bin/posture.js +157 -0
- package/dist/bin/reconfigure.d.ts +64 -0
- package/dist/bin/reconfigure.js +195 -0
- package/dist/bin/serve.d.ts +103 -0
- package/dist/bin/serve.js +441 -0
- package/dist/bin/status.d.ts +11 -0
- package/dist/bin/status.js +49 -0
- package/dist/bin/workspaces.d.ts +109 -0
- package/dist/bin/workspaces.js +215 -0
- package/dist/control/backoff.d.ts +25 -0
- package/dist/control/backoff.js +20 -0
- package/dist/control/codec.d.ts +15 -0
- package/dist/control/codec.js +501 -0
- package/dist/control/credential.d.ts +23 -0
- package/dist/control/credential.js +8 -0
- package/dist/control/frames.d.ts +1180 -0
- package/dist/control/frames.js +441 -0
- package/dist/control/link-state.d.ts +32 -0
- package/dist/control/link-state.js +39 -0
- package/dist/control/link.d.ts +109 -0
- package/dist/control/link.js +736 -0
- package/dist/control/queue.d.ts +110 -0
- package/dist/control/queue.js +174 -0
- package/dist/control/seq.d.ts +49 -0
- package/dist/control/seq.js +52 -0
- package/dist/control/stream-routing.d.ts +200 -0
- package/dist/control/stream-routing.js +167 -0
- package/dist/control/stream.d.ts +43 -0
- package/dist/control/stream.js +64 -0
- package/dist/core/async-queue.d.ts +26 -0
- package/dist/core/async-queue.js +63 -0
- package/dist/core/failure.d.ts +22 -0
- package/dist/core/failure.js +53 -0
- package/dist/core/index.d.ts +18 -0
- package/dist/core/index.js +7 -0
- package/dist/core/keyed-turns.d.ts +28 -0
- package/dist/core/keyed-turns.js +44 -0
- package/dist/core/paths.d.ts +25 -0
- package/dist/core/paths.js +77 -0
- package/dist/core/refusal.d.ts +43 -0
- package/dist/core/refusal.js +399 -0
- package/dist/core/result.d.ts +22 -0
- package/dist/core/result.js +16 -0
- package/dist/core/time.d.ts +19 -0
- package/dist/core/time.js +25 -0
- package/dist/core/vocab.d.ts +23 -0
- package/dist/core/vocab.js +37 -0
- package/dist/core/workspace-id.d.ts +12 -0
- package/dist/core/workspace-id.js +12 -0
- package/dist/gate/command.d.ts +136 -0
- package/dist/gate/command.js +551 -0
- package/dist/gate/decision.d.ts +111 -0
- package/dist/gate/decision.js +97 -0
- package/dist/gate/escalate.d.ts +82 -0
- package/dist/gate/escalate.js +92 -0
- package/dist/gate/gate.d.ts +173 -0
- package/dist/gate/gate.js +417 -0
- package/dist/gate/index.d.ts +39 -0
- package/dist/gate/index.js +31 -0
- package/dist/gate/jail.d.ts +92 -0
- package/dist/gate/jail.js +131 -0
- package/dist/gate/local.d.ts +69 -0
- package/dist/gate/local.js +51 -0
- package/dist/gate/outcome.d.ts +72 -0
- package/dist/gate/outcome.js +91 -0
- package/dist/gate/shell.d.ts +36 -0
- package/dist/gate/shell.js +226 -0
- package/dist/host/agent-process.d.ts +452 -0
- package/dist/host/agent-process.js +382 -0
- package/dist/host/bulk-post.d.ts +44 -0
- package/dist/host/bulk-post.js +114 -0
- package/dist/host/claude-transcripts.d.ts +84 -0
- package/dist/host/claude-transcripts.js +339 -0
- package/dist/host/config-file.d.ts +60 -0
- package/dist/host/config-file.js +216 -0
- package/dist/host/git-facts.d.ts +7 -0
- package/dist/host/git-facts.js +106 -0
- package/dist/host/hooks.d.ts +56 -0
- package/dist/host/hooks.js +75 -0
- package/dist/host/host.d.ts +356 -0
- package/dist/host/host.js +1294 -0
- package/dist/host/index.d.ts +35 -0
- package/dist/host/index.js +22 -0
- package/dist/host/link-state-file.d.ts +18 -0
- package/dist/host/link-state-file.js +66 -0
- package/dist/host/loopback.d.ts +38 -0
- package/dist/host/loopback.js +122 -0
- package/dist/host/machine.d.ts +9 -0
- package/dist/host/machine.js +19 -0
- package/dist/host/mcp-server.d.ts +11 -0
- package/dist/host/mcp-server.js +48 -0
- package/dist/host/package-facts.d.ts +3 -0
- package/dist/host/package-facts.js +26 -0
- package/dist/host/paired-credential-store.d.ts +32 -0
- package/dist/host/paired-credential-store.js +112 -0
- package/dist/host/paths.d.ts +80 -0
- package/dist/host/paths.js +165 -0
- package/dist/host/repository-read.d.ts +33 -0
- package/dist/host/repository-read.js +201 -0
- package/dist/host/session-store.d.ts +53 -0
- package/dist/host/session-store.js +100 -0
- package/dist/host/sign-in.d.ts +90 -0
- package/dist/host/sign-in.js +239 -0
- package/dist/host/telemetry.d.ts +42 -0
- package/dist/host/telemetry.js +74 -0
- package/dist/host/token-cache.d.ts +62 -0
- package/dist/host/token-cache.js +185 -0
- package/dist/host/transcript-fs.d.ts +4 -0
- package/dist/host/transcript-fs.js +104 -0
- package/dist/host/wire-request.d.ts +80 -0
- package/dist/host/wire-request.js +196 -0
- package/dist/host/workspace-fs.d.ts +14 -0
- package/dist/host/workspace-fs.js +84 -0
- package/dist/host/workspace-trust.d.ts +23 -0
- package/dist/host/workspace-trust.js +80 -0
- package/dist/identity/authorize.d.ts +59 -0
- package/dist/identity/authorize.js +72 -0
- package/dist/identity/config.d.ts +98 -0
- package/dist/identity/config.js +130 -0
- package/dist/identity/credential.d.ts +91 -0
- package/dist/identity/credential.js +76 -0
- package/dist/identity/device-code.d.ts +115 -0
- package/dist/identity/device-code.js +134 -0
- package/dist/identity/index.d.ts +33 -0
- package/dist/identity/index.js +10 -0
- package/dist/identity/mode.d.ts +106 -0
- package/dist/identity/mode.js +69 -0
- package/dist/identity/paired-credential.d.ts +52 -0
- package/dist/identity/paired-credential.js +43 -0
- package/dist/identity/pkce.d.ts +50 -0
- package/dist/identity/pkce.js +94 -0
- package/dist/identity/store.d.ts +43 -0
- package/dist/identity/store.js +43 -0
- package/dist/identity/token.d.ts +58 -0
- package/dist/identity/token.js +149 -0
- package/dist/index.d.ts +45 -0
- package/dist/index.js +53 -0
- package/dist/mcp/descriptor.d.ts +59 -0
- package/dist/mcp/descriptor.js +14 -0
- package/dist/mcp/index.d.ts +14 -0
- package/dist/mcp/index.js +4 -0
- package/dist/mcp/schema.d.ts +37 -0
- package/dist/mcp/schema.js +175 -0
- package/dist/mcp/server.d.ts +107 -0
- package/dist/mcp/server.js +66 -0
- package/dist/persistence/entry.d.ts +74 -0
- package/dist/persistence/entry.js +105 -0
- package/dist/persistence/index.d.ts +22 -0
- package/dist/persistence/index.js +8 -0
- package/dist/persistence/key.d.ts +46 -0
- package/dist/persistence/key.js +33 -0
- package/dist/persistence/mirror.d.ts +71 -0
- package/dist/persistence/mirror.js +57 -0
- package/dist/persistence/receipt.d.ts +111 -0
- package/dist/persistence/receipt.js +85 -0
- package/dist/persistence/retention.d.ts +68 -0
- package/dist/persistence/retention.js +68 -0
- package/dist/persistence/store.d.ts +106 -0
- package/dist/persistence/store.js +86 -0
- package/dist/persistence/transition-log.d.ts +73 -0
- package/dist/persistence/transition-log.js +133 -0
- package/dist/protocol.d.ts +27 -0
- package/dist/protocol.js +12 -0
- package/dist/sessions/index.d.ts +8 -0
- package/dist/sessions/index.js +4 -0
- package/dist/sessions/registry.d.ts +186 -0
- package/dist/sessions/registry.js +190 -0
- package/dist/sessions/session.d.ts +178 -0
- package/dist/sessions/session.js +288 -0
- package/dist/sessions/spawn-env.d.ts +88 -0
- package/dist/sessions/spawn-env.js +277 -0
- package/dist/state/coverage.d.ts +308 -0
- package/dist/state/coverage.js +315 -0
- package/dist/state/index.d.ts +12 -0
- package/dist/state/index.js +7 -0
- package/dist/state/machine.d.ts +112 -0
- package/dist/state/machine.js +237 -0
- package/dist/state/model.d.ts +233 -0
- package/dist/state/model.js +214 -0
- package/dist/state/observer.d.ts +55 -0
- package/dist/state/observer.js +413 -0
- package/dist/state/reporter.d.ts +45 -0
- package/dist/state/reporter.js +35 -0
- package/dist/state/store.d.ts +56 -0
- package/dist/state/store.js +120 -0
- package/dist/telemetry/index.d.ts +11 -0
- package/dist/telemetry/index.js +2 -0
- package/dist/telemetry/usage.d.ts +78 -0
- package/dist/telemetry/usage.js +69 -0
- package/dist/workspace/git-worktree.d.ts +150 -0
- package/dist/workspace/git-worktree.js +417 -0
- package/dist/workspace/index.d.ts +9 -0
- package/dist/workspace/index.js +4 -0
- package/dist/workspace/plain-dir.d.ts +34 -0
- package/dist/workspace/plain-dir.js +90 -0
- package/dist/workspace/provider.d.ts +152 -0
- package/dist/workspace/provider.js +2 -0
- package/dist/workspace/worktree-porcelain.d.ts +29 -0
- package/dist/workspace/worktree-porcelain.js +100 -0
- package/docs/architecture.md +277 -0
- package/docs/configuration.md +187 -0
- package/docs/gate.md +219 -0
- package/docs/identity.md +107 -0
- package/docs/protocol.md +501 -0
- package/docs/state-machine.md +160 -0
- package/examples/README.md +141 -0
- package/examples/minimal-controller/controller.ts +38 -0
- package/examples/parallel-run-proof/permission-mode-probe.ts +129 -0
- package/examples/parallel-run-proof/run.ts +850 -0
- package/examples/test-controller/controller.ts +655 -0
- package/examples/test-controller/serve.ts +29 -0
- package/examples/tsconfig.json +30 -0
- 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` |
|
package/docs/identity.md
ADDED
|
@@ -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 |
|