@runbooks/supervise 0.1.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.
Files changed (47) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +47 -0
  3. package/dist/approval.d.ts +132 -0
  4. package/dist/approval.js +148 -0
  5. package/dist/approval.test.d.ts +1 -0
  6. package/dist/approval.test.js +190 -0
  7. package/dist/budgets.test.d.ts +1 -0
  8. package/dist/budgets.test.js +148 -0
  9. package/dist/contract.d.ts +38 -0
  10. package/dist/contract.js +77 -0
  11. package/dist/contract.test.d.ts +1 -0
  12. package/dist/contract.test.js +131 -0
  13. package/dist/deviations.d.ts +77 -0
  14. package/dist/deviations.js +81 -0
  15. package/dist/deviations.test.d.ts +1 -0
  16. package/dist/deviations.test.js +241 -0
  17. package/dist/emit.d.ts +97 -0
  18. package/dist/emit.js +184 -0
  19. package/dist/emit.test.d.ts +1 -0
  20. package/dist/emit.test.js +187 -0
  21. package/dist/enforce.d.ts +29 -0
  22. package/dist/enforce.js +100 -0
  23. package/dist/enforce.test.d.ts +1 -0
  24. package/dist/enforce.test.js +112 -0
  25. package/dist/expect.d.ts +67 -0
  26. package/dist/expect.js +187 -0
  27. package/dist/expect.test.d.ts +1 -0
  28. package/dist/expect.test.js +120 -0
  29. package/dist/index.d.ts +33 -0
  30. package/dist/index.js +34 -0
  31. package/dist/index.test.d.ts +1 -0
  32. package/dist/index.test.js +30 -0
  33. package/dist/observe.test.d.ts +1 -0
  34. package/dist/observe.test.js +246 -0
  35. package/dist/policy.d.ts +122 -0
  36. package/dist/policy.js +147 -0
  37. package/dist/policy.test.d.ts +1 -0
  38. package/dist/policy.test.js +124 -0
  39. package/dist/purity.test.d.ts +1 -0
  40. package/dist/purity.test.js +191 -0
  41. package/dist/report.d.ts +72 -0
  42. package/dist/report.js +67 -0
  43. package/dist/run.d.ts +216 -0
  44. package/dist/run.js +445 -0
  45. package/dist/run.test.d.ts +1 -0
  46. package/dist/run.test.js +198 -0
  47. package/package.json +42 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Mikhail Dorokhovich
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,47 @@
1
+ # `@runbooks/supervise`
2
+
3
+ The reference supervisor — the implementation of the execution contract.
4
+
5
+ **Tasks:** [R-01](../../tasks/v0/R-01-supervisor-core.md) through
6
+ [R-09](../../tasks/v2/R-09-run-attestation.md) · **Normative:** RUNBOOK.md §13
7
+
8
+ ## The one sentence that defines scope
9
+
10
+ > A **runner** performs the steps. A **supervisor** permits them, blocks them, and
11
+ > records what happened. The supervisor does no work of its own and holds no
12
+ > credentials of its own.
13
+
14
+ Test every proposed feature against it. A feature requiring the supervisor to do work,
15
+ or to hold a secret, is out of scope by construction rather than by preference — and
16
+ the moment this becomes something we host, NG1 is dead.
17
+
18
+ ## What it enforces
19
+
20
+ Derived from the document, never authored separately (§13.2):
21
+
22
+ | Document element | Runtime meaning |
23
+ |---|---|
24
+ | `capabilities[]` | the allowlist |
25
+ | current step's `tool` | the **scope** — only this step's declaration, not the run's union |
26
+ | `risk` + `requires_approval` | a gate, decided by a human who is not the agent |
27
+ | `expect` | a postcondition the supervisor evaluates |
28
+ | `on_fail` | a route the supervisor takes |
29
+ | `retry(n)` | a counted budget |
30
+ | terminals | the stop conditions |
31
+ | anything else | a deviation: blocked, recorded, escalated |
32
+
33
+ Per-step scope is the row that does the most work. Granting an agent the union of a
34
+ procedure's capabilities for the whole run is how a diagnostic session ends in a
35
+ deletion.
36
+
37
+ ## Where it runs
38
+
39
+ In the operator's environment, between their agent and their tools. No telemetry, no
40
+ callback, no network dependency. Reports and attestations are opt-in, per run, and
41
+ default to emitting nowhere.
42
+
43
+ ## What it is not
44
+
45
+ Not a sandbox, not a general agent policy engine, not a replacement for the client's
46
+ own allowlist, and not a guarantee. It bounds what the runbook authorizes — a real
47
+ reduction, not a total one (§13.5).
@@ -0,0 +1,132 @@
1
+ /**
2
+ * The approval gate protocol.
3
+ *
4
+ * `requires_approval` is forced on for destructive and irreversible work regardless of
5
+ * what the source document says (RUNBOOK.md 12). At run time that becomes a halt, and
6
+ * §13.4 adds the requirement that carries the weight: **an actual human decision, not a
7
+ * self-approval.** An agent approving its own destructive step is the failure this
8
+ * product exists to prevent, so the protocol makes it impossible rather than
9
+ * discouraged.
10
+ *
11
+ * Two properties are structural here, not policy:
12
+ *
13
+ * - A grant names the identity that decided, and that identity may not be the agent's.
14
+ * - A grant authorizes **one invocation of one step**. It is consumed on use, so it can
15
+ * never become a standing permission for the rest of the run.
16
+ */
17
+ import type { PolicyEnvelope } from "./policy.js";
18
+ import type { RunState } from "./run.js";
19
+ import type { Risk } from "@runbooks/schema";
20
+ export declare const REDACTED = "***";
21
+ export interface ApprovalRequest {
22
+ readonly id: string;
23
+ readonly stepId: string;
24
+ readonly title: string;
25
+ readonly risk: Risk;
26
+ /** Rendered with secret inputs redacted. */
27
+ readonly command?: string;
28
+ readonly capabilities: readonly string[];
29
+ /** Whether the graph can undo this, and where. Absent means it cannot. */
30
+ readonly rollbackTo?: string;
31
+ /** Seconds from issue after which the gate aborts the run. */
32
+ readonly expiresInSeconds: number;
33
+ readonly issuedAt: number;
34
+ }
35
+ export interface Grant {
36
+ readonly requestId: string;
37
+ readonly stepId: string;
38
+ /** Who decided. Never the agent — checked when the grant is made. */
39
+ readonly decidedBy: string;
40
+ }
41
+ export interface Decision {
42
+ readonly requestId: string;
43
+ readonly decidedBy: string;
44
+ readonly at?: number;
45
+ }
46
+ export declare class SelfApprovalError extends Error {
47
+ constructor(identity: string);
48
+ }
49
+ export type BoundCommand = {
50
+ readonly ok: true;
51
+ readonly command: string;
52
+ }
53
+ /** Named, because "it did not run" is not an answer somebody can act on. */
54
+ | {
55
+ readonly ok: false;
56
+ readonly missing: readonly string[];
57
+ };
58
+ /**
59
+ * The command as it will actually be executed.
60
+ *
61
+ * Distinct from `renderCommand` on purpose, and the distinction is the point: that one
62
+ * redacts declared secrets because an approval channel is somewhere a credential must
63
+ * never arrive, and executing its output would run the redaction mark as an argument.
64
+ *
65
+ * Nothing is substituted for a value that was not supplied. An empty string does not make
66
+ * a command narrower — `rm -rf /var/log/{{inputs.dir}}` with nothing bound is not a
67
+ * smaller command than the one that was reviewed, it is a different and larger one. So a
68
+ * missing value is a refusal that names it.
69
+ */
70
+ export declare function bindCommand(envelope: PolicyEnvelope, command: string, bound: Readonly<Record<string, unknown>>): BoundCommand;
71
+ export declare function renderCommand(envelope: PolicyEnvelope, command: string, bound: Readonly<Record<string, unknown>>): string;
72
+ export interface RequestOptions {
73
+ readonly expiresInSeconds?: number;
74
+ readonly now?: number;
75
+ readonly id?: string;
76
+ }
77
+ /** The default is short on purpose: a gate held open is a gate. */
78
+ export declare const DEFAULT_EXPIRY_SECONDS = 900;
79
+ export declare function requestApproval(envelope: PolicyEnvelope, state: RunState, options?: RequestOptions): ApprovalRequest | undefined;
80
+ /**
81
+ * Record a decision. Throws on self-approval rather than returning a refusal: a caller
82
+ * that ignores a returned error would ship the exact hole this prevents, and there is no
83
+ * sensible way to continue.
84
+ */
85
+ export declare function grant(request: ApprovalRequest, decision: Decision, agentIdentity: string): Grant;
86
+ /**
87
+ * A person looked at the request and said no.
88
+ *
89
+ * `RunOutcome` has always documented `aborted` as "stopped deliberately: a rollback, an
90
+ * operator, **a refused gate**", and until now there was no way to refuse one. The only
91
+ * exit from a pending gate was expiry — so a considered no was indistinguishable from
92
+ * nobody having looked, both in the run and in everything downstream of it.
93
+ *
94
+ * The reason is required, and that is the point of the type. An approval needs no
95
+ * justification: proceeding is what the runbook already asked for. A refusal is a
96
+ * departure from the written procedure, and the next person to meet this step is entitled
97
+ * to know why the last one stopped.
98
+ */
99
+ export interface GateRefusal {
100
+ readonly requestId: string;
101
+ readonly stepId: string;
102
+ readonly decidedBy: string;
103
+ readonly reason: string;
104
+ }
105
+ /**
106
+ * Record a refusal, under the same rules a grant is recorded under.
107
+ *
108
+ * Self-refusal throws for the same reason self-approval does: a decision the agent made
109
+ * about its own request is not a decision, whichever way it went. An agent that can refuse
110
+ * its own gate can route around a step it does not want to take.
111
+ */
112
+ export declare function refuse(request: ApprovalRequest, decision: Decision & {
113
+ readonly reason: string;
114
+ }, agentIdentity: string): GateRefusal;
115
+ /**
116
+ * What a refused gate does: end the run, deliberately, with the refusal recorded.
117
+ *
118
+ * The same outcome an expiry produces and not the same event: `aborted` is where both
119
+ * land, and what distinguishes them is that this one carries who decided and why. A report
120
+ * that cannot tell a considered no from an unanswered question is a report that will be
121
+ * read as the flattering one.
122
+ */
123
+ export declare function refuseGate(state: RunState, refusal: GateRefusal): RunState;
124
+ export declare function hasExpired(request: ApprovalRequest, now: number): boolean;
125
+ /**
126
+ * What a pending, expired gate does: abort.
127
+ *
128
+ * "Proceed on timeout" is the convenient default someone will eventually add, and it
129
+ * turns every gate into a delay. A gate whose answer never came is an unanswered
130
+ * question, not a yes.
131
+ */
132
+ export declare function expireGate(state: RunState): RunState;
@@ -0,0 +1,148 @@
1
+ export const REDACTED = "***";
2
+ export class SelfApprovalError extends Error {
3
+ constructor(identity) {
4
+ super(`"${identity}" is the identity running this procedure and cannot approve its own step. An approval must come from someone else.`);
5
+ this.name = "SelfApprovalError";
6
+ }
7
+ }
8
+ /**
9
+ * Render a command for a human, redacting inputs declared secret.
10
+ *
11
+ * The target is never redacted: someone approving a destructive operation has to see
12
+ * what it acts on, and an approval request that hides the target is theatre. Only values
13
+ * the runbook declares as credentials are hidden — an approval channel is somewhere a
14
+ * secret should never arrive.
15
+ */
16
+ /**
17
+ * What an interpolation looks like, in one place.
18
+ *
19
+ * The grammar is the schema's — `checkInterpolations` validates a document against it —
20
+ * and it is written here once for the two things that act on it: showing a human what
21
+ * will run, and binding what actually runs. A third spelling is how the two come to
22
+ * disagree about a command somebody approved.
23
+ */
24
+ const INTERPOLATION = /\{\{\s*inputs\.([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}/g;
25
+ /**
26
+ * The command as it will actually be executed.
27
+ *
28
+ * Distinct from `renderCommand` on purpose, and the distinction is the point: that one
29
+ * redacts declared secrets because an approval channel is somewhere a credential must
30
+ * never arrive, and executing its output would run the redaction mark as an argument.
31
+ *
32
+ * Nothing is substituted for a value that was not supplied. An empty string does not make
33
+ * a command narrower — `rm -rf /var/log/{{inputs.dir}}` with nothing bound is not a
34
+ * smaller command than the one that was reviewed, it is a different and larger one. So a
35
+ * missing value is a refusal that names it.
36
+ */
37
+ export function bindCommand(envelope, command, bound) {
38
+ const missing = [];
39
+ const filled = command.replace(INTERPOLATION, (match, name) => {
40
+ const value = envelope.inputs[name] ? bound[name] : undefined;
41
+ if (value === undefined || String(value) === "") {
42
+ missing.push(name);
43
+ return match;
44
+ }
45
+ return String(value);
46
+ });
47
+ return missing.length > 0 ? { ok: false, missing: [...new Set(missing)] } : { ok: true, command: filled };
48
+ }
49
+ export function renderCommand(envelope, command, bound) {
50
+ return command.replace(INTERPOLATION, (_match, name) => {
51
+ const declaration = envelope.inputs[name];
52
+ if (!declaration)
53
+ return REDACTED;
54
+ if (declaration.secret)
55
+ return REDACTED;
56
+ const value = bound[name];
57
+ return value === undefined ? REDACTED : String(value);
58
+ });
59
+ }
60
+ /** The default is short on purpose: a gate held open is a gate. */
61
+ export const DEFAULT_EXPIRY_SECONDS = 900;
62
+ export function requestApproval(envelope, state, options = {}) {
63
+ const stepId = state.current;
64
+ if (!stepId)
65
+ return undefined;
66
+ const scope = envelope.steps[stepId];
67
+ if (!scope || !scope.gated)
68
+ return undefined;
69
+ const issuedAt = options.now ?? 0;
70
+ return {
71
+ id: options.id ?? `${stepId}@${issuedAt}`,
72
+ stepId,
73
+ title: scope.title,
74
+ risk: scope.risk,
75
+ ...(scope.command
76
+ ? { command: renderCommand(envelope, scope.command, state.inputs) }
77
+ : {}),
78
+ capabilities: scope.tools,
79
+ ...(scope.rollbackTo ? { rollbackTo: scope.rollbackTo } : {}),
80
+ expiresInSeconds: options.expiresInSeconds ?? DEFAULT_EXPIRY_SECONDS,
81
+ issuedAt,
82
+ };
83
+ }
84
+ /**
85
+ * Record a decision. Throws on self-approval rather than returning a refusal: a caller
86
+ * that ignores a returned error would ship the exact hole this prevents, and there is no
87
+ * sensible way to continue.
88
+ */
89
+ export function grant(request, decision, agentIdentity) {
90
+ if (decision.decidedBy === agentIdentity) {
91
+ throw new SelfApprovalError(decision.decidedBy);
92
+ }
93
+ if (decision.requestId !== request.id) {
94
+ throw new Error(`Decision names request ${decision.requestId}, but the pending request is ${request.id}. An approval authorizes one invocation of one step.`);
95
+ }
96
+ if (hasExpired(request, decision.at ?? request.issuedAt)) {
97
+ throw new Error(`Request ${request.id} expired after ${request.expiresInSeconds}s. Ask again rather than acting on a stale decision.`);
98
+ }
99
+ return { requestId: request.id, stepId: request.stepId, decidedBy: decision.decidedBy };
100
+ }
101
+ /**
102
+ * Record a refusal, under the same rules a grant is recorded under.
103
+ *
104
+ * Self-refusal throws for the same reason self-approval does: a decision the agent made
105
+ * about its own request is not a decision, whichever way it went. An agent that can refuse
106
+ * its own gate can route around a step it does not want to take.
107
+ */
108
+ export function refuse(request, decision, agentIdentity) {
109
+ if (decision.decidedBy === agentIdentity) {
110
+ throw new SelfApprovalError(decision.decidedBy);
111
+ }
112
+ if (decision.requestId !== request.id) {
113
+ throw new Error(`Decision names request ${decision.requestId}, but the pending request is ${request.id}. A refusal answers one request.`);
114
+ }
115
+ if (decision.reason.trim() === "") {
116
+ throw new Error("A refusal needs a reason. Approving needs none — the runbook already asked for the step — but stopping is a departure from it, and the next person here is owed the why.");
117
+ }
118
+ return {
119
+ requestId: request.id,
120
+ stepId: request.stepId,
121
+ decidedBy: decision.decidedBy,
122
+ reason: decision.reason,
123
+ };
124
+ }
125
+ /**
126
+ * What a refused gate does: end the run, deliberately, with the refusal recorded.
127
+ *
128
+ * The same outcome an expiry produces and not the same event: `aborted` is where both
129
+ * land, and what distinguishes them is that this one carries who decided and why. A report
130
+ * that cannot tell a considered no from an unanswered question is a report that will be
131
+ * read as the flattering one.
132
+ */
133
+ export function refuseGate(state, refusal) {
134
+ return { ...state, status: "ended", outcome: "aborted", refusal };
135
+ }
136
+ export function hasExpired(request, now) {
137
+ return now - request.issuedAt > request.expiresInSeconds;
138
+ }
139
+ /**
140
+ * What a pending, expired gate does: abort.
141
+ *
142
+ * "Proceed on timeout" is the convenient default someone will eventually add, and it
143
+ * turns every gate into a delay. A gate whose answer never came is an unanswered
144
+ * question, not a yes.
145
+ */
146
+ export function expireGate(state) {
147
+ return { ...state, status: "ended", outcome: "aborted" };
148
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,190 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { derivePolicy } from "./policy.js";
3
+ import { startRun, propose, applyGrant, advance } from "./run.js";
4
+ import { requestApproval, grant, refuse, refuseGate, hasExpired, expireGate, renderCommand, SelfApprovalError, REDACTED, DEFAULT_EXPIRY_SECONDS, } from "./approval.js";
5
+ const runbook = (r) => ({ runbook: r });
6
+ const doc = runbook({
7
+ capabilities: ["mcp:postgres"],
8
+ inputs: {
9
+ slot: { type: "string", required: true },
10
+ token: { type: "string", required: true, secret: true },
11
+ },
12
+ steps: [
13
+ {
14
+ id: "s1", kind: "action", title: "Drop the replication slot", risk: "destructive",
15
+ tool: "mcp:postgres", requires_approval: true,
16
+ command: "psql --token {{inputs.token}} -c \"select pg_drop_replication_slot('{{inputs.slot}}')\"",
17
+ next: "end:success",
18
+ },
19
+ ],
20
+ });
21
+ function start(agentIdentity = "agent-7") {
22
+ const env = derivePolicy(doc);
23
+ const r = startRun(env, { inputs: { slot: "wal_keeper", token: "hunter2" }, agentIdentity });
24
+ if (!r.ok)
25
+ throw new Error("expected a start");
26
+ return { env, state: r.state };
27
+ }
28
+ describe("the request carries enough to decide, and nothing more", () => {
29
+ it("names the step, its risk, its capabilities and whether it can be undone", () => {
30
+ const { env, state } = start();
31
+ const request = requestApproval(env, state, { now: 1000 });
32
+ expect(request.stepId).toBe("s1");
33
+ expect(request.risk).toBe("destructive");
34
+ expect(request.title).toBe("Drop the replication slot");
35
+ expect(request.capabilities).toEqual(["mcp:postgres"]);
36
+ expect(request.rollbackTo).toBeUndefined();
37
+ });
38
+ // The target is never hidden: someone approving a destructive operation has to see
39
+ // what it acts on, and a request that hides the target is theatre.
40
+ it("shows the target", () => {
41
+ const { env, state } = start();
42
+ expect(requestApproval(env, state, { now: 0 }).command).toContain("wal_keeper");
43
+ });
44
+ // An approval channel is somewhere a secret should never arrive.
45
+ it("redacts an input declared secret", () => {
46
+ const { env, state } = start();
47
+ const command = requestApproval(env, state, { now: 0 }).command;
48
+ expect(command).not.toContain("hunter2");
49
+ expect(command).toContain(REDACTED);
50
+ });
51
+ it("redacts an interpolation it cannot account for", () => {
52
+ const { env } = start();
53
+ expect(renderCommand(env, "x {{inputs.unknown}}", {})).toBe(`x ${REDACTED}`);
54
+ });
55
+ it("is not issued for an ungated step", () => {
56
+ const ungated = derivePolicy(runbook({
57
+ capabilities: [],
58
+ steps: [{ id: "s1", kind: "action", title: "Go", risk: "read-only", next: "end:success" }],
59
+ }));
60
+ const r = startRun(ungated, {});
61
+ expect(r.ok && requestApproval(ungated, r.state, {})).toBeUndefined();
62
+ });
63
+ });
64
+ describe("self-approval is impossible, not discouraged", () => {
65
+ it("refuses a decision from the identity running the procedure", () => {
66
+ const { env, state } = start("agent-7");
67
+ const request = requestApproval(env, state, { now: 0 });
68
+ expect(() => grant(request, { requestId: request.id, decidedBy: "agent-7" }, state.agentIdentity))
69
+ .toThrow(SelfApprovalError);
70
+ });
71
+ it("accepts a decision from anyone else", () => {
72
+ const { env, state } = start("agent-7");
73
+ const request = requestApproval(env, state, { now: 0 });
74
+ expect(grant(request, { requestId: request.id, decidedBy: "operator" }, state.agentIdentity).decidedBy)
75
+ .toBe("operator");
76
+ });
77
+ });
78
+ describe("a grant authorizes one entry into one step", () => {
79
+ it("permits the gated step once granted", () => {
80
+ const { env, state } = start();
81
+ const request = requestApproval(env, state, { now: 0 });
82
+ const granted = applyGrant(state, grant(request, { requestId: request.id, decidedBy: "operator" }, state.agentIdentity)).state;
83
+ expect(propose(env, granted, { tool: "mcp:postgres" }).authorization.verdict).toBe("permit");
84
+ });
85
+ it("refuses a decision naming a different request", () => {
86
+ const { env, state } = start();
87
+ const request = requestApproval(env, state, { now: 0 });
88
+ expect(() => grant(request, { requestId: "someone-elses", decidedBy: "operator" }, state.agentIdentity))
89
+ .toThrow(/one invocation of one step/);
90
+ });
91
+ // The realistic replay: a retry loop returning to the gated step and reusing the one
92
+ // approval a human gave for the first attempt.
93
+ it("does not survive leaving and re-entering the step", () => {
94
+ const loop = derivePolicy(runbook({
95
+ capabilities: ["mcp:postgres"],
96
+ steps: [
97
+ { id: "s1", kind: "action", title: "Drop it", risk: "destructive", tool: "mcp:postgres", requires_approval: true, next: "s2" },
98
+ { id: "s2", kind: "wait", title: "Wait", duration: "5s", retry: { max: 3, target: "s1" }, next: "end:success" },
99
+ ],
100
+ }));
101
+ const r = startRun(loop, { agentIdentity: "agent-7" });
102
+ expect(r.ok).toBe(true);
103
+ if (!r.ok)
104
+ return;
105
+ const request = requestApproval(loop, r.state, { now: 0 });
106
+ let s = applyGrant(r.state, grant(request, { requestId: request.id, decidedBy: "operator" }, "agent-7")).state;
107
+ expect(propose(loop, s, { tool: "mcp:postgres" }).authorization.verdict).toBe("permit");
108
+ s = advance(loop, s, "s2").state;
109
+ s = advance(loop, s, "s1").state;
110
+ expect(s.grant).toBeUndefined();
111
+ expect(propose(loop, s, { tool: "mcp:postgres" }).authorization.verdict).toBe("require-approval");
112
+ });
113
+ });
114
+ describe("a gate that is never answered aborts", () => {
115
+ it("treats an expired request as expired", () => {
116
+ const { env, state } = start();
117
+ const request = requestApproval(env, state, { now: 0, expiresInSeconds: 60 });
118
+ expect(hasExpired(request, 59)).toBe(false);
119
+ expect(hasExpired(request, 61)).toBe(true);
120
+ });
121
+ it("refuses a decision that arrives after expiry", () => {
122
+ const { env, state } = start();
123
+ const request = requestApproval(env, state, { now: 0, expiresInSeconds: 60 });
124
+ expect(() => grant(request, { requestId: request.id, decidedBy: "operator", at: 120 }, state.agentIdentity))
125
+ .toThrow(/stale decision/);
126
+ });
127
+ // "Proceed on timeout" is the convenient default someone will eventually add, and it
128
+ // turns every gate into a delay. An unanswered question is not a yes.
129
+ it("aborts the run rather than proceeding", () => {
130
+ const { state } = start();
131
+ const expired = expireGate(state);
132
+ expect(expired.status).toBe("ended");
133
+ expect(expired.outcome).toBe("aborted");
134
+ });
135
+ it("defaults to a short expiry", () => {
136
+ expect(DEFAULT_EXPIRY_SECONDS).toBeLessThanOrEqual(3600);
137
+ });
138
+ });
139
+ /**
140
+ * §13: a person may say no, and the run has to be able to record that.
141
+ *
142
+ * `RunOutcome` has documented `aborted` as "stopped deliberately: a rollback, an operator,
143
+ * **a refused gate**" since it was written, and there was no way to refuse one. The only
144
+ * exit from a pending gate was expiry — so a considered no and nobody having looked landed
145
+ * in the same state, indistinguishable to everything downstream.
146
+ *
147
+ * This catalog publishes `adapted/design-the-rejection-path-too`, whose whole point is that
148
+ * approval paths get tested and rejection paths get discovered in production. It had one.
149
+ */
150
+ describe("a gate can be refused, not only granted or expired", () => {
151
+ const request = (over = {}) => ({
152
+ id: "req-1",
153
+ stepId: "s5",
154
+ reason: "destructive",
155
+ capabilities: [],
156
+ expiresInSeconds: 300,
157
+ issuedAt: 0,
158
+ ...over,
159
+ });
160
+ it("records who refused and why", () => {
161
+ const refusal = refuse(request(), { requestId: "req-1", decidedBy: "ada", reason: "wrong node" }, "agent-7");
162
+ expect(refusal.decidedBy).toBe("ada");
163
+ expect(refusal.reason).toBe("wrong node");
164
+ expect(refusal.stepId).toBe("s5");
165
+ });
166
+ /**
167
+ * A refusal needs a reason and an approval does not: proceeding is what the runbook
168
+ * already asked for, stopping is a departure from it.
169
+ */
170
+ it("refuses to record a refusal with no reason", () => {
171
+ expect(() => refuse(request(), { requestId: "req-1", decidedBy: "ada", reason: " " }, "agent-7")).toThrow(/needs a reason/);
172
+ });
173
+ it("will not let the agent refuse its own gate", () => {
174
+ expect(() => refuse(request(), { requestId: "req-1", decidedBy: "agent-7", reason: "no" }, "agent-7")).toThrow(SelfApprovalError);
175
+ });
176
+ it("answers one request, like a grant", () => {
177
+ expect(() => refuse(request(), { requestId: "req-2", decidedBy: "ada", reason: "no" }, "agent-7")).toThrow(/answers one request/);
178
+ });
179
+ /** Both end the run; only one of them says a person decided. */
180
+ it("is distinguishable from an expiry in the run state", () => {
181
+ const pending = { status: "awaiting-approval", visited: [], agentIdentity: "agent-7" };
182
+ const refusal = refuse(request(), { requestId: "req-1", decidedBy: "ada", reason: "wrong node" }, "agent-7");
183
+ const refused = refuseGate(pending, refusal);
184
+ const expired = expireGate(pending);
185
+ expect(refused.outcome).toBe("aborted");
186
+ expect(expired.outcome).toBe("aborted");
187
+ expect(refused.refusal?.reason).toBe("wrong node");
188
+ expect(expired.refusal).toBeUndefined();
189
+ });
190
+ });
@@ -0,0 +1 @@
1
+ export {};