seisin 0.5.0 → 0.6.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/README.md +385 -129
- package/package.json +13 -5
- package/src/browsers.js +52 -0
- package/src/canary.js +149 -0
- package/src/cli.js +47 -23
- package/src/commands/check.js +154 -3
- package/src/commands/explain.js +2 -5
- package/src/commands/hook.js +7 -3
- package/src/commands/init.js +57 -50
- package/src/commands/requests.js +31 -56
- package/src/commands/review.js +33 -6
- package/src/commands/run.js +332 -110
- package/src/commands/walls.js +39 -7
- package/src/commands/whose.js +2 -1
- package/src/config.js +321 -15
- package/src/controls.js +5 -5
- package/src/diagnose.js +67 -16
- package/src/env.js +3 -0
- package/src/grants.js +107 -2
- package/src/handoff.js +285 -10
- package/src/help.js +39 -19
- package/src/hook.js +59 -30
- package/src/index.d.ts +217 -0
- package/src/index.js +20 -12
- package/src/inspect/codex.js +64 -0
- package/src/inspect/credentials.js +48 -0
- package/src/inspect/enforce.js +24 -0
- package/src/inspect/files.js +71 -0
- package/src/inspect/home.js +43 -0
- package/src/inspect/inject.js +68 -0
- package/src/inspect/keys.js +74 -0
- package/src/inspect/layout.js +258 -0
- package/src/inspect/limits.js +98 -0
- package/src/inspect/machine.js +107 -0
- package/src/inspect/network.js +37 -0
- package/src/inspect/providers.js +71 -0
- package/src/inspect/read-territory.js +105 -0
- package/src/inspect/role-keys.js +136 -0
- package/src/inspect/shared.js +37 -0
- package/src/inspect/warnings.js +58 -0
- package/src/inspect.js +13 -815
- package/src/intake.js +131 -21
- package/src/keys.js +223 -67
- package/src/kinds.js +42 -0
- package/src/lines.js +26 -0
- package/src/log.js +7 -2
- package/src/mcp.js +28 -43
- package/src/nested.js +12 -3
- package/src/notify.js +53 -12
- package/src/owners.js +165 -3
- package/src/paths.js +17 -1
- package/src/provider-run.js +81 -0
- package/src/redact.js +34 -15
- package/src/render.js +35 -9
- package/src/requests.js +363 -45
- package/src/review.js +21 -9
- package/src/rundir.js +29 -8
- package/src/sandboxes/command.js +93 -0
- package/src/sandboxes/index.js +113 -0
- package/src/sandboxes/mach-profile.js +31 -0
- package/src/sandboxes/srt-mach.mjs +43 -0
- package/src/sandboxes/srt.js +163 -0
- package/src/sandboxes/tartaros.js +80 -0
- package/src/scan.js +7 -3
- package/src/serve.js +35 -17
- package/src/spool.js +8 -17
- package/src/srt.js +263 -33
- package/src/surface.js +129 -16
- package/src/territory.js +261 -0
- package/src/trace.js +33 -0
- package/src/uilink.js +15 -6
- package/src/views.js +40 -14
- package/src/violations.js +143 -7
- package/src/walls.js +62 -9
- package/ui/index.html +85 -13
- package/ui/favicon.svg +0 -1
package/src/diagnose.js
CHANGED
|
@@ -28,10 +28,14 @@
|
|
|
28
28
|
* On Linux the runtime's refusals cannot be read from outside (violations.js),
|
|
29
29
|
* so the after-the-fact half finds nothing there and stays quiet.
|
|
30
30
|
*/
|
|
31
|
+
import { isAbsolute } from "node:path";
|
|
31
32
|
import { readEntries } from "./log.js";
|
|
32
33
|
import { explain } from "./owners.js";
|
|
33
|
-
import {
|
|
34
|
+
import { hitsOf, againWords, walls } from "./walls.js";
|
|
34
35
|
import { collapseSidecars } from "./render.js";
|
|
36
|
+
import { decideHandoff, toAgent, isStopped, ELSEWHERE } from "./handoff.js";
|
|
37
|
+
import { keyOf } from "./requests.js";
|
|
38
|
+
import { destinationOf } from "./intake.js";
|
|
35
39
|
|
|
36
40
|
/** What a refused write or read looks like in a tool's output. A trigger to look, not a verdict. */
|
|
37
41
|
export const DENIAL_SIGNS = /Operation not permitted|EPERM|EACCES|Permission denied|Read-only file system/i;
|
|
@@ -75,6 +79,8 @@ export function recentKernelDenials(file, role, sinceMs) {
|
|
|
75
79
|
const seen = new Map();
|
|
76
80
|
for (const e of readEntries(file, { tail: TAIL_BYTES }).reverse()) {
|
|
77
81
|
if (e.role !== role || e.verdict !== "denied" || e.source !== "kernel") continue;
|
|
82
|
+
// A system service is not a path the agent can act on, and the sentence below is about paths.
|
|
83
|
+
if (e.kind === "system") continue;
|
|
78
84
|
if (!e.at || e.at < since || !e.target) continue;
|
|
79
85
|
const k = `${e.action}\u0000${e.target}`;
|
|
80
86
|
if (!seen.has(k)) seen.set(k, e);
|
|
@@ -83,16 +89,32 @@ export function recentKernelDenials(file, role, sinceMs) {
|
|
|
83
89
|
}
|
|
84
90
|
|
|
85
91
|
/** The sentence for one refusal, in the words the PreToolUse hook uses. */
|
|
86
|
-
function sentence(config, role, file, e) {
|
|
92
|
+
function sentence(config, role, file, e, chain = null, run = null) {
|
|
87
93
|
const action = e.kind === "key" ? "read" : e.action === "read" || e.action === "connect" ? e.action : "write";
|
|
94
|
+
// An atomic save refused at its temporary file is about the file it was
|
|
95
|
+
// saving. Said about that file, or the agent goes looking for a missing
|
|
96
|
+
// directory (measured).
|
|
97
|
+
const destination = action === "write" ? destinationOf(e.target) : null;
|
|
98
|
+
if (destination) {
|
|
99
|
+
if (explain(config, role, "write", destination).allowed)
|
|
100
|
+
return `${destination} was not saved: it is ${role}'s to write, but the sandbox denied the temporary file ` +
|
|
101
|
+
`${e.target} that the save writes beside it, and no grant can name that file. Write ${destination} with the shell ` +
|
|
102
|
+
"instead (a redirect writes the file itself).";
|
|
103
|
+
e = { ...e, target: destination };
|
|
104
|
+
}
|
|
88
105
|
const v = explain(config, role, action, e.target);
|
|
89
106
|
if (v.allowed) return null; // the policy moved since: nothing to say
|
|
90
|
-
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
if (v.
|
|
94
|
-
|
|
95
|
-
|
|
107
|
+
// Read after the refusal was logged, so the counts include it.
|
|
108
|
+
const { total: times, inRun } = hitsOf(file, role, action, e.target, run);
|
|
109
|
+
const again = againWords({ times, inRun });
|
|
110
|
+
if (v.neverWrites) return `${v.reason}. Do not ask for it. ${ELSEWHERE}${again}`;
|
|
111
|
+
// A write, or a key: the denials that become requests, worded from the same
|
|
112
|
+
// handoff decision the hook and the queue use. Everything else — a protected
|
|
113
|
+
// file, a connection, a read the mode shuts — keeps the policy's own reason.
|
|
114
|
+
const handed = (action === "write" || e.kind === "key") && !v.gitMetadata && !v.protected && !v.privateTo;
|
|
115
|
+
if (handed)
|
|
116
|
+
return toAgent(decideHandoff({ request: { role, action, target: e.target }, config, chain }),
|
|
117
|
+
{ target: e.target, role, key: keyOf({ role, action, target: e.target }), again });
|
|
96
118
|
return `${e.target}: ${v.reason}.${again}`;
|
|
97
119
|
}
|
|
98
120
|
|
|
@@ -129,7 +151,7 @@ function evidenceOf(event) {
|
|
|
129
151
|
* refusal, and a loaded machine can be slower than the hook. Only when the tool
|
|
130
152
|
* reported a failure — see {@link evidenceOf}.
|
|
131
153
|
*/
|
|
132
|
-
export async function afterTool(config, role, event, { file, now = Date.now, wait = [0, 100, 300] } = {}) {
|
|
154
|
+
export async function afterTool(config, role, event, { file, now = Date.now, wait = [0, 100, 300], chain = null, run = process.env.SEISIN_RUN_ID ?? null } = {}) {
|
|
133
155
|
const { text: evidence, failed } = evidenceOf(event);
|
|
134
156
|
if (!DENIAL_SIGNS.test(evidence)) return null;
|
|
135
157
|
// JSON escapes what the program printed; the signatures are written against the text.
|
|
@@ -140,7 +162,7 @@ export async function afterTool(config, role, event, { file, now = Date.now, wai
|
|
|
140
162
|
found = recentKernelDenials(file, role, now() - WINDOW_MS);
|
|
141
163
|
if (found.length) break;
|
|
142
164
|
}
|
|
143
|
-
const lines = [...known, ...found.map((e) => sentence(config, role, file, e)).filter(Boolean)].slice(0, MAX_NAMED);
|
|
165
|
+
const lines = [...known, ...found.map((e) => sentence(config, role, file, e, chain, run)).filter(Boolean)].slice(0, MAX_NAMED);
|
|
144
166
|
if (!lines.length) return null;
|
|
145
167
|
const total = known.length + found.length;
|
|
146
168
|
const more = total > MAX_NAMED ? ` (and ${total - MAX_NAMED} more — \`seisin walls ${role}\`)` : "";
|
|
@@ -153,7 +175,7 @@ export async function afterTool(config, role, event, { file, now = Date.now, wai
|
|
|
153
175
|
}
|
|
154
176
|
|
|
155
177
|
/** When a session starts, resumes or is compacted: the map, before the first wall. */
|
|
156
|
-
export function atSessionStart(config, role, event, { file } = {}) {
|
|
178
|
+
export function atSessionStart(config, role, event, { file, chain = null } = {}) {
|
|
157
179
|
const r = config.roles[role];
|
|
158
180
|
const writes = collapseSidecars(r.writesDeclared ?? r.writes);
|
|
159
181
|
const lines = [
|
|
@@ -162,13 +184,42 @@ export function atSessionStart(config, role, event, { file } = {}) {
|
|
|
162
184
|
];
|
|
163
185
|
if ((r.neverWritesDeclared ?? r.neverWrites ?? []).length)
|
|
164
186
|
lines.push(`Never, even inside that: ${(r.neverWritesDeclared ?? r.neverWrites).join(", ")}.`);
|
|
187
|
+
if (r.private?.length) lines.push(`Private to you (yours to read and write; no other role reads it): ${r.private.join(", ")}.`);
|
|
188
|
+
const theirs = Object.values(config.roles).filter((o) => o.name !== role && o.private?.length)
|
|
189
|
+
.map((o) => `${o.private.join(", ")} (${o.name})`);
|
|
190
|
+
if (theirs.length) lines.push(`Private to another role — you can neither read nor write it: ${theirs.join("; ")}.`);
|
|
165
191
|
if (r.keys.length) lines.push(`Keys you hold: ${r.keys.join(", ")}.`);
|
|
166
|
-
|
|
167
|
-
|
|
192
|
+
// An expected refusal (Claude Code's plugin updater) is not the agent's to
|
|
193
|
+
// stop trying, so it does not take one of the five lines.
|
|
194
|
+
const w = file ? walls(config, role, { file, limit: 0, fresh: true }).filter((x) => !x.expected).slice(0, 5) : [];
|
|
195
|
+
// Outside the repository nobody owns anything, so there is no one to hand
|
|
196
|
+
// it to: under "hand it over", ~/.npm/_logs read as work for somebody else.
|
|
197
|
+
const outside = (x) => isAbsolute(x.target) || x.target.startsWith("~");
|
|
198
|
+
// Where this run's handoff chain stops, the wall names no owner: measured,
|
|
199
|
+
// an agent told the owner here repeated it after the sentence its denial
|
|
200
|
+
// told it to pass on alone.
|
|
201
|
+
const stops = (x) => isStopped(decideHandoff({ request: { role, action: x.action, target: x.target }, config, chain }));
|
|
202
|
+
const inRepo = w.filter((x) => !outside(x)), out = w.filter(outside);
|
|
203
|
+
const stopped = inRepo.filter(stops), mine = inRepo.filter((x) => !stops(x));
|
|
204
|
+
const wall = (x) => `- ${x.action} ${x.target} (${x.times}×) — ${x.owners?.length ? `belongs to ${x.owners.join(", ")}` : x.reason}`;
|
|
205
|
+
if (mine.length) {
|
|
168
206
|
lines.push("Already denied more than once, and still denied — do not try again, hand it over:");
|
|
169
|
-
for (const x of
|
|
170
|
-
|
|
207
|
+
for (const x of mine) lines.push(wall(x));
|
|
208
|
+
}
|
|
209
|
+
if (stopped.length) {
|
|
210
|
+
lines.push("Already denied more than once, where this run's handoff chain stops — do not try again; a person decides, " +
|
|
211
|
+
"and the denial gives you the one sentence to pass on:");
|
|
212
|
+
for (const x of stopped) lines.push(`- ${x.action} ${x.target} (${x.times}×)`);
|
|
213
|
+
}
|
|
214
|
+
if (out.length) {
|
|
215
|
+
lines.push("Denied more than once outside the repository, where no role owns anything — nothing to hand over; leave it:");
|
|
216
|
+
for (const x of out) lines.push(`- ${x.action} ${x.target} (${x.times}×)`);
|
|
171
217
|
}
|
|
172
|
-
|
|
218
|
+
// In a handed run a denial can be where the chain stops, and there the agent
|
|
219
|
+
// is to say one sentence and not whose the file is: the map cannot ask the
|
|
220
|
+
// opposite.
|
|
221
|
+
lines.push(chain?.visited?.length
|
|
222
|
+
? "A file outside that is not yours to change: do not work around it. Where a denial gives you a sentence to pass on, pass on that sentence and nothing else about the file."
|
|
223
|
+
: "A file outside that is somebody else's: say whose it is and hand it over, rather than working around it.");
|
|
173
224
|
return { hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: lines.join("\n") } };
|
|
174
225
|
}
|
package/src/env.js
CHANGED
|
@@ -29,6 +29,9 @@ export const BASE = [
|
|
|
29
29
|
"XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_STATE_HOME", "XDG_CACHE_HOME",
|
|
30
30
|
"TERM", "TERM_PROGRAM", "COLORTERM", "LANG", "LC_ALL", "LC_CTYPE", "TZ",
|
|
31
31
|
"SSL_CERT_FILE", "SSL_CERT_DIR", "NODE_EXTRA_CA_CERTS",
|
|
32
|
+
// The launcher's W3C trace context: ids that join the agent's own telemetry to
|
|
33
|
+
// the launcher's trace (trace.js). By the spec they are not secrets.
|
|
34
|
+
"TRACEPARENT", "TRACESTATE",
|
|
32
35
|
];
|
|
33
36
|
|
|
34
37
|
/**
|
package/src/grants.js
CHANGED
|
@@ -128,6 +128,111 @@ export const RUNTIME_WRITES = [
|
|
|
128
128
|
"~/.cache", "$TMPDIR", "/tmp",
|
|
129
129
|
];
|
|
130
130
|
|
|
131
|
+
/**
|
|
132
|
+
* The agents that keep their state — and their sign-in — under a directory of
|
|
133
|
+
* their own name, and where that sign-in sits when it is a file.
|
|
134
|
+
*
|
|
135
|
+
* `~/.claude` and `~/.codex` used to be granted to every run, whatever it ran.
|
|
136
|
+
* The cost was measured on 2026-09-30: a role running nothing but `cat` read
|
|
137
|
+
* `~/.codex/auth.json`, the ChatGPT tokens of whoever signed Codex in. The
|
|
138
|
+
* directory is the agent's scratch, so it goes to a run of that agent; and the
|
|
139
|
+
* other agents' sign-in files are denied to it, read and write, so a Claude
|
|
140
|
+
* run cannot read Codex's tokens or swap them for another account's.
|
|
141
|
+
*
|
|
142
|
+
* `~/.claude/.credentials.json` is where Claude Code keeps its sign-in when
|
|
143
|
+
* there is no keychain (Linux, and macOS when the keychain is unavailable).
|
|
144
|
+
* On macOS it is normally in the login keychain, which no file rule reaches.
|
|
145
|
+
*
|
|
146
|
+
* What this does NOT close, said where it is decided: a run of Codex can read
|
|
147
|
+
* its own `auth.json`, and so can every command Codex runs, because they share
|
|
148
|
+
* one sandbox. Only Codex storing its sign-in in the keychain closes that
|
|
149
|
+
* (`cli_auth_credentials_store = "keyring"`), and that is Codex's setting.
|
|
150
|
+
*/
|
|
151
|
+
export const AGENT_HOMES = { claude: "~/.claude", codex: "~/.codex" };
|
|
152
|
+
export const AGENT_CREDENTIALS = {
|
|
153
|
+
claude: ["~/.claude/.credentials.json"],
|
|
154
|
+
codex: ["~/.codex/auth.json"],
|
|
155
|
+
};
|
|
156
|
+
export const AGENTS = Object.keys(AGENT_HOMES);
|
|
157
|
+
|
|
158
|
+
const SHELLS = new Set(["sh", "bash", "zsh", "dash", "fish"]);
|
|
159
|
+
const LAUNCHERS = new Set(["node", "npx", "bunx", "pnpx", "bun", "deno"]);
|
|
160
|
+
|
|
161
|
+
/** Which agent a token names, by its basename or its package path, or null. */
|
|
162
|
+
function agentNamed(token) {
|
|
163
|
+
const t = String(token ?? "");
|
|
164
|
+
const base = t.split("/").pop().replace(/\.(c|m)?js$/, "");
|
|
165
|
+
if (AGENTS.includes(base)) return base;
|
|
166
|
+
if (/@openai\/codex(\/|$)/.test(t)) return "codex";
|
|
167
|
+
if (/@anthropic-ai\/claude-code(\/|$)/.test(t)) return "claude";
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* The agent a command line runs, or null when it is none seisin knows.
|
|
173
|
+
*
|
|
174
|
+
* Reads what a person writes after `--`: the program, through `env` and
|
|
175
|
+
* `VAR=value`, through a launcher (`node …/@openai/codex/bin/codex.js`,
|
|
176
|
+
* `npx @anthropic-ai/claude-code`), and through `sh -c "codex exec …"` by the
|
|
177
|
+
* first word of the script. Nothing cleverer: a prompt that mentions "claude"
|
|
178
|
+
* must not turn a run into a Claude run. A wrapper script is not followed —
|
|
179
|
+
* `seisin run <role> --agent codex -- ./wrapper.sh` says it instead.
|
|
180
|
+
*/
|
|
181
|
+
export function agentOf(cmd = []) {
|
|
182
|
+
const argv = [...cmd];
|
|
183
|
+
for (let depth = 0; depth < 4 && argv.length; depth++) {
|
|
184
|
+
while (argv.length && (argv[0] === "env" || /^[A-Za-z_][A-Za-z0-9_]*=/.test(argv[0]) ||
|
|
185
|
+
(argv[0].startsWith("-") && depth > 0))) argv.shift();
|
|
186
|
+
if (!argv.length) return null;
|
|
187
|
+
const head = argv.shift();
|
|
188
|
+
const named = agentNamed(head);
|
|
189
|
+
if (named) return named;
|
|
190
|
+
const base = head.split("/").pop();
|
|
191
|
+
if (LAUNCHERS.has(base)) {
|
|
192
|
+
const next = argv.find((a) => !a.startsWith("-"));
|
|
193
|
+
return next ? agentNamed(next) : null;
|
|
194
|
+
}
|
|
195
|
+
if (SHELLS.has(base)) {
|
|
196
|
+
const i = argv.findIndex((a) => /^-[a-z]*c$/.test(a));
|
|
197
|
+
if (i === -1 || argv[i + 1] === undefined) return null;
|
|
198
|
+
argv.splice(0, argv.length, ...argv[i + 1].trim().split(/\s+/));
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* The scratch list for a run of `agent`: every entry, minus the homes of the
|
|
208
|
+
* agents it is not.
|
|
209
|
+
*
|
|
210
|
+
* `agent === undefined` is "no command known" — check, console, explain, and
|
|
211
|
+
* the union surface.js needs — and answers with every home, which is the most
|
|
212
|
+
* any run of the role gets. `null` is a command that is no agent seisin knows,
|
|
213
|
+
* and gets neither home. An explicit `[runtime] writes` goes through the same
|
|
214
|
+
* filter: listing `~/.codex` there says what a Codex run may write, not that
|
|
215
|
+
* a `cat` may.
|
|
216
|
+
*/
|
|
217
|
+
export function runtimeWritesFor(config, agent) {
|
|
218
|
+
const list = (config.runtimeWrites ?? RUNTIME_WRITES).map(expand);
|
|
219
|
+
if (agent === undefined) return list;
|
|
220
|
+
const others = AGENTS.filter((a) => a !== agent).map((a) => expand(AGENT_HOMES[a]));
|
|
221
|
+
return list.filter((p) => !others.some((h) => p === h || p.startsWith(h + "/")));
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* The sign-in files of every agent this run is not, absolute, under `home`.
|
|
226
|
+
*
|
|
227
|
+
* Empty when no command is known: a role's settings without a command are the
|
|
228
|
+
* most it can get, and the most includes being run as either agent.
|
|
229
|
+
*/
|
|
230
|
+
export function otherAgentsCredentials(agent, home = homedir()) {
|
|
231
|
+
if (agent === undefined) return [];
|
|
232
|
+
const at = (p) => (p.startsWith("~/") ? join(home, p.slice(2)) : p);
|
|
233
|
+
return AGENTS.filter((a) => a !== agent).flatMap((a) => AGENT_CREDENTIALS[a].map(at)).map((p) => realOrSelf(p));
|
|
234
|
+
}
|
|
235
|
+
|
|
131
236
|
/**
|
|
132
237
|
* `~` and `$TMPDIR` are the only expansions; everything else is a literal path.
|
|
133
238
|
*
|
|
@@ -234,7 +339,7 @@ function unexpressible(glob, key = "writes") {
|
|
|
234
339
|
* the real ~/.claude, ~/.cache and /tmp, which every role shares — convenient,
|
|
235
340
|
* and the reason `isolate` exists.
|
|
236
341
|
*/
|
|
237
|
-
export function writePathsOf(config, role, { observe = false } = {}) {
|
|
342
|
+
export function writePathsOf(config, role, { observe = false, agent } = {}) {
|
|
238
343
|
const abs = (p) => (p.startsWith("/") ? p : join(config.root, p));
|
|
239
344
|
const level = config.isolate === true ? "home" : config.isolate;
|
|
240
345
|
const home = level === "home" ? roleHome(config, role.name) : null;
|
|
@@ -242,6 +347,6 @@ export function writePathsOf(config, role, { observe = false } = {}) {
|
|
|
242
347
|
...(observe ? [abs(".")] : role.writes.map((g) => abs(toWritePath(g)))),
|
|
243
348
|
...(home
|
|
244
349
|
? [home, join(home, ".config"), join(home, ".local"), join(home, ".cache"), join(home, "tmp")]
|
|
245
|
-
: (config
|
|
350
|
+
: runtimeWritesFor(config, agent)),
|
|
246
351
|
];
|
|
247
352
|
}
|
package/src/handoff.js
CHANGED
|
@@ -16,8 +16,18 @@
|
|
|
16
16
|
* *demand* for work. It may not determine authority, routing identity,
|
|
17
17
|
* admission or policy. Every input below is either derived from the boundary
|
|
18
18
|
* or supplied by the caller; none of them is a role name a model chose.
|
|
19
|
+
*
|
|
20
|
+
* ── Where the decision is made ──
|
|
21
|
+
* Where the request is read, not where it is written. Every reader of the
|
|
22
|
+
* queue — `seisin requests`, the end of `seisin run`, the notification, the
|
|
23
|
+
* console, the MCP — gets it from requests.js (withHandoffs), against the
|
|
24
|
+
* policy in front of it, so a grant made since changes it at once. What is
|
|
25
|
+
* written down is only history: the chain a request was asked in, set by the
|
|
26
|
+
* parent of a run started with `--handoff`, and what the execution plane did
|
|
27
|
+
* about it (recordHandoff). Reads go to a person: a key is shared by a grant,
|
|
28
|
+
* never handed over.
|
|
19
29
|
*/
|
|
20
|
-
import { ownersOf } from "./owners.js";
|
|
30
|
+
import { ownersOf, keyHolders, inKeyDir } from "./owners.js";
|
|
21
31
|
|
|
22
32
|
/** Small on purpose: a fuse for a topology nobody predicted, not a budget. */
|
|
23
33
|
export const MAX_DEPTH = 4;
|
|
@@ -44,10 +54,26 @@ export function decideHandoff({
|
|
|
44
54
|
if (!config || !config.roles) throw new Error("decideHandoff: config needs roles");
|
|
45
55
|
|
|
46
56
|
const depthMax = limits.depth ?? MAX_DEPTH;
|
|
47
|
-
|
|
57
|
+
// A caller may provide incomplete history. The requester is always visited.
|
|
58
|
+
const visited = [...new Set([...(chain?.visited ?? []), request.role])];
|
|
48
59
|
const depth = chain?.depth ?? 0;
|
|
49
60
|
const at = (type, extra = {}) => ({ type, revision, chainId: chain?.id ?? null, ...extra });
|
|
50
61
|
|
|
62
|
+
/**
|
|
63
|
+
* A read is not handed to anybody. A key is declared, not owned, and the
|
|
64
|
+
* only thing that changes who reads it is a person sharing it; another
|
|
65
|
+
* role's private folder is the policy working. Who declares or owns it is
|
|
66
|
+
* still worked out here — the queue shows it — but the decision is a
|
|
67
|
+
* person's, unless the policy already moved and the asker has it now.
|
|
68
|
+
*/
|
|
69
|
+
if (request.action === "read") {
|
|
70
|
+
const key = isKeyRequest(config, request);
|
|
71
|
+
const holders = key ? keyHolders(config, request.target) : ownersOf(config, request.target);
|
|
72
|
+
if (holders.includes(request.role))
|
|
73
|
+
return at("resolved", { resolution: "policy_changed", role: request.role, owners: holders });
|
|
74
|
+
return at("human", { reason: key ? "key" : "read", owners: holders });
|
|
75
|
+
}
|
|
76
|
+
|
|
51
77
|
const owners = ownersOf(config, request.target);
|
|
52
78
|
|
|
53
79
|
/**
|
|
@@ -61,9 +87,6 @@ export function decideHandoff({
|
|
|
61
87
|
* wrong thing.
|
|
62
88
|
*/
|
|
63
89
|
if (owners.length === 0) return at("human", { reason: "unowned", owners });
|
|
64
|
-
if (owners.length > 1) return at("human", { reason: "ambiguous", owners });
|
|
65
|
-
|
|
66
|
-
const [owner] = owners;
|
|
67
90
|
|
|
68
91
|
/**
|
|
69
92
|
* The policy moved and the asker owns it now. Nobody granted anything, so
|
|
@@ -71,8 +94,30 @@ export function decideHandoff({
|
|
|
71
94
|
* so is what keeps the human queue readable. Sending this to a person would
|
|
72
95
|
* be asking them to decide something already decided.
|
|
73
96
|
*/
|
|
97
|
+
if (owners.length === 1 && owners[0] === request.role)
|
|
98
|
+
return at("resolved", { resolution: "policy_changed", role: request.role, owners });
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Only an owner that takes handed work can be handed it.
|
|
102
|
+
*
|
|
103
|
+
* A role marked `handoff = false` is a pipeline phase, not a worker: it owns
|
|
104
|
+
* its territory — often all of it, `**` — and starting it because some other
|
|
105
|
+
* role reached for a file there would run a phase for a reason that has
|
|
106
|
+
* nothing to do with it. So it is left out of the choice, and the existing
|
|
107
|
+
* rule runs over who is left: one receiver is a route, several are for a
|
|
108
|
+
* person, and none is a person's too, with a reason that says the owner is
|
|
109
|
+
* there and does not take the work. Ownership itself is untouched — the
|
|
110
|
+
* queue and the agent still hear whose it is.
|
|
111
|
+
*/
|
|
112
|
+
const receivers = owners.filter((o) => config.roles[o]?.handoff !== false);
|
|
113
|
+
const narrowed = receivers.length < owners.length ? { receivers } : {};
|
|
114
|
+
if (receivers.length === 0) return at("human", { reason: "no-receiver", owners, receivers });
|
|
115
|
+
if (receivers.length > 1) return at("human", { reason: "ambiguous", owners, ...narrowed });
|
|
116
|
+
|
|
117
|
+
const [owner] = receivers;
|
|
118
|
+
// The asker is that one receiver: it owns the path now, whoever else does.
|
|
74
119
|
if (owner === request.role)
|
|
75
|
-
return at("resolved", { resolution: "policy_changed", role: owner });
|
|
120
|
+
return at("resolved", { resolution: "policy_changed", role: owner, owners });
|
|
76
121
|
|
|
77
122
|
/**
|
|
78
123
|
* A role already in this chain does not get re-entered, even for a different
|
|
@@ -81,10 +126,10 @@ export function decideHandoff({
|
|
|
81
126
|
* person should make on purpose.
|
|
82
127
|
*/
|
|
83
128
|
if (visited.includes(owner))
|
|
84
|
-
return at("cycle", { role: owner, visited: [...visited] });
|
|
129
|
+
return at("cycle", { role: owner, owners, visited: [...visited] });
|
|
85
130
|
|
|
86
131
|
if (depth + 1 > depthMax)
|
|
87
|
-
return at("depth", { role: owner, depth: depth + 1, max: depthMax });
|
|
132
|
+
return at("depth", { role: owner, owners, depth: depth + 1, max: depthMax });
|
|
88
133
|
|
|
89
134
|
/**
|
|
90
135
|
* Bounded invocation. A role cannot write another role's territory, but it
|
|
@@ -97,15 +142,245 @@ export function decideHandoff({
|
|
|
97
142
|
*/
|
|
98
143
|
const senderMax = limits.senderChains ?? Infinity;
|
|
99
144
|
if ((load.senderOpenChains ?? 0) >= senderMax)
|
|
100
|
-
return at("throttled", { role: owner, limit: "senderChains", max: senderMax });
|
|
145
|
+
return at("throttled", { role: owner, owners, limit: "senderChains", max: senderMax });
|
|
101
146
|
|
|
102
147
|
const receiverMax = limits.receiverConcurrent ?? Infinity;
|
|
103
148
|
if ((load.receiverRunning ?? 0) >= receiverMax)
|
|
104
|
-
return at("throttled", { role: owner, limit: "receiverConcurrent", max: receiverMax });
|
|
149
|
+
return at("throttled", { role: owner, owners, limit: "receiverConcurrent", max: receiverMax });
|
|
105
150
|
|
|
106
151
|
return at("route", {
|
|
107
152
|
role: owner,
|
|
153
|
+
owners,
|
|
154
|
+
...narrowed,
|
|
108
155
|
depth: depth + 1,
|
|
109
156
|
visited: [...visited, owner],
|
|
110
157
|
});
|
|
111
158
|
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Whether a read request is for a key: inside a key directory, or a bare name,
|
|
162
|
+
* which is how a key in the first key directory is written.
|
|
163
|
+
*/
|
|
164
|
+
export function isKeyRequest(config, request) {
|
|
165
|
+
const t = request?.target;
|
|
166
|
+
return request?.action === "read" && typeof t === "string" && (inKeyDir(config, t) || !t.includes("/"));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* The chain a request belongs to, as decideHandoff takes it.
|
|
171
|
+
*
|
|
172
|
+
* A request filed by a run that was itself handed work carries the chain that
|
|
173
|
+
* run was started with (see chainFrom); one filed by any other run starts a
|
|
174
|
+
* chain of its own, named by its own id. Never read from inside the sandbox:
|
|
175
|
+
* the parent writes it, from the decision it checked before starting the run.
|
|
176
|
+
*/
|
|
177
|
+
export function chainOf(request) {
|
|
178
|
+
const c = request?.chain;
|
|
179
|
+
const ok = c && typeof c.id === "string" && Number.isInteger(c.depth) && c.depth >= 0 &&
|
|
180
|
+
Array.isArray(c.visited) && c.visited.every((r) => typeof r === "string");
|
|
181
|
+
return ok ? { id: c.id, depth: c.depth, visited: [...c.visited] } : { id: request?.key ?? null, depth: 0, visited: [] };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* What a run started for a routed decision carries into the requests it files.
|
|
186
|
+
* Null for anything but a route: only a route starts work.
|
|
187
|
+
*/
|
|
188
|
+
export function chainFrom(decision) {
|
|
189
|
+
if (decision?.type !== "route") return null;
|
|
190
|
+
return { id: decision.chainId, depth: decision.depth, visited: [...decision.visited] };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* The variable `seisin run --handoff` sets in the receiver's environment, so
|
|
195
|
+
* the hook inside can word a denial with the chain the run belongs to.
|
|
196
|
+
*
|
|
197
|
+
* Words only. The chain a request is recorded in is the parent's, written by
|
|
198
|
+
* intake from the run it started (requests.js, record), and nothing read from
|
|
199
|
+
* here reaches the queue. A process inside that rewrote it would change the
|
|
200
|
+
* sentence it is told about its own denial, and nothing else. Reserved, so a
|
|
201
|
+
* policy cannot deliver a key under this name (keys.js, RESERVED_ENV).
|
|
202
|
+
*/
|
|
203
|
+
export const CHAIN_ENV = "SEISIN_HANDOFF_CHAIN";
|
|
204
|
+
|
|
205
|
+
/** The run's chain from its environment, or null: absent and malformed are the same. */
|
|
206
|
+
export function chainFromEnv(env = process.env) {
|
|
207
|
+
const raw = env?.[CHAIN_ENV];
|
|
208
|
+
if (!raw) return null;
|
|
209
|
+
let c;
|
|
210
|
+
try { c = JSON.parse(raw); } catch { return null; }
|
|
211
|
+
const ok = c && typeof c === "object" && typeof c.id === "string" && Number.isInteger(c.depth) && c.depth >= 0 &&
|
|
212
|
+
Array.isArray(c.visited) && c.visited.every((r) => typeof r === "string");
|
|
213
|
+
return ok ? { id: c.id, depth: c.depth, visited: [...c.visited] } : null;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* The decision as the denied agent is told it: whose the path is, where the
|
|
218
|
+
* work goes, and what to do instead of retrying. One sentence, the same from
|
|
219
|
+
* the PreToolUse hook, after a failed tool call and from `seisin whose`.
|
|
220
|
+
*
|
|
221
|
+
* It informs and offers nothing to act with. Every role it names is one the
|
|
222
|
+
* policy named; it never asks the agent to pick one, and there is no answer
|
|
223
|
+
* the agent can give back that would change who takes the work — the queue
|
|
224
|
+
* decides that again, from the policy, whenever it is read.
|
|
225
|
+
*/
|
|
226
|
+
export function toAgent(decision, { target, role, key = null, again = "" }) {
|
|
227
|
+
const d = decision ?? {};
|
|
228
|
+
// Where a chain stops, the agent is not told whose the file is, or the chain
|
|
229
|
+
// it came along. Measured: with the owner in the message, two runs of three
|
|
230
|
+
// added whose file it was after the sentence they were told to say alone.
|
|
231
|
+
if (isStopped(d))
|
|
232
|
+
return `${target} is not ${role}'s to change, and its handoff chain stops here: ` +
|
|
233
|
+
(d.type === "cycle" ? "handing it on would send it back the way it came." : `${d.depth} of ${d.max} deep.`) +
|
|
234
|
+
` A person decides. Do not work around it. ${ELSEWHERE}${again}` + stopped(d, target, key);
|
|
235
|
+
const owners = d.owners ?? [];
|
|
236
|
+
const whose = d.reason === "key"
|
|
237
|
+
? (owners.length ? `${target} is declared for ${owners.join(", ")}, not ${role}.` : `No role declares ${target}.`)
|
|
238
|
+
: owners.length
|
|
239
|
+
? `${target} belongs to ${owners.join(", ")}. It is not ${role}'s to change.`
|
|
240
|
+
: `${target} has no owner.`;
|
|
241
|
+
// A key or a read is not a change to put somewhere else.
|
|
242
|
+
const write = d.reason !== "key" && d.reason !== "read" && d.type !== "resolved";
|
|
243
|
+
return `${whose} ${nextStep(d, role)}` + (write ? ` ${ELSEWHERE}` : "") + again;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** A chain that stops: a cycle, or one too deep. Its denial ends on the sentence to pass on. */
|
|
247
|
+
export function isStopped(decision) {
|
|
248
|
+
return decision?.type === "cycle" || decision?.type === "depth";
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Where a handoff chain stops, the words to pass on, given literally.
|
|
253
|
+
*
|
|
254
|
+
* Measured with real runs: told "not that frontend will make the change", an
|
|
255
|
+
* agent stopped saying frontend would make it and, in one run of two, told
|
|
256
|
+
* its user to go and coordinate with frontend instead. A negative instruction
|
|
257
|
+
* leaves the agent to compose the sentence; this one hands it over.
|
|
258
|
+
*/
|
|
259
|
+
function stopped(d, target, key) {
|
|
260
|
+
const waiting = key ? `the request ${key} is waiting in seisin requests` : "the request is waiting in seisin requests";
|
|
261
|
+
// Measured again with the literal words: passed on in three runs of three,
|
|
262
|
+
// and then contradicted in two, by an agent adding who should do it or where
|
|
263
|
+
// else it could go. So the sentence ends on that, too. Measured a third time:
|
|
264
|
+
// no run added either, but one added whose file it was, and one dropped the
|
|
265
|
+
// path from the front ("Not written. …"). So both are said as well. And
|
|
266
|
+
// nothing comes after the sentence: what not to add is said first, so the
|
|
267
|
+
// quote is the last thing the agent reads (hook.js ends the denial on it).
|
|
268
|
+
// Measured a fourth time: an agent that headed its line with the path ate
|
|
269
|
+
// the sentence's subject ("src/x — Not written. …"). So the path is no
|
|
270
|
+
// longer the subject, and the sentence is to be said whole regardless.
|
|
271
|
+
return " About this file, tell whoever sent you one sentence and add nothing to it: not whose file it is, " +
|
|
272
|
+
"who should do it or where else it could go. The sentence, word for word and whole, even where your line " +
|
|
273
|
+
`already names the file: "Not written: ${target}. A person has to decide who does it; ${waiting}."`;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* What "working around it" means, said once. Measured: an agent that would
|
|
278
|
+
* never retry the write still offered, twice, to leave the file in a folder of
|
|
279
|
+
* its own — the same change, under a path nobody reviews.
|
|
280
|
+
*/
|
|
281
|
+
export const ELSEWHERE = "Do not put the change somewhere else instead (another path, a copy, /tmp).";
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* How the denial ends: that it is already queued, and for whom. Said apart
|
|
285
|
+
* from toAgent because only a denial that files a request may say it — the
|
|
286
|
+
* note after a failed call and `seisin whose` file nothing.
|
|
287
|
+
*
|
|
288
|
+
* A route ends on the role it goes to. Ending it on "a person", as every
|
|
289
|
+
* denial once did, told the agent two owners in one sentence, and with a
|
|
290
|
+
* handoff chain in it the agent repeated the wrong one.
|
|
291
|
+
*/
|
|
292
|
+
export function queuedLine(decision) {
|
|
293
|
+
return decision?.type === "route"
|
|
294
|
+
? `Already queued as a handoff to ${decision.role}: a run of ${decision.role}, or a person, picks it up — retrying or waiting will not move it.`
|
|
295
|
+
: "Already queued for a person to answer — retrying or waiting will not move it.";
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** What to do about it, from the decision alone: the second half of toAgent, and `seisin whose`. */
|
|
299
|
+
export function nextStep(decision, role) {
|
|
300
|
+
const d = decision ?? {};
|
|
301
|
+
switch (d.type) {
|
|
302
|
+
case "route":
|
|
303
|
+
return `It is handed to ${d.role}` +
|
|
304
|
+
(d.depth > 1 ? ` (handoff chain ${(d.visited ?? []).join(" → ")}, ${d.depth} of ${MAX_DEPTH} deep)` : "") +
|
|
305
|
+
": hand it over rather than working around it.";
|
|
306
|
+
case "human":
|
|
307
|
+
if (d.reason === "key") return "Ask for what you need from it rather than reading the key.";
|
|
308
|
+
if (d.reason === "no-receiver")
|
|
309
|
+
return `${(d.owners ?? []).join(", ") || "Its owner"} ${(d.owners ?? []).length > 1 ? "do" : "does"} not take handed work, ` +
|
|
310
|
+
"so a person decides who takes it. Do not work around it.";
|
|
311
|
+
return d.reason === "ambiguous"
|
|
312
|
+
? "With more than one owner, a person decides who takes it. Do not pick one, and do not work around it."
|
|
313
|
+
: "A person decides who takes it. Do not work around it.";
|
|
314
|
+
// Measured: told only "not handed back", an agent still told its user
|
|
315
|
+
// that the owner would make the change. It has to hear that nothing is
|
|
316
|
+
// asked of the owner, and what to say instead.
|
|
317
|
+
case "cycle":
|
|
318
|
+
return `It is not handed back to ${d.role}: this work already came through ${d.role} ` +
|
|
319
|
+
`(handoff chain ${(d.visited ?? []).join(" → ")}), so nothing is asked of ${d.role}. ` +
|
|
320
|
+
"A person decides how to split it. Do not work around it.";
|
|
321
|
+
case "depth":
|
|
322
|
+
return `The handoff chain stops here, ${d.depth} of ${d.max} deep. A person decides. Do not work around it.`;
|
|
323
|
+
case "resolved":
|
|
324
|
+
return `The policy now gives it to ${role}: the next run has it.`;
|
|
325
|
+
default: {
|
|
326
|
+
const said = describeHandoff(d) || "a person decides";
|
|
327
|
+
return `${said[0].toUpperCase()}${said.slice(1)}. Do not work around it.`;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* The decision in words a person reads, one line, no colour.
|
|
334
|
+
*
|
|
335
|
+
* `attempt` is what the execution plane last recorded about the request
|
|
336
|
+
* (requests.js, recordHandoff). It is shown only where seisin's own decision
|
|
337
|
+
* is a route to the same role: whether that role could be started is
|
|
338
|
+
* admission, which only whatever starts it knows. Every other outcome is the
|
|
339
|
+
* policy's, recomputed now, and a recorded attempt cannot overrule it.
|
|
340
|
+
*/
|
|
341
|
+
export function describeHandoff(decision, attempt = null, chainStopped = null) {
|
|
342
|
+
const said = describeDecision(decision, attempt);
|
|
343
|
+
// A run took it and its chain then stopped further on: the request is still
|
|
344
|
+
// pending, and without this it read as handed and done.
|
|
345
|
+
return said && chainStopped?.length
|
|
346
|
+
? `${said}; its handoff chain stopped at ${chainStopped.join(", ")}, which needs a person`
|
|
347
|
+
: said;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function describeDecision(decision, attempt) {
|
|
351
|
+
const d = decision;
|
|
352
|
+
if (!d) return "";
|
|
353
|
+
if (d.type === "route" && attempt && attempt.role === d.role) {
|
|
354
|
+
if (attempt.outcome === "throttled")
|
|
355
|
+
return `handoff held: ${limitWords(attempt.limit, attempt.role)} ${attempt.max}/${attempt.max} — still to do`;
|
|
356
|
+
if (attempt.outcome === "route") return `handed to ${d.role} — a run of ${d.role} took it`;
|
|
357
|
+
}
|
|
358
|
+
switch (d.type) {
|
|
359
|
+
case "route":
|
|
360
|
+
return `handed to ${d.role}` + (d.depth > 1 ? ` (handoff chain, ${d.depth} of ${MAX_DEPTH})` : "");
|
|
361
|
+
case "throttled":
|
|
362
|
+
return `handoff held: ${limitWords(d.limit, d.role)} ${d.max}/${d.max} — still to do`;
|
|
363
|
+
case "cycle":
|
|
364
|
+
return `handoff stopped: ${d.role} is already in this handoff chain — needs a person to split the work`;
|
|
365
|
+
case "depth":
|
|
366
|
+
return `handoff stopped: handoff chain reached ${d.depth} of ${d.max} — needs a person`;
|
|
367
|
+
case "human":
|
|
368
|
+
if (d.reason === "ambiguous") return `a person decides: more than one role owns this (${(d.receivers ?? d.owners).join(", ")})`;
|
|
369
|
+
if (d.reason === "no-receiver")
|
|
370
|
+
return `a person decides: ${d.owners.join(", ")} ${d.owners.length > 1 ? "own" : "owns"} this and ${d.owners.length > 1 ? "take" : "takes"} no handed work (handoff = false)`;
|
|
371
|
+
if (d.reason === "key") return "a person decides: a key is shared by a grant, never handed over";
|
|
372
|
+
if (d.reason === "read") return "a person decides: a read is not handed over";
|
|
373
|
+
return "a person decides: no role owns this";
|
|
374
|
+
case "resolved":
|
|
375
|
+
return `no handoff needed — the policy moved and ${d.role} has it now`;
|
|
376
|
+
default:
|
|
377
|
+
return `handoff: ${d.type}`;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** The limit names are internal; what a person reads should not be a field name. */
|
|
382
|
+
function limitWords(limit, role) {
|
|
383
|
+
if (limit === "senderChains") return "open handoff chains for this role";
|
|
384
|
+
if (limit === "receiverConcurrent") return `${role ?? "the owner"} already running`;
|
|
385
|
+
return limit ?? "a limit";
|
|
386
|
+
}
|