@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
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this package are recorded here. The format follows
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow semantic versioning: patch
|
|
5
|
+
for no wire or API change, minor for a protocol bump (the previous version stays supported for one
|
|
6
|
+
minor), an agent SDK pin bump or an additive API, major for a wire change outside the window or a
|
|
7
|
+
removed export. The package version and the wire protocol version are separate numbers.
|
|
8
|
+
|
|
9
|
+
## [Unreleased]
|
|
10
|
+
|
|
11
|
+
## [1.0.0] - 2026-09-17
|
|
12
|
+
|
|
13
|
+
Initial public release.
|
|
14
|
+
|
|
15
|
+
- One outbound WebSocket, commands only. Bulk content leaves by HTTP on a lane the controller names.
|
|
16
|
+
Sequence numbers are dense per session per direction, minted at the first write; frames are
|
|
17
|
+
retained until acknowledged and replayed from the controller's cursors after a reconnect.
|
|
18
|
+
- A fail-closed permission gate on every session: a `PreToolUse` hook with no permission prompt
|
|
19
|
+
anywhere, local refusals for path escapes, credential reads and unrecognised git verbs, then an
|
|
20
|
+
HTTP decision endpoint for everything else; no answer is a refusal.
|
|
21
|
+
- Workspaces: a plain directory or a git worktree per session, a branch scheme, list and release
|
|
22
|
+
over the wire, and a repository read door jailed to the root and to the protected set.
|
|
23
|
+
- Identity: a generic OIDC client (authorization code with PKCE over loopback; device code opt-in)
|
|
24
|
+
or a paired machine credential shaped `p1.<hostId>.<secret>`, presented on the upgrade, the
|
|
25
|
+
decision POST and the bulk POST.
|
|
26
|
+
- The `periscope` binary: `serve`, `login`, `pair`, `config`, `status`, `version`.
|
|
27
|
+
- The contracts a second implementer proves against: the wire vectors under
|
|
28
|
+
`contracts/wire-vectors/`, the public-API snapshot, and the installed agent SDK's type hash.
|
|
29
|
+
|
|
30
|
+
Protocol v9; the negotiated window opens at v9. Tested against `@anthropic-ai/claude-agent-sdk`
|
|
31
|
+
0.3.220 (Claude Code 2.1.220) on ubuntu and windows with node 22 and 24: 1485 tests, 1462 passing,
|
|
32
|
+
0 failing, 23 skipped on every leg (the skips are the live probes and one platform-only case),
|
|
33
|
+
measured by this repository's CI on the commit `v1.0.0` names.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Nathan Sloma
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# Periscope
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/@naswerks/periscope)
|
|
4
|
+
[](https://github.com/naswerks/periscope/actions/workflows/ci.yml)
|
|
5
|
+
[](LICENSE)
|
|
6
|
+
[](package.json)
|
|
7
|
+
|
|
8
|
+
Self-hosted runner for agent sessions: dials out to your controller, then spawns, gates, observes,
|
|
9
|
+
prompts and streams. It runs where the code is; the controller runs wherever you put it.
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
npm install -g @naswerks/periscope # the host, on the machine that runs sessions
|
|
13
|
+
npm install @naswerks/periscope # the library: wire types, codec, and the host as a module
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
The process on your machine dials out to the controller and takes its orders from there. What it
|
|
17
|
+
hosts is a live conversation you can steer mid-turn, not a job you collect at the end. It opens no
|
|
18
|
+
port and carries no opinion about what a session _means_: it emits what happened, and the
|
|
19
|
+
controller decides what to do about it. The agent it hosts is Claude Code, through the Claude Agent
|
|
20
|
+
SDK, behind a seam that names no agent on the wire.
|
|
21
|
+
|
|
22
|
+
## Run a host
|
|
23
|
+
|
|
24
|
+
You have a controller's address and a pair code from whoever runs it.
|
|
25
|
+
|
|
26
|
+
```sh
|
|
27
|
+
npm install -g @naswerks/periscope
|
|
28
|
+
npm install -g @anthropic-ai/claude-code && claude auth login
|
|
29
|
+
periscope pair <code> --controller https://controller.example --label "build box"
|
|
30
|
+
periscope
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
The second line signs the agent in, once, as the OS user that runs the host (`claude auth status`
|
|
34
|
+
says whether it already is): the host runs the Claude Code CLI headless, and it authenticates
|
|
35
|
+
through the credential under `~/.claude` that `claude auth login` writes. `ANTHROPIC_API_KEY` in
|
|
36
|
+
your shell is not inherited by a session.
|
|
37
|
+
|
|
38
|
+
The third line answers with the host's new id and writes the credential and the two addresses the
|
|
39
|
+
host dials, so nothing else needs setting:
|
|
40
|
+
|
|
41
|
+
```
|
|
42
|
+
paired as ph-8cb226ae…; credential written to ~/.periscope/paired-credential.json
|
|
43
|
+
this host now dials with the paired credential - the sign-in token expiry no longer applies to it
|
|
44
|
+
PERISCOPE_CONTROLLER_URL and PERISCOPE_DECISION_URL written to the config file - serve needs nothing else
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
The fourth starts the host, in the foreground, until you stop it:
|
|
48
|
+
|
|
49
|
+
```
|
|
50
|
+
[host] periscope 1.0.0 · host ph-8cb226ae… (paired; configured build-box) · credential paired · workspace none · config file ~/.periscope/config.json
|
|
51
|
+
[credential] paired as ph-8cb226ae… - the paired credential is presented on every dial
|
|
52
|
+
[link] idle -> connecting (start_requested)
|
|
53
|
+
[link] connecting -> open (socket_connected)
|
|
54
|
+
[link] open -> accepted (hello_completed) — protocol v9
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
`periscope status` prints the same posture from any terminal and never dials. A controller on a
|
|
58
|
+
development certificate (`https://localhost:…`) is refused by Node until Node is pointed at that
|
|
59
|
+
certificate: [configuration](docs/configuration.md#the-two-addresses).
|
|
60
|
+
|
|
61
|
+
To give sessions a repository and a git worktree each, from the terminal or from the controller, to
|
|
62
|
+
run under a supervisor, or to set anything by hand: [configuration](docs/configuration.md). To pair
|
|
63
|
+
with a signed-in identity instead of a machine credential: [identity](docs/identity.md).
|
|
64
|
+
|
|
65
|
+
## Write a controller
|
|
66
|
+
|
|
67
|
+
A controller serves two transports: the WebSocket the host dials, and an HTTP endpoint the host
|
|
68
|
+
POSTs each permission decision to. The second is easy to miss, because nothing on the wire announces
|
|
69
|
+
it. `@naswerks/periscope/protocol` ships the wire types and the codec without anything that can
|
|
70
|
+
reach a process or a disk; `contracts/wire-vectors/` is the same contract as bytes, for a controller
|
|
71
|
+
in any language.
|
|
72
|
+
|
|
73
|
+
```sh
|
|
74
|
+
git clone https://github.com/naswerks/periscope && cd periscope && npm ci && npm run build
|
|
75
|
+
node examples/test-controller/serve.ts
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
That runs the reference controller and prints the exact `periscope pair` line that redeems a code
|
|
79
|
+
against it. [The wire protocol](docs/protocol.md) is the contract: the envelope, the sequence rules,
|
|
80
|
+
the handshake, pairing, the decision endpoint's request and answer, and a checklist of what a
|
|
81
|
+
controller owes. [`examples/`](examples/README.md) holds the smallest controller that completes a
|
|
82
|
+
link (forty lines) and the reference.
|
|
83
|
+
|
|
84
|
+
## How it works
|
|
85
|
+
|
|
86
|
+
**One socket out, nothing in.** The host opens a single WebSocket to the controller and keeps it
|
|
87
|
+
alive: a heartbeat both ways, jittered backoff, a bounded offline queue. Frames carry commands and
|
|
88
|
+
facts (every state transition the machine records rides the wire); bulk content leaves by an HTTP
|
|
89
|
+
POST the controller asks for. Sequence numbers are dense per session per direction, so a reconnect
|
|
90
|
+
replays exactly what was missed. [protocol](docs/protocol.md)
|
|
91
|
+
|
|
92
|
+
**The gate fails closed.** There is no permission prompt: the host runs the agent headless, does not
|
|
93
|
+
pass `--dangerously-skip-permissions`, and registers a `PreToolUse` hook on every session that is
|
|
94
|
+
the only path to a yes. That is a stricter gate than the prompt, not a weaker one: path escapes,
|
|
95
|
+
credential reads and unrecognised git verbs are refused locally before the controller is asked,
|
|
96
|
+
everything else is the controller's decision, and no answer is a refusal. What a paired controller
|
|
97
|
+
can reach on the machine is stated in [`SECURITY.md`](SECURITY.md); read it before you install this.
|
|
98
|
+
[gate](docs/gate.md)
|
|
99
|
+
|
|
100
|
+
**Workspaces are the host's.** A session runs in the directory the controller names, or in a plain
|
|
101
|
+
directory per workspace key, or in a linked git worktree of a repository on its own branch, and the
|
|
102
|
+
controller can list and release them over the wire. [configuration](docs/configuration.md)
|
|
103
|
+
|
|
104
|
+
**Identity is paired or signed in.** A paired machine credential has no clock and dies only when the
|
|
105
|
+
controller revokes it; a signed-in user's token goes through a generic OIDC client with no provider
|
|
106
|
+
baked in. The agent's own sign-in is a separate credential. [identity](docs/identity.md)
|
|
107
|
+
|
|
108
|
+
**The session is a state machine.** Every message, hook and gate outcome is a recorded transition
|
|
109
|
+
with a cause from a closed vocabulary, so an unattended night is readable afterwards.
|
|
110
|
+
[state machine](docs/state-machine.md)
|
|
111
|
+
|
|
112
|
+
## Does it work, and against what
|
|
113
|
+
|
|
114
|
+
`npm test` is a clean build then `node --test` over the compiled output; CI runs it on
|
|
115
|
+
`{ubuntu-latest, windows-latest}` x node `{22, 24}` on every change, ratchets line coverage against
|
|
116
|
+
`coverage.floor`, packs the tarball and installs it into an empty project, and runs mutation testing
|
|
117
|
+
weekly. Both operating systems are load-bearing: Windows is where `USERPROFILE`, case-insensitive
|
|
118
|
+
env matching and path handling are observable, and Linux is the only place POSIX file modes mean
|
|
119
|
+
anything. The figure for a release is in [`CHANGELOG.md`](CHANGELOG.md).
|
|
120
|
+
|
|
121
|
+
| | |
|
|
122
|
+
| -------------------------------- | ------------------------------------- |
|
|
123
|
+
| `@anthropic-ai/claude-agent-sdk` | **0.3.220**, pinned exactly, no caret |
|
|
124
|
+
| Claude Code CLI | **2.1.220** (bundled with that SDK) |
|
|
125
|
+
| Node | 22 or later; CI proves 22 and 24 |
|
|
126
|
+
|
|
127
|
+
The SDK is pre-1.0, so the SHA-256 of its installed type definitions is kept in
|
|
128
|
+
`contracts/sdk.sha256` and CI fails on any drift; a bump cannot land without someone reading what
|
|
129
|
+
changed. The package follows semantic versioning; the wire protocol version is a separate number
|
|
130
|
+
with its own window ([versioning](CONTRIBUTING.md#versioning)).
|
|
131
|
+
|
|
132
|
+
## Naming
|
|
133
|
+
|
|
134
|
+
Method and field naming follows the [Agent Client Protocol](https://agentclientprotocol.com) (Zed
|
|
135
|
+
Industries, Apache-2.0): `session/new`, `session/prompt`, `session/cancel`, `session/update`, its
|
|
136
|
+
camelCase keys and snake_case discriminators. This is not ACP compatibility, and Periscope must not
|
|
137
|
+
be described as ACP-compatible: ACP points the connection inbound, Periscope dials out, and every
|
|
138
|
+
type here is written from scratch. Elsewhere the vocabulary is the SDK's own.
|
|
139
|
+
|
|
140
|
+
## Documents
|
|
141
|
+
|
|
142
|
+
[architecture](docs/architecture.md) · [protocol](docs/protocol.md) ·
|
|
143
|
+
[configuration](docs/configuration.md) · [identity](docs/identity.md) · [gate](docs/gate.md) ·
|
|
144
|
+
[state machine](docs/state-machine.md) · [`examples/`](examples/README.md) ·
|
|
145
|
+
[`SECURITY.md`](SECURITY.md) · [`CONTRIBUTING.md`](CONTRIBUTING.md) · [`CHANGELOG.md`](CHANGELOG.md) ·
|
|
146
|
+
licence [MIT](LICENSE).
|
package/SECURITY.md
ADDED
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
# Security
|
|
2
|
+
|
|
3
|
+
Periscope runs Claude Code sessions on your machine on behalf of a remote controller. That is a
|
|
4
|
+
serious thing to install, and this document is written to be read _before_ you do. It states the
|
|
5
|
+
posture, what each control covers, and what it does not.
|
|
6
|
+
|
|
7
|
+
## The permission model
|
|
8
|
+
|
|
9
|
+
Periscope runs the agent headless under the Agent SDK, so there is no interactive prompt. It does
|
|
10
|
+
not pass `--dangerously-skip-permissions` and sets no permission mode; it registers a `PreToolUse`
|
|
11
|
+
hook on every session, and that hook is the only path to a yes: the host turns `grantOnAllow` on
|
|
12
|
+
for every session it opens, so the hook's allow is what lets a tool run and its deny, or its
|
|
13
|
+
silence, is what stops one. Read on its own that looks like the safety being switched off. It is
|
|
14
|
+
the opposite: the interactive prompt is replaced by a stricter gate, not removed.
|
|
15
|
+
|
|
16
|
+
The interactive prompt asks a human sitting at the terminal. That is a fine control when someone is
|
|
17
|
+
sitting there. Periscope exists for the case where nobody is, so the question has to be answered by
|
|
18
|
+
something that is still awake at 3am, and the options are _a stricter gate_ or _no gate_.
|
|
19
|
+
|
|
20
|
+
| | The interactive prompt | Periscope's gate |
|
|
21
|
+
| ----------------------------- | -------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
|
|
22
|
+
| **Who decides** | whoever is at the keyboard | your controller, or a rule that runs locally |
|
|
23
|
+
| **When nobody answers** | the session blocks forever | the tool does not run: every timeout, error and outage is a refusal |
|
|
24
|
+
| **Is the decision recorded** | no | yes: every call, decision and reason is a state transition on the wire |
|
|
25
|
+
| **Can it be checked offline** | n/a | partly: the local gate refuses path escapes, credential reads and unrecognised git verbs with the controller unreachable |
|
|
26
|
+
| **What a crash does** | n/a | refuses. A hook that _throws_ is treated by the SDK as absent, so every handler is wrapped and returns an explicit deny |
|
|
27
|
+
|
|
28
|
+
The invariant is absolute: no answer, any error, any timeout, any outage means the tool does not
|
|
29
|
+
run. It is fail-closed on every path; the suite exercises each path rather than reading it off the
|
|
30
|
+
types.
|
|
31
|
+
|
|
32
|
+
### The gate has a local half, and that is what makes it provable offline
|
|
33
|
+
|
|
34
|
+
Before anything is asked of your controller, a local rule runs. It only ever _adds_ refusals; it
|
|
35
|
+
can never turn a "no" into a "yes". It refuses, locally and immediately: a path that escapes the
|
|
36
|
+
session's workspace, a read of the credential set, and a git invocation whose verb is not on the
|
|
37
|
+
allow-list, by parsing the command rather than by matching strings. A boundary-crossing shape (a
|
|
38
|
+
push, a force, a remote change, a branch deletion, a merge) is recognised locally but escalated to
|
|
39
|
+
the controller like any other call, so a person can answer it; with the controller unreachable it
|
|
40
|
+
refuses when the decision deadline passes. So the answer to _"what happens if the controller is
|
|
41
|
+
compromised or unreachable?"_ is: the local classes are refused before the question is asked, and
|
|
42
|
+
everything else refuses because nobody answered.
|
|
43
|
+
|
|
44
|
+
## What this can touch on your machine
|
|
45
|
+
|
|
46
|
+
Nothing outside `src/host/` imports `node:fs`, `node:fs/promises`, `node:child_process` or
|
|
47
|
+
`node:os`. One directory. You can answer _"what can this touch?"_ by reading it, and the rule is
|
|
48
|
+
enforced twice: an ESLint rule for editor feedback, and a tree-walking test that survives the lint
|
|
49
|
+
config being edited, disabled or deleted.
|
|
50
|
+
|
|
51
|
+
`@naswerks/periscope/protocol`, the subpath a controller imports, structurally cannot reach that directory,
|
|
52
|
+
does not pull the Agent SDK, and uses no Node-only global. Importing the wire contract does not hand
|
|
53
|
+
you a package that can read your disk.
|
|
54
|
+
|
|
55
|
+
## The posture: five facts that are one picture
|
|
56
|
+
|
|
57
|
+
What you actually get: two facts about credentials, two about the tool surface, and one about what
|
|
58
|
+
the gate's decision is worth. Stated together rather than scattered, because taken singly each one
|
|
59
|
+
reads milder than the situation is.
|
|
60
|
+
|
|
61
|
+
### 1. On Windows the token cache has no OS-level protection at all
|
|
62
|
+
|
|
63
|
+
POSIX modes are inert on win32. Measured on win32 with node v24.16.0: a file written `0o600`, a file
|
|
64
|
+
`chmod`ed to `0o600`, and a deliberately world-readable `0o666` all report `666`. Only the write bit
|
|
65
|
+
is real: `chmod 0444` does read back as `444`.
|
|
66
|
+
|
|
67
|
+
The genuine controls on Windows would be NTFS ACLs, and both routes to them were rejected
|
|
68
|
+
deliberately: a native module would be the first compiled code in a dependency set that is
|
|
69
|
+
otherwise plain JavaScript, and shelling out to `icacls` on every credential write puts a
|
|
70
|
+
`child_process` call in the credential path, inside the boundary that exists to keep that surface
|
|
71
|
+
small.
|
|
72
|
+
|
|
73
|
+
> So on Windows, the controls over your token cache are: the gate's credential-path denial (scoped
|
|
74
|
+
> as fact 3 states, not total), your controller's decisions, and your OS user account. That is a
|
|
75
|
+
> real degradation from the POSIX story. If you assumed file permissions were protecting that file,
|
|
76
|
+
> they are not.
|
|
77
|
+
|
|
78
|
+
### 2. And on Windows the host cannot even measure that protection
|
|
79
|
+
|
|
80
|
+
This is a _different_ fact from the one above, and collapsing the two would be misleading. Above is
|
|
81
|
+
_there is no protection_; this is _the instrument cannot see it_. A `0600` assertion on win32 cannot
|
|
82
|
+
distinguish an owner-only file from a world-readable one, so a check written the obvious way would
|
|
83
|
+
pass vacuously and report a privacy it never confirmed.
|
|
84
|
+
|
|
85
|
+
Periscope therefore probes what the filesystem actually records and reports one of three outcomes,
|
|
86
|
+
never two:
|
|
87
|
+
|
|
88
|
+
| | |
|
|
89
|
+
| ---------------- | ---------------------------------------------------------------------------------------- |
|
|
90
|
+
| `enforced` | modes are honoured; the file is verified, and one wider than requested is refused |
|
|
91
|
+
| `write-bit-only` | only writability is recorded, so privacy is unconfirmable. A named degrade, not a pass |
|
|
92
|
+
| `unobservable` | not even clearing the write bit changed anything: inconclusive, and reported differently |
|
|
93
|
+
|
|
94
|
+
The third outcome exists because an instrument with no inconclusive state reports confidence it has
|
|
95
|
+
not earned. `chmod 0444` is used as a positive control, proving the probe can observe _something_
|
|
96
|
+
before it reports that it cannot observe privacy.
|
|
97
|
+
|
|
98
|
+
A Linux CI leg closes the second of these two and not the first. Being able to measure protection
|
|
99
|
+
on POSIX does not create protection on Windows.
|
|
100
|
+
|
|
101
|
+
### 3. The gate has no opinion about MCP tools you register, or about built-in tools outside its families
|
|
102
|
+
|
|
103
|
+
The local gate matches on tool name, in three families (`DEFAULT_TOOL_FAMILIES`): writes are `Write`,
|
|
104
|
+
`Edit`, `MultiEdit`, `NotebookEdit`; reads are `Read`, `NotebookRead`; shells are `Bash`,
|
|
105
|
+
`PowerShell`. An MCP tool
|
|
106
|
+
arrives as `mcp__{server}__{tool}`, matches nothing, and gets no local opinion, so the decision
|
|
107
|
+
escalates to your controller. That is fail-closed and correct.
|
|
108
|
+
|
|
109
|
+
The same fall-through applies to built-in tools the families do not name, and that scopes the
|
|
110
|
+
credential-path denial itself. The default read family is `Read`/`NotebookRead` only, so built-in
|
|
111
|
+
`Grep` and `Glob`, both read primitives, get no local opinion and escalate; the gate's own suite
|
|
112
|
+
pins that deliberately (`local.test.ts`, _"a tool outside every declared family gets no local
|
|
113
|
+
opinion"_, with `Grep` as the example). The shell scan matches a protected path written literally,
|
|
114
|
+
so expansion forms (`~`, `$HOME`, `%USERPROFILE%`) and symlink indirection escalate too. What this
|
|
115
|
+
means for fact 1's picture: offline, every one of those escalations is refused as an outage, which
|
|
116
|
+
is still fail-closed, a narrower control than a by-name denial rather than an open door; online, for
|
|
117
|
+
exactly those vectors, what stands between the agent and the token cache is your controller, not
|
|
118
|
+
this gate. Widening the local denial to cover them is a known open question, deliberately left open
|
|
119
|
+
rather than closed in passing: a `Grep` refused for carrying no path at all is the kind of
|
|
120
|
+
over-refusal a widening has to weigh, and that deserves its own decision.
|
|
121
|
+
|
|
122
|
+
Name what is lost, because "no opinion" undersells it. With the controller unreachable, such a call
|
|
123
|
+
is refused as an outage rather than by name, which is precisely the distinction the local gate was
|
|
124
|
+
built to make. The cost is the kind of refusal, not the delay, and the by-name half of that is a
|
|
125
|
+
measurement: with both of the controller's transports dead, a built-in naming the token cache was
|
|
126
|
+
refused 5s after the turn was queued, by name (`credential-path-denied`), against an escalation
|
|
127
|
+
deadline of 15s, so the answer was the local gate's, not a timeout expiring. A call with no local
|
|
128
|
+
opinion in the same outage is refused as exactly that, an outage. A refused connection fails fast;
|
|
129
|
+
the decision timeout is only paid when the controller _accepts_ and does not answer. An outage
|
|
130
|
+
cannot be told apart from a failure on the controller's side, and that is what is lost. So:
|
|
131
|
+
|
|
132
|
+
> If you register MCP tools and never touch `ToolFamilies`, your gate is offline-provable for
|
|
133
|
+
> `Bash` and not for your own tools.
|
|
134
|
+
|
|
135
|
+
The mechanism to close it is `PeriscopeHostOptions.toolFamilies`: `ToolFamilies` is embedder-supplied
|
|
136
|
+
data, so naming your tool in the right family gives it the same local treatment as the built-in it
|
|
137
|
+
resembles. The git verb allow-list inside the shell family is not configurable; an unrecognised verb
|
|
138
|
+
refuses locally and a boundary shape escalates, whoever the embedder is.
|
|
139
|
+
Periscope deliberately does not guess which of your tools are dangerous. It cannot know, and a host
|
|
140
|
+
that guessed would be wrong in exactly the cases that matter.
|
|
141
|
+
|
|
142
|
+
### 4. `strictMcpConfig` defaults ON, and turning it off re-admits servers that can fail silently
|
|
143
|
+
|
|
144
|
+
Periscope registers MCP servers in-process. There is no stdio child, no connect race, and nothing
|
|
145
|
+
for the CLI to fail to reconnect, which is why the absence of a status/reconnect surface is harmless
|
|
146
|
+
rather than a gap. That sentence is about the server this package registers itself; a server the
|
|
147
|
+
controller declares in `session_new.request.mcpServers` is whatever it declares, a stdio child
|
|
148
|
+
included (see _What a paired controller can reach_ below). The reasoning holds only while
|
|
149
|
+
`strictMcpConfig` is on, which excludes servers nobody declared: project `.mcp.json`, user settings,
|
|
150
|
+
plugin MCP, agent frontmatter.
|
|
151
|
+
|
|
152
|
+
> Turn `strictMcpConfig` off and you re-admit process-transport servers that can fail, with no
|
|
153
|
+
> status or reconnect surface to notice or recover. It is one flag, and it is the whole residual.
|
|
154
|
+
|
|
155
|
+
### 5. A composed host grants what its gate approves, and a hand-composed one does not
|
|
156
|
+
|
|
157
|
+
The gate returns _no opinion_ on an allow by default, so that it can only ever add a refusal and
|
|
158
|
+
never delete one the operator configured. On its own that makes it a veto rather than a gate: the
|
|
159
|
+
agent's own permission mode is then the decider, and in a host with nobody at a keyboard there is
|
|
160
|
+
nobody to answer it. Measured on real sessions, three, identical but for the decision:
|
|
161
|
+
|
|
162
|
+
| Decision | File on disk | What the model was told |
|
|
163
|
+
| ---------------------- | ------------ | ------------------------------------------------------------------------------- |
|
|
164
|
+
| **deny** | no | _"the probe controller refuses this write"_: your reason, verbatim |
|
|
165
|
+
| **allow**, not granted | **no** | _"Claude requested permissions to write to ... but you haven't granted it yet"_ |
|
|
166
|
+
| **allow**, granted | **yes** | _"File created successfully at: ..."_ |
|
|
167
|
+
|
|
168
|
+
So `PeriscopeHost` sets `grantOnAllow`: it makes the decision your gate already took take effect,
|
|
169
|
+
one call at a time, for exactly the calls it approved. It is not `bypassPermissions`; nothing is
|
|
170
|
+
disabled wholesale.
|
|
171
|
+
|
|
172
|
+
> Three residuals.
|
|
173
|
+
> **(1)** The flag defaults OFF. An embedder who calls `composeSession` by hand and does not set it
|
|
174
|
+
> gets a gate that cannot say yes: the tool simply does not run and the agent reports a permission
|
|
175
|
+
> it was never going to be granted. This raises a `gate-cannot-grant` degrade on the first allow
|
|
176
|
+
> that does not take effect, and the consequence is stated on `GateTimings.grantOnAllow` itself,
|
|
177
|
+
> where an embedder actually reads it.
|
|
178
|
+
> **(2)** `grantOnAllow` with settings files loaded is refused by name
|
|
179
|
+
> (`permission-grant-shadows-settings`). Load no settings, or do not grant.
|
|
180
|
+
> **(3)** That refusal is incomplete on its own logic, and knowing why matters more than the rule:
|
|
181
|
+
> `settingSources: []` does _not_ mean no operator rules are live. Managed policy settings and
|
|
182
|
+
> `~/.claude.json` load regardless of that field, and a managed machine is exactly where such a
|
|
183
|
+
> policy exists. The refusal sees the tiers you name and cannot see the two that are always on.
|
|
184
|
+
|
|
185
|
+
> **What an effective allow actually skips.** The [Claude Code permissions
|
|
186
|
+
> documentation](https://code.claude.com/docs/en/permissions#extend-permissions-with-hooks) states
|
|
187
|
+
> that a hook's decision does not bypass permission rules: deny and ask rules are evaluated whatever
|
|
188
|
+
> a `PreToolUse` hook returns. So a grant skips only the permission mode, the allow rules and
|
|
189
|
+
> `canUseTool`; the operator's deny and ask rules survive it. A veto-only gate still cannot say yes
|
|
190
|
+
> (that was measured, above), so the default is still correct; the residual exposure of a grant is
|
|
191
|
+
> smaller than a full bypass, and the refusal in (2) rests on "two authorities, no stated
|
|
192
|
+
> precedence" rather than on a bypass.
|
|
193
|
+
>
|
|
194
|
+
> That paragraph is documented, not measured. The probe that would settle it exists in
|
|
195
|
+
> `gate.live.test.ts` (does a hook allow override an operator deny rule?) and has not been
|
|
196
|
+
> exercised cleanly: run from inside an agent session, the child inherits the enclosing session's
|
|
197
|
+
> tool surface, satisfies the prompt with a tool the deny rule did not name, and never calls the
|
|
198
|
+
> denied one. Run it on a machine that is not itself an agent session before treating any of this
|
|
199
|
+
> as observed.
|
|
200
|
+
|
|
201
|
+
## The outbound-only posture
|
|
202
|
+
|
|
203
|
+
There is no inbound port anywhere in this package. Periscope dials out to your controller over a
|
|
204
|
+
single WebSocket and keeps it alive. Nothing can connect _to_ it, which is what lets it run on a
|
|
205
|
+
machine behind a firewall that would never allow a listener.
|
|
206
|
+
|
|
207
|
+
One exception, and it is bounded: during an interactive sign-in a loopback listener binds
|
|
208
|
+
`127.0.0.1` explicitly, accepts exactly one callback, and times out. Because the agent shares this
|
|
209
|
+
host's OS user, any local process could reach that listener, so `state` is verified on every
|
|
210
|
+
callback, always. PKCE protects the code exchange; `state` protects the callback. They are not
|
|
211
|
+
substitutes for each other.
|
|
212
|
+
|
|
213
|
+
## Pairing and revocation
|
|
214
|
+
|
|
215
|
+
The credential a running host presents is, in the shipped configuration, a **paired bearer**: an
|
|
216
|
+
string shaped `p1.<hostId>.<secret>` minted by your controller when a signed-in user redeems a
|
|
217
|
+
short-lived pair code (`periscope pair <code>`); the shape is the contract, because the host reads
|
|
218
|
+
its own id out of it. A controller should keep only a hash of the secret, so that this machine
|
|
219
|
+
holds the one copy, in `paired-credential.json` under the config directory (`~/.periscope`
|
|
220
|
+
or `PERISCOPE_CONFIG_DIR`), written `0600` and verified after the write. It has no expiry and no
|
|
221
|
+
rotation schedule. On POSIX the mode is enforced and a wider file is refused; on Windows the mode is
|
|
222
|
+
not real (fact 1 above), the host reports `write-bit-only` or `unobservable`, and the file's only
|
|
223
|
+
protections are the gate's credential-path denial, your controller, and your OS account.
|
|
224
|
+
|
|
225
|
+
Revocation is the controller's, per machine: it refuses the bearer at the upgrade (401 or 403) and
|
|
226
|
+
closes the link, and the host reads that refusal as `link-unauthorized`, which is terminal: it exits
|
|
227
|
+
non-zero naming the remedy. Rotation is re-pairing: mint a new code, run `periscope pair` again, and
|
|
228
|
+
a controller should invalidate the old bearer when the new one is issued. Nothing in this package
|
|
229
|
+
rotates the bearer on its own, and a copied bearer works from any machine until it is revoked.
|
|
230
|
+
|
|
231
|
+
The signed-in alternative (`periscope login`) presents a real user's own access token: nothing about
|
|
232
|
+
the provider is baked in, its revocation is the provider's, and a wrong identity configuration refuses
|
|
233
|
+
to start rather than authenticating as nobody. A provider may let its refresh token lapse after a
|
|
234
|
+
period of inactivity, which is why the paired bearer is the shipped default for an unattended host.
|
|
235
|
+
|
|
236
|
+
### What a paired controller can reach on this machine
|
|
237
|
+
|
|
238
|
+
Pairing extends trust, and this is its exact extent. A controller holding this host's bearer can,
|
|
239
|
+
over the link and without a further credential:
|
|
240
|
+
|
|
241
|
+
- **Run a command on this machine at session start.** `session_new.request.mcpServers` is passed
|
|
242
|
+
to the agent as declared; a stdio server declaration (`{ type: 'stdio', command, args }`) is a
|
|
243
|
+
process the CLI spawns as this host's OS user, before the gate sees a single tool call. The host
|
|
244
|
+
screens the shape of the declaration, not what the command does.
|
|
245
|
+
- **Set the agent's environment.** `session_new.request.env.extraEnv` sets any variable in the
|
|
246
|
+
spawn environment after the allow-list has run, `PATH` and `NODE_OPTIONS` included, and
|
|
247
|
+
`extraAllowedKeys` re-admits keys of this host's own environment by name (the credential-shaped
|
|
248
|
+
deny list still wins there); only the host-session markers are stripped after it.
|
|
249
|
+
- **Remove directories.** `workspace_release` deletes a worktree under the workspace root, and the
|
|
250
|
+
controller can set that root through `host_configure` (a root change is refused only while a
|
|
251
|
+
session is live or opening).
|
|
252
|
+
- **Read files.** Every transcript under the agent home (`<agent home>/projects`, by default
|
|
253
|
+
`~/.claude/projects`), which includes sessions the operator ran from an editor or a terminal,
|
|
254
|
+
not only sessions this host started; and any directory listing or text-file head under the
|
|
255
|
+
repository root (`repository_list` / `repository_read`, read-only and bounded), a root the
|
|
256
|
+
controller can re-point through `host_configure`. Both doors are jailed to their root, and the
|
|
257
|
+
repository doors also honour the host's protected set: a path at or beneath a credential
|
|
258
|
+
directory refuses `credential-path-denied` whatever the root is, on the lexical resolution and
|
|
259
|
+
on the real path.
|
|
260
|
+
- **Reconfigure the host.** The workspace root, the repository root, the branch scheme, the agent
|
|
261
|
+
home and the two controller URLs through `host_configure`, written to the config file; the URLs
|
|
262
|
+
apply at the next start.
|
|
263
|
+
- **Start sessions** that run the agent with the interactive prompt replaced by the gate, and
|
|
264
|
+
answer every permission decision those sessions raise.
|
|
265
|
+
|
|
266
|
+
None of this is a defect to be closed: a runner that could not run a tool server, set a session's
|
|
267
|
+
environment or clean up its worktrees would not be a runner. It is the trust a pairing extends, so
|
|
268
|
+
pair a host only with a controller you would trust with all of it, and revoke the pairing when that
|
|
269
|
+
stops being true.
|
|
270
|
+
|
|
271
|
+
## Running in a container
|
|
272
|
+
|
|
273
|
+
- **Do not run as root.** An unattended agent running as root has the whole machine on every tool
|
|
274
|
+
call, and a container built the obvious way runs as root. Periscope refuses to start as uid 0 by
|
|
275
|
+
policy, before anything else runs, so this arrives as one clear line at start-up rather than as a
|
|
276
|
+
session that can reach everything.
|
|
277
|
+
- **`exec` the process so it receives SIGTERM.** PID 1 gets no default signal dispositions, so
|
|
278
|
+
without `exec` every `docker stop` is a SIGKILL that runs no cleanup path and abandons credential
|
|
279
|
+
files mid-write.
|
|
280
|
+
- **Keep the shebang LF.** A CRLF shebang fails as _"no such file or directory"_ naming the
|
|
281
|
+
interpreter rather than the line ending. `.gitattributes` pins it.
|
|
282
|
+
|
|
283
|
+
## Versions this was tested against
|
|
284
|
+
|
|
285
|
+
| | |
|
|
286
|
+
| -------------------------------- | ------------------------------------------------------------ |
|
|
287
|
+
| `@anthropic-ai/claude-agent-sdk` | **0.3.220**, pinned exactly, no caret |
|
|
288
|
+
| Bundled Claude Code CLI | **2.1.220** |
|
|
289
|
+
| Node | 22 or later, and CI runs 22 and 24 on both Linux and Windows |
|
|
290
|
+
|
|
291
|
+
The SDK is pre-1.0 and its surface moves without semver protection, so a SHA-256 of the installed
|
|
292
|
+
type definitions is kept in `contracts/sdk.sha256` and a CI job fails on any drift between the installed
|
|
293
|
+
types and that baseline; the SDK's own files are not redistributed. A bump cannot land without someone
|
|
294
|
+
reading what changed.
|
|
295
|
+
|
|
296
|
+
## Supported versions
|
|
297
|
+
|
|
298
|
+
The latest minor of the current major receives fixes; older minors do not. The wire protocol has
|
|
299
|
+
its own window, stated in [the wire protocol](docs/protocol.md): a controller and a host one
|
|
300
|
+
protocol release apart connect. `periscope version` prints the running version.
|
|
301
|
+
|
|
302
|
+
## Reporting a vulnerability
|
|
303
|
+
|
|
304
|
+
Report it through the repository's private security advisory form (GitHub Security Advisories:
|
|
305
|
+
<https://github.com/naswerks/periscope/security/advisories/new>) rather than a public issue. If you
|
|
306
|
+
are unsure whether something is a vulnerability, report it anyway: a false alarm costs a reply, and
|
|
307
|
+
the alternative costs more.
|
|
308
|
+
|
|
309
|
+
Please include the version (`periscope version`), the operating system, and what you observed. A
|
|
310
|
+
reproduction is welcome but not required; a clear description of the mechanism is worth more than a
|
|
311
|
+
script that only runs on your machine.
|
|
312
|
+
|
|
313
|
+
Expect an acknowledgement within seven days. A confirmed report is fixed in a release before it is
|
|
314
|
+
described publicly, with credit to the reporter unless they ask otherwise; a report that turns out
|
|
315
|
+
not to be a vulnerability gets an explanation of why.
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which command the binary was asked for.
|
|
3
|
+
*
|
|
4
|
+
* Extracted out of the composition root so it can be called without starting a host, the same
|
|
5
|
+
* reason, and the same shape, as `workspaces.ts`. `bin/periscope.ts` calls `main()` on load, so
|
|
6
|
+
* importing it to test one function starts a host, opens a socket and spawns nothing useful. The
|
|
7
|
+
* fix is to split the function out; it is not to add a startup guard to the entry point, because
|
|
8
|
+
* changing production behaviour to make a test possible is how a suite starts describing a system
|
|
9
|
+
* nobody ships.
|
|
10
|
+
*
|
|
11
|
+
* The parser is deliberately tiny and not an option library. This package's direct dependencies
|
|
12
|
+
* are the agent SDK with its peer closure, `ws` and `zod`; a CLI framework for a handful of verbs
|
|
13
|
+
* would be a new dependency in a package whose boundary claims are auditable from one directory.
|
|
14
|
+
*/
|
|
15
|
+
/** The verbs this binary answers to. Anything else is named rather than guessed at. */
|
|
16
|
+
export type Command =
|
|
17
|
+
/** Run the host: dial the controller, serve sessions. The default, and what a supervisor starts. */
|
|
18
|
+
{
|
|
19
|
+
readonly kind: 'serve';
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Acquire a token interactively and write it to the cache the daemon reads.
|
|
23
|
+
*
|
|
24
|
+
* Why the verb exists: `bin/serve.ts` never calls `signIn`. It builds a credential over the
|
|
25
|
+
* file token cache and a refresher, so it presents a token that is already there, and nothing
|
|
26
|
+
* else in this package ever writes one. An `npx`-installed host on someone's laptop with no way
|
|
27
|
+
* to sign in is not a host; it is a process that reads an empty cache, refuses by name, and
|
|
28
|
+
* (because the link is fail-open) connects with no headers at all.
|
|
29
|
+
*/
|
|
30
|
+
| {
|
|
31
|
+
readonly kind: 'login';
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Trade a pair code for this machine's durable credential and write it beside the token cache.
|
|
35
|
+
*
|
|
36
|
+
* An OIDC refresh token can expire after a period of inactivity, so an unattended host on the
|
|
37
|
+
* `login` credential can quietly stop being able to dial. A paired credential has no clock; it
|
|
38
|
+
* dies only when the controller revokes it, and that refusal is loud (the upgrade 401 is terminal
|
|
39
|
+
* and the process exits naming it). `code: null` is the operator forgetting the argument, named
|
|
40
|
+
* at parse so the message can say what to type.
|
|
41
|
+
*/
|
|
42
|
+
| {
|
|
43
|
+
readonly kind: 'pair';
|
|
44
|
+
readonly code: string | null;
|
|
45
|
+
/** `--controller <origin>`: where to redeem, and what the controller names its routes from. */
|
|
46
|
+
readonly controller: string | null;
|
|
47
|
+
/** `--label <name>`: what the controller lists this machine as; overrides the environment. */
|
|
48
|
+
readonly label: string | null;
|
|
49
|
+
/** An argument the parser could not place, named so the usage line can say what to type. */
|
|
50
|
+
readonly problem: string | null;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Read or write the config file the daemon falls back to.
|
|
54
|
+
*
|
|
55
|
+
* Why the verb exists: with every setting readable from the environment only, a user with no UI
|
|
56
|
+
* and no supervisor has no way to configure a repository short of exporting variables into every
|
|
57
|
+
* shell that starts the host. The file is a fallback (the environment still wins per key) and
|
|
58
|
+
* nothing else in this package writes it. `key: null` lists the file; `value: null` reads one
|
|
59
|
+
* key; both stated is a write.
|
|
60
|
+
*/
|
|
61
|
+
| {
|
|
62
|
+
readonly kind: 'config';
|
|
63
|
+
readonly key: string | null;
|
|
64
|
+
readonly value: string | null;
|
|
65
|
+
/** `--unset <key>`: remove the key from the file. */
|
|
66
|
+
readonly unset: boolean;
|
|
67
|
+
}
|
|
68
|
+
/** Print the package version. */
|
|
69
|
+
| {
|
|
70
|
+
readonly kind: 'version';
|
|
71
|
+
}
|
|
72
|
+
/** Say what the verbs are. */
|
|
73
|
+
| {
|
|
74
|
+
readonly kind: 'status';
|
|
75
|
+
} | {
|
|
76
|
+
readonly kind: 'help';
|
|
77
|
+
}
|
|
78
|
+
/** A verb this binary does not have. Named, never silently treated as `serve`. */
|
|
79
|
+
| {
|
|
80
|
+
readonly kind: 'unknown';
|
|
81
|
+
readonly name: string;
|
|
82
|
+
};
|
|
83
|
+
/**
|
|
84
|
+
* Read the command from an argv tail (`process.argv.slice(2)`).
|
|
85
|
+
*
|
|
86
|
+
* No arguments means `serve`, and that is a contract rather than a preference: a supervisor or
|
|
87
|
+
* container starts this binary with no arguments, so a bare invocation is the daemon.
|
|
88
|
+
*
|
|
89
|
+
* An unrecognised first argument is `unknown`, never `serve`. Falling back to the default would
|
|
90
|
+
* mean `periscope logn` silently starts a host: the operator believes they are signing in, the
|
|
91
|
+
* process dials out and starts accepting sessions, and the mistake surfaces much later as an empty
|
|
92
|
+
* token cache. A typo must fail loudly at the only moment it is cheap to fix.
|
|
93
|
+
*/
|
|
94
|
+
export declare function readCommand(argv: readonly string[]): Command;
|
|
95
|
+
/** What `help` prints. Kept beside the parser so a new verb cannot be added without a line here. */
|
|
96
|
+
export declare const USAGE: string;
|
|
97
|
+
//# sourceMappingURL=command.d.ts.map
|