@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,131 @@
|
|
|
1
|
+
import { refusal } from '../core/refusal.js';
|
|
2
|
+
import { isContainedBy, isAbsolutePath, normalizePath } from '../core/paths.js';
|
|
3
|
+
/** The tool-input fields that carry a path, in precedence order. First readable one wins. */
|
|
4
|
+
const PATH_FIELDS = ['file_path', 'notebook_path', 'path'];
|
|
5
|
+
/**
|
|
6
|
+
* The path this tool call is about, or null when the input carries none.
|
|
7
|
+
*
|
|
8
|
+
* Null is a refusable state, not a missing value — see `checkPath`. A call whose target cannot be
|
|
9
|
+
* found is not a call that can be bounded, and guessing one would authorize something nobody named.
|
|
10
|
+
*/
|
|
11
|
+
export function pathFromToolInput(toolInput) {
|
|
12
|
+
if (typeof toolInput !== 'object' || toolInput === null)
|
|
13
|
+
return null;
|
|
14
|
+
const record = toolInput;
|
|
15
|
+
for (const field of PATH_FIELDS) {
|
|
16
|
+
const value = record[field];
|
|
17
|
+
if (typeof value === 'string' && value.trim() !== '')
|
|
18
|
+
return value;
|
|
19
|
+
}
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
/** The command this shell call is about, or null when the input carries none. */
|
|
23
|
+
export function commandFromToolInput(toolInput) {
|
|
24
|
+
if (typeof toolInput !== 'object' || toolInput === null)
|
|
25
|
+
return null;
|
|
26
|
+
const value = toolInput['command'];
|
|
27
|
+
return typeof value === 'string' && value.trim() !== '' ? value : null;
|
|
28
|
+
}
|
|
29
|
+
/** Resolve, or say why it could not be done. Never throws — a throwing resolver becomes a refusal. */
|
|
30
|
+
function resolveOrRefuse(candidate, resolve) {
|
|
31
|
+
let resolved;
|
|
32
|
+
try {
|
|
33
|
+
resolved = resolve(candidate);
|
|
34
|
+
}
|
|
35
|
+
catch (error) {
|
|
36
|
+
return {
|
|
37
|
+
refusal: refusal('path-unresolvable', `${candidate} could not be normalized (${String(error)}), so it is not provably inside the workspace`),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
if (typeof resolved !== 'string' || resolved === '') {
|
|
41
|
+
return { refusal: refusal('path-unresolvable', `normalizing ${candidate} produced no path at all`) };
|
|
42
|
+
}
|
|
43
|
+
if (!isAbsolutePath(resolved)) {
|
|
44
|
+
return {
|
|
45
|
+
refusal: refusal('path-not-absolute', `${candidate} does not resolve to an absolute path (${resolved})`),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
return { resolved };
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Is this resolved path at or beneath one of the protected paths?
|
|
52
|
+
*
|
|
53
|
+
* `isContainedBy` compares segment-wise after normalizing both sides, so a protected `C:\Users\x\.claude`
|
|
54
|
+
* does not also protect `C:\Users\x\.claude-notes`, and the protected path itself counts as protected.
|
|
55
|
+
*/
|
|
56
|
+
function protectedPathCovering(resolved, protectedPaths) {
|
|
57
|
+
for (const candidate of protectedPaths) {
|
|
58
|
+
if (isContainedBy(resolved, candidate))
|
|
59
|
+
return candidate;
|
|
60
|
+
}
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Check a path-taking tool call against the jail and the protected set.
|
|
65
|
+
*
|
|
66
|
+
* The credential check runs first and applies whatever the workspace root is: a token cache that
|
|
67
|
+
* happens to sit inside the workspace is still a token cache. Order matters for the message the
|
|
68
|
+
* reader gets, not for whether the call is refused — both answers block.
|
|
69
|
+
*/
|
|
70
|
+
export function checkPath(candidate, options) {
|
|
71
|
+
if (candidate === null) {
|
|
72
|
+
return refusal('path-input-missing', 'this tool takes a path and the input carries none, so there is nothing to bound — refused rather than guessed');
|
|
73
|
+
}
|
|
74
|
+
const outcome = resolveOrRefuse(candidate, options.resolve);
|
|
75
|
+
if ('refusal' in outcome)
|
|
76
|
+
return outcome.refusal;
|
|
77
|
+
const { resolved } = outcome;
|
|
78
|
+
const covering = protectedPathCovering(resolved, options.protectedPaths);
|
|
79
|
+
if (covering !== null) {
|
|
80
|
+
return refusal('credential-path-denied', `${resolved} is at or beneath ${covering}, which holds credential material; the agent shares the host's OS user, so this gate is the only control over it`);
|
|
81
|
+
}
|
|
82
|
+
if (options.workspaceRoot === null || options.workspaceRoot.trim() === '') {
|
|
83
|
+
return refusal('path-escapes-root', 'no workspace root is declared, so no path can be shown to be inside one — a jail with no walls is not a jail');
|
|
84
|
+
}
|
|
85
|
+
const rootOutcome = resolveOrRefuse(options.workspaceRoot, options.resolve);
|
|
86
|
+
if ('refusal' in rootOutcome)
|
|
87
|
+
return rootOutcome.refusal;
|
|
88
|
+
if (!isContainedBy(resolved, rootOutcome.resolved)) {
|
|
89
|
+
return refusal('path-escapes-root', `${resolved} is outside the declared workspace root ${rootOutcome.resolved}`);
|
|
90
|
+
}
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Check a shell command for credential material named anywhere in it.
|
|
95
|
+
*
|
|
96
|
+
* This one scans the whole command, and that is deliberately unlike the rest of the local gate.
|
|
97
|
+
* Everything else here parses precisely so that a mention of a boundary word does not refuse a
|
|
98
|
+
* benign call, so a reader who notices this function will reasonably wonder whether it was missed.
|
|
99
|
+
*
|
|
100
|
+
* It was not. The two cases are not the same shape. A boundary verb is only dangerous at a command
|
|
101
|
+
* position, so parsing tells you whether it is one. A credential path is dangerous wherever it
|
|
102
|
+
* appears: as an argument to any reader, inside a redirect, in a substitution, or handed to a program
|
|
103
|
+
* this parser has no model of. There is no position at which naming the host's token cache in a shell
|
|
104
|
+
* command is routine, so there is nothing to gain by locating it precisely — and every parser gap
|
|
105
|
+
* would become a way to read the credential. The cost of the choice is bounded and stated: a command
|
|
106
|
+
* that merely mentions the path — an `echo` of a diagnostic, say — is refused, which costs one human
|
|
107
|
+
* click. The alternative costs the credential.
|
|
108
|
+
*
|
|
109
|
+
* So do not narrow this to match the rest of the file. Both credential defences — this one and
|
|
110
|
+
* `checkPath`'s — are covered by tests that fail when either is removed. Reproduce that before
|
|
111
|
+
* changing anything here: make `protectedPathCovering` return null and make this function's
|
|
112
|
+
* haystack empty, then run the suite. The over-refusal is the price of those tests, not an
|
|
113
|
+
* oversight in them.
|
|
114
|
+
*
|
|
115
|
+
* The comparison is on the resolved protected paths and on the raw command text, case-insensitively,
|
|
116
|
+
* because Windows paths reach here in both slash styles and either case.
|
|
117
|
+
*/
|
|
118
|
+
export function checkShellForProtectedPaths(command, options) {
|
|
119
|
+
const haystack = normalizePath(command).toLowerCase();
|
|
120
|
+
for (const candidate of options.protectedPaths) {
|
|
121
|
+
const outcome = resolveOrRefuse(candidate, options.resolve);
|
|
122
|
+
// A protected path this host cannot resolve is still protected — fall back to its literal form
|
|
123
|
+
// rather than dropping it from the set, which would silently shrink the protected surface.
|
|
124
|
+
const needle = normalizePath('refusal' in outcome ? candidate : outcome.resolved).toLowerCase();
|
|
125
|
+
if (needle !== '' && haystack.includes(needle)) {
|
|
126
|
+
return refusal('credential-path-denied', `the command names ${needle}, which holds credential material; the agent shares the host's OS user, so this gate is the only control over it`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
//# sourceMappingURL=jail.js.map
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The host's own gate — a refusal that does not depend on the controller behaving.
|
|
3
|
+
*
|
|
4
|
+
* This is what makes the package installable by a stranger. A host that executes whatever a
|
|
5
|
+
* remote server sends is, mechanically, a code-execution service on somebody else's machine. An
|
|
6
|
+
* optional callback defaulting to escalate-everything is the opposite of an answer to that. This
|
|
7
|
+
* module refuses locally, offline, by rules that ship with the package — so the property can be
|
|
8
|
+
* demonstrated by somebody who does not trust the controller at all, which is the only audience the
|
|
9
|
+
* claim matters to.
|
|
10
|
+
*
|
|
11
|
+
* The vocabulary is owned locally, not received. A policy the controller can change remotely is a
|
|
12
|
+
* policy that depends on the controller behaving, which defeats the module. At the exact moment this
|
|
13
|
+
* is load-bearing — the controller unreachable — a cached remote policy is the only thing acting, so
|
|
14
|
+
* receiving-and-caching is local ownership plus a remote mutation path, for no benefit when it
|
|
15
|
+
* counts.
|
|
16
|
+
*
|
|
17
|
+
* Owned is not hardcoded, and that is what makes it survivable. The embedder chooses the tool
|
|
18
|
+
* families and the protected-path list at construction, in-process, before any session exists. The
|
|
19
|
+
* wire carries no policy in either direction.
|
|
20
|
+
*
|
|
21
|
+
* It adds refusals and never removes one. A tool this module has no opinion about returns null and
|
|
22
|
+
* the surrounding gate goes on to ask whoever it was going to ask. Two mechanisms, one invariant —
|
|
23
|
+
* the same posture the outer gate takes toward the operator's own settings.
|
|
24
|
+
*/
|
|
25
|
+
import type { Refusal } from '../core/refusal.js';
|
|
26
|
+
import type { DecisionRequest } from './decision.js';
|
|
27
|
+
import type { PathResolver } from './jail.js';
|
|
28
|
+
/**
|
|
29
|
+
* Decides one tool call, locally. Null means no local opinion, never "allowed".
|
|
30
|
+
*
|
|
31
|
+
* Synchronous and total by contract: this runs before anything is asked of anyone, and an
|
|
32
|
+
* asynchronous local policy would be a second place a decision can hang.
|
|
33
|
+
*/
|
|
34
|
+
export type LocalGate = (request: DecisionRequest) => Refusal | null;
|
|
35
|
+
/** The tool families this module recognises. Data, so an embedder can state its own. */
|
|
36
|
+
export interface ToolFamilies {
|
|
37
|
+
/** Tools whose input names a path they will write. */
|
|
38
|
+
readonly write: readonly string[];
|
|
39
|
+
/** Tools whose input names a path they will read. Present because of the credential denial. */
|
|
40
|
+
readonly read: readonly string[];
|
|
41
|
+
/** Tools that run a command. */
|
|
42
|
+
readonly shell: readonly string[];
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* The SDK's own tool names.
|
|
46
|
+
*
|
|
47
|
+
* Read tools are jailed too. Reading source is benign; reading the host's token cache is not, and
|
|
48
|
+
* the two arrive through the same tool. The jail
|
|
49
|
+
* bounds where reads may go and the protected set names what is off-limits wherever it sits.
|
|
50
|
+
*/
|
|
51
|
+
export declare const DEFAULT_TOOL_FAMILIES: ToolFamilies;
|
|
52
|
+
export interface LocalGateOptions {
|
|
53
|
+
/** The absolute root every path-taking call must resolve inside. */
|
|
54
|
+
readonly workspaceRoot: string | null;
|
|
55
|
+
/** The real resolver. `host/paths.ts` holds the one built on `node:path`. */
|
|
56
|
+
readonly resolve: PathResolver;
|
|
57
|
+
/** Absolute paths holding credential material. `host/paths.ts` computes the default set. */
|
|
58
|
+
readonly protectedPaths: readonly string[];
|
|
59
|
+
/** Defaults to `DEFAULT_TOOL_FAMILIES`. */
|
|
60
|
+
readonly toolFamilies?: ToolFamilies;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Build the local gate.
|
|
64
|
+
*
|
|
65
|
+
* The returned function is pure, synchronous and total — so it composes into whatever assembles a
|
|
66
|
+
* session later, and needs no composition root to exist first.
|
|
67
|
+
*/
|
|
68
|
+
export declare function localGate(options: LocalGateOptions): LocalGate;
|
|
69
|
+
//# sourceMappingURL=local.d.ts.map
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { refusal } from '../core/refusal.js';
|
|
2
|
+
import { checkPath, checkShellForProtectedPaths, commandFromToolInput, pathFromToolInput } from './jail.js';
|
|
3
|
+
import { classifyShellCommand } from './shell.js';
|
|
4
|
+
import { parseCommand } from './command.js';
|
|
5
|
+
/**
|
|
6
|
+
* The SDK's own tool names.
|
|
7
|
+
*
|
|
8
|
+
* Read tools are jailed too. Reading source is benign; reading the host's token cache is not, and
|
|
9
|
+
* the two arrive through the same tool. The jail
|
|
10
|
+
* bounds where reads may go and the protected set names what is off-limits wherever it sits.
|
|
11
|
+
*/
|
|
12
|
+
export const DEFAULT_TOOL_FAMILIES = {
|
|
13
|
+
write: ['Write', 'Edit', 'MultiEdit', 'NotebookEdit'],
|
|
14
|
+
read: ['Read', 'NotebookRead'],
|
|
15
|
+
shell: ['Bash', 'PowerShell'],
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* Build the local gate.
|
|
19
|
+
*
|
|
20
|
+
* The returned function is pure, synchronous and total — so it composes into whatever assembles a
|
|
21
|
+
* session later, and needs no composition root to exist first.
|
|
22
|
+
*/
|
|
23
|
+
export function localGate(options) {
|
|
24
|
+
const families = options.toolFamilies ?? DEFAULT_TOOL_FAMILIES;
|
|
25
|
+
const jail = {
|
|
26
|
+
workspaceRoot: options.workspaceRoot,
|
|
27
|
+
resolve: options.resolve,
|
|
28
|
+
protectedPaths: options.protectedPaths,
|
|
29
|
+
};
|
|
30
|
+
const writeTools = new Set(families.write);
|
|
31
|
+
const readTools = new Set(families.read);
|
|
32
|
+
const shellTools = new Set(families.shell);
|
|
33
|
+
return (request) => {
|
|
34
|
+
if (shellTools.has(request.toolName)) {
|
|
35
|
+
const command = commandFromToolInput(request.toolInput);
|
|
36
|
+
if (command === null) {
|
|
37
|
+
return refusal('shell-command-missing', 'this tool runs a command and the input carries none, so there is nothing to classify — refused rather than guessed');
|
|
38
|
+
}
|
|
39
|
+
// One parse, both consumers. The credential check runs first because a command naming the
|
|
40
|
+
// token cache is the more serious of the two answers and should be the one the reader gets.
|
|
41
|
+
const parsed = parseCommand(command);
|
|
42
|
+
return checkShellForProtectedPaths(command, jail) ?? classifyShellCommand(command, parsed);
|
|
43
|
+
}
|
|
44
|
+
if (writeTools.has(request.toolName) || readTools.has(request.toolName)) {
|
|
45
|
+
return checkPath(pathFromToolInput(request.toolInput), jail);
|
|
46
|
+
}
|
|
47
|
+
// No opinion. The surrounding gate asks whoever it was going to ask.
|
|
48
|
+
return null;
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
//# sourceMappingURL=local.js.map
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the gate did, and the transitions that say so.
|
|
3
|
+
*
|
|
4
|
+
* This file is pure, and that is the point. Turning an outcome into transitions has no clock, no
|
|
5
|
+
* machine and no SDK in it, so the mapping every emitted trace depends on can be checked exhaustively
|
|
6
|
+
* without a session anywhere near it. `recordGateOutcome` below is the two-line impure half.
|
|
7
|
+
*
|
|
8
|
+
* The gate declares no status of its own. Every outcome here lands on the declared state model's
|
|
9
|
+
* existing states, activities and causes. A gate that found itself wanting a status word would have
|
|
10
|
+
* found a state missing from that model — a finding for it, never a column here.
|
|
11
|
+
*
|
|
12
|
+
* Five outcomes, three cause kinds, and the separation is the whole value. A denial is
|
|
13
|
+
* `control/permission_denied` — somebody decided. An outage and an unrecognised answer are
|
|
14
|
+
* `refusal/…` — nobody decided, and the two are different investigations. An expiry is
|
|
15
|
+
* `timeout/hook_timed_out`. Every one of them blocks the tool, so the invariant is identical and
|
|
16
|
+
* only the story differs; conflating an outage with a deliberate "no" is an expensive failure, and
|
|
17
|
+
* it is why the kinds are separate rather than one kind with a different string in `detail`.
|
|
18
|
+
*/
|
|
19
|
+
import type { Refusal } from '../core/refusal.js';
|
|
20
|
+
import type { SessionStateMachine, TransitionRequest } from '../state/machine.js';
|
|
21
|
+
import type { Result } from '../core/result.js';
|
|
22
|
+
import type { SessionTransition } from '../state/model.js';
|
|
23
|
+
import type { DecisionRequest } from './decision.js';
|
|
24
|
+
/**
|
|
25
|
+
* What happened to one tool call.
|
|
26
|
+
*
|
|
27
|
+
* `held` says whether a permission entry was opened for this call — i.e. whether the decision took
|
|
28
|
+
* long enough to be worth reporting as a session that is waiting. It is carried on every terminal
|
|
29
|
+
* outcome because the close is only correct when there was an open.
|
|
30
|
+
*/
|
|
31
|
+
export type GateOutcome =
|
|
32
|
+
/** The decision is taking long enough to be a hold. Opens the permission entry. */
|
|
33
|
+
{
|
|
34
|
+
readonly kind: 'holding';
|
|
35
|
+
readonly request: DecisionRequest;
|
|
36
|
+
} | {
|
|
37
|
+
readonly kind: 'allow';
|
|
38
|
+
readonly request: DecisionRequest;
|
|
39
|
+
readonly updatedInput?: Record<string, unknown>;
|
|
40
|
+
readonly held: boolean;
|
|
41
|
+
} | {
|
|
42
|
+
readonly kind: 'deny';
|
|
43
|
+
readonly request: DecisionRequest;
|
|
44
|
+
readonly message: string;
|
|
45
|
+
readonly held: boolean;
|
|
46
|
+
}
|
|
47
|
+
/** Nobody decided: the decider threw, the escalation failed, or the answer was not understood. */
|
|
48
|
+
| {
|
|
49
|
+
readonly kind: 'refused';
|
|
50
|
+
readonly request: DecisionRequest;
|
|
51
|
+
readonly refusal: Refusal;
|
|
52
|
+
readonly held: boolean;
|
|
53
|
+
}
|
|
54
|
+
/** The host's own deadline passed before an answer arrived. */
|
|
55
|
+
| {
|
|
56
|
+
readonly kind: 'expired';
|
|
57
|
+
readonly request: DecisionRequest;
|
|
58
|
+
readonly detail: string;
|
|
59
|
+
readonly held: boolean;
|
|
60
|
+
};
|
|
61
|
+
/**
|
|
62
|
+
* The transitions one outcome produces. Pure, total, and often empty.
|
|
63
|
+
*
|
|
64
|
+
* An ordinary allow emits nothing, deliberately. The observer's own `PreToolUse` record already
|
|
65
|
+
* says the tool started, and a second transition saying it was permitted would be one fact recorded
|
|
66
|
+
* twice — which is how a trace stops being countable. A held allow is different: an entry was
|
|
67
|
+
* opened, so it has to be closed, and that close is a real event with a real duration behind it.
|
|
68
|
+
*/
|
|
69
|
+
export declare function gateTransitions(outcome: GateOutcome): TransitionRequest[];
|
|
70
|
+
/** The impure half: put an outcome's transitions on a machine. Returns what it recorded. */
|
|
71
|
+
export declare function recordGateOutcome(machine: SessionStateMachine, outcome: GateOutcome): Result<SessionTransition>[];
|
|
72
|
+
//# sourceMappingURL=outcome.d.ts.map
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { HOOK_TIMEOUT_EVENT } from '../state/model.js';
|
|
2
|
+
const permissionActivity = (toolName) => ({ kind: 'permission', name: toolName });
|
|
3
|
+
/**
|
|
4
|
+
* How the gate keys its hold entry: by `tool_use_id`, which every `PreToolUse` carries.
|
|
5
|
+
*
|
|
6
|
+
* Deliberately not the observer's name-keyed `permission:<toolName>`. The observer's `PreToolUse`
|
|
7
|
+
* branch closes any open name-keyed permission entry — the allow-path exit for its own
|
|
8
|
+
* `PermissionRequest` lane — so a name-keyed hold could be closed by the next `PreToolUse` for the
|
|
9
|
+
* same tool while this call's decision is still outstanding, recording a resolution that never
|
|
10
|
+
* happened. Two writers, two key spaces: the observer owns `permission:<toolName>`, the gate owns
|
|
11
|
+
* `permission:<toolUseId>`, and two simultaneous holds for one tool are two entries. The
|
|
12
|
+
* `permission:` prefix is kept so this key can never collide with the observer's `tool` entry,
|
|
13
|
+
* which is the raw tool_use_id from the same event.
|
|
14
|
+
*/
|
|
15
|
+
const holdEntryId = (toolUseId) => `permission:${toolUseId}`;
|
|
16
|
+
/**
|
|
17
|
+
* The transitions one outcome produces. Pure, total, and often empty.
|
|
18
|
+
*
|
|
19
|
+
* An ordinary allow emits nothing, deliberately. The observer's own `PreToolUse` record already
|
|
20
|
+
* says the tool started, and a second transition saying it was permitted would be one fact recorded
|
|
21
|
+
* twice — which is how a trace stops being countable. A held allow is different: an entry was
|
|
22
|
+
* opened, so it has to be closed, and that close is a real event with a real duration behind it.
|
|
23
|
+
*/
|
|
24
|
+
export function gateTransitions(outcome) {
|
|
25
|
+
const { request } = outcome;
|
|
26
|
+
const entryId = holdEntryId(request.toolUseId);
|
|
27
|
+
// The event that fired is `PreToolUse`. Naming `PermissionRequest` or `PermissionDenied` here
|
|
28
|
+
// would be truer to the concept and false about the world — observed, neither of those hooks fires
|
|
29
|
+
// for a hook-authored decision — and a trace that names an event which never happened teaches its
|
|
30
|
+
// next reader something untrue.
|
|
31
|
+
const onTheHook = (detail) => ({ kind: 'hook', event: 'PreToolUse', detail });
|
|
32
|
+
if (outcome.kind === 'holding') {
|
|
33
|
+
return [
|
|
34
|
+
{
|
|
35
|
+
to: 'working',
|
|
36
|
+
entry: {
|
|
37
|
+
op: 'open',
|
|
38
|
+
entryId,
|
|
39
|
+
activity: permissionActivity(request.toolName),
|
|
40
|
+
agentId: request.agentId,
|
|
41
|
+
},
|
|
42
|
+
cause: onTheHook(`a permission decision for ${request.toolName} is outstanding`),
|
|
43
|
+
},
|
|
44
|
+
];
|
|
45
|
+
}
|
|
46
|
+
const closeIfHeld = outcome.held ? { op: 'close', entryId } : null;
|
|
47
|
+
if (outcome.kind === 'allow') {
|
|
48
|
+
if (!outcome.held)
|
|
49
|
+
return [];
|
|
50
|
+
return [
|
|
51
|
+
{
|
|
52
|
+
to: 'working',
|
|
53
|
+
entry: closeIfHeld,
|
|
54
|
+
cause: onTheHook(`the permission for ${request.toolName} resolved: allowed`),
|
|
55
|
+
},
|
|
56
|
+
];
|
|
57
|
+
}
|
|
58
|
+
if (outcome.kind === 'deny') {
|
|
59
|
+
return [
|
|
60
|
+
{
|
|
61
|
+
to: 'working',
|
|
62
|
+
entry: closeIfHeld,
|
|
63
|
+
// A call the host made on itself — which is what `control` means here, and what a
|
|
64
|
+
// hook-authored deny literally is. See CONTROL_EVENTS' own note: kind and event are
|
|
65
|
+
// validated independently and never as a pair, so this pairing is an authorial choice.
|
|
66
|
+
cause: { kind: 'control', event: 'permission_denied', detail: outcome.message },
|
|
67
|
+
},
|
|
68
|
+
];
|
|
69
|
+
}
|
|
70
|
+
if (outcome.kind === 'refused') {
|
|
71
|
+
return [
|
|
72
|
+
{
|
|
73
|
+
to: 'working',
|
|
74
|
+
entry: closeIfHeld,
|
|
75
|
+
cause: { kind: 'refusal', event: outcome.refusal.reason, detail: outcome.refusal.detail },
|
|
76
|
+
},
|
|
77
|
+
];
|
|
78
|
+
}
|
|
79
|
+
return [
|
|
80
|
+
{
|
|
81
|
+
to: 'working',
|
|
82
|
+
entry: closeIfHeld,
|
|
83
|
+
cause: { kind: 'timeout', event: HOOK_TIMEOUT_EVENT, detail: outcome.detail },
|
|
84
|
+
},
|
|
85
|
+
];
|
|
86
|
+
}
|
|
87
|
+
/** The impure half: put an outcome's transitions on a machine. Returns what it recorded. */
|
|
88
|
+
export function recordGateOutcome(machine, outcome) {
|
|
89
|
+
return gateTransitions(outcome).map((request) => machine.record(request));
|
|
90
|
+
}
|
|
91
|
+
//# sourceMappingURL=outcome.js.map
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shell backstop — two layers, and the second exists because the first was insufficient.
|
|
3
|
+
*
|
|
4
|
+
* A shell command cannot be proven safe by looking at a path, so this is the only control over what
|
|
5
|
+
* a shell tool call can reach outside the workspace. Both layers run; either one refusing is enough.
|
|
6
|
+
*
|
|
7
|
+
* Layer 1, the denylist. Named boundary operations — publishing, remote surgery, branch deletion,
|
|
8
|
+
* merging — matched over the whole scannable text with `[\s\S]`, so a newline cannot hide one
|
|
9
|
+
* inside a compound command.
|
|
10
|
+
*
|
|
11
|
+
* Layer 2, the git verb allowlist. This layer is not redundancy; it closes a real defect class.
|
|
12
|
+
* `git send-pack` — the plumbing that `git push` calls underneath — matches no denylist pattern,
|
|
13
|
+
* and a denylist-only classifier approved it as benign shell, which force-pushed a main branch
|
|
14
|
+
* under an automated actor. A denylist under-includes by construction: it can only refuse what
|
|
15
|
+
* somebody thought of. So a git invocation whose verb is not provably safe is refused without
|
|
16
|
+
* enumeration, which covers `send-pack`, `receive-pack`, aliases, and every verb git ships in a
|
|
17
|
+
* future release.
|
|
18
|
+
*
|
|
19
|
+
* The input is parsed, not raw — see `command.ts` for the defects a raw scan produces. Layer 1
|
|
20
|
+
* scans `ParsedCommand.scannable`, which is the raw command minus comments and provably-inert data
|
|
21
|
+
* payloads; layer 2 reads the parsed invocations. That narrows what is scanned and never what is
|
|
22
|
+
* denied.
|
|
23
|
+
*
|
|
24
|
+
* The rule that decides every ambiguous case: a false refusal costs one human click; a false allow
|
|
25
|
+
* costs the invariant. Every unknown shape in this file resolves toward refusing.
|
|
26
|
+
*/
|
|
27
|
+
import type { Refusal } from '../core/refusal.js';
|
|
28
|
+
import type { ParsedCommand } from './command.js';
|
|
29
|
+
/**
|
|
30
|
+
* Classify a shell command. Returns the refusal that fired, or null when nothing did.
|
|
31
|
+
*
|
|
32
|
+
* `parsed` may be supplied by a caller that already parsed the command — the credential check does,
|
|
33
|
+
* and parsing twice would be work for nothing.
|
|
34
|
+
*/
|
|
35
|
+
export declare function classifyShellCommand(command: string, parsed?: ParsedCommand): Refusal | null;
|
|
36
|
+
//# sourceMappingURL=shell.d.ts.map
|