@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,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a decision about a tool call is, and how an unrecognised one is read.
|
|
3
|
+
*
|
|
4
|
+
* The shape is the SDK's `PermissionResult`, field for field. Not a shape of this package's own
|
|
5
|
+
* that happens to mean the same thing: the host's own local gate returns the same type, and a
|
|
6
|
+
* `canUseTool` implementation would return it directly, so a second spelling would need a
|
|
7
|
+
* translation in the one place a translation buys nothing. The SDK's word wins, exactly.
|
|
8
|
+
*
|
|
9
|
+
* The SDK documentation page names `PermissionResultDeny`, which the shipped `sdk.d.ts` does not
|
|
10
|
+
* declare (`grep -c PermissionResultDeny` returns 0). The docs page additionally states
|
|
11
|
+
* `{allow: false, reason}`, which does not exist either. Anything written that way fails to
|
|
12
|
+
* compile or, worse, type-widens.
|
|
13
|
+
*
|
|
14
|
+
* A decider returns `unknown`, deliberately. A decision arriving from a controller was never seen
|
|
15
|
+
* by this build's compiler — it is a value off a wire, from a peer that may be newer than this host.
|
|
16
|
+
* Typing the decider as returning a `Decision` would make the compiler assert a fact only the
|
|
17
|
+
* runtime can establish, and the unknown-decision rule below exists precisely because that fact is
|
|
18
|
+
* sometimes false. So the type says `unknown` and `readDecision` is the total function that narrows
|
|
19
|
+
* it — the honesty is in the signature, not in a comment.
|
|
20
|
+
*/
|
|
21
|
+
/**
|
|
22
|
+
* What a decision about one tool call says.
|
|
23
|
+
*
|
|
24
|
+
* `allow` may rewrite the call's arguments through `updatedInput`. The model is never told that
|
|
25
|
+
* happened — observed on a real session — so a rewrite is invisible from the transcript's side.
|
|
26
|
+
*/
|
|
27
|
+
export type Decision = {
|
|
28
|
+
readonly behavior: 'allow';
|
|
29
|
+
readonly updatedInput?: Record<string, unknown>;
|
|
30
|
+
} | {
|
|
31
|
+
readonly behavior: 'deny';
|
|
32
|
+
readonly message: string;
|
|
33
|
+
readonly interrupt?: boolean;
|
|
34
|
+
};
|
|
35
|
+
/** What a decider is asked about. Everything the hook knows, in this package's own terms. */
|
|
36
|
+
export interface DecisionRequest {
|
|
37
|
+
readonly toolName: string;
|
|
38
|
+
readonly toolUseId: string;
|
|
39
|
+
readonly toolInput: unknown;
|
|
40
|
+
/**
|
|
41
|
+
* The agent's own session id, read off the hook input. Not the controller's handle.
|
|
42
|
+
*
|
|
43
|
+
* Read `sessionKey` below before using this to correlate anything. This value is minted by the
|
|
44
|
+
* agent at `system/init`; every frame on the link is keyed by the controller's handle instead. So
|
|
45
|
+
* a decision request identified only by this arrives naming an id the controller may never have
|
|
46
|
+
* seen — a session that dies during start-up never mints one at all, and the first transitions of
|
|
47
|
+
* every session carry `null` here. Correlating on it is a correlation that silently fails exactly
|
|
48
|
+
* when the session is in trouble, which is when a decision matters most.
|
|
49
|
+
*/
|
|
50
|
+
readonly sessionId: string;
|
|
51
|
+
/**
|
|
52
|
+
* The controller's handle — the same string every wire frame for this session is keyed by.
|
|
53
|
+
*
|
|
54
|
+
* This is the field to correlate on, and it exists because nothing else here could. The gate is
|
|
55
|
+
* the second transport: it is an ordinary HTTP POST to a URL nothing on the wire announces, so the
|
|
56
|
+
* body is the only place the two transports can be tied together. Without it a controller has to
|
|
57
|
+
* build an agent-id index and answer non-2xx for any id it has not seen yet.
|
|
58
|
+
*
|
|
59
|
+
* The host supplies it; it is never read from the agent's input, because the agent has no idea
|
|
60
|
+
* what its controller calls it.
|
|
61
|
+
*/
|
|
62
|
+
readonly sessionKey: string;
|
|
63
|
+
readonly cwd: string;
|
|
64
|
+
/**
|
|
65
|
+
* The subagent this call came from, or null on the main thread.
|
|
66
|
+
*
|
|
67
|
+
* `agent_id` is the field that distinguishes a subagent call — `agent_type` is also present on the
|
|
68
|
+
* main thread of a session started with `--agent`, so branching on the type alone would read a
|
|
69
|
+
* main-thread call as a subagent one.
|
|
70
|
+
*/
|
|
71
|
+
readonly agentId: string | null;
|
|
72
|
+
readonly agentType: string | null;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Answers one tool call. Returns `unknown` — see this file's header.
|
|
76
|
+
*
|
|
77
|
+
* The `signal` aborts when the surrounding turn is cancelled. A decider that reaches a controller
|
|
78
|
+
* passes it through so a cancelled turn does not leave a request in flight.
|
|
79
|
+
*/
|
|
80
|
+
export type Decider = (request: DecisionRequest, signal: AbortSignal) => Promise<unknown>;
|
|
81
|
+
/**
|
|
82
|
+
* A decision read from an untrusted value: either one this build understands, or the raw payload.
|
|
83
|
+
*
|
|
84
|
+
* The raw payload is preserved rather than discarded. A host that silently drops what it did not
|
|
85
|
+
* understand makes a controller-side bug invisible on the only side that could have seen it — the
|
|
86
|
+
* controller believes it answered, the tool is blocked, and nothing anywhere says why.
|
|
87
|
+
*/
|
|
88
|
+
export type DecisionReading = {
|
|
89
|
+
readonly recognised: true;
|
|
90
|
+
readonly decision: Decision;
|
|
91
|
+
} | {
|
|
92
|
+
readonly recognised: false;
|
|
93
|
+
readonly raw: string;
|
|
94
|
+
};
|
|
95
|
+
/** The raw value as one line, bounded and marked. Never throws — a cyclic value is still evidence. */
|
|
96
|
+
export declare function describeRaw(raw: unknown): string;
|
|
97
|
+
/**
|
|
98
|
+
* Read an untrusted value as a decision. Total: every input produces a reading.
|
|
99
|
+
*
|
|
100
|
+
* A value this build has never seen is never an allow. A controller running ahead of a host, a
|
|
101
|
+
* decision tier added later, a rolled-back deploy — each produces a `behavior` this code does not
|
|
102
|
+
* know, and the only safe reading of "I do not understand your answer" is that no answer was given.
|
|
103
|
+
* Borrowed from the Agent Client Protocol's `RequestPermissionOutcome::Other`, whose rule is that an
|
|
104
|
+
* agent which does not understand an outcome MUST NOT treat it as approval.
|
|
105
|
+
*
|
|
106
|
+
* It is refused here, by the gate, rather than at the transport. A strict codec could reject the
|
|
107
|
+
* frame instead — but then the gate never runs, and the tool's fate depends on a parse error rather
|
|
108
|
+
* than on a policy act with a receipt.
|
|
109
|
+
*/
|
|
110
|
+
export declare function readDecision(raw: unknown): DecisionReading;
|
|
111
|
+
//# sourceMappingURL=decision.d.ts.map
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a decision about a tool call is, and how an unrecognised one is read.
|
|
3
|
+
*
|
|
4
|
+
* The shape is the SDK's `PermissionResult`, field for field. Not a shape of this package's own
|
|
5
|
+
* that happens to mean the same thing: the host's own local gate returns the same type, and a
|
|
6
|
+
* `canUseTool` implementation would return it directly, so a second spelling would need a
|
|
7
|
+
* translation in the one place a translation buys nothing. The SDK's word wins, exactly.
|
|
8
|
+
*
|
|
9
|
+
* The SDK documentation page names `PermissionResultDeny`, which the shipped `sdk.d.ts` does not
|
|
10
|
+
* declare (`grep -c PermissionResultDeny` returns 0). The docs page additionally states
|
|
11
|
+
* `{allow: false, reason}`, which does not exist either. Anything written that way fails to
|
|
12
|
+
* compile or, worse, type-widens.
|
|
13
|
+
*
|
|
14
|
+
* A decider returns `unknown`, deliberately. A decision arriving from a controller was never seen
|
|
15
|
+
* by this build's compiler — it is a value off a wire, from a peer that may be newer than this host.
|
|
16
|
+
* Typing the decider as returning a `Decision` would make the compiler assert a fact only the
|
|
17
|
+
* runtime can establish, and the unknown-decision rule below exists precisely because that fact is
|
|
18
|
+
* sometimes false. So the type says `unknown` and `readDecision` is the total function that narrows
|
|
19
|
+
* it — the honesty is in the signature, not in a comment.
|
|
20
|
+
*/
|
|
21
|
+
/**
|
|
22
|
+
* How much of an unrecognised payload is carried into the trace.
|
|
23
|
+
*
|
|
24
|
+
* Bounded because the value goes into a transition that is retained and may cross the wire, and an
|
|
25
|
+
* unbounded field there is a way for a peer to fill this host's memory. Truncation is marked, so a
|
|
26
|
+
* reader can tell a short payload from a clipped one.
|
|
27
|
+
*/
|
|
28
|
+
const RAW_PAYLOAD_LIMIT = 512;
|
|
29
|
+
/** The raw value as one line, bounded and marked. Never throws — a cyclic value is still evidence. */
|
|
30
|
+
export function describeRaw(raw) {
|
|
31
|
+
let text;
|
|
32
|
+
try {
|
|
33
|
+
text = raw === undefined ? 'undefined' : JSON.stringify(raw);
|
|
34
|
+
if (text === undefined)
|
|
35
|
+
text = String(raw);
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
// A cyclic or otherwise unserialisable payload still tells the reader its type, which is more
|
|
39
|
+
// than nothing and is the whole point of preserving it.
|
|
40
|
+
text = `[unserialisable ${typeof raw}]`;
|
|
41
|
+
}
|
|
42
|
+
return text.length > RAW_PAYLOAD_LIMIT ? `${text.slice(0, RAW_PAYLOAD_LIMIT)}…[truncated]` : text;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Read an untrusted value as a decision. Total: every input produces a reading.
|
|
46
|
+
*
|
|
47
|
+
* A value this build has never seen is never an allow. A controller running ahead of a host, a
|
|
48
|
+
* decision tier added later, a rolled-back deploy — each produces a `behavior` this code does not
|
|
49
|
+
* know, and the only safe reading of "I do not understand your answer" is that no answer was given.
|
|
50
|
+
* Borrowed from the Agent Client Protocol's `RequestPermissionOutcome::Other`, whose rule is that an
|
|
51
|
+
* agent which does not understand an outcome MUST NOT treat it as approval.
|
|
52
|
+
*
|
|
53
|
+
* It is refused here, by the gate, rather than at the transport. A strict codec could reject the
|
|
54
|
+
* frame instead — but then the gate never runs, and the tool's fate depends on a parse error rather
|
|
55
|
+
* than on a policy act with a receipt.
|
|
56
|
+
*/
|
|
57
|
+
export function readDecision(raw) {
|
|
58
|
+
if (typeof raw !== 'object' || raw === null)
|
|
59
|
+
return { recognised: false, raw: describeRaw(raw) };
|
|
60
|
+
const candidate = raw;
|
|
61
|
+
if (candidate.behavior === 'allow') {
|
|
62
|
+
const updatedInput = candidate.updatedInput;
|
|
63
|
+
// An `allow` whose updatedInput is present but not an object is not an allow with the field
|
|
64
|
+
// dropped: it is a decision this build cannot carry out, and carrying out the half it
|
|
65
|
+
// understood would run the tool with arguments nobody approved.
|
|
66
|
+
if (updatedInput === undefined)
|
|
67
|
+
return { recognised: true, decision: { behavior: 'allow' } };
|
|
68
|
+
if (typeof updatedInput !== 'object' || updatedInput === null || Array.isArray(updatedInput)) {
|
|
69
|
+
return { recognised: false, raw: describeRaw(raw) };
|
|
70
|
+
}
|
|
71
|
+
return {
|
|
72
|
+
recognised: true,
|
|
73
|
+
decision: { behavior: 'allow', updatedInput: updatedInput },
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
if (candidate.behavior === 'deny') {
|
|
77
|
+
// `message` is required on a deny by the SDK's own type. A deny without one would reach the
|
|
78
|
+
// model as a blocked call with no stated reason, which is the silent refusal this package
|
|
79
|
+
// forbids everywhere else — so it is unrecognised rather than quietly given a default.
|
|
80
|
+
if (typeof candidate.message !== 'string' || candidate.message.length === 0) {
|
|
81
|
+
return { recognised: false, raw: describeRaw(raw) };
|
|
82
|
+
}
|
|
83
|
+
const interrupt = candidate.interrupt;
|
|
84
|
+
if (interrupt !== undefined && typeof interrupt !== 'boolean')
|
|
85
|
+
return { recognised: false, raw: describeRaw(raw) };
|
|
86
|
+
return {
|
|
87
|
+
recognised: true,
|
|
88
|
+
decision: {
|
|
89
|
+
behavior: 'deny',
|
|
90
|
+
message: candidate.message,
|
|
91
|
+
...(interrupt === undefined ? {} : { interrupt }),
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
return { recognised: false, raw: describeRaw(raw) };
|
|
96
|
+
}
|
|
97
|
+
//# sourceMappingURL=decision.js.map
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import type { ControllerCredential } from '../control/credential.js';
|
|
2
|
+
import type { Decider } from './decision.js';
|
|
3
|
+
/** Just enough of a response to decide on it. `fetch`'s own `Response` satisfies this. */
|
|
4
|
+
export interface EscalationResponse {
|
|
5
|
+
readonly status: number;
|
|
6
|
+
text(): Promise<string>;
|
|
7
|
+
}
|
|
8
|
+
export type EscalationTransport = (url: string, init: {
|
|
9
|
+
readonly method: string;
|
|
10
|
+
readonly headers: Record<string, string>;
|
|
11
|
+
readonly body: string;
|
|
12
|
+
readonly signal: AbortSignal;
|
|
13
|
+
}) => Promise<EscalationResponse>;
|
|
14
|
+
export interface EscalationOptions {
|
|
15
|
+
/** Where the controller answers. Supplied by the embedder; never derived here. */
|
|
16
|
+
readonly url: string;
|
|
17
|
+
readonly transport: EscalationTransport;
|
|
18
|
+
/** Sent verbatim, on every request. For a scheme whose value does not change. */
|
|
19
|
+
readonly headers?: Record<string, string>;
|
|
20
|
+
/**
|
|
21
|
+
* The credential this host presents on each decision request. Resolved per request.
|
|
22
|
+
*
|
|
23
|
+
* Why it is a credential and not a header. The static `headers` above cannot carry a bearer
|
|
24
|
+
* token: a token is refreshed on a schedule this module does not know, so a value captured once
|
|
25
|
+
* would be presented for the life of the host and start failing silently at the first expiry —
|
|
26
|
+
* as an outage, on every tool call, which is the failure shape this whole file is written against.
|
|
27
|
+
* The same seam the link uses is asked again here, and it hands back a live value.
|
|
28
|
+
*
|
|
29
|
+
* Its failure posture is the opposite of the link's, deliberately. The link connects with no
|
|
30
|
+
* headers when its credential refuses — a stated decision, on the ground that a version without
|
|
31
|
+
* identity is better than one pretending to have a scheme. That reasoning does not transfer here.
|
|
32
|
+
* The link carries observations; this endpoint decides whether a tool runs. An unauthenticated
|
|
33
|
+
* request to it is an unauthenticated permission decision, and anyone who can reach the URL can
|
|
34
|
+
* answer for the agent. So a credential that refuses is an outage and the tool does not run — the
|
|
35
|
+
* gate's own invariant, applied to the gate's own transport. Two transports, two postures, and the
|
|
36
|
+
* difference is what each one can be used to do.
|
|
37
|
+
*
|
|
38
|
+
* Absent means no credential is presented, which is the previous behaviour and stays available for
|
|
39
|
+
* an embedder whose endpoint is reachable only from inside its own network.
|
|
40
|
+
*
|
|
41
|
+
* The precondition: a credential that refuses is not a quieter credential — it is an outage on
|
|
42
|
+
* every tool call, so the option must be omitted rather than filled with a placeholder when there
|
|
43
|
+
* is no identity. `bin/periscope.ts` does exactly that, and the composition is the contract.
|
|
44
|
+
*
|
|
45
|
+
* What this package can present is narrower than what a controller may require. `identity/`
|
|
46
|
+
* implements the authorization-code, refresh-token and device-code grants — every one of which
|
|
47
|
+
* mints a delegated user token. There is no client-credentials grant here, so this host cannot
|
|
48
|
+
* obtain an app-only token at all, and `Authorization` carries one header, so it cannot present a
|
|
49
|
+
* second scheme selector alongside the bearer. A controller that admits only a machine app role
|
|
50
|
+
* therefore refuses both postures — a configured host with a user token and an unconfigured host
|
|
51
|
+
* with none — and it refuses them at the door, fail-closed. Nothing in this file grants this host
|
|
52
|
+
* a machine identity, and a reader should not infer from the presence of this option that one
|
|
53
|
+
* exists.
|
|
54
|
+
*/
|
|
55
|
+
readonly credential?: ControllerCredential;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Thrown for every way the ask failed, as distinct from a decision that said no.
|
|
59
|
+
*
|
|
60
|
+
* It is an error rather than a returned value because the gate's contract is that the decider
|
|
61
|
+
* returns whatever the controller said; "the controller said nothing" is not something it said.
|
|
62
|
+
* The gate turns this into `permission-decision-unavailable` — an outage, never a denial.
|
|
63
|
+
*/
|
|
64
|
+
export declare class EscalationUnavailable extends Error {
|
|
65
|
+
constructor(detail: string);
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* A decider that asks a controller over HTTP.
|
|
69
|
+
*
|
|
70
|
+
* Returns the parsed body as `unknown`: reading it as a decision is `readDecision`'s job, and a
|
|
71
|
+
* well-formed answer this build does not recognise must reach the gate to be refused by the gate.
|
|
72
|
+
*
|
|
73
|
+
* The two failure kinds are split on purpose, and the split is not obvious.
|
|
74
|
+
* - A non-2xx, a transport error, or a body that is not JSON is an outage. The controller did not
|
|
75
|
+
* answer, or answered something that is not an answer. A controller-side fix.
|
|
76
|
+
* - Valid JSON that is not a decision this build knows is not handled here. It is returned intact
|
|
77
|
+
* and the gate refuses it as unrecognised. A controller-version fix.
|
|
78
|
+
* Malformed JSON is an outage rather than an unrecognised decision because version skew produces a
|
|
79
|
+
* *different* decision, never a broken one.
|
|
80
|
+
*/
|
|
81
|
+
export declare function escalatingDecider(options: EscalationOptions): Decider;
|
|
82
|
+
//# sourceMappingURL=escalate.d.ts.map
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Asking a controller. The one place this package makes an outbound decision request.
|
|
3
|
+
*
|
|
4
|
+
* The status is discriminated before the body is read, and that order is the whole file. A client
|
|
5
|
+
* that parses first and branches second turns a 500 carrying a perfectly valid problem-details body
|
|
6
|
+
* into an object with no `allow` field, whose falsy value renders as a refusal somebody made. That
|
|
7
|
+
* is a controller outage impersonating a human decision — nobody had said no, and the trace said
|
|
8
|
+
* somebody had. Reading `status` first makes that shape unwritable.
|
|
9
|
+
*
|
|
10
|
+
* The transport is injected and structural. A minimal `{status, text()}` shape rather than the DOM
|
|
11
|
+
* `Response` type: the global `fetch` satisfies it, a test double satisfies it without a server, and
|
|
12
|
+
* nothing here acquires a dependency on a browser lib for a package that runs on a server.
|
|
13
|
+
*/
|
|
14
|
+
import { certificateRemedy, describeFailure, isCertificateRefusal } from '../core/failure.js';
|
|
15
|
+
/**
|
|
16
|
+
* Thrown for every way the ask failed, as distinct from a decision that said no.
|
|
17
|
+
*
|
|
18
|
+
* It is an error rather than a returned value because the gate's contract is that the decider
|
|
19
|
+
* returns whatever the controller said; "the controller said nothing" is not something it said.
|
|
20
|
+
* The gate turns this into `permission-decision-unavailable` — an outage, never a denial.
|
|
21
|
+
*/
|
|
22
|
+
export class EscalationUnavailable extends Error {
|
|
23
|
+
constructor(detail) {
|
|
24
|
+
super(detail);
|
|
25
|
+
this.name = 'EscalationUnavailable';
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* A decider that asks a controller over HTTP.
|
|
30
|
+
*
|
|
31
|
+
* Returns the parsed body as `unknown`: reading it as a decision is `readDecision`'s job, and a
|
|
32
|
+
* well-formed answer this build does not recognise must reach the gate to be refused by the gate.
|
|
33
|
+
*
|
|
34
|
+
* The two failure kinds are split on purpose, and the split is not obvious.
|
|
35
|
+
* - A non-2xx, a transport error, or a body that is not JSON is an outage. The controller did not
|
|
36
|
+
* answer, or answered something that is not an answer. A controller-side fix.
|
|
37
|
+
* - Valid JSON that is not a decision this build knows is not handled here. It is returned intact
|
|
38
|
+
* and the gate refuses it as unrecognised. A controller-version fix.
|
|
39
|
+
* Malformed JSON is an outage rather than an unrecognised decision because version skew produces a
|
|
40
|
+
* *different* decision, never a broken one.
|
|
41
|
+
*/
|
|
42
|
+
export function escalatingDecider(options) {
|
|
43
|
+
return async (request, signal) => {
|
|
44
|
+
// ---- The credential, before the request exists. A refusal here never becomes a request: an
|
|
45
|
+
// unauthenticated ask is not a quieter version of an authenticated one, it is a different act.
|
|
46
|
+
const authorization = {};
|
|
47
|
+
if (options.credential !== undefined) {
|
|
48
|
+
const authorized = await options.credential.authorize();
|
|
49
|
+
if (!authorized.ok) {
|
|
50
|
+
throw new EscalationUnavailable(`this host has no credential to present on a decision request for ${request.toolName} ` +
|
|
51
|
+
`(${authorized.refusal.reason}: ${authorized.refusal.detail}). The request was NOT sent: ` +
|
|
52
|
+
`an unauthenticated permission decision is one anybody who can reach the endpoint could ` +
|
|
53
|
+
`answer, so the gate treats this as an outage and the tool does not run`);
|
|
54
|
+
}
|
|
55
|
+
authorization[authorized.value.header] = authorized.value.value;
|
|
56
|
+
}
|
|
57
|
+
let response;
|
|
58
|
+
try {
|
|
59
|
+
response = await options.transport(options.url, {
|
|
60
|
+
method: 'POST',
|
|
61
|
+
headers: { 'content-type': 'application/json', ...(options.headers ?? {}), ...authorization },
|
|
62
|
+
body: JSON.stringify(request),
|
|
63
|
+
signal,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
const why = describeFailure(error);
|
|
68
|
+
throw new EscalationUnavailable(isCertificateRefusal(error)
|
|
69
|
+
? `the decision request to ${options.url} was refused at TLS (${why}). ${certificateRemedy('the decision endpoint')}`
|
|
70
|
+
: `the decision request to ${options.url} did not complete: ${why}`);
|
|
71
|
+
}
|
|
72
|
+
// ---- Status first. Nothing below this line has read the body. ----
|
|
73
|
+
if (response.status < 200 || response.status >= 300) {
|
|
74
|
+
throw new EscalationUnavailable(`the controller answered ${response.status} to a decision request for ${request.toolName}; ` +
|
|
75
|
+
'the body was not read, because a body that parses is not an answer that was given');
|
|
76
|
+
}
|
|
77
|
+
let body;
|
|
78
|
+
try {
|
|
79
|
+
body = await response.text();
|
|
80
|
+
}
|
|
81
|
+
catch (error) {
|
|
82
|
+
throw new EscalationUnavailable(`the decision response body could not be read: ${String(error)}`);
|
|
83
|
+
}
|
|
84
|
+
try {
|
|
85
|
+
return JSON.parse(body);
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
throw new EscalationUnavailable(`the controller answered ${response.status} with a body that is not JSON (${body.length} bytes)`);
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
//# sourceMappingURL=escalate.js.map
|
|
@@ -0,0 +1,173 @@
|
|
|
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 { HookRegistrations } from '../host/agent-process.js';
|
|
56
|
+
import type { RefusalReason } from '../core/refusal.js';
|
|
57
|
+
import type { Decider } from './decision.js';
|
|
58
|
+
import { describeRaw } from './decision.js';
|
|
59
|
+
import type { GateOutcome } from './outcome.js';
|
|
60
|
+
import type { LocalGate } from './local.js';
|
|
61
|
+
/** Told about every outcome — allows, denials, outages, expiries. Never only the interesting ones. */
|
|
62
|
+
export type OutcomeListener = (outcome: GateOutcome) => void;
|
|
63
|
+
export interface PermissionGateOptions {
|
|
64
|
+
readonly decide: Decider;
|
|
65
|
+
readonly onOutcome: OutcomeListener;
|
|
66
|
+
/**
|
|
67
|
+
* The controller's handle for this session — carried onto every decision request.
|
|
68
|
+
*
|
|
69
|
+
* Required rather than optional on purpose: a gate composed without it would send escalations a
|
|
70
|
+
* controller cannot route, and an optional field defaulting to `''` would make that failure
|
|
71
|
+
* silent. Every construction site already has the value. See `DecisionRequest.sessionKey`.
|
|
72
|
+
*/
|
|
73
|
+
readonly sessionKey: string;
|
|
74
|
+
/**
|
|
75
|
+
* The host's own gate, consulted before anything is asked of anyone. Optional; absent means the
|
|
76
|
+
* gate behaves exactly as it did without it.
|
|
77
|
+
*
|
|
78
|
+
* The order is the point, not an optimisation. A local policy consulted after the decider would
|
|
79
|
+
* still block a boundary command, but only by waiting out a 50-second deadline and reporting
|
|
80
|
+
* `permission-decision-unavailable` — an outage, which is not what happened. Consulted first, an
|
|
81
|
+
* unreachable controller is never asked at all and the refusal is immediate, local and named.
|
|
82
|
+
* That is the difference between "refused" and "deferred, and eventually nothing", and it is the
|
|
83
|
+
* property somebody deciding whether to install this package actually checks.
|
|
84
|
+
*
|
|
85
|
+
* It returns a refusal rather than a decision, and that is the audit contract. A `deny` decision
|
|
86
|
+
* is recorded `control/permission_denied` — the same cause a controller's deny produces — so a
|
|
87
|
+
* locally-decided refusal expressed that way would be indistinguishable from a remote one except
|
|
88
|
+
* by free text nobody branches on. A `Refusal` becomes `refusal/<reason>` on the transition, which
|
|
89
|
+
* separates the three cases that must never blur: somebody decided, this host decided, nobody
|
|
90
|
+
* decided.
|
|
91
|
+
*/
|
|
92
|
+
readonly localGate?: LocalGate;
|
|
93
|
+
/**
|
|
94
|
+
* How long the host waits for a decision before blocking and saying so. Milliseconds.
|
|
95
|
+
*
|
|
96
|
+
* Kept meaningfully below `matcherTimeoutSeconds` so this handler is the one that expires. If the
|
|
97
|
+
* matcher's timeout fired first the tool would still be blocked — the CLI is fail-closed — but
|
|
98
|
+
* nothing would be recorded, and a block nobody can explain reads as a hang. Enforced at
|
|
99
|
+
* construction: `permissionHooks` refuses a pair where this does not expire first.
|
|
100
|
+
*/
|
|
101
|
+
readonly decisionTimeoutMs?: number;
|
|
102
|
+
/** After this long with no answer, the call is reported as held. Milliseconds. */
|
|
103
|
+
readonly holdAfterMs?: number;
|
|
104
|
+
/** Handed to the CLI as the matcher's own timeout. Seconds — the SDK's unit, not this package's. */
|
|
105
|
+
readonly matcherTimeoutSeconds?: number;
|
|
106
|
+
/**
|
|
107
|
+
* Make an allow effective, rather than silent. Defaults to false.
|
|
108
|
+
*
|
|
109
|
+
* Off by default, and on is a decision with a named cost. An effective allow skips the
|
|
110
|
+
* permission mode, the allow rules and `canUseTool`. With no settings
|
|
111
|
+
* files loaded, the only one of those with anything to say is the agent's own permission mode,
|
|
112
|
+
* which has nobody to answer it, so skipping it is the whole point.
|
|
113
|
+
*
|
|
114
|
+
* It does not skip operator deny or ask rules; the documentation states those are evaluated
|
|
115
|
+
* whatever a hook returns (see this module's header for the source and for why that is documented
|
|
116
|
+
* rather than measured). The `composeSession` refusal on this flag plus `settingSources` therefore
|
|
117
|
+
* guards two authorities with no stated precedence, not a bypass.
|
|
118
|
+
*
|
|
119
|
+
* An embedder who composes by hand and leaves this off gets a gate that cannot say yes: the tool
|
|
120
|
+
* simply does not run and the agent reports a permission it was never going to be granted. That
|
|
121
|
+
* case raises `gate-cannot-grant` through `onDegrade` on the first allow that does not take
|
|
122
|
+
* effect.
|
|
123
|
+
*/
|
|
124
|
+
readonly grantOnAllow?: boolean;
|
|
125
|
+
/**
|
|
126
|
+
* A named degrade, raised at most once per gate.
|
|
127
|
+
*
|
|
128
|
+
* A degrade is a named outcome, not a comment. The `grantOnAllow`-off residual is raised where an
|
|
129
|
+
* embedder hits it — a running session in which every approved tool call silently fails to
|
|
130
|
+
* happen — rather than only documented where an installer reads. Optional: an embedder who does
|
|
131
|
+
* not pass it gets exactly the previous behaviour, so this adds an observation and never a
|
|
132
|
+
* requirement.
|
|
133
|
+
*
|
|
134
|
+
* `name` is a `RefusalReason`, not a free string — so a degrade cannot be invented at the call
|
|
135
|
+
* site. Adding one means declaring it in `core/refusal.ts` beside every other named outcome, which
|
|
136
|
+
* is what keeps the vocabulary a vocabulary.
|
|
137
|
+
*/
|
|
138
|
+
readonly onDegrade?: (degrade: {
|
|
139
|
+
readonly name: RefusalReason;
|
|
140
|
+
readonly detail: string;
|
|
141
|
+
}) => void;
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* The two-deadline invariant, as one declaration with two consumers.
|
|
145
|
+
*
|
|
146
|
+
* It is a function rather than a repeated `if` because two enforcement points for one rule drift
|
|
147
|
+
* apart: `permissionHooks` throws on an inverted pair for an embedder, and `composeSession` must
|
|
148
|
+
* refuse on one for a controller. So the rule lives here and both read it.
|
|
149
|
+
*
|
|
150
|
+
* Returns the explanation when the pair is invalid, or null when it is fine. The caller decides
|
|
151
|
+
* whether that becomes a throw or a named refusal — which is the only thing the two sites disagree
|
|
152
|
+
* about, and it is a decision about audience rather than about the rule.
|
|
153
|
+
*/
|
|
154
|
+
export declare function deadlineOrderRefusal(decisionTimeoutMs: number | undefined, matcherTimeoutSeconds: number | undefined): string | null;
|
|
155
|
+
/**
|
|
156
|
+
* The `PreToolUse` registration for a session's gate.
|
|
157
|
+
*
|
|
158
|
+
* Register this after `observationHooks()` — `mergeHooks(observationHooks(…), permissionHooks(…))`.
|
|
159
|
+
* Handlers on one event have their synchronous prologues run in array order and are then awaited
|
|
160
|
+
* concurrently (measured; `mergeHooks`'s own "earlier arguments run first" describes dispatch, not
|
|
161
|
+
* completion). The order is a convention, not a race guard: this gate opens its `permission` entry
|
|
162
|
+
* only from the hold timer (`holdAfterMs`, 250ms by default), after every same-event synchronous
|
|
163
|
+
* prologue has finished — so under either order the observer's `PreToolUse` check runs before any
|
|
164
|
+
* hold entry from this event exists, and cannot close one. Across events the guard is the key, not
|
|
165
|
+
* the timing: the hold entry is keyed by tool_use_id (`gate/outcome.ts`), so a later `PreToolUse`
|
|
166
|
+
* for the same tool — whose observer branch closes name-keyed permission entries — cannot close a
|
|
167
|
+
* hold whose decision is still outstanding.
|
|
168
|
+
*
|
|
169
|
+
* No `matcher` is set. A matcher filters by tool name, and every tool call must reach the gate.
|
|
170
|
+
*/
|
|
171
|
+
export declare function permissionHooks(options: PermissionGateOptions): HookRegistrations;
|
|
172
|
+
export { describeRaw };
|
|
173
|
+
//# sourceMappingURL=gate.d.ts.map
|