@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,417 @@
|
|
|
1
|
+
import { refusal } from '../core/refusal.js';
|
|
2
|
+
import { HOOK_TIMEOUT_EVENT } from '../state/model.js';
|
|
3
|
+
import { describeRaw, readDecision } from './decision.js';
|
|
4
|
+
import { EscalationUnavailable } from './escalate.js';
|
|
5
|
+
const DEFAULT_MATCHER_TIMEOUT_SECONDS = 60;
|
|
6
|
+
const DEFAULT_DECISION_TIMEOUT_MS = 50_000;
|
|
7
|
+
/**
|
|
8
|
+
* The two-deadline invariant, as one declaration with two consumers.
|
|
9
|
+
*
|
|
10
|
+
* It is a function rather than a repeated `if` because two enforcement points for one rule drift
|
|
11
|
+
* apart: `permissionHooks` throws on an inverted pair for an embedder, and `composeSession` must
|
|
12
|
+
* refuse on one for a controller. So the rule lives here and both read it.
|
|
13
|
+
*
|
|
14
|
+
* Returns the explanation when the pair is invalid, or null when it is fine. The caller decides
|
|
15
|
+
* whether that becomes a throw or a named refusal — which is the only thing the two sites disagree
|
|
16
|
+
* about, and it is a decision about audience rather than about the rule.
|
|
17
|
+
*/
|
|
18
|
+
export function deadlineOrderRefusal(decisionTimeoutMs, matcherTimeoutSeconds) {
|
|
19
|
+
const decision = decisionTimeoutMs ?? DEFAULT_DECISION_TIMEOUT_MS;
|
|
20
|
+
const matcher = matcherTimeoutSeconds ?? DEFAULT_MATCHER_TIMEOUT_SECONDS;
|
|
21
|
+
if (decision < matcher * 1000)
|
|
22
|
+
return null;
|
|
23
|
+
return (`decisionTimeoutMs (${decision}ms) must be below matcherTimeoutSeconds ` +
|
|
24
|
+
`(${matcher}s = ${matcher * 1000}ms). The host's own deadline must expire first: if the matcher ` +
|
|
25
|
+
'expires first the tool is still blocked, but nothing is recorded and the block reads as a hang — ' +
|
|
26
|
+
'the exact failure this gate exists to avoid.');
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* 250ms. Below this, opening and closing a permission entry records a session as "waiting" for
|
|
30
|
+
* less time than it takes to read the record — noise in the one signal the entry exists to carry.
|
|
31
|
+
*/
|
|
32
|
+
const DEFAULT_HOLD_AFTER_MS = 250;
|
|
33
|
+
/**
|
|
34
|
+
* The `PreToolUse` registration for a session's gate.
|
|
35
|
+
*
|
|
36
|
+
* Register this after `observationHooks()` — `mergeHooks(observationHooks(…), permissionHooks(…))`.
|
|
37
|
+
* Handlers on one event have their synchronous prologues run in array order and are then awaited
|
|
38
|
+
* concurrently (measured; `mergeHooks`'s own "earlier arguments run first" describes dispatch, not
|
|
39
|
+
* completion). The order is a convention, not a race guard: this gate opens its `permission` entry
|
|
40
|
+
* only from the hold timer (`holdAfterMs`, 250ms by default), after every same-event synchronous
|
|
41
|
+
* prologue has finished — so under either order the observer's `PreToolUse` check runs before any
|
|
42
|
+
* hold entry from this event exists, and cannot close one. Across events the guard is the key, not
|
|
43
|
+
* the timing: the hold entry is keyed by tool_use_id (`gate/outcome.ts`), so a later `PreToolUse`
|
|
44
|
+
* for the same tool — whose observer branch closes name-keyed permission entries — cannot close a
|
|
45
|
+
* hold whose decision is still outstanding.
|
|
46
|
+
*
|
|
47
|
+
* No `matcher` is set. A matcher filters by tool name, and every tool call must reach the gate.
|
|
48
|
+
*/
|
|
49
|
+
export function permissionHooks(options) {
|
|
50
|
+
const decisionTimeoutMs = options.decisionTimeoutMs ?? DEFAULT_DECISION_TIMEOUT_MS;
|
|
51
|
+
const holdAfterMs = options.holdAfterMs ?? DEFAULT_HOLD_AFTER_MS;
|
|
52
|
+
const matcherTimeoutSeconds = options.matcherTimeoutSeconds ?? DEFAULT_MATCHER_TIMEOUT_SECONDS;
|
|
53
|
+
// The two-deadline invariant, enforced where it still can be. A pair where the matcher expires
|
|
54
|
+
// first would still block the tool — the CLI is fail-closed — but this handler would never learn
|
|
55
|
+
// it happened: no outcome, no transition, and a trace showing a `tool` and a `permission` entry
|
|
56
|
+
// opened and never closed. Refused rather than clamped: a silent clamp would alter a value the
|
|
57
|
+
// embedder stated, and this happens before any session exists, so failing fast is safe.
|
|
58
|
+
const inverted = deadlineOrderRefusal(decisionTimeoutMs, matcherTimeoutSeconds);
|
|
59
|
+
if (inverted !== null)
|
|
60
|
+
throw new Error(inverted);
|
|
61
|
+
// Per gate, not per call — see the degrade's own note on why it fires once.
|
|
62
|
+
let grantDegradeRaised = false;
|
|
63
|
+
const emit = (outcome) => {
|
|
64
|
+
try {
|
|
65
|
+
options.onOutcome(outcome);
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
// A listener that throws must not reach the CLI as a thrown hook, because a thrown hook is an
|
|
69
|
+
// absent hook and the tool would run. Losing one record is bad; losing the refusal is worse.
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
const handler = async (input, _toolUseId, hookOptions) => {
|
|
73
|
+
// The hold flag lives here, outside decide(), so the recovery below can tell the truth about
|
|
74
|
+
// whether a hold entry was opened. A hardcoded `held: false` in the catch would leave any
|
|
75
|
+
// opened entry with no close — the invariant would hold while the trace showed a session
|
|
76
|
+
// eternally waiting on a decision that was in fact refused.
|
|
77
|
+
const held = { value: false };
|
|
78
|
+
let outcome;
|
|
79
|
+
try {
|
|
80
|
+
if (input.hook_event_name !== 'PreToolUse')
|
|
81
|
+
return {};
|
|
82
|
+
outcome = await decide(input, options.decide, emit, hookOptions.signal, decisionTimeoutMs, holdAfterMs, held, options.localGate, options.sessionKey);
|
|
83
|
+
}
|
|
84
|
+
catch (error) {
|
|
85
|
+
// The fail-open hole, closed. Anything at all that escaped above — a bug in this file, a
|
|
86
|
+
// decider that threw synchronously, a request that could not be read — becomes an explicit
|
|
87
|
+
// refusal rather than an absent hook.
|
|
88
|
+
outcome = {
|
|
89
|
+
kind: 'refused',
|
|
90
|
+
request: readRequest(input, options.sessionKey) ?? unreadableRequest(options.sessionKey),
|
|
91
|
+
held: held.value,
|
|
92
|
+
refusal: refusal('permission-decision-unavailable', `the gate itself failed: ${String(error)}`),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
emit(outcome);
|
|
96
|
+
// The silent failure, made audible — once. An allow the CLI will not act on is the shape of
|
|
97
|
+
// this gate's worst outcome: the call is approved, the tool does not run, and the agent is told
|
|
98
|
+
// it lacks a permission nobody was ever going to grant. Raised on the first occurrence only,
|
|
99
|
+
// because a session that hits this hits it on every approved call and a degrade per call would
|
|
100
|
+
// bury the signal it exists to carry.
|
|
101
|
+
if (outcome.kind === 'allow' && !(options.grantOnAllow ?? false) && !grantDegradeRaised) {
|
|
102
|
+
grantDegradeRaised = true;
|
|
103
|
+
try {
|
|
104
|
+
options.onDegrade?.({
|
|
105
|
+
name: 'gate-cannot-grant',
|
|
106
|
+
detail: `the gate ALLOWED ${outcome.request.toolName} but grantOnAllow is off, so the allow is silent and the ` +
|
|
107
|
+
`tool will not run — this gate can refuse a call and cannot let one through. Set grantOnAllow, or ` +
|
|
108
|
+
`expect every approved call to fail as an ungranted permission.`,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
// Same reason `emit` swallows: a listener that throws must not reach the CLI as a thrown
|
|
113
|
+
// hook, because a thrown hook is an absent hook and the tool would then run.
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return outputFor(outcome, options.grantOnAllow ?? false);
|
|
117
|
+
};
|
|
118
|
+
const matcher = {
|
|
119
|
+
hooks: [handler],
|
|
120
|
+
timeout: matcherTimeoutSeconds,
|
|
121
|
+
};
|
|
122
|
+
return { PreToolUse: [matcher] };
|
|
123
|
+
}
|
|
124
|
+
// ---------------------------------------------------------------------------
|
|
125
|
+
/**
|
|
126
|
+
* Ask, with a hold report and a deadline. Every return is a terminal outcome.
|
|
127
|
+
*
|
|
128
|
+
* The decider is started before either timer, so a fast answer never pays for the hold machinery.
|
|
129
|
+
*
|
|
130
|
+
* `held` is the caller's box, not a local: the handler's own catch reads it, so even a throw this
|
|
131
|
+
* function does not anticipate reports whether a hold entry was opened — and the terminal outcome
|
|
132
|
+
* then closes it instead of orphaning it.
|
|
133
|
+
*/
|
|
134
|
+
async function decide(input, ask, emit, signal, decisionTimeoutMs, holdAfterMs, held, localGate, sessionKey) {
|
|
135
|
+
const request = readRequest(input, sessionKey);
|
|
136
|
+
if (request === null) {
|
|
137
|
+
// The gate cannot say which tool this is, so it cannot authorize it. Blocking is the only
|
|
138
|
+
// answer that does not amount to permitting an unidentified call.
|
|
139
|
+
return {
|
|
140
|
+
kind: 'refused',
|
|
141
|
+
request: unreadableRequest(sessionKey),
|
|
142
|
+
held: false,
|
|
143
|
+
refusal: refusal('permission-decision-unavailable', 'the hook input could not be read, so the call it describes has no name to decide about'),
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
// The host's own gate, before anything is asked of anyone. It is consulted here rather than
|
|
147
|
+
// after the decider because a refusal that waits for an unreachable controller to time out is not
|
|
148
|
+
// a refusal, it is a `decisionTimeoutMs` silence that ends in an outage report — and the property
|
|
149
|
+
// this package states is that a locally refused call is refused immediately, by name.
|
|
150
|
+
const localRefusal = askLocalGate(localGate, request);
|
|
151
|
+
if (localRefusal !== null && localRefusal.reason !== 'shell-boundary-command') {
|
|
152
|
+
// `held: false` is a fact, not a default: nothing has been armed yet, so no entry was opened.
|
|
153
|
+
return { kind: 'refused', request, held: false, refusal: localRefusal };
|
|
154
|
+
}
|
|
155
|
+
// regression: a boundary-shaped shell command (publishing, remote surgery, branch deletion, a PR
|
|
156
|
+
// merge) was refused here, in-process, before the controller was asked, so a controller that
|
|
157
|
+
// holds such a call for a person could never deliver that answer and nothing on this host could
|
|
158
|
+
// publish. A boundary shape is escalated like any other call; when the controller cannot be
|
|
159
|
+
// reached the deadline below refuses it. Every other local refusal (the jail, the credential set,
|
|
160
|
+
// an unrecognised git verb) stays local and immediate.
|
|
161
|
+
// Both timers are armed inside the try whose finally disarms them. Between arming and the
|
|
162
|
+
// `finally` there is no window a throw can cross with a timer left live — that is a structural
|
|
163
|
+
// property of this block, not an audit of what happens to be between the lines.
|
|
164
|
+
let holdTimer;
|
|
165
|
+
let deadlineTimer;
|
|
166
|
+
let abortListener;
|
|
167
|
+
try {
|
|
168
|
+
const answer = (async () => ask(request, signal))();
|
|
169
|
+
// The promise is consumed by the race below; this keeps a rejection from being unhandled in the
|
|
170
|
+
// window before the race attaches, which node reports as a process-level warning.
|
|
171
|
+
answer.catch(() => undefined);
|
|
172
|
+
holdTimer = setTimeout(() => {
|
|
173
|
+
held.value = true;
|
|
174
|
+
emit({ kind: 'holding', request });
|
|
175
|
+
}, holdAfterMs);
|
|
176
|
+
holdTimer.unref?.();
|
|
177
|
+
// The deadline timer is ref'd, and that is the fail-closed guarantee itself.
|
|
178
|
+
//
|
|
179
|
+
// An unref'd timer here is the same bug as a gate that cannot say no. An unref'd timer does
|
|
180
|
+
// not hold the event loop open, so a host with nothing else pending — exactly the state a host
|
|
181
|
+
// is in while it waits for a decision — drains the loop before the deadline fires. The decider
|
|
182
|
+
// never answers, the deadline never fires, and this handler never returns: the tool call has no
|
|
183
|
+
// fate at all, which is strictly worse than either answer.
|
|
184
|
+
//
|
|
185
|
+
// Observed on Linux: `gate.test.ts`'s "a decision that never arrives blocks" — the test that
|
|
186
|
+
// proves this exact property — reported "Promise resolution is still pending but the event
|
|
187
|
+
// loop has already resolved" and cancelled, taking its sibling tests with it. It passed on
|
|
188
|
+
// win32 only because something else in that process happened to keep the loop alive; the
|
|
189
|
+
// guarantee was never unconditional, and it is stated as unconditional.
|
|
190
|
+
//
|
|
191
|
+
// Holding the loop open is the correct cost. A tool call is in flight and undecided; a host
|
|
192
|
+
// that exits underneath it has answered nothing. The bound is `decisionTimeoutMs`, and the
|
|
193
|
+
// `finally` below clears the timer on every exit path, so nothing outlives the call. The
|
|
194
|
+
// hold timer stays unref'd — it only emits an observation, and this deadline keeps the loop
|
|
195
|
+
// alive for both.
|
|
196
|
+
const deadline = new Promise((resolve) => {
|
|
197
|
+
deadlineTimer = setTimeout(() => resolve('expired'), decisionTimeoutMs);
|
|
198
|
+
});
|
|
199
|
+
// A missing or hostile signal cannot escape: a throw inside a Promise executor rejects the
|
|
200
|
+
// promise, and the race below carries that rejection into this function's own catch.
|
|
201
|
+
const aborted = new Promise((resolve) => {
|
|
202
|
+
if (signal.aborted)
|
|
203
|
+
resolve('aborted');
|
|
204
|
+
else {
|
|
205
|
+
// Held so the `finally` can detach it. `{once: true}` self-removes only when the event
|
|
206
|
+
// fires, and the ordinary case is that it never does — so on a signal that outlives one tool
|
|
207
|
+
// call, every call would leave a listener behind on it.
|
|
208
|
+
abortListener = () => resolve('aborted');
|
|
209
|
+
signal.addEventListener('abort', abortListener, { once: true });
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
const settled = await Promise.race([
|
|
213
|
+
answer.then((value) => ({ answered: value })),
|
|
214
|
+
deadline,
|
|
215
|
+
aborted,
|
|
216
|
+
]);
|
|
217
|
+
if (settled === 'expired') {
|
|
218
|
+
return {
|
|
219
|
+
kind: 'expired',
|
|
220
|
+
request,
|
|
221
|
+
held: held.value,
|
|
222
|
+
detail: `no decision for ${request.toolName} within ${decisionTimeoutMs}ms; the tool did not run`,
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
if (settled === 'aborted') {
|
|
226
|
+
return {
|
|
227
|
+
kind: 'refused',
|
|
228
|
+
request,
|
|
229
|
+
held: held.value,
|
|
230
|
+
refusal: refusal('permission-decision-unavailable', `the turn was cancelled while a decision for ${request.toolName} was outstanding`),
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
return terminal(request, held.value, settled.answered);
|
|
234
|
+
}
|
|
235
|
+
catch (error) {
|
|
236
|
+
// The decider rejected. An outage — nobody decided — and never a denial, which is why it is a
|
|
237
|
+
// refusal reason rather than a deny with a borrowed message.
|
|
238
|
+
const detail = error instanceof EscalationUnavailable
|
|
239
|
+
? error.message
|
|
240
|
+
: `the decision for ${request.toolName} could not be obtained: ${String(error)}`;
|
|
241
|
+
return {
|
|
242
|
+
kind: 'refused',
|
|
243
|
+
request,
|
|
244
|
+
held: held.value,
|
|
245
|
+
refusal: refusal('permission-decision-unavailable', detail),
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
finally {
|
|
249
|
+
// Both timers are cleared however this returned. A hook handler runs once per tool call, so a
|
|
250
|
+
// timer left armed here is a slow leak that only shows up on a long session. The abort listener
|
|
251
|
+
// is detached for the same reason and in the same place — one exit, three things released.
|
|
252
|
+
if (holdTimer !== undefined)
|
|
253
|
+
clearTimeout(holdTimer);
|
|
254
|
+
if (deadlineTimer !== undefined)
|
|
255
|
+
clearTimeout(deadlineTimer);
|
|
256
|
+
if (abortListener !== undefined) {
|
|
257
|
+
// A signal that does not implement removal must not turn a resolved decision into a thrown
|
|
258
|
+
// hook, which the CLI reads as an absent one.
|
|
259
|
+
try {
|
|
260
|
+
signal.removeEventListener('abort', abortListener);
|
|
261
|
+
}
|
|
262
|
+
catch {
|
|
263
|
+
// Nothing to recover: the listener resolves a promise nobody is waiting on any more.
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* Ask the host's own gate, if there is one.
|
|
270
|
+
*
|
|
271
|
+
* A local gate that throws refuses. It must not fall through to the decider, and the wrong
|
|
272
|
+
* implementation here does not look wrong: falling through is not fail-open — the controller is
|
|
273
|
+
* still asked — so nothing would break in a test and nothing would show in a trace. What it would do
|
|
274
|
+
* is silently convert a local refusal into a remote question, which is the offline property
|
|
275
|
+
* evaporating at the exact moment the controller is unreachable. The invariant is absolute: any
|
|
276
|
+
* error, any outage, any timeout means the tool does not run.
|
|
277
|
+
*/
|
|
278
|
+
function askLocalGate(localGate, request) {
|
|
279
|
+
if (localGate === undefined)
|
|
280
|
+
return null;
|
|
281
|
+
try {
|
|
282
|
+
return localGate(request) ?? null;
|
|
283
|
+
}
|
|
284
|
+
catch (error) {
|
|
285
|
+
return refusal('permission-decision-unavailable', `the host's own gate failed while deciding ${request.toolName}, so the call is refused rather than escalated: ${String(error)}`);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
/** A settled answer, read. The unknown-decision rule lands here. */
|
|
289
|
+
function terminal(request, held, answered) {
|
|
290
|
+
const reading = readDecision(answered);
|
|
291
|
+
if (!reading.recognised) {
|
|
292
|
+
return {
|
|
293
|
+
kind: 'refused',
|
|
294
|
+
request,
|
|
295
|
+
held,
|
|
296
|
+
refusal: refusal('permission-decision-unrecognised',
|
|
297
|
+
// The raw payload travels. A host that drops what it did not understand makes a
|
|
298
|
+
// controller-side bug invisible on the only side that could have seen it.
|
|
299
|
+
`the decision for ${request.toolName} was not one this build understands: ${reading.raw}`),
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
if (reading.decision.behavior === 'deny') {
|
|
303
|
+
return { kind: 'deny', request, held, message: reading.decision.message };
|
|
304
|
+
}
|
|
305
|
+
const { updatedInput } = reading.decision;
|
|
306
|
+
return { kind: 'allow', request, held, ...(updatedInput === undefined ? {} : { updatedInput }) };
|
|
307
|
+
}
|
|
308
|
+
/** Everything unknown, for an input that could not be read at all. Never a thrown alternative. */
|
|
309
|
+
function unreadableRequest(sessionKey) {
|
|
310
|
+
return {
|
|
311
|
+
toolName: '(unnamed tool)',
|
|
312
|
+
toolUseId: '(no tool_use_id)',
|
|
313
|
+
toolInput: null,
|
|
314
|
+
sessionId: '',
|
|
315
|
+
// The one field that is still true when nothing else is. Everything above is a placeholder for
|
|
316
|
+
// an input this code could not read; the controller handle comes from the host, not from that
|
|
317
|
+
// input, so an unreadable call is still attributable to the session it happened in. That is the
|
|
318
|
+
// difference between a refusal a controller can file and one it can only count.
|
|
319
|
+
sessionKey,
|
|
320
|
+
cwd: '',
|
|
321
|
+
agentId: null,
|
|
322
|
+
agentType: null,
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
/**
|
|
326
|
+
* The hook's input in this package's terms, or null if it could not be read at all.
|
|
327
|
+
*
|
|
328
|
+
* The recovery path must not use the thing that broke. This is called from the handler's own
|
|
329
|
+
* `catch`, so if reading the input is what threw, an unguarded read here throws a second time —
|
|
330
|
+
* inside the catch — and the whole handler escapes as a thrown hook, which the CLI treats as
|
|
331
|
+
* absent. A fail-closed wrapper whose recovery path can fail is fail-open, and it looks completely
|
|
332
|
+
* correct.
|
|
333
|
+
*
|
|
334
|
+
* Null rather than a filled-in default, because the two are not the same answer. An input this
|
|
335
|
+
* code cannot read names a tool it cannot name, and asking a decider to authorize "(unnamed tool)"
|
|
336
|
+
* would get a decision about nothing. An unnameable call is not an authorizable one.
|
|
337
|
+
*/
|
|
338
|
+
function readRequest(input, sessionKey) {
|
|
339
|
+
try {
|
|
340
|
+
const anyInput = input;
|
|
341
|
+
const text = (value) => (typeof value === 'string' ? value : null);
|
|
342
|
+
return {
|
|
343
|
+
toolName: text(anyInput['tool_name']) ?? '(unnamed tool)',
|
|
344
|
+
toolUseId: text(anyInput['tool_use_id']) ?? '(no tool_use_id)',
|
|
345
|
+
toolInput: anyInput['tool_input'],
|
|
346
|
+
sessionId: text(anyInput['session_id']) ?? '',
|
|
347
|
+
// Never read from the input: the agent does not know what its controller calls this session.
|
|
348
|
+
sessionKey,
|
|
349
|
+
cwd: text(anyInput['cwd']) ?? '',
|
|
350
|
+
// `agent_id` is the discriminator, not `agent_type`: the type is also present on the main
|
|
351
|
+
// thread of a session started with --agent, so reading it alone calls a main-thread call a
|
|
352
|
+
// subagent one.
|
|
353
|
+
agentId: text(anyInput['agent_id']),
|
|
354
|
+
agentType: text(anyInput['agent_type']),
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
catch {
|
|
358
|
+
return null;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
/**
|
|
362
|
+
* What the CLI is told.
|
|
363
|
+
*
|
|
364
|
+
* Every blocking outcome carries a reason that names which rule fired — a degrade is a named
|
|
365
|
+
* outcome, and the model receives this string verbatim as an `is_error` tool result, so it is the
|
|
366
|
+
* only explanation anyone downstream gets.
|
|
367
|
+
*
|
|
368
|
+
* `holding` is excluded by the type, not by a branch. A hold is emitted while a decision is
|
|
369
|
+
* outstanding; it is never a terminal outcome, so it can never be what this function renders. A
|
|
370
|
+
* branch returning `{}` for it — no opinion, which the CLI reads as allow — would be the one branch
|
|
371
|
+
* that could open the door while looking most harmless. Narrowing the parameter makes that state
|
|
372
|
+
* unrepresentable: the compiler refuses it, where a safer return value would only have waited to
|
|
373
|
+
* be simplified back.
|
|
374
|
+
*/
|
|
375
|
+
function outputFor(outcome, grantOnAllow) {
|
|
376
|
+
if (outcome.kind === 'allow') {
|
|
377
|
+
if (grantOnAllow) {
|
|
378
|
+
// The gate's own decision, made effective. It grants this call and nothing else: the decision
|
|
379
|
+
// was already taken above, and this only stops it from being silently discarded.
|
|
380
|
+
return {
|
|
381
|
+
hookSpecificOutput: {
|
|
382
|
+
hookEventName: 'PreToolUse',
|
|
383
|
+
permissionDecision: 'allow',
|
|
384
|
+
permissionDecisionReason: `the host's gate allowed ${outcome.request.toolName}`,
|
|
385
|
+
...(outcome.updatedInput === undefined ? {} : { updatedInput: outcome.updatedInput }),
|
|
386
|
+
},
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
if (outcome.updatedInput === undefined)
|
|
390
|
+
return {};
|
|
391
|
+
// No `permissionDecision`. See this file's header: an explicit allow skips permission mode,
|
|
392
|
+
// allow rules and `canUseTool` (steps 4-6; operator deny and ask rules still run), so without
|
|
393
|
+
// `grantOnAllow` this gate only ever adds a refusal.
|
|
394
|
+
return {
|
|
395
|
+
hookSpecificOutput: {
|
|
396
|
+
hookEventName: 'PreToolUse',
|
|
397
|
+
updatedInput: outcome.updatedInput,
|
|
398
|
+
},
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
const reason = outcome.kind === 'deny'
|
|
402
|
+
? outcome.message
|
|
403
|
+
: outcome.kind === 'refused'
|
|
404
|
+
? `${outcome.refusal.reason}: ${outcome.refusal.detail}`
|
|
405
|
+
: // One spelling for one concept: the same name the transition carries, so a reader who
|
|
406
|
+
// looks this prefix up finds it.
|
|
407
|
+
`${HOOK_TIMEOUT_EVENT}: ${outcome.detail}`;
|
|
408
|
+
return {
|
|
409
|
+
hookSpecificOutput: {
|
|
410
|
+
hookEventName: 'PreToolUse',
|
|
411
|
+
permissionDecision: 'deny',
|
|
412
|
+
permissionDecisionReason: reason,
|
|
413
|
+
},
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
export { describeRaw };
|
|
417
|
+
//# sourceMappingURL=gate.js.map
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/** The PreToolUse hook and the permission decision path. */
|
|
2
|
+
export type { Decider, Decision, DecisionReading, DecisionRequest } from './decision.js';
|
|
3
|
+
export { describeRaw, readDecision } from './decision.js';
|
|
4
|
+
export type { GateOutcome } from './outcome.js';
|
|
5
|
+
export { gateTransitions, recordGateOutcome } from './outcome.js';
|
|
6
|
+
export type { OutcomeListener, PermissionGateOptions } from './gate.js';
|
|
7
|
+
/**
|
|
8
|
+
* The PreToolUse gate itself.
|
|
9
|
+
*
|
|
10
|
+
* `composeSession` is a convenience that refuses, not a boundary. `composeSession` declines to
|
|
11
|
+
* assemble `grantOnAllow` together with a non-empty `settingSources`
|
|
12
|
+
* (`permission-grant-shadows-settings`), and it is the only place that can see both facts. But
|
|
13
|
+
* every part it composes is exported, including this one, so four lines reach the refused pair
|
|
14
|
+
* without going near it:
|
|
15
|
+
*
|
|
16
|
+
* ```ts
|
|
17
|
+
* const hooks = mergeHooks(observationHooks({ observer }), permissionHooks({ decide, onOutcome, grantOnAllow: true }));
|
|
18
|
+
* registry.create({ cwd, hooks, settingSources: ['project'] }); // no refusal fires
|
|
19
|
+
* ```
|
|
20
|
+
*
|
|
21
|
+
* This is stated rather than closed, deliberately. Hand-composition is a supported posture — the
|
|
22
|
+
* gate is usable without the host — and un-exporting it would remove a legitimate capability to
|
|
23
|
+
* discourage a combination that, per the SDK's documented evaluation order, does not actually
|
|
24
|
+
* override operator deny rules (see `gate.ts` for the order, and for why that is documented rather
|
|
25
|
+
* than measured). An embedder who assembles the pair on purpose should know they have done it;
|
|
26
|
+
* an embedder who expected `composeSession`'s refusal to be a package-wide guarantee should know
|
|
27
|
+
* it is not.
|
|
28
|
+
*/
|
|
29
|
+
export { permissionHooks } from './gate.js';
|
|
30
|
+
export type { EscalationOptions, EscalationResponse, EscalationTransport } from './escalate.js';
|
|
31
|
+
export { EscalationUnavailable, escalatingDecider } from './escalate.js';
|
|
32
|
+
export type { LocalGate, LocalGateOptions, ToolFamilies } from './local.js';
|
|
33
|
+
export { DEFAULT_TOOL_FAMILIES, localGate } from './local.js';
|
|
34
|
+
export type { JailOptions, PathResolver } from './jail.js';
|
|
35
|
+
export { checkPath, checkShellForProtectedPaths, commandFromToolInput, pathFromToolInput } from './jail.js';
|
|
36
|
+
export { classifyShellCommand } from './shell.js';
|
|
37
|
+
export type { GlobalFlag, Invocation, ParsedCommand } from './command.js';
|
|
38
|
+
export { INTERPRETER_NAMES, isInertLiteral, parseCommand, programNameOf, stripComments, tokenize, } from './command.js';
|
|
39
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export { describeRaw, readDecision } from './decision.js';
|
|
2
|
+
export { gateTransitions, recordGateOutcome } from './outcome.js';
|
|
3
|
+
/**
|
|
4
|
+
* The PreToolUse gate itself.
|
|
5
|
+
*
|
|
6
|
+
* `composeSession` is a convenience that refuses, not a boundary. `composeSession` declines to
|
|
7
|
+
* assemble `grantOnAllow` together with a non-empty `settingSources`
|
|
8
|
+
* (`permission-grant-shadows-settings`), and it is the only place that can see both facts. But
|
|
9
|
+
* every part it composes is exported, including this one, so four lines reach the refused pair
|
|
10
|
+
* without going near it:
|
|
11
|
+
*
|
|
12
|
+
* ```ts
|
|
13
|
+
* const hooks = mergeHooks(observationHooks({ observer }), permissionHooks({ decide, onOutcome, grantOnAllow: true }));
|
|
14
|
+
* registry.create({ cwd, hooks, settingSources: ['project'] }); // no refusal fires
|
|
15
|
+
* ```
|
|
16
|
+
*
|
|
17
|
+
* This is stated rather than closed, deliberately. Hand-composition is a supported posture — the
|
|
18
|
+
* gate is usable without the host — and un-exporting it would remove a legitimate capability to
|
|
19
|
+
* discourage a combination that, per the SDK's documented evaluation order, does not actually
|
|
20
|
+
* override operator deny rules (see `gate.ts` for the order, and for why that is documented rather
|
|
21
|
+
* than measured). An embedder who assembles the pair on purpose should know they have done it;
|
|
22
|
+
* an embedder who expected `composeSession`'s refusal to be a package-wide guarantee should know
|
|
23
|
+
* it is not.
|
|
24
|
+
*/
|
|
25
|
+
export { permissionHooks } from './gate.js';
|
|
26
|
+
export { EscalationUnavailable, escalatingDecider } from './escalate.js';
|
|
27
|
+
export { DEFAULT_TOOL_FAMILIES, localGate } from './local.js';
|
|
28
|
+
export { checkPath, checkShellForProtectedPaths, commandFromToolInput, pathFromToolInput } from './jail.js';
|
|
29
|
+
export { classifyShellCommand } from './shell.js';
|
|
30
|
+
export { INTERPRETER_NAMES, isInertLiteral, parseCommand, programNameOf, stripComments, tokenize, } from './command.js';
|
|
31
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The path jail, and the credential denial.
|
|
3
|
+
*
|
|
4
|
+
* The jail bounds where an agent can write at all. It is self-contained: a call whose target
|
|
5
|
+
* resolves outside the declared workspace root is refused without asking anything, so it holds
|
|
6
|
+
* with the controller unreachable, unresponsive, or wrong.
|
|
7
|
+
*
|
|
8
|
+
* The credential denial covers what a default-open read policy misses. Default-open reads are
|
|
9
|
+
* correct for source files and wrong for the host's own token cache. The agent runs as the same
|
|
10
|
+
* OS user as the host, so file permissions are not a boundary against it: an 0600 credential is
|
|
11
|
+
* readable by the agent exactly as it is by the host. This denial is the local control, and it is
|
|
12
|
+
* scoped, not total: it refuses reads through the declared read tools (`Read`, `NotebookRead` by
|
|
13
|
+
* default), writes, and shell commands naming a protected path literally — each by absolute path,
|
|
14
|
+
* with a named refusal. Built-in tools outside the declared families (`Grep`, `Glob`), shell
|
|
15
|
+
* expansion forms (`~`, `$HOME`, `%USERPROFILE%`) and symlink indirection get no opinion here —
|
|
16
|
+
* those calls escalate to the controller, and with it unreachable they are refused as outages
|
|
17
|
+
* rather than by name. Widening the local denial to cover them is a known open question,
|
|
18
|
+
* deliberately not taken in passing: every widening is an over-refusal risk that deserves its own
|
|
19
|
+
* decision.
|
|
20
|
+
*
|
|
21
|
+
* Every unknown resolves toward refusing. No path in the input: refuse. A resolver that throws:
|
|
22
|
+
* refuse. Not absolute: refuse. No declared root: refuse, because a jail with no walls is not a
|
|
23
|
+
* jail. A false refusal costs one human click; a false allow costs the invariant.
|
|
24
|
+
*/
|
|
25
|
+
import type { Refusal } from '../core/refusal.js';
|
|
26
|
+
/**
|
|
27
|
+
* Turns a path into its canonical absolute form.
|
|
28
|
+
*
|
|
29
|
+
* Injected rather than imported, and `core/paths.ts` says why in its own header: its resolution is
|
|
30
|
+
* textual and deliberately never consults the filesystem, so a caller enforcing a real jail supplies
|
|
31
|
+
* a real resolver. `host/` holds the one built on `node:path`; a test supplies one that throws, which
|
|
32
|
+
* is the only way to exercise the normalization-failure path at all.
|
|
33
|
+
*
|
|
34
|
+
* It may throw. A throw is a refusal, never a fall-through.
|
|
35
|
+
*/
|
|
36
|
+
export type PathResolver = (candidate: string) => string;
|
|
37
|
+
export interface JailOptions {
|
|
38
|
+
/** The absolute root every path-taking call must resolve inside. */
|
|
39
|
+
readonly workspaceRoot: string | null;
|
|
40
|
+
readonly resolve: PathResolver;
|
|
41
|
+
/**
|
|
42
|
+
* Absolute paths the agent may not read, write or name in a shell command.
|
|
43
|
+
*
|
|
44
|
+
* Supplied by the embedder at construction — `host/paths.ts` computes the default set. It is a
|
|
45
|
+
* list rather than a predicate so an embedder can read back exactly what is protected.
|
|
46
|
+
*/
|
|
47
|
+
readonly protectedPaths: readonly string[];
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* The path this tool call is about, or null when the input carries none.
|
|
51
|
+
*
|
|
52
|
+
* Null is a refusable state, not a missing value — see `checkPath`. A call whose target cannot be
|
|
53
|
+
* found is not a call that can be bounded, and guessing one would authorize something nobody named.
|
|
54
|
+
*/
|
|
55
|
+
export declare function pathFromToolInput(toolInput: unknown): string | null;
|
|
56
|
+
/** The command this shell call is about, or null when the input carries none. */
|
|
57
|
+
export declare function commandFromToolInput(toolInput: unknown): string | null;
|
|
58
|
+
/**
|
|
59
|
+
* Check a path-taking tool call against the jail and the protected set.
|
|
60
|
+
*
|
|
61
|
+
* The credential check runs first and applies whatever the workspace root is: a token cache that
|
|
62
|
+
* happens to sit inside the workspace is still a token cache. Order matters for the message the
|
|
63
|
+
* reader gets, not for whether the call is refused — both answers block.
|
|
64
|
+
*/
|
|
65
|
+
export declare function checkPath(candidate: string | null, options: JailOptions): Refusal | null;
|
|
66
|
+
/**
|
|
67
|
+
* Check a shell command for credential material named anywhere in it.
|
|
68
|
+
*
|
|
69
|
+
* This one scans the whole command, and that is deliberately unlike the rest of the local gate.
|
|
70
|
+
* Everything else here parses precisely so that a mention of a boundary word does not refuse a
|
|
71
|
+
* benign call, so a reader who notices this function will reasonably wonder whether it was missed.
|
|
72
|
+
*
|
|
73
|
+
* It was not. The two cases are not the same shape. A boundary verb is only dangerous at a command
|
|
74
|
+
* position, so parsing tells you whether it is one. A credential path is dangerous wherever it
|
|
75
|
+
* appears: as an argument to any reader, inside a redirect, in a substitution, or handed to a program
|
|
76
|
+
* this parser has no model of. There is no position at which naming the host's token cache in a shell
|
|
77
|
+
* command is routine, so there is nothing to gain by locating it precisely — and every parser gap
|
|
78
|
+
* would become a way to read the credential. The cost of the choice is bounded and stated: a command
|
|
79
|
+
* that merely mentions the path — an `echo` of a diagnostic, say — is refused, which costs one human
|
|
80
|
+
* click. The alternative costs the credential.
|
|
81
|
+
*
|
|
82
|
+
* So do not narrow this to match the rest of the file. Both credential defences — this one and
|
|
83
|
+
* `checkPath`'s — are covered by tests that fail when either is removed. Reproduce that before
|
|
84
|
+
* changing anything here: make `protectedPathCovering` return null and make this function's
|
|
85
|
+
* haystack empty, then run the suite. The over-refusal is the price of those tests, not an
|
|
86
|
+
* oversight in them.
|
|
87
|
+
*
|
|
88
|
+
* The comparison is on the resolved protected paths and on the raw command text, case-insensitively,
|
|
89
|
+
* because Windows paths reach here in both slash styles and either case.
|
|
90
|
+
*/
|
|
91
|
+
export declare function checkShellForProtectedPaths(command: string, options: JailOptions): Refusal | null;
|
|
92
|
+
//# sourceMappingURL=jail.d.ts.map
|