@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.
- package/LICENSE +21 -0
- package/README.md +47 -0
- package/dist/approval.d.ts +132 -0
- package/dist/approval.js +148 -0
- package/dist/approval.test.d.ts +1 -0
- package/dist/approval.test.js +190 -0
- package/dist/budgets.test.d.ts +1 -0
- package/dist/budgets.test.js +148 -0
- package/dist/contract.d.ts +38 -0
- package/dist/contract.js +77 -0
- package/dist/contract.test.d.ts +1 -0
- package/dist/contract.test.js +131 -0
- package/dist/deviations.d.ts +77 -0
- package/dist/deviations.js +81 -0
- package/dist/deviations.test.d.ts +1 -0
- package/dist/deviations.test.js +241 -0
- package/dist/emit.d.ts +97 -0
- package/dist/emit.js +184 -0
- package/dist/emit.test.d.ts +1 -0
- package/dist/emit.test.js +187 -0
- package/dist/enforce.d.ts +29 -0
- package/dist/enforce.js +100 -0
- package/dist/enforce.test.d.ts +1 -0
- package/dist/enforce.test.js +112 -0
- package/dist/expect.d.ts +67 -0
- package/dist/expect.js +187 -0
- package/dist/expect.test.d.ts +1 -0
- package/dist/expect.test.js +120 -0
- package/dist/index.d.ts +33 -0
- package/dist/index.js +34 -0
- package/dist/index.test.d.ts +1 -0
- package/dist/index.test.js +30 -0
- package/dist/observe.test.d.ts +1 -0
- package/dist/observe.test.js +246 -0
- package/dist/policy.d.ts +122 -0
- package/dist/policy.js +147 -0
- package/dist/policy.test.d.ts +1 -0
- package/dist/policy.test.js +124 -0
- package/dist/purity.test.d.ts +1 -0
- package/dist/purity.test.js +191 -0
- package/dist/report.d.ts +72 -0
- package/dist/report.js +67 -0
- package/dist/run.d.ts +216 -0
- package/dist/run.js +445 -0
- package/dist/run.test.d.ts +1 -0
- package/dist/run.test.js +198 -0
- package/package.json +42 -0
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { loadCorpus } from "@runbooks/fixtures";
|
|
3
|
+
import { derivePolicy } from "./policy.js";
|
|
4
|
+
import { startRun, advance, propose, outOfTime } from "./run.js";
|
|
5
|
+
import { report } from "./report.js";
|
|
6
|
+
const flaky = () => {
|
|
7
|
+
const record = loadCorpus().find((r) => r.name.startsWith("flaky-check"));
|
|
8
|
+
return derivePolicy(record.doc);
|
|
9
|
+
};
|
|
10
|
+
function begin(envelope = flaky(), options = {}) {
|
|
11
|
+
const started = startRun(envelope, { agentIdentity: "agent-7", ...options });
|
|
12
|
+
if (!started.ok)
|
|
13
|
+
throw new Error(JSON.stringify(started.refusals));
|
|
14
|
+
return started.state;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Invariant 6 exists because an unbounded loop in an autonomous agent costs money and
|
|
18
|
+
* causes incidents. The linter guarantees the graph has no illegal cycle; this
|
|
19
|
+
* guarantees the legal one terminates.
|
|
20
|
+
*/
|
|
21
|
+
describe("a loop that would run forever stops", () => {
|
|
22
|
+
const envelope = flaky();
|
|
23
|
+
const loop = Object.keys(envelope.budgets)[0];
|
|
24
|
+
const max = envelope.budgets[loop];
|
|
25
|
+
const [from, to] = loop.split("->");
|
|
26
|
+
it("has a fixture whose cycle really is a cycle", () => {
|
|
27
|
+
expect(max).toBeGreaterThan(0);
|
|
28
|
+
expect(envelope.steps[to].transitions).toContain(from);
|
|
29
|
+
expect(envelope.steps[from].transitions).toContain(to);
|
|
30
|
+
});
|
|
31
|
+
/**
|
|
32
|
+
* Take the cycle as an agent would: back and forth, forever, if nothing stopped it.
|
|
33
|
+
* Bounded here so a wrong turn fails the test instead of hanging it.
|
|
34
|
+
*/
|
|
35
|
+
function spin() {
|
|
36
|
+
let state = begin(envelope);
|
|
37
|
+
let last;
|
|
38
|
+
let laps = 0;
|
|
39
|
+
for (let i = 0; i < (max + 5) * 2 && state.status === "running"; i++) {
|
|
40
|
+
last = advance(envelope, state, state.current === to ? from : to);
|
|
41
|
+
if (state.current === from)
|
|
42
|
+
laps += 1;
|
|
43
|
+
state = last.state;
|
|
44
|
+
}
|
|
45
|
+
return { state, laps, ...(last ? { last } : {}) };
|
|
46
|
+
}
|
|
47
|
+
it("terminates instead of looping", () => {
|
|
48
|
+
expect(spin().state.status).toBe("ended");
|
|
49
|
+
});
|
|
50
|
+
it("stops at the limit the document declared, not one past it", () => {
|
|
51
|
+
expect(spin().laps).toBe(max + 1);
|
|
52
|
+
});
|
|
53
|
+
it("gives up with its own outcome rather than reporting a failed step", () => {
|
|
54
|
+
const { state } = spin();
|
|
55
|
+
expect(state.outcome).toBe("budget-exhausted");
|
|
56
|
+
expect(report(state).caveats.join(" ")).toMatch(/not a report of a step that failed/);
|
|
57
|
+
});
|
|
58
|
+
it("says which edge ran out and that the step decided nothing", () => {
|
|
59
|
+
const { last } = spin();
|
|
60
|
+
expect(last.events[0].message).toMatch(new RegExp(`${from}->${to}`));
|
|
61
|
+
expect(last.events[0].message).toMatch(/decided nothing/);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
/** Two independent loops must not consume each other's budget. */
|
|
65
|
+
describe("counters are per edge", () => {
|
|
66
|
+
const envelope = {
|
|
67
|
+
...flaky(),
|
|
68
|
+
steps: {
|
|
69
|
+
a: { id: "a", kind: "wait", risk: "read-only", tools: [], undeclared: [], gated: false, title: "a", transitions: ["a", "b"] },
|
|
70
|
+
b: { id: "b", kind: "wait", risk: "read-only", tools: [], undeclared: [], gated: false, title: "b", transitions: ["b", "c"] },
|
|
71
|
+
c: { id: "c", kind: "check", risk: "read-only", tools: [], undeclared: [], gated: false, title: "c", transitions: [] },
|
|
72
|
+
},
|
|
73
|
+
entry: "a",
|
|
74
|
+
terminals: [],
|
|
75
|
+
budgets: { "a->a": 1, "b->b": 1 },
|
|
76
|
+
};
|
|
77
|
+
it("does not let one loop spend another's budget", () => {
|
|
78
|
+
let state = begin(envelope);
|
|
79
|
+
state = advance(envelope, state, "a").state; // a->a, 1 of 1
|
|
80
|
+
state = advance(envelope, state, "b").state;
|
|
81
|
+
const second = advance(envelope, state, "b"); // b->b, 1 of 1 — its own budget
|
|
82
|
+
expect(second.state.status).toBe("running");
|
|
83
|
+
expect(second.state.retries).toEqual({ "a->a": 1, "b->b": 1 });
|
|
84
|
+
});
|
|
85
|
+
it("stops the loop that actually exhausted, not the run's first loop", () => {
|
|
86
|
+
let state = begin(envelope);
|
|
87
|
+
state = advance(envelope, state, "b").state;
|
|
88
|
+
state = advance(envelope, state, "b").state;
|
|
89
|
+
const over = advance(envelope, state, "b");
|
|
90
|
+
expect(over.state.outcome).toBe("budget-exhausted");
|
|
91
|
+
expect(over.events[0].message).toMatch(/b->b/);
|
|
92
|
+
});
|
|
93
|
+
it("reports each edge's usage against its limit", () => {
|
|
94
|
+
let state = begin(envelope);
|
|
95
|
+
state = advance(envelope, state, "a").state;
|
|
96
|
+
const r = report(state, [], { envelope });
|
|
97
|
+
expect(r.budgets.retries).toContainEqual({ edge: "a->a", used: 1, max: 1 });
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
/**
|
|
101
|
+
* A procedure can be slow without looping, so time is counted separately. The budget is
|
|
102
|
+
* the operator's: the document's `duration` is a bucket written to set a reader's
|
|
103
|
+
* expectation, and enforcing it would turn a hint into a kill switch.
|
|
104
|
+
*/
|
|
105
|
+
describe("the wall clock is the operator's budget, not the document's", () => {
|
|
106
|
+
it("suggests a limit from the duration bucket without enforcing one", () => {
|
|
107
|
+
const record = loadCorpus().find((r) => r.name.startsWith("disk-pressure"));
|
|
108
|
+
const envelope = derivePolicy(record.doc);
|
|
109
|
+
expect(envelope.suggestedWallClockSeconds).toBe(300);
|
|
110
|
+
expect(begin(envelope, { inputs: { mount: "/tmp" } }).wallClockSeconds).toBeUndefined();
|
|
111
|
+
});
|
|
112
|
+
it("does not bound a run for which no budget was set", () => {
|
|
113
|
+
const state = begin(flaky(), { now: 0 });
|
|
114
|
+
expect(outOfTime(state, 10_000_000)).toBe(false);
|
|
115
|
+
});
|
|
116
|
+
it("says in the report that nothing was bounding the run in time", () => {
|
|
117
|
+
expect(report(begin()).caveats.join(" ")).toMatch(/An absent limit is not a generous one/);
|
|
118
|
+
});
|
|
119
|
+
it("ends the run once the budget passes, with its own outcome", () => {
|
|
120
|
+
const state = begin(flaky(), { now: 100, wallClockSeconds: 60 });
|
|
121
|
+
const late = advance(flaky(), state, state.current === "s1" ? "s2" : "s1", 200);
|
|
122
|
+
expect(late.state.status).toBe("ended");
|
|
123
|
+
expect(late.state.outcome).toBe("timed-out");
|
|
124
|
+
});
|
|
125
|
+
it("blocks a proposed call after the budget rather than authorizing it", () => {
|
|
126
|
+
const state = begin(flaky(), { now: 100, wallClockSeconds: 60 });
|
|
127
|
+
const decided = propose(flaky(), state, { tool: "cli:kubectl", now: 200 });
|
|
128
|
+
expect(decided.authorization.verdict).toBe("block");
|
|
129
|
+
expect(decided.state.outcome).toBe("timed-out");
|
|
130
|
+
});
|
|
131
|
+
it("keeps authorizing while the run is inside its budget", () => {
|
|
132
|
+
const envelope = flaky();
|
|
133
|
+
const declared = envelope.steps[envelope.entry].tools[0];
|
|
134
|
+
const state = begin(envelope, { now: 100, wallClockSeconds: 600 });
|
|
135
|
+
expect(propose(envelope, state, { tool: declared, now: 200 }).state.outcome).toBeUndefined();
|
|
136
|
+
});
|
|
137
|
+
it("reports elapsed against the limit", () => {
|
|
138
|
+
const state = begin(flaky(), { now: 100, wallClockSeconds: 600 });
|
|
139
|
+
expect(report(state, [], { now: 250 }).budgets.wallClock)
|
|
140
|
+
.toEqual({ limitSeconds: 600, elapsedSeconds: 150, exhausted: false });
|
|
141
|
+
});
|
|
142
|
+
/** The core has no clock: the same state and the same `now` decide the same way. */
|
|
143
|
+
it("reads no clock of its own", () => {
|
|
144
|
+
const state = begin(flaky(), { now: 0, wallClockSeconds: 1 });
|
|
145
|
+
expect(outOfTime(state, undefined)).toBe(false);
|
|
146
|
+
expect(outOfTime(state, 2)).toBe(true);
|
|
147
|
+
});
|
|
148
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { Risk, RuntimeProfile } from "@runbooks/schema";
|
|
2
|
+
import type { AdapterCoverage } from "./index.js";
|
|
3
|
+
export declare const RUNTIME_ORDER: readonly RuntimeProfile[];
|
|
4
|
+
export declare function atLeast(actual: RuntimeProfile, required: RuntimeProfile): boolean;
|
|
5
|
+
export type Execution = "human-only" | "human-with-agent" | "agent-autonomous";
|
|
6
|
+
export interface Deployment {
|
|
7
|
+
/** What the client claims to enforce. */
|
|
8
|
+
readonly profile: RuntimeProfile;
|
|
9
|
+
/** What its adapter can actually observe. A claim above coverage is not a profile. */
|
|
10
|
+
readonly coverage: AdapterCoverage;
|
|
11
|
+
/** Highest risk the operator has permitted for this run. */
|
|
12
|
+
readonly riskCeiling?: Risk;
|
|
13
|
+
}
|
|
14
|
+
export interface Procedure {
|
|
15
|
+
readonly maxRisk: Risk;
|
|
16
|
+
readonly execution: Execution;
|
|
17
|
+
readonly minRuntimeProfile?: RuntimeProfile;
|
|
18
|
+
}
|
|
19
|
+
export type RefusalCode = "runtime/profile-too-low" | "runtime/coverage-insufficient" | "runtime/risk-above-ceiling" | "runtime/autonomous-unsupervised";
|
|
20
|
+
export interface Refusal {
|
|
21
|
+
readonly code: RefusalCode;
|
|
22
|
+
readonly message: string;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Whether a deployment may start a procedure at all.
|
|
26
|
+
*
|
|
27
|
+
* Refusing before the first step rather than failing halfway is itself normative
|
|
28
|
+
* (§13.4): a run that stops in the middle has already done some of the work, and "some
|
|
29
|
+
* of a destructive procedure" is the worst available outcome.
|
|
30
|
+
*/
|
|
31
|
+
export declare function refusalsToStart(p: Procedure, d: Deployment): Refusal[];
|
|
32
|
+
export declare function mayStart(p: Procedure, d: Deployment): boolean;
|
|
33
|
+
/**
|
|
34
|
+
* The lowest runtime profile that can honestly run a procedure, given nothing but the
|
|
35
|
+
* procedure itself. Used to fill `min_runtime_profile` when an author leaves it out,
|
|
36
|
+
* and to warn when an author declares one lower than the content justifies.
|
|
37
|
+
*/
|
|
38
|
+
export declare function impliedMinimum(p: Pick<Procedure, "maxRisk" | "execution">): RuntimeProfile;
|
package/dist/contract.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The execution contract: runtime profiles and what each one binds a client to.
|
|
3
|
+
*
|
|
4
|
+
* P0/P1/P2 describe how much structure a *document* has. R0/R1/R2 describe how much of
|
|
5
|
+
* the contract a *client* actually enforces. They are independent axes, and the catalog
|
|
6
|
+
* should be able to say so: a perfect P2 document read by an R0 client is unsupervised.
|
|
7
|
+
*/
|
|
8
|
+
import { isDangerous } from "@runbooks/schema";
|
|
9
|
+
import { mayRunUnder } from "./index.js";
|
|
10
|
+
export const RUNTIME_ORDER = ["R0", "R1", "R2"];
|
|
11
|
+
export function atLeast(actual, required) {
|
|
12
|
+
return RUNTIME_ORDER.indexOf(actual) >= RUNTIME_ORDER.indexOf(required);
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Whether a deployment may start a procedure at all.
|
|
16
|
+
*
|
|
17
|
+
* Refusing before the first step rather than failing halfway is itself normative
|
|
18
|
+
* (§13.4): a run that stops in the middle has already done some of the work, and "some
|
|
19
|
+
* of a destructive procedure" is the worst available outcome.
|
|
20
|
+
*/
|
|
21
|
+
export function refusalsToStart(p, d) {
|
|
22
|
+
const out = [];
|
|
23
|
+
if (p.minRuntimeProfile && !atLeast(d.profile, p.minRuntimeProfile)) {
|
|
24
|
+
out.push({
|
|
25
|
+
code: "runtime/profile-too-low",
|
|
26
|
+
message: `This runbook requires ${p.minRuntimeProfile}; this client enforces ${d.profile}. Run it under a client that enforces ${p.minRuntimeProfile}, or run it by hand.`,
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
// §13.3, stated once. This condition was written out here as well as in `mayRunUnder`,
|
|
30
|
+
// in the same two terms — identical, and two places to tighten when the rule moves.
|
|
31
|
+
if (!mayRunUnder(p.maxRisk, d.coverage)) {
|
|
32
|
+
out.push({
|
|
33
|
+
code: "runtime/coverage-insufficient",
|
|
34
|
+
message: `This runbook contains ${p.maxRisk} steps, and this adapter cannot observe every tool invocation. An adapter that cannot see a call cannot bound it. Use a tool-call hook rather than a proxy.`,
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
if (d.riskCeiling && !riskAtMostLocal(p.maxRisk, d.riskCeiling)) {
|
|
38
|
+
out.push({
|
|
39
|
+
code: "runtime/risk-above-ceiling",
|
|
40
|
+
message: `This runbook contains ${p.maxRisk} steps and the configured ceiling is ${d.riskCeiling}. Raise the ceiling deliberately, or pick a different procedure.`,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
// An autonomous agent under an advisory client has nothing between it and the tools
|
|
44
|
+
// but its own judgement — which is the arrangement this specification exists to
|
|
45
|
+
// replace, so it is an error rather than a warning.
|
|
46
|
+
if (p.execution === "agent-autonomous" && d.profile === "R0") {
|
|
47
|
+
out.push({
|
|
48
|
+
code: "runtime/autonomous-unsupervised",
|
|
49
|
+
message: `This runbook declares agent-autonomous execution and this client enforces nothing (R0). Autonomous execution needs at least R1: an allowlist, per-step scope and approval gates.`,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
54
|
+
export function mayStart(p, d) {
|
|
55
|
+
return refusalsToStart(p, d).length === 0;
|
|
56
|
+
}
|
|
57
|
+
const RISK_RANK = {
|
|
58
|
+
"read-only": 0,
|
|
59
|
+
"reversible-write": 1,
|
|
60
|
+
destructive: 2,
|
|
61
|
+
irreversible: 3,
|
|
62
|
+
};
|
|
63
|
+
function riskAtMostLocal(actual, ceiling) {
|
|
64
|
+
return RISK_RANK[actual] <= RISK_RANK[ceiling];
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* The lowest runtime profile that can honestly run a procedure, given nothing but the
|
|
68
|
+
* procedure itself. Used to fill `min_runtime_profile` when an author leaves it out,
|
|
69
|
+
* and to warn when an author declares one lower than the content justifies.
|
|
70
|
+
*/
|
|
71
|
+
export function impliedMinimum(p) {
|
|
72
|
+
if (isDangerous(p.maxRisk))
|
|
73
|
+
return "R2";
|
|
74
|
+
if (p.execution === "agent-autonomous")
|
|
75
|
+
return "R1";
|
|
76
|
+
return "R0";
|
|
77
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { refusalsToStart, mayStart, impliedMinimum, atLeast } from "./contract.js";
|
|
3
|
+
import { mayRunUnder } from "./index.js";
|
|
4
|
+
const proxy = { mcpCalls: true, shell: false, fileEdits: false };
|
|
5
|
+
const hook = { mcpCalls: true, shell: true, fileEdits: true };
|
|
6
|
+
const deployment = (over = {}) => ({
|
|
7
|
+
profile: "R1",
|
|
8
|
+
coverage: hook,
|
|
9
|
+
...over,
|
|
10
|
+
});
|
|
11
|
+
const procedure = (over = {}) => ({
|
|
12
|
+
maxRisk: "reversible-write",
|
|
13
|
+
execution: "human-with-agent",
|
|
14
|
+
...over,
|
|
15
|
+
});
|
|
16
|
+
const codes = (p, d) => refusalsToStart(p, d).map((r) => r.code);
|
|
17
|
+
/**
|
|
18
|
+
* S-14 acceptance: each MUST is phrased so a conformance test could be written for it.
|
|
19
|
+
* These are those tests — if a rule here cannot be expressed as an assertion, the rule
|
|
20
|
+
* is wrong rather than the test.
|
|
21
|
+
*/
|
|
22
|
+
describe("a client MUST refuse a document whose minimum exceeds what it enforces", () => {
|
|
23
|
+
it("refuses R2 work on an R1 client", () => {
|
|
24
|
+
expect(codes(procedure({ minRuntimeProfile: "R2" }), deployment({ profile: "R1" })))
|
|
25
|
+
.toContain("runtime/profile-too-low");
|
|
26
|
+
});
|
|
27
|
+
it("allows a client that enforces more than the minimum", () => {
|
|
28
|
+
expect(mayStart(procedure({ minRuntimeProfile: "R1" }), deployment({ profile: "R2" })))
|
|
29
|
+
.toBe(true);
|
|
30
|
+
});
|
|
31
|
+
it("orders the profiles least to most enforcing", () => {
|
|
32
|
+
expect(atLeast("R2", "R1")).toBe(true);
|
|
33
|
+
expect(atLeast("R0", "R1")).toBe(false);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
describe("a profile is bounded by coverage, not by the client's claim (13.3)", () => {
|
|
37
|
+
// The rule someone will want to bypass under time pressure.
|
|
38
|
+
it("refuses destructive work under a proxy that cannot see shell access", () => {
|
|
39
|
+
expect(codes(procedure({ maxRisk: "destructive" }), deployment({ coverage: proxy })))
|
|
40
|
+
.toContain("runtime/coverage-insufficient");
|
|
41
|
+
});
|
|
42
|
+
it("still refuses when the client claims R2", () => {
|
|
43
|
+
expect(codes(procedure({ maxRisk: "irreversible" }), deployment({ profile: "R2", coverage: proxy }))).toContain("runtime/coverage-insufficient");
|
|
44
|
+
});
|
|
45
|
+
it("permits reversible work under a proxy", () => {
|
|
46
|
+
expect(mayStart(procedure({ maxRisk: "reversible-write" }), deployment({ coverage: proxy })))
|
|
47
|
+
.toBe(true);
|
|
48
|
+
});
|
|
49
|
+
it("permits destructive work under an adapter that sees everything", () => {
|
|
50
|
+
expect(mayStart(procedure({ maxRisk: "destructive" }), deployment({ coverage: hook })))
|
|
51
|
+
.toBe(true);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
describe("refusal happens before the first step, not halfway", () => {
|
|
55
|
+
// "Some of a destructive procedure" is the worst available outcome, so every reason
|
|
56
|
+
// to refuse is collected up front rather than discovered in sequence.
|
|
57
|
+
it("reports every reason at once", () => {
|
|
58
|
+
const found = codes(procedure({ maxRisk: "irreversible", execution: "agent-autonomous", minRuntimeProfile: "R2" }), deployment({ profile: "R0", coverage: proxy, riskCeiling: "read-only" }));
|
|
59
|
+
expect(found).toEqual(expect.arrayContaining([
|
|
60
|
+
"runtime/profile-too-low",
|
|
61
|
+
"runtime/coverage-insufficient",
|
|
62
|
+
"runtime/risk-above-ceiling",
|
|
63
|
+
"runtime/autonomous-unsupervised",
|
|
64
|
+
]));
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
describe("autonomous execution needs supervision", () => {
|
|
68
|
+
it("refuses an autonomous runbook on an advisory client", () => {
|
|
69
|
+
expect(codes(procedure({ execution: "agent-autonomous" }), deployment({ profile: "R0" })))
|
|
70
|
+
.toContain("runtime/autonomous-unsupervised");
|
|
71
|
+
});
|
|
72
|
+
it("leaves human-only procedures alone at R0", () => {
|
|
73
|
+
expect(mayStart(procedure({ execution: "human-only" }), deployment({ profile: "R0" })))
|
|
74
|
+
.toBe(true);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
describe("the risk ceiling is the operator's, and it is honoured", () => {
|
|
78
|
+
it("refuses work above the configured ceiling", () => {
|
|
79
|
+
expect(codes(procedure({ maxRisk: "destructive" }), deployment({ riskCeiling: "reversible-write" })))
|
|
80
|
+
.toContain("runtime/risk-above-ceiling");
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
describe("implied minimum profile", () => {
|
|
84
|
+
it("puts destructive work at R2", () => {
|
|
85
|
+
expect(impliedMinimum({ maxRisk: "destructive", execution: "human-with-agent" })).toBe("R2");
|
|
86
|
+
});
|
|
87
|
+
it("puts autonomous execution at R1", () => {
|
|
88
|
+
expect(impliedMinimum({ maxRisk: "read-only", execution: "agent-autonomous" })).toBe("R1");
|
|
89
|
+
});
|
|
90
|
+
it("leaves a human read-only procedure at R0", () => {
|
|
91
|
+
expect(impliedMinimum({ maxRisk: "read-only", execution: "human-only" })).toBe("R0");
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
describe("refusals say what to do", () => {
|
|
95
|
+
it("names the fix rather than the failure", () => {
|
|
96
|
+
const [refusal] = refusalsToStart(procedure({ maxRisk: "destructive" }), deployment({ coverage: proxy }));
|
|
97
|
+
expect(refusal.message).toMatch(/tool-call hook/);
|
|
98
|
+
expect(refusal.message).not.toMatch(/something went wrong|invalid|error/i);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
/**
|
|
102
|
+
* §13.3 said twice.
|
|
103
|
+
*
|
|
104
|
+
* `mayRunUnder` and the coverage refusal were written out separately, in the same two
|
|
105
|
+
* terms. Identical is not the problem — two places to change when the rule moves is, and
|
|
106
|
+
* the copy on the package's public surface was the one nothing exercised. This walks the
|
|
107
|
+
* whole matrix rather than the interesting corner, because a divergence would arrive in
|
|
108
|
+
* whichever cell somebody forgot.
|
|
109
|
+
*/
|
|
110
|
+
describe("the coverage rule has one statement", () => {
|
|
111
|
+
const risks = ["read-only", "reversible-write", "destructive", "irreversible"];
|
|
112
|
+
const coverages = [
|
|
113
|
+
{ mcpCalls: false, shell: false, fileEdits: false },
|
|
114
|
+
{ mcpCalls: true, shell: false, fileEdits: false },
|
|
115
|
+
{ mcpCalls: true, shell: true, fileEdits: false },
|
|
116
|
+
{ mcpCalls: false, shell: true, fileEdits: true },
|
|
117
|
+
{ mcpCalls: true, shell: true, fileEdits: true },
|
|
118
|
+
];
|
|
119
|
+
it("refuses on coverage exactly when the run may not proceed under the adapter", () => {
|
|
120
|
+
for (const maxRisk of risks) {
|
|
121
|
+
for (const coverage of coverages) {
|
|
122
|
+
const refused = codes(procedure({ maxRisk }), deployment({ coverage, profile: "R2" })).includes("runtime/coverage-insufficient");
|
|
123
|
+
expect(refused, `${maxRisk} under ${JSON.stringify(coverage)}`).toBe(!mayRunUnder(maxRisk, coverage));
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
it("covers both answers, so agreement is not agreement on one of them", () => {
|
|
128
|
+
const answers = risks.flatMap((maxRisk) => coverages.map((c) => mayRunUnder(maxRisk, c)));
|
|
129
|
+
expect(new Set(answers)).toEqual(new Set([true, false]));
|
|
130
|
+
});
|
|
131
|
+
});
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deviations: recording and escalation (R-06).
|
|
3
|
+
*
|
|
4
|
+
* "Anything not in the graph is a deviation: blocked, recorded, and optionally
|
|
5
|
+
* escalated" (§13.2). Blocking is the safety half. This is the diagnostic half, and it
|
|
6
|
+
* is the more valuable one over time: **a procedure that constantly provokes deviations
|
|
7
|
+
* is a procedure whose graph does not match reality.** No other signal surfaces that,
|
|
8
|
+
* and it is the most useful thing a supervised run can tell a runbook's author.
|
|
9
|
+
*
|
|
10
|
+
* Which means the record has to aggregate, and a record that aggregates cannot contain
|
|
11
|
+
* free text. §14's rule applies to everything that might leave the operator's
|
|
12
|
+
* environment: a deviation carries its class, its step and the *class* of capability
|
|
13
|
+
* involved — never the command, never an argument, never the value that made it fail.
|
|
14
|
+
*/
|
|
15
|
+
import type { DeviationClass } from "./index.js";
|
|
16
|
+
import type { RunEvent, RunState } from "./run.js";
|
|
17
|
+
export interface DeviationRecord {
|
|
18
|
+
readonly class: DeviationClass;
|
|
19
|
+
/** Where in the graph, which is what an author needs to fix their step 4. */
|
|
20
|
+
readonly stepId?: string;
|
|
21
|
+
/**
|
|
22
|
+
* The capability class alone: `cli`, `mcp`, `http`. Not `cli:kubectl`, and above all
|
|
23
|
+
* not what it was pointed at. The tool name is already close to a payload — it names
|
|
24
|
+
* the system — and the aggregate question ("what kind of thing do agents keep
|
|
25
|
+
* reaching for here") is answered by the class.
|
|
26
|
+
*/
|
|
27
|
+
readonly capabilityClass?: string;
|
|
28
|
+
/** Ordering without a clock, so the core stays pure and the sequence is reproducible. */
|
|
29
|
+
readonly sequence: number;
|
|
30
|
+
}
|
|
31
|
+
/** `cli:kubectl` -> `cli`. Anything unrecognisable contributes nothing rather than itself. */
|
|
32
|
+
export declare function capabilityClassOf(capability: string | undefined): string | undefined;
|
|
33
|
+
/** What the supervisor does about a class of deviation, beyond blocking the call. */
|
|
34
|
+
export type DeviationResponse = "abort" | "block-and-continue";
|
|
35
|
+
export type EscalationPolicy = {
|
|
36
|
+
readonly responses: Readonly<Record<DeviationClass, DeviationResponse>>;
|
|
37
|
+
/**
|
|
38
|
+
* How many deviations of any class a run may accumulate before it is stopped.
|
|
39
|
+
*
|
|
40
|
+
* An agent that keeps proposing things the document does not allow is not having one
|
|
41
|
+
* bad moment; either the graph does not match reality or the agent is off the
|
|
42
|
+
* procedure, and both are reasons to stop rather than to keep blocking calls forever.
|
|
43
|
+
*/
|
|
44
|
+
readonly maxTotal: number;
|
|
45
|
+
};
|
|
46
|
+
/**
|
|
47
|
+
* The default, and it is conservative on purpose.
|
|
48
|
+
*
|
|
49
|
+
* Anything that breaks control flow aborts: if the run is not where the graph says it
|
|
50
|
+
* is, every later authorization is being decided against the wrong step, and continuing
|
|
51
|
+
* would mean enforcing a policy that no longer describes the run. A tool the runbook
|
|
52
|
+
* declares but this step does not is the one case that merely blocks — it is the classic
|
|
53
|
+
* agent-runs-ahead mistake, the call is refused, and the procedure is still on its
|
|
54
|
+
* rails.
|
|
55
|
+
*/
|
|
56
|
+
export declare const DEFAULT_ESCALATION: EscalationPolicy;
|
|
57
|
+
export interface DeviationInput {
|
|
58
|
+
readonly class: DeviationClass;
|
|
59
|
+
readonly stepId?: string;
|
|
60
|
+
/** A capability URN. Only its class is kept. */
|
|
61
|
+
readonly capability?: string;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Record a deviation and apply the policy.
|
|
65
|
+
*
|
|
66
|
+
* One place, because there were seven: every path that blocked something appended to the
|
|
67
|
+
* same array in its own way, and a taxonomy maintained in seven places is one that
|
|
68
|
+
* disagrees with itself as soon as a class is added.
|
|
69
|
+
*/
|
|
70
|
+
export declare function recordDeviation(state: RunState, input: DeviationInput): {
|
|
71
|
+
state: RunState;
|
|
72
|
+
events: readonly RunEvent[];
|
|
73
|
+
};
|
|
74
|
+
/** How many, by class — the aggregate an author reads. */
|
|
75
|
+
export declare function countByClass(deviations: readonly DeviationRecord[]): Readonly<Record<string, number>>;
|
|
76
|
+
/** How many, by step — the aggregate that says which step does not match reality. */
|
|
77
|
+
export declare function countByStep(deviations: readonly DeviationRecord[]): Readonly<Record<string, number>>;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/** `cli:kubectl` -> `cli`. Anything unrecognisable contributes nothing rather than itself. */
|
|
2
|
+
export function capabilityClassOf(capability) {
|
|
3
|
+
if (!capability)
|
|
4
|
+
return undefined;
|
|
5
|
+
const head = capability.split(":", 1)[0];
|
|
6
|
+
return /^[a-z][a-z0-9-]*$/.test(head) ? head : undefined;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* The default, and it is conservative on purpose.
|
|
10
|
+
*
|
|
11
|
+
* Anything that breaks control flow aborts: if the run is not where the graph says it
|
|
12
|
+
* is, every later authorization is being decided against the wrong step, and continuing
|
|
13
|
+
* would mean enforcing a policy that no longer describes the run. A tool the runbook
|
|
14
|
+
* declares but this step does not is the one case that merely blocks — it is the classic
|
|
15
|
+
* agent-runs-ahead mistake, the call is refused, and the procedure is still on its
|
|
16
|
+
* rails.
|
|
17
|
+
*/
|
|
18
|
+
export const DEFAULT_ESCALATION = {
|
|
19
|
+
responses: {
|
|
20
|
+
"undeclared-tool": "abort",
|
|
21
|
+
"out-of-scope-tool": "block-and-continue",
|
|
22
|
+
"out-of-order-step": "abort",
|
|
23
|
+
"unchecked-advance": "abort",
|
|
24
|
+
"budget-exhausted": "abort",
|
|
25
|
+
"unrouted-failure": "abort",
|
|
26
|
+
"terminal-not-reached": "abort",
|
|
27
|
+
},
|
|
28
|
+
maxTotal: 3,
|
|
29
|
+
};
|
|
30
|
+
/**
|
|
31
|
+
* Record a deviation and apply the policy.
|
|
32
|
+
*
|
|
33
|
+
* One place, because there were seven: every path that blocked something appended to the
|
|
34
|
+
* same array in its own way, and a taxonomy maintained in seven places is one that
|
|
35
|
+
* disagrees with itself as soon as a class is added.
|
|
36
|
+
*/
|
|
37
|
+
export function recordDeviation(state, input) {
|
|
38
|
+
const stepId = input.stepId ?? state.current;
|
|
39
|
+
const record = {
|
|
40
|
+
class: input.class,
|
|
41
|
+
...(stepId ? { stepId } : {}),
|
|
42
|
+
...(capabilityClassOf(input.capability) ? { capabilityClass: capabilityClassOf(input.capability) } : {}),
|
|
43
|
+
sequence: state.deviations.length,
|
|
44
|
+
};
|
|
45
|
+
const deviations = [...state.deviations, record];
|
|
46
|
+
const policy = state.escalation;
|
|
47
|
+
const response = policy.responses[input.class];
|
|
48
|
+
const overRun = deviations.length > policy.maxTotal;
|
|
49
|
+
if (state.status === "ended" || (response !== "abort" && !overRun)) {
|
|
50
|
+
return { state: { ...state, deviations }, events: [] };
|
|
51
|
+
}
|
|
52
|
+
return {
|
|
53
|
+
state: { ...state, deviations, status: "ended", outcome: state.outcome ?? "aborted" },
|
|
54
|
+
events: [
|
|
55
|
+
{
|
|
56
|
+
kind: "escalated",
|
|
57
|
+
code: overRun ? "run/deviation-limit" : `run/escalated-${input.class}`,
|
|
58
|
+
message: overRun
|
|
59
|
+
? `${deviations.length} deviations in one run, past the limit of ${policy.maxTotal}. Stopping: either the graph does not match reality or the run has left the procedure, and both are reasons to stop rather than to keep refusing calls.`
|
|
60
|
+
: `${input.class} at ${stepId ?? "an unknown step"} stops the run. The run is not where the graph says it is, so every later decision would be made against the wrong step.`,
|
|
61
|
+
...(stepId ? { at: stepId } : {}),
|
|
62
|
+
},
|
|
63
|
+
],
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
/** How many, by class — the aggregate an author reads. */
|
|
67
|
+
export function countByClass(deviations) {
|
|
68
|
+
const counts = {};
|
|
69
|
+
for (const d of deviations)
|
|
70
|
+
counts[d.class] = (counts[d.class] ?? 0) + 1;
|
|
71
|
+
return counts;
|
|
72
|
+
}
|
|
73
|
+
/** How many, by step — the aggregate that says which step does not match reality. */
|
|
74
|
+
export function countByStep(deviations) {
|
|
75
|
+
const counts = {};
|
|
76
|
+
for (const d of deviations) {
|
|
77
|
+
if (d.stepId)
|
|
78
|
+
counts[d.stepId] = (counts[d.stepId] ?? 0) + 1;
|
|
79
|
+
}
|
|
80
|
+
return counts;
|
|
81
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|