@naswerks/periscope 1.0.0 → 1.0.1
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 +6 -0
- package/README.md +2 -2
- package/contracts/wire-vectors/bulk_delivered.full.json +21 -0
- package/contracts/wire-vectors/bulk_delivered.minimal.json +21 -0
- package/contracts/wire-vectors/bulk_delivered.without-stat-pair.json +22 -0
- package/contracts/wire-vectors/bulk_failed.declared-reason.json +22 -0
- package/contracts/wire-vectors/bulk_failed.unknown-reason.json +23 -0
- package/contracts/wire-vectors/bulk_request.full.json +21 -0
- package/contracts/wire-vectors/host_configure.set-and-remove.json +28 -0
- package/contracts/wire-vectors/host_configure_result.applied.json +34 -0
- package/contracts/wire-vectors/host_configure_result.refused.json +33 -0
- package/contracts/wire-vectors/link_ack.cursors.json +25 -0
- package/contracts/wire-vectors/link_ack.empty.json +16 -0
- package/contracts/wire-vectors/link_bye.cause.json +16 -0
- package/contracts/wire-vectors/link_hello.empty.json +33 -0
- package/contracts/wire-vectors/link_hello.full.json +44 -0
- package/contracts/wire-vectors/link_ping.nonce.json +16 -0
- package/contracts/wire-vectors/link_ping.unknown-payload-key.json +18 -0
- package/contracts/wire-vectors/link_pong.nonce.json +16 -0
- package/contracts/wire-vectors/link_welcome.empty.json +18 -0
- package/contracts/wire-vectors/link_welcome.full.json +25 -0
- package/contracts/wire-vectors/refused.empty.json +10 -0
- package/contracts/wire-vectors/refused.encode.empty-at.json +18 -0
- package/contracts/wire-vectors/refused.encode.empty-session-id.json +18 -0
- package/contracts/wire-vectors/refused.encode.missing-member.json +22 -0
- package/contracts/wire-vectors/refused.encode.seq-zero.json +18 -0
- package/contracts/wire-vectors/refused.encode.undeclared-configure-refusal.json +34 -0
- package/contracts/wire-vectors/refused.encode.undeclared-wire-refusal.json +27 -0
- package/contracts/wire-vectors/refused.missing-member.json +10 -0
- package/contracts/wire-vectors/refused.not-json.json +10 -0
- package/contracts/wire-vectors/refused.over-limit.json +10 -0
- package/contracts/wire-vectors/refused.seq-zero.json +10 -0
- package/contracts/wire-vectors/refused.unknown-frame.json +10 -0
- package/contracts/wire-vectors/refused.unknown-kind.json +10 -0
- package/contracts/wire-vectors/repository_list.root.json +19 -0
- package/contracts/wire-vectors/repository_list.subdirectory.json +19 -0
- package/contracts/wire-vectors/repository_list_result.entries.json +34 -0
- package/contracts/wire-vectors/repository_list_result.refused.json +24 -0
- package/contracts/wire-vectors/repository_read.head.json +20 -0
- package/contracts/wire-vectors/repository_read_result.refused.json +25 -0
- package/contracts/wire-vectors/repository_read_result.truncated.json +22 -0
- package/contracts/wire-vectors/session_cancel.bare.json +17 -0
- package/contracts/wire-vectors/session_configure.full.json +22 -0
- package/contracts/wire-vectors/session_configure.minimal.json +20 -0
- package/contracts/wire-vectors/session_delta.agent-message.json +27 -0
- package/contracts/wire-vectors/session_list.request.json +18 -0
- package/contracts/wire-vectors/session_list_result.empty.json +21 -0
- package/contracts/wire-vectors/session_list_result.full.json +36 -0
- package/contracts/wire-vectors/session_new.full.json +72 -0
- package/contracts/wire-vectors/session_new.minimal.json +22 -0
- package/contracts/wire-vectors/session_new.request-all-null.json +41 -0
- package/contracts/wire-vectors/session_prompt.empty.json +18 -0
- package/contracts/wire-vectors/session_prompt.multibyte.json +18 -0
- package/contracts/wire-vectors/session_prompt.text.json +18 -0
- package/contracts/wire-vectors/session_prompt.unknown-frame-key.json +20 -0
- package/contracts/wire-vectors/session_prompt.unknown-payload-key.json +20 -0
- package/contracts/wire-vectors/session_update.agent-message.json +26 -0
- package/contracts/wire-vectors/session_update.state-transition-with-activity.json +44 -0
- package/contracts/wire-vectors/session_update.state-transition.json +41 -0
- package/contracts/wire-vectors/session_update.wire-refusal.json +26 -0
- package/contracts/wire-vectors/transcript_failed.declared-reason.json +22 -0
- package/contracts/wire-vectors/transcript_list.from-start.json +19 -0
- package/contracts/wire-vectors/transcript_list.paged.json +19 -0
- package/contracts/wire-vectors/transcript_list_result.entry-without-cwd.json +30 -0
- package/contracts/wire-vectors/transcript_list_result.full.json +29 -0
- package/contracts/wire-vectors/transcript_list_result.last-page.json +29 -0
- package/contracts/wire-vectors/transcript_tail.any-user-entry.json +22 -0
- package/contracts/wire-vectors/transcript_tail.with-needle.json +22 -0
- package/contracts/wire-vectors/transcript_tail_result.absent.json +23 -0
- package/contracts/wire-vectors/transcript_tail_result.found.json +23 -0
- package/contracts/wire-vectors/workspace_list.from-start.json +19 -0
- package/contracts/wire-vectors/workspace_list_result.page.json +48 -0
- package/contracts/wire-vectors/workspace_list_result.refused.json +26 -0
- package/contracts/wire-vectors/workspace_release.by-path-delete-branch.json +22 -0
- package/contracts/wire-vectors/workspace_release.request.json +22 -0
- package/contracts/wire-vectors/workspace_release_bulk.two-entries.json +32 -0
- package/contracts/wire-vectors/workspace_release_bulk_result.mixed.json +37 -0
- package/contracts/wire-vectors/workspace_release_result.partial.json +26 -0
- package/contracts/wire-vectors/workspace_release_result.refused.json +26 -0
- package/contracts/wire-vectors/workspace_release_result.released.json +23 -0
- package/package.json +6 -1
- package/src/bin/command.ts +198 -0
- package/src/bin/config.ts +104 -0
- package/src/bin/login.ts +149 -0
- package/src/bin/main.ts +204 -0
- package/src/bin/pair.ts +246 -0
- package/src/bin/periscope.ts +3 -0
- package/src/bin/posture.ts +232 -0
- package/src/bin/reconfigure.ts +266 -0
- package/src/bin/serve.ts +615 -0
- package/src/bin/status.ts +61 -0
- package/src/bin/workspaces.ts +280 -0
- package/src/control/backoff.ts +40 -0
- package/src/control/codec.ts +557 -0
- package/src/control/credential.ts +33 -0
- package/src/control/frames.ts +1586 -0
- package/src/control/link-state.ts +67 -0
- package/src/control/link.ts +894 -0
- package/src/control/queue.ts +248 -0
- package/src/control/seq.ts +76 -0
- package/src/control/stream-routing.ts +216 -0
- package/src/control/stream.ts +149 -0
- package/src/core/async-queue.ts +63 -0
- package/src/core/failure.ts +61 -0
- package/src/core/index.ts +22 -0
- package/src/core/keyed-turns.ts +47 -0
- package/src/core/paths.ts +84 -0
- package/src/core/refusal.ts +430 -0
- package/src/core/result.ts +27 -0
- package/src/core/time.ts +34 -0
- package/src/core/vocab.ts +44 -0
- package/src/core/workspace-id.ts +11 -0
- package/src/gate/command.ts +617 -0
- package/src/gate/decision.ts +173 -0
- package/src/gate/escalate.ts +164 -0
- package/src/gate/gate.ts +635 -0
- package/src/gate/index.ts +53 -0
- package/src/gate/jail.ts +210 -0
- package/src/gate/local.ts +115 -0
- package/src/gate/outcome.ts +164 -0
- package/src/gate/shell.ts +295 -0
- package/src/host/agent-process.ts +694 -0
- package/src/host/bulk-post.ts +166 -0
- package/src/host/claude-transcripts.ts +404 -0
- package/src/host/config-file.ts +246 -0
- package/src/host/git-facts.ts +119 -0
- package/src/host/hooks.ts +102 -0
- package/src/host/host.ts +1901 -0
- package/src/host/index.ts +92 -0
- package/src/host/link-state-file.ts +79 -0
- package/src/host/loopback.ts +177 -0
- package/src/host/machine.ts +27 -0
- package/src/host/mcp-server.ts +66 -0
- package/src/host/package-facts.ts +25 -0
- package/src/host/paired-credential-store.ts +140 -0
- package/src/host/paths.ts +175 -0
- package/src/host/repository-read.ts +245 -0
- package/src/host/session-store.ts +133 -0
- package/src/host/sign-in.ts +352 -0
- package/src/host/telemetry.ts +105 -0
- package/src/host/token-cache.ts +214 -0
- package/src/host/transcript-fs.ts +110 -0
- package/src/host/wire-request.ts +285 -0
- package/src/host/workspace-fs.ts +90 -0
- package/src/host/workspace-trust.ts +92 -0
- package/src/identity/authorize.ts +127 -0
- package/src/identity/config.ts +227 -0
- package/src/identity/credential.ts +176 -0
- package/src/identity/device-code.ts +217 -0
- package/src/identity/index.ts +84 -0
- package/src/identity/mode.ts +161 -0
- package/src/identity/paired-credential.ts +89 -0
- package/src/identity/pkce.ts +113 -0
- package/src/identity/store.ts +98 -0
- package/src/identity/token.ts +203 -0
- package/src/index.ts +140 -0
- package/src/mcp/descriptor.ts +75 -0
- package/src/mcp/index.ts +23 -0
- package/src/mcp/schema.ts +214 -0
- package/src/mcp/server.ts +180 -0
- package/src/persistence/entry.ts +142 -0
- package/src/persistence/index.ts +47 -0
- package/src/persistence/key.ts +77 -0
- package/src/persistence/mirror.ts +118 -0
- package/src/persistence/receipt.ts +182 -0
- package/src/persistence/retention.ts +118 -0
- package/src/persistence/store.ts +183 -0
- package/src/persistence/transition-log.ts +176 -0
- package/src/protocol.ts +215 -0
- package/src/sessions/index.ts +27 -0
- package/src/sessions/registry.ts +392 -0
- package/src/sessions/session.ts +440 -0
- package/src/sessions/spawn-env.ts +318 -0
- package/src/state/coverage.ts +360 -0
- package/src/state/index.ts +58 -0
- package/src/state/machine.ts +359 -0
- package/src/state/model.ts +417 -0
- package/src/state/observer.ts +499 -0
- package/src/state/reporter.ts +60 -0
- package/src/state/store.ts +157 -0
- package/src/telemetry/index.ts +10 -0
- package/src/telemetry/usage.ts +117 -0
- package/src/workspace/git-worktree.ts +551 -0
- package/src/workspace/index.ts +17 -0
- package/src/workspace/plain-dir.ts +132 -0
- package/src/workspace/provider.ts +159 -0
- package/src/workspace/worktree-porcelain.ts +118 -0
package/src/gate/gate.ts
ADDED
|
@@ -0,0 +1,635 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The gate: `options.hooks.PreToolUse` as the permission mechanism.
|
|
3
|
+
*
|
|
4
|
+
* Why the hook and not `canUseTool`, settled by execution rather than by reading. The hook fires
|
|
5
|
+
* for every tool call — MCP tools and calls inside spawned subagents included, carrying
|
|
6
|
+
* `agent_id`/`agent_type`. `canUseTool` is shadowed by a settings-file allow rule, by
|
|
7
|
+
* `allowedTools`, and by `bypassPermissions` (which additionally emits
|
|
8
|
+
* `CLAUDE_SDK_CAN_USE_TOOL_SHADOWED` on stderr and does not call it). Those are configurations an
|
|
9
|
+
* embedder chooses, so a gate built on `canUseTool` would silently not run on somebody else's setup
|
|
10
|
+
* — and a refusal that does not happen leaves no trace saying so.
|
|
11
|
+
*
|
|
12
|
+
* A hook that throws is fail-open. The CLI treats a throwing handler as absent rather than as a
|
|
13
|
+
* denial, so under `bypassPermissions` — where nothing else is left to say no — a bug in this file
|
|
14
|
+
* would be an open door. Every path here is inside a `try/catch` that returns an explicit
|
|
15
|
+
* `permissionDecision: 'deny'`. This is not defensive style; it is the difference between
|
|
16
|
+
* fail-closed and fail-open, and `gate.test.ts` pins it by throwing on purpose.
|
|
17
|
+
*
|
|
18
|
+
* The emission is unconditional and sits outside the deny path. A gate that fails closed must
|
|
19
|
+
* still say that it did — otherwise the invariant holds while the trace goes silent, and a denial
|
|
20
|
+
* becomes indistinguishable from an outage. So the outcome is computed on both paths, emitted once,
|
|
21
|
+
* and only then converted to a hook output; and the emission has its own guard, because a listener
|
|
22
|
+
* that throws must not be able to convert a deny back into an absent hook.
|
|
23
|
+
*
|
|
24
|
+
* An allow returns no opinion by default, never an explicit `permissionDecision: 'allow'`. This
|
|
25
|
+
* gate exists to add a refusal, never to remove one — two mechanisms, one invariant.
|
|
26
|
+
*
|
|
27
|
+
* What an explicit allow actually skips. The Claude Code permissions documentation states that a
|
|
28
|
+
* PreToolUse hook's decision does not bypass permission rules: deny and ask rules are evaluated
|
|
29
|
+
* whatever the hook returned. So an allow from this gate leaves the operator's deny and ask rules
|
|
30
|
+
* standing and skips only the permission mode, the allow rules and `canUseTool`. Source:
|
|
31
|
+
* https://code.claude.com/docs/en/permissions#extend-permissions-with-hooks
|
|
32
|
+
*
|
|
33
|
+
* That order is documented, not measured, and this package treats the two differently: the runtime
|
|
34
|
+
* beats the docs, and this module exists because executing something found what the documentation
|
|
35
|
+
* denied. The probe that would settle it is written in `gate.live.test.ts` ("does a hook allow
|
|
36
|
+
* override an operator deny rule?") and is not exercised: attempts to run it from inside an agent
|
|
37
|
+
* session were contaminated by the enclosing tool surface, so the denied tool was never the one
|
|
38
|
+
* called. Do not restate this paragraph as a measured claim without that receipt.
|
|
39
|
+
*
|
|
40
|
+
* Without `grantOnAllow` the gate is a veto rather than a gate, which was observed rather than
|
|
41
|
+
* inferred. Saying nothing leaves the agent's own permission mode as the decider, and an embedder
|
|
42
|
+
* who loads no settings files — the default, and the only posture under which this host can state
|
|
43
|
+
* what an agent's permissions are — has left nobody who can say yes. On a real session the gate
|
|
44
|
+
* allowed a `Write`, the tool did not run, and the result read "Claude requested permissions to
|
|
45
|
+
* write to …, but you haven't granted it yet" — in a host with no user to grant anything.
|
|
46
|
+
* `grantOnAllow` does not weaken this gate; it makes the decision this gate already made take
|
|
47
|
+
* effect, one call at a time, for exactly the calls it approved.
|
|
48
|
+
*
|
|
49
|
+
* Two deadlines, and the inner one belongs to this host. `HookCallbackMatcher.timeout` is
|
|
50
|
+
* per-matcher, in seconds, and expires fail-closed on CLI 2.1.210 and later — but the CLI enforces
|
|
51
|
+
* it, so this handler never learns it happened and the trace would show nothing at all. So the
|
|
52
|
+
* host runs its own shorter deadline: it fires first, blocks, and names the expiry; the matcher's
|
|
53
|
+
* remains as the backstop for the case where this code is the thing that hung.
|
|
54
|
+
*/
|
|
55
|
+
import type {
|
|
56
|
+
HookCallbackMatcher,
|
|
57
|
+
HookInput,
|
|
58
|
+
HookJSONOutput,
|
|
59
|
+
HookRegistrations,
|
|
60
|
+
} from '../host/agent-process.js';
|
|
61
|
+
import type { Refusal, RefusalReason } from '../core/refusal.js';
|
|
62
|
+
import { refusal } from '../core/refusal.js';
|
|
63
|
+
import { HOOK_TIMEOUT_EVENT } from '../state/model.js';
|
|
64
|
+
import type { Decider, DecisionRequest } from './decision.js';
|
|
65
|
+
import { describeRaw, readDecision } from './decision.js';
|
|
66
|
+
import type { GateOutcome } from './outcome.js';
|
|
67
|
+
import { EscalationUnavailable } from './escalate.js';
|
|
68
|
+
import type { LocalGate } from './local.js';
|
|
69
|
+
|
|
70
|
+
/** Told about every outcome — allows, denials, outages, expiries. Never only the interesting ones. */
|
|
71
|
+
export type OutcomeListener = (outcome: GateOutcome) => void;
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* An outcome that ends a decision. A hold is not one: it is emitted while a decision is still
|
|
75
|
+
* outstanding, and something else terminates it later.
|
|
76
|
+
*
|
|
77
|
+
* Naming it rather than leaving it implied is what lets `outputFor` refuse the hold at compile time.
|
|
78
|
+
*/
|
|
79
|
+
type TerminalOutcome = Exclude<GateOutcome, { kind: 'holding' }>;
|
|
80
|
+
|
|
81
|
+
export interface PermissionGateOptions {
|
|
82
|
+
readonly decide: Decider;
|
|
83
|
+
readonly onOutcome: OutcomeListener;
|
|
84
|
+
/**
|
|
85
|
+
* The controller's handle for this session — carried onto every decision request.
|
|
86
|
+
*
|
|
87
|
+
* Required rather than optional on purpose: a gate composed without it would send escalations a
|
|
88
|
+
* controller cannot route, and an optional field defaulting to `''` would make that failure
|
|
89
|
+
* silent. Every construction site already has the value. See `DecisionRequest.sessionKey`.
|
|
90
|
+
*/
|
|
91
|
+
readonly sessionKey: string;
|
|
92
|
+
/**
|
|
93
|
+
* The host's own gate, consulted before anything is asked of anyone. Optional; absent means the
|
|
94
|
+
* gate behaves exactly as it did without it.
|
|
95
|
+
*
|
|
96
|
+
* The order is the point, not an optimisation. A local policy consulted after the decider would
|
|
97
|
+
* still block a boundary command, but only by waiting out a 50-second deadline and reporting
|
|
98
|
+
* `permission-decision-unavailable` — an outage, which is not what happened. Consulted first, an
|
|
99
|
+
* unreachable controller is never asked at all and the refusal is immediate, local and named.
|
|
100
|
+
* That is the difference between "refused" and "deferred, and eventually nothing", and it is the
|
|
101
|
+
* property somebody deciding whether to install this package actually checks.
|
|
102
|
+
*
|
|
103
|
+
* It returns a refusal rather than a decision, and that is the audit contract. A `deny` decision
|
|
104
|
+
* is recorded `control/permission_denied` — the same cause a controller's deny produces — so a
|
|
105
|
+
* locally-decided refusal expressed that way would be indistinguishable from a remote one except
|
|
106
|
+
* by free text nobody branches on. A `Refusal` becomes `refusal/<reason>` on the transition, which
|
|
107
|
+
* separates the three cases that must never blur: somebody decided, this host decided, nobody
|
|
108
|
+
* decided.
|
|
109
|
+
*/
|
|
110
|
+
readonly localGate?: LocalGate;
|
|
111
|
+
/**
|
|
112
|
+
* How long the host waits for a decision before blocking and saying so. Milliseconds.
|
|
113
|
+
*
|
|
114
|
+
* Kept meaningfully below `matcherTimeoutSeconds` so this handler is the one that expires. If the
|
|
115
|
+
* matcher's timeout fired first the tool would still be blocked — the CLI is fail-closed — but
|
|
116
|
+
* nothing would be recorded, and a block nobody can explain reads as a hang. Enforced at
|
|
117
|
+
* construction: `permissionHooks` refuses a pair where this does not expire first.
|
|
118
|
+
*/
|
|
119
|
+
readonly decisionTimeoutMs?: number;
|
|
120
|
+
/** After this long with no answer, the call is reported as held. Milliseconds. */
|
|
121
|
+
readonly holdAfterMs?: number;
|
|
122
|
+
/** Handed to the CLI as the matcher's own timeout. Seconds — the SDK's unit, not this package's. */
|
|
123
|
+
readonly matcherTimeoutSeconds?: number;
|
|
124
|
+
/**
|
|
125
|
+
* Make an allow effective, rather than silent. Defaults to false.
|
|
126
|
+
*
|
|
127
|
+
* Off by default, and on is a decision with a named cost. An effective allow skips the
|
|
128
|
+
* permission mode, the allow rules and `canUseTool`. With no settings
|
|
129
|
+
* files loaded, the only one of those with anything to say is the agent's own permission mode,
|
|
130
|
+
* which has nobody to answer it, so skipping it is the whole point.
|
|
131
|
+
*
|
|
132
|
+
* It does not skip operator deny or ask rules; the documentation states those are evaluated
|
|
133
|
+
* whatever a hook returns (see this module's header for the source and for why that is documented
|
|
134
|
+
* rather than measured). The `composeSession` refusal on this flag plus `settingSources` therefore
|
|
135
|
+
* guards two authorities with no stated precedence, not a bypass.
|
|
136
|
+
*
|
|
137
|
+
* An embedder who composes by hand and leaves this off gets a gate that cannot say yes: the tool
|
|
138
|
+
* simply does not run and the agent reports a permission it was never going to be granted. That
|
|
139
|
+
* case raises `gate-cannot-grant` through `onDegrade` on the first allow that does not take
|
|
140
|
+
* effect.
|
|
141
|
+
*/
|
|
142
|
+
readonly grantOnAllow?: boolean;
|
|
143
|
+
/**
|
|
144
|
+
* A named degrade, raised at most once per gate.
|
|
145
|
+
*
|
|
146
|
+
* A degrade is a named outcome, not a comment. The `grantOnAllow`-off residual is raised where an
|
|
147
|
+
* embedder hits it — a running session in which every approved tool call silently fails to
|
|
148
|
+
* happen — rather than only documented where an installer reads. Optional: an embedder who does
|
|
149
|
+
* not pass it gets exactly the previous behaviour, so this adds an observation and never a
|
|
150
|
+
* requirement.
|
|
151
|
+
*
|
|
152
|
+
* `name` is a `RefusalReason`, not a free string — so a degrade cannot be invented at the call
|
|
153
|
+
* site. Adding one means declaring it in `core/refusal.ts` beside every other named outcome, which
|
|
154
|
+
* is what keeps the vocabulary a vocabulary.
|
|
155
|
+
*/
|
|
156
|
+
readonly onDegrade?: (degrade: { readonly name: RefusalReason; readonly detail: string }) => void;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const DEFAULT_MATCHER_TIMEOUT_SECONDS = 60;
|
|
160
|
+
const DEFAULT_DECISION_TIMEOUT_MS = 50_000;
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* The two-deadline invariant, as one declaration with two consumers.
|
|
164
|
+
*
|
|
165
|
+
* It is a function rather than a repeated `if` because two enforcement points for one rule drift
|
|
166
|
+
* apart: `permissionHooks` throws on an inverted pair for an embedder, and `composeSession` must
|
|
167
|
+
* refuse on one for a controller. So the rule lives here and both read it.
|
|
168
|
+
*
|
|
169
|
+
* Returns the explanation when the pair is invalid, or null when it is fine. The caller decides
|
|
170
|
+
* whether that becomes a throw or a named refusal — which is the only thing the two sites disagree
|
|
171
|
+
* about, and it is a decision about audience rather than about the rule.
|
|
172
|
+
*/
|
|
173
|
+
export function deadlineOrderRefusal(
|
|
174
|
+
decisionTimeoutMs: number | undefined,
|
|
175
|
+
matcherTimeoutSeconds: number | undefined,
|
|
176
|
+
): string | null {
|
|
177
|
+
const decision = decisionTimeoutMs ?? DEFAULT_DECISION_TIMEOUT_MS;
|
|
178
|
+
const matcher = matcherTimeoutSeconds ?? DEFAULT_MATCHER_TIMEOUT_SECONDS;
|
|
179
|
+
if (decision < matcher * 1000) return null;
|
|
180
|
+
return (
|
|
181
|
+
`decisionTimeoutMs (${decision}ms) must be below matcherTimeoutSeconds ` +
|
|
182
|
+
`(${matcher}s = ${matcher * 1000}ms). The host's own deadline must expire first: if the matcher ` +
|
|
183
|
+
'expires first the tool is still blocked, but nothing is recorded and the block reads as a hang — ' +
|
|
184
|
+
'the exact failure this gate exists to avoid.'
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* 250ms. Below this, opening and closing a permission entry records a session as "waiting" for
|
|
189
|
+
* less time than it takes to read the record — noise in the one signal the entry exists to carry.
|
|
190
|
+
*/
|
|
191
|
+
const DEFAULT_HOLD_AFTER_MS = 250;
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* The `PreToolUse` registration for a session's gate.
|
|
195
|
+
*
|
|
196
|
+
* Register this after `observationHooks()` — `mergeHooks(observationHooks(…), permissionHooks(…))`.
|
|
197
|
+
* Handlers on one event have their synchronous prologues run in array order and are then awaited
|
|
198
|
+
* concurrently (measured; `mergeHooks`'s own "earlier arguments run first" describes dispatch, not
|
|
199
|
+
* completion). The order is a convention, not a race guard: this gate opens its `permission` entry
|
|
200
|
+
* only from the hold timer (`holdAfterMs`, 250ms by default), after every same-event synchronous
|
|
201
|
+
* prologue has finished — so under either order the observer's `PreToolUse` check runs before any
|
|
202
|
+
* hold entry from this event exists, and cannot close one. Across events the guard is the key, not
|
|
203
|
+
* the timing: the hold entry is keyed by tool_use_id (`gate/outcome.ts`), so a later `PreToolUse`
|
|
204
|
+
* for the same tool — whose observer branch closes name-keyed permission entries — cannot close a
|
|
205
|
+
* hold whose decision is still outstanding.
|
|
206
|
+
*
|
|
207
|
+
* No `matcher` is set. A matcher filters by tool name, and every tool call must reach the gate.
|
|
208
|
+
*/
|
|
209
|
+
export function permissionHooks(options: PermissionGateOptions): HookRegistrations {
|
|
210
|
+
const decisionTimeoutMs = options.decisionTimeoutMs ?? DEFAULT_DECISION_TIMEOUT_MS;
|
|
211
|
+
const holdAfterMs = options.holdAfterMs ?? DEFAULT_HOLD_AFTER_MS;
|
|
212
|
+
const matcherTimeoutSeconds = options.matcherTimeoutSeconds ?? DEFAULT_MATCHER_TIMEOUT_SECONDS;
|
|
213
|
+
|
|
214
|
+
// The two-deadline invariant, enforced where it still can be. A pair where the matcher expires
|
|
215
|
+
// first would still block the tool — the CLI is fail-closed — but this handler would never learn
|
|
216
|
+
// it happened: no outcome, no transition, and a trace showing a `tool` and a `permission` entry
|
|
217
|
+
// opened and never closed. Refused rather than clamped: a silent clamp would alter a value the
|
|
218
|
+
// embedder stated, and this happens before any session exists, so failing fast is safe.
|
|
219
|
+
const inverted = deadlineOrderRefusal(decisionTimeoutMs, matcherTimeoutSeconds);
|
|
220
|
+
if (inverted !== null) throw new Error(inverted);
|
|
221
|
+
|
|
222
|
+
// Per gate, not per call — see the degrade's own note on why it fires once.
|
|
223
|
+
let grantDegradeRaised = false;
|
|
224
|
+
|
|
225
|
+
const emit = (outcome: GateOutcome): void => {
|
|
226
|
+
try {
|
|
227
|
+
options.onOutcome(outcome);
|
|
228
|
+
} catch {
|
|
229
|
+
// A listener that throws must not reach the CLI as a thrown hook, because a thrown hook is an
|
|
230
|
+
// absent hook and the tool would run. Losing one record is bad; losing the refusal is worse.
|
|
231
|
+
}
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
const handler = async (
|
|
235
|
+
input: HookInput,
|
|
236
|
+
_toolUseId: string | undefined,
|
|
237
|
+
hookOptions: { signal: AbortSignal },
|
|
238
|
+
) => {
|
|
239
|
+
// The hold flag lives here, outside decide(), so the recovery below can tell the truth about
|
|
240
|
+
// whether a hold entry was opened. A hardcoded `held: false` in the catch would leave any
|
|
241
|
+
// opened entry with no close — the invariant would hold while the trace showed a session
|
|
242
|
+
// eternally waiting on a decision that was in fact refused.
|
|
243
|
+
const held = { value: false };
|
|
244
|
+
let outcome: TerminalOutcome;
|
|
245
|
+
try {
|
|
246
|
+
if (input.hook_event_name !== 'PreToolUse') return {};
|
|
247
|
+
outcome = await decide(
|
|
248
|
+
input,
|
|
249
|
+
options.decide,
|
|
250
|
+
emit,
|
|
251
|
+
hookOptions.signal,
|
|
252
|
+
decisionTimeoutMs,
|
|
253
|
+
holdAfterMs,
|
|
254
|
+
held,
|
|
255
|
+
options.localGate,
|
|
256
|
+
options.sessionKey,
|
|
257
|
+
);
|
|
258
|
+
} catch (error) {
|
|
259
|
+
// The fail-open hole, closed. Anything at all that escaped above — a bug in this file, a
|
|
260
|
+
// decider that threw synchronously, a request that could not be read — becomes an explicit
|
|
261
|
+
// refusal rather than an absent hook.
|
|
262
|
+
outcome = {
|
|
263
|
+
kind: 'refused',
|
|
264
|
+
request: readRequest(input, options.sessionKey) ?? unreadableRequest(options.sessionKey),
|
|
265
|
+
held: held.value,
|
|
266
|
+
refusal: refusal('permission-decision-unavailable', `the gate itself failed: ${String(error)}`),
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
emit(outcome);
|
|
271
|
+
|
|
272
|
+
// The silent failure, made audible — once. An allow the CLI will not act on is the shape of
|
|
273
|
+
// this gate's worst outcome: the call is approved, the tool does not run, and the agent is told
|
|
274
|
+
// it lacks a permission nobody was ever going to grant. Raised on the first occurrence only,
|
|
275
|
+
// because a session that hits this hits it on every approved call and a degrade per call would
|
|
276
|
+
// bury the signal it exists to carry.
|
|
277
|
+
if (outcome.kind === 'allow' && !(options.grantOnAllow ?? false) && !grantDegradeRaised) {
|
|
278
|
+
grantDegradeRaised = true;
|
|
279
|
+
try {
|
|
280
|
+
options.onDegrade?.({
|
|
281
|
+
name: 'gate-cannot-grant',
|
|
282
|
+
detail:
|
|
283
|
+
`the gate ALLOWED ${outcome.request.toolName} but grantOnAllow is off, so the allow is silent and the ` +
|
|
284
|
+
`tool will not run — this gate can refuse a call and cannot let one through. Set grantOnAllow, or ` +
|
|
285
|
+
`expect every approved call to fail as an ungranted permission.`,
|
|
286
|
+
});
|
|
287
|
+
} catch {
|
|
288
|
+
// Same reason `emit` swallows: a listener that throws must not reach the CLI as a thrown
|
|
289
|
+
// hook, because a thrown hook is an absent hook and the tool would then run.
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
return outputFor(outcome, options.grantOnAllow ?? false);
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
const matcher: HookCallbackMatcher = {
|
|
297
|
+
hooks: [handler],
|
|
298
|
+
timeout: matcherTimeoutSeconds,
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
return { PreToolUse: [matcher] };
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// ---------------------------------------------------------------------------
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Ask, with a hold report and a deadline. Every return is a terminal outcome.
|
|
308
|
+
*
|
|
309
|
+
* The decider is started before either timer, so a fast answer never pays for the hold machinery.
|
|
310
|
+
*
|
|
311
|
+
* `held` is the caller's box, not a local: the handler's own catch reads it, so even a throw this
|
|
312
|
+
* function does not anticipate reports whether a hold entry was opened — and the terminal outcome
|
|
313
|
+
* then closes it instead of orphaning it.
|
|
314
|
+
*/
|
|
315
|
+
async function decide(
|
|
316
|
+
input: Extract<HookInput, { hook_event_name: 'PreToolUse' }>,
|
|
317
|
+
ask: Decider,
|
|
318
|
+
emit: (outcome: GateOutcome) => void,
|
|
319
|
+
signal: AbortSignal,
|
|
320
|
+
decisionTimeoutMs: number,
|
|
321
|
+
holdAfterMs: number,
|
|
322
|
+
held: { value: boolean },
|
|
323
|
+
localGate: LocalGate | undefined,
|
|
324
|
+
sessionKey: string,
|
|
325
|
+
): Promise<TerminalOutcome> {
|
|
326
|
+
const request = readRequest(input, sessionKey);
|
|
327
|
+
if (request === null) {
|
|
328
|
+
// The gate cannot say which tool this is, so it cannot authorize it. Blocking is the only
|
|
329
|
+
// answer that does not amount to permitting an unidentified call.
|
|
330
|
+
return {
|
|
331
|
+
kind: 'refused',
|
|
332
|
+
request: unreadableRequest(sessionKey),
|
|
333
|
+
held: false,
|
|
334
|
+
refusal: refusal(
|
|
335
|
+
'permission-decision-unavailable',
|
|
336
|
+
'the hook input could not be read, so the call it describes has no name to decide about',
|
|
337
|
+
),
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// The host's own gate, before anything is asked of anyone. It is consulted here rather than
|
|
342
|
+
// after the decider because a refusal that waits for an unreachable controller to time out is not
|
|
343
|
+
// a refusal, it is a `decisionTimeoutMs` silence that ends in an outage report — and the property
|
|
344
|
+
// this package states is that a locally refused call is refused immediately, by name.
|
|
345
|
+
const localRefusal = askLocalGate(localGate, request);
|
|
346
|
+
if (localRefusal !== null && localRefusal.reason !== 'shell-boundary-command') {
|
|
347
|
+
// `held: false` is a fact, not a default: nothing has been armed yet, so no entry was opened.
|
|
348
|
+
return { kind: 'refused', request, held: false, refusal: localRefusal };
|
|
349
|
+
}
|
|
350
|
+
// regression: a boundary-shaped shell command (publishing, remote surgery, branch deletion, a PR
|
|
351
|
+
// merge) was refused here, in-process, before the controller was asked, so a controller that
|
|
352
|
+
// holds such a call for a person could never deliver that answer and nothing on this host could
|
|
353
|
+
// publish. A boundary shape is escalated like any other call; when the controller cannot be
|
|
354
|
+
// reached the deadline below refuses it. Every other local refusal (the jail, the credential set,
|
|
355
|
+
// an unrecognised git verb) stays local and immediate.
|
|
356
|
+
|
|
357
|
+
// Both timers are armed inside the try whose finally disarms them. Between arming and the
|
|
358
|
+
// `finally` there is no window a throw can cross with a timer left live — that is a structural
|
|
359
|
+
// property of this block, not an audit of what happens to be between the lines.
|
|
360
|
+
let holdTimer: ReturnType<typeof setTimeout> | undefined;
|
|
361
|
+
let deadlineTimer: ReturnType<typeof setTimeout> | undefined;
|
|
362
|
+
let abortListener: (() => void) | undefined;
|
|
363
|
+
|
|
364
|
+
try {
|
|
365
|
+
const answer = (async (): Promise<unknown> => ask(request, signal))();
|
|
366
|
+
// The promise is consumed by the race below; this keeps a rejection from being unhandled in the
|
|
367
|
+
// window before the race attaches, which node reports as a process-level warning.
|
|
368
|
+
answer.catch(() => undefined);
|
|
369
|
+
|
|
370
|
+
holdTimer = setTimeout(() => {
|
|
371
|
+
held.value = true;
|
|
372
|
+
emit({ kind: 'holding', request });
|
|
373
|
+
}, holdAfterMs);
|
|
374
|
+
holdTimer.unref?.();
|
|
375
|
+
|
|
376
|
+
// The deadline timer is ref'd, and that is the fail-closed guarantee itself.
|
|
377
|
+
//
|
|
378
|
+
// An unref'd timer here is the same bug as a gate that cannot say no. An unref'd timer does
|
|
379
|
+
// not hold the event loop open, so a host with nothing else pending — exactly the state a host
|
|
380
|
+
// is in while it waits for a decision — drains the loop before the deadline fires. The decider
|
|
381
|
+
// never answers, the deadline never fires, and this handler never returns: the tool call has no
|
|
382
|
+
// fate at all, which is strictly worse than either answer.
|
|
383
|
+
//
|
|
384
|
+
// Observed on Linux: `gate.test.ts`'s "a decision that never arrives blocks" — the test that
|
|
385
|
+
// proves this exact property — reported "Promise resolution is still pending but the event
|
|
386
|
+
// loop has already resolved" and cancelled, taking its sibling tests with it. It passed on
|
|
387
|
+
// win32 only because something else in that process happened to keep the loop alive; the
|
|
388
|
+
// guarantee was never unconditional, and it is stated as unconditional.
|
|
389
|
+
//
|
|
390
|
+
// Holding the loop open is the correct cost. A tool call is in flight and undecided; a host
|
|
391
|
+
// that exits underneath it has answered nothing. The bound is `decisionTimeoutMs`, and the
|
|
392
|
+
// `finally` below clears the timer on every exit path, so nothing outlives the call. The
|
|
393
|
+
// hold timer stays unref'd — it only emits an observation, and this deadline keeps the loop
|
|
394
|
+
// alive for both.
|
|
395
|
+
const deadline = new Promise<'expired'>((resolve) => {
|
|
396
|
+
deadlineTimer = setTimeout(() => resolve('expired'), decisionTimeoutMs);
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
// A missing or hostile signal cannot escape: a throw inside a Promise executor rejects the
|
|
400
|
+
// promise, and the race below carries that rejection into this function's own catch.
|
|
401
|
+
const aborted = new Promise<'aborted'>((resolve) => {
|
|
402
|
+
if (signal.aborted) resolve('aborted');
|
|
403
|
+
else {
|
|
404
|
+
// Held so the `finally` can detach it. `{once: true}` self-removes only when the event
|
|
405
|
+
// fires, and the ordinary case is that it never does — so on a signal that outlives one tool
|
|
406
|
+
// call, every call would leave a listener behind on it.
|
|
407
|
+
abortListener = (): void => resolve('aborted');
|
|
408
|
+
signal.addEventListener('abort', abortListener, { once: true });
|
|
409
|
+
}
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
const settled = await Promise.race([
|
|
413
|
+
answer.then((value) => ({ answered: value }) as const),
|
|
414
|
+
deadline,
|
|
415
|
+
aborted,
|
|
416
|
+
]);
|
|
417
|
+
|
|
418
|
+
if (settled === 'expired') {
|
|
419
|
+
return {
|
|
420
|
+
kind: 'expired',
|
|
421
|
+
request,
|
|
422
|
+
held: held.value,
|
|
423
|
+
detail: `no decision for ${request.toolName} within ${decisionTimeoutMs}ms; the tool did not run`,
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
if (settled === 'aborted') {
|
|
428
|
+
return {
|
|
429
|
+
kind: 'refused',
|
|
430
|
+
request,
|
|
431
|
+
held: held.value,
|
|
432
|
+
refusal: refusal(
|
|
433
|
+
'permission-decision-unavailable',
|
|
434
|
+
`the turn was cancelled while a decision for ${request.toolName} was outstanding`,
|
|
435
|
+
),
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
return terminal(request, held.value, settled.answered);
|
|
440
|
+
} catch (error) {
|
|
441
|
+
// The decider rejected. An outage — nobody decided — and never a denial, which is why it is a
|
|
442
|
+
// refusal reason rather than a deny with a borrowed message.
|
|
443
|
+
const detail =
|
|
444
|
+
error instanceof EscalationUnavailable
|
|
445
|
+
? error.message
|
|
446
|
+
: `the decision for ${request.toolName} could not be obtained: ${String(error)}`;
|
|
447
|
+
return {
|
|
448
|
+
kind: 'refused',
|
|
449
|
+
request,
|
|
450
|
+
held: held.value,
|
|
451
|
+
refusal: refusal('permission-decision-unavailable', detail),
|
|
452
|
+
};
|
|
453
|
+
} finally {
|
|
454
|
+
// Both timers are cleared however this returned. A hook handler runs once per tool call, so a
|
|
455
|
+
// timer left armed here is a slow leak that only shows up on a long session. The abort listener
|
|
456
|
+
// is detached for the same reason and in the same place — one exit, three things released.
|
|
457
|
+
if (holdTimer !== undefined) clearTimeout(holdTimer);
|
|
458
|
+
if (deadlineTimer !== undefined) clearTimeout(deadlineTimer);
|
|
459
|
+
if (abortListener !== undefined) {
|
|
460
|
+
// A signal that does not implement removal must not turn a resolved decision into a thrown
|
|
461
|
+
// hook, which the CLI reads as an absent one.
|
|
462
|
+
try {
|
|
463
|
+
signal.removeEventListener('abort', abortListener);
|
|
464
|
+
} catch {
|
|
465
|
+
// Nothing to recover: the listener resolves a promise nobody is waiting on any more.
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* Ask the host's own gate, if there is one.
|
|
473
|
+
*
|
|
474
|
+
* A local gate that throws refuses. It must not fall through to the decider, and the wrong
|
|
475
|
+
* implementation here does not look wrong: falling through is not fail-open — the controller is
|
|
476
|
+
* still asked — so nothing would break in a test and nothing would show in a trace. What it would do
|
|
477
|
+
* is silently convert a local refusal into a remote question, which is the offline property
|
|
478
|
+
* evaporating at the exact moment the controller is unreachable. The invariant is absolute: any
|
|
479
|
+
* error, any outage, any timeout means the tool does not run.
|
|
480
|
+
*/
|
|
481
|
+
function askLocalGate(localGate: LocalGate | undefined, request: DecisionRequest): Refusal | null {
|
|
482
|
+
if (localGate === undefined) return null;
|
|
483
|
+
try {
|
|
484
|
+
return localGate(request) ?? null;
|
|
485
|
+
} catch (error) {
|
|
486
|
+
return refusal(
|
|
487
|
+
'permission-decision-unavailable',
|
|
488
|
+
`the host's own gate failed while deciding ${request.toolName}, so the call is refused rather than escalated: ${String(error)}`,
|
|
489
|
+
);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/** A settled answer, read. The unknown-decision rule lands here. */
|
|
494
|
+
function terminal(request: DecisionRequest, held: boolean, answered: unknown): TerminalOutcome {
|
|
495
|
+
const reading = readDecision(answered);
|
|
496
|
+
|
|
497
|
+
if (!reading.recognised) {
|
|
498
|
+
return {
|
|
499
|
+
kind: 'refused',
|
|
500
|
+
request,
|
|
501
|
+
held,
|
|
502
|
+
refusal: refusal(
|
|
503
|
+
'permission-decision-unrecognised',
|
|
504
|
+
// The raw payload travels. A host that drops what it did not understand makes a
|
|
505
|
+
// controller-side bug invisible on the only side that could have seen it.
|
|
506
|
+
`the decision for ${request.toolName} was not one this build understands: ${reading.raw}`,
|
|
507
|
+
),
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
if (reading.decision.behavior === 'deny') {
|
|
512
|
+
return { kind: 'deny', request, held, message: reading.decision.message };
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
const { updatedInput } = reading.decision;
|
|
516
|
+
return { kind: 'allow', request, held, ...(updatedInput === undefined ? {} : { updatedInput }) };
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/** Everything unknown, for an input that could not be read at all. Never a thrown alternative. */
|
|
520
|
+
function unreadableRequest(sessionKey: string): DecisionRequest {
|
|
521
|
+
return {
|
|
522
|
+
toolName: '(unnamed tool)',
|
|
523
|
+
toolUseId: '(no tool_use_id)',
|
|
524
|
+
toolInput: null,
|
|
525
|
+
sessionId: '',
|
|
526
|
+
// The one field that is still true when nothing else is. Everything above is a placeholder for
|
|
527
|
+
// an input this code could not read; the controller handle comes from the host, not from that
|
|
528
|
+
// input, so an unreadable call is still attributable to the session it happened in. That is the
|
|
529
|
+
// difference between a refusal a controller can file and one it can only count.
|
|
530
|
+
sessionKey,
|
|
531
|
+
cwd: '',
|
|
532
|
+
agentId: null,
|
|
533
|
+
agentType: null,
|
|
534
|
+
};
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/**
|
|
538
|
+
* The hook's input in this package's terms, or null if it could not be read at all.
|
|
539
|
+
*
|
|
540
|
+
* The recovery path must not use the thing that broke. This is called from the handler's own
|
|
541
|
+
* `catch`, so if reading the input is what threw, an unguarded read here throws a second time —
|
|
542
|
+
* inside the catch — and the whole handler escapes as a thrown hook, which the CLI treats as
|
|
543
|
+
* absent. A fail-closed wrapper whose recovery path can fail is fail-open, and it looks completely
|
|
544
|
+
* correct.
|
|
545
|
+
*
|
|
546
|
+
* Null rather than a filled-in default, because the two are not the same answer. An input this
|
|
547
|
+
* code cannot read names a tool it cannot name, and asking a decider to authorize "(unnamed tool)"
|
|
548
|
+
* would get a decision about nothing. An unnameable call is not an authorizable one.
|
|
549
|
+
*/
|
|
550
|
+
function readRequest(input: HookInput, sessionKey: string): DecisionRequest | null {
|
|
551
|
+
try {
|
|
552
|
+
const anyInput = input as Partial<Record<string, unknown>>;
|
|
553
|
+
const text = (value: unknown): string | null => (typeof value === 'string' ? value : null);
|
|
554
|
+
return {
|
|
555
|
+
toolName: text(anyInput['tool_name']) ?? '(unnamed tool)',
|
|
556
|
+
toolUseId: text(anyInput['tool_use_id']) ?? '(no tool_use_id)',
|
|
557
|
+
toolInput: anyInput['tool_input'],
|
|
558
|
+
sessionId: text(anyInput['session_id']) ?? '',
|
|
559
|
+
// Never read from the input: the agent does not know what its controller calls this session.
|
|
560
|
+
sessionKey,
|
|
561
|
+
cwd: text(anyInput['cwd']) ?? '',
|
|
562
|
+
// `agent_id` is the discriminator, not `agent_type`: the type is also present on the main
|
|
563
|
+
// thread of a session started with --agent, so reading it alone calls a main-thread call a
|
|
564
|
+
// subagent one.
|
|
565
|
+
agentId: text(anyInput['agent_id']),
|
|
566
|
+
agentType: text(anyInput['agent_type']),
|
|
567
|
+
};
|
|
568
|
+
} catch {
|
|
569
|
+
return null;
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/**
|
|
574
|
+
* What the CLI is told.
|
|
575
|
+
*
|
|
576
|
+
* Every blocking outcome carries a reason that names which rule fired — a degrade is a named
|
|
577
|
+
* outcome, and the model receives this string verbatim as an `is_error` tool result, so it is the
|
|
578
|
+
* only explanation anyone downstream gets.
|
|
579
|
+
*
|
|
580
|
+
* `holding` is excluded by the type, not by a branch. A hold is emitted while a decision is
|
|
581
|
+
* outstanding; it is never a terminal outcome, so it can never be what this function renders. A
|
|
582
|
+
* branch returning `{}` for it — no opinion, which the CLI reads as allow — would be the one branch
|
|
583
|
+
* that could open the door while looking most harmless. Narrowing the parameter makes that state
|
|
584
|
+
* unrepresentable: the compiler refuses it, where a safer return value would only have waited to
|
|
585
|
+
* be simplified back.
|
|
586
|
+
*/
|
|
587
|
+
function outputFor(
|
|
588
|
+
outcome: Exclude<GateOutcome, { kind: 'holding' }>,
|
|
589
|
+
grantOnAllow: boolean,
|
|
590
|
+
): HookJSONOutput {
|
|
591
|
+
if (outcome.kind === 'allow') {
|
|
592
|
+
if (grantOnAllow) {
|
|
593
|
+
// The gate's own decision, made effective. It grants this call and nothing else: the decision
|
|
594
|
+
// was already taken above, and this only stops it from being silently discarded.
|
|
595
|
+
return {
|
|
596
|
+
hookSpecificOutput: {
|
|
597
|
+
hookEventName: 'PreToolUse',
|
|
598
|
+
permissionDecision: 'allow',
|
|
599
|
+
permissionDecisionReason: `the host's gate allowed ${outcome.request.toolName}`,
|
|
600
|
+
...(outcome.updatedInput === undefined ? {} : { updatedInput: outcome.updatedInput }),
|
|
601
|
+
},
|
|
602
|
+
};
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
if (outcome.updatedInput === undefined) return {};
|
|
606
|
+
// No `permissionDecision`. See this file's header: an explicit allow skips permission mode,
|
|
607
|
+
// allow rules and `canUseTool` (steps 4-6; operator deny and ask rules still run), so without
|
|
608
|
+
// `grantOnAllow` this gate only ever adds a refusal.
|
|
609
|
+
return {
|
|
610
|
+
hookSpecificOutput: {
|
|
611
|
+
hookEventName: 'PreToolUse',
|
|
612
|
+
updatedInput: outcome.updatedInput,
|
|
613
|
+
},
|
|
614
|
+
};
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
const reason =
|
|
618
|
+
outcome.kind === 'deny'
|
|
619
|
+
? outcome.message
|
|
620
|
+
: outcome.kind === 'refused'
|
|
621
|
+
? `${outcome.refusal.reason}: ${outcome.refusal.detail}`
|
|
622
|
+
: // One spelling for one concept: the same name the transition carries, so a reader who
|
|
623
|
+
// looks this prefix up finds it.
|
|
624
|
+
`${HOOK_TIMEOUT_EVENT}: ${outcome.detail}`;
|
|
625
|
+
|
|
626
|
+
return {
|
|
627
|
+
hookSpecificOutput: {
|
|
628
|
+
hookEventName: 'PreToolUse',
|
|
629
|
+
permissionDecision: 'deny',
|
|
630
|
+
permissionDecisionReason: reason,
|
|
631
|
+
},
|
|
632
|
+
};
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
export { describeRaw };
|