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/help.js
CHANGED
|
@@ -8,23 +8,33 @@
|
|
|
8
8
|
*
|
|
9
9
|
* `values` are flags that take the next word as their value.
|
|
10
10
|
*/
|
|
11
|
+
import { LIMITS } from "./inspect/limits.js";
|
|
12
|
+
|
|
11
13
|
export const COMMAND_HELP = {
|
|
12
14
|
run: {
|
|
13
|
-
usage: ["seisin run <role> [--observe] [--debug-env] -- <command...>"],
|
|
15
|
+
usage: ["seisin run <role> [--observe] [--debug-env] [--agent <name>] [--handoff <id>] [--expect-denied <path>]... -- <command...>"],
|
|
16
|
+
values: ["--agent", "--handoff", "--expect-denied"],
|
|
14
17
|
flags: [
|
|
15
18
|
["--observe", "open the repo for this run and record what the agent writes; keys and network stay shut"],
|
|
16
19
|
["--debug-env", "print the environment variables the role does not get"],
|
|
20
|
+
["--agent <name>", "claude, codex or none: which agent the command runs, when seisin cannot tell from it (a wrapper script). Its home is the run's scratch; the other agents' sign-in is closed"],
|
|
21
|
+
["--handoff <id>", "this run takes a pending request handed to this role, and starts only if the policy routes that request here. Its own requests join the handoff chain"],
|
|
22
|
+
["--expect-denied <path>", "a path the caller will probe and expects to be denied (its own canary). That exact path is logged as expected and files no request; repeatable. Any other path is asked as usual"],
|
|
17
23
|
],
|
|
18
24
|
notes: ["Everything after -- is the command's own, flags included: `-- claude --settings x.json` goes to claude."],
|
|
19
25
|
examples: ['seisin run frontend -- claude', 'seisin run backend --observe -- claude -p "…"'],
|
|
20
26
|
exits: [["n", "the command's own exit status"], ["127", "the command is not on the role's PATH"],
|
|
27
|
+
["86", "the canary proved the generated boundary does not hold; the command was not started"],
|
|
21
28
|
["128+n", "killed by signal n (130 for ctrl-c)"], ["2", "seisin could not start it (usage, policy, runtime)"]],
|
|
22
29
|
},
|
|
23
30
|
check: {
|
|
24
|
-
usage: ["seisin check [role] [--verbose]"],
|
|
25
|
-
flags: [["--verbose", "also print the protected paths
|
|
26
|
-
|
|
27
|
-
|
|
31
|
+
usage: ["seisin check [role] [--verbose] [--verify] [--parallel] [--system-log]"],
|
|
32
|
+
flags: [["--verbose", "also print the protected paths and each warning's explanation (the limits of every repo: seisin help limits)"],
|
|
33
|
+
["--verify", "run each role's verify command inside its sandbox (the one thing check executes)"],
|
|
34
|
+
["--parallel", "experimental: with --verify, run role checks concurrently; shared resources may race"],
|
|
35
|
+
["--system-log", "macOS: measure for 3 s whether the system log is dropping messages, and who writes the most; exits 1 when it is"]],
|
|
36
|
+
examples: ["seisin check", "seisin check frontend --verbose", "seisin check backend --verify", "seisin check --verify --parallel"],
|
|
37
|
+
exits: [["0", "the policy can be enforced as written (warnings included)"], ["1", "a role cannot be enforced as written, or its verify failed"], ["2", "usage or policy error"]],
|
|
28
38
|
},
|
|
29
39
|
explain: {
|
|
30
40
|
usage: ["seisin explain <role> read|write <path>", "seisin explain <role> read <key>", "seisin explain <role> mcp <server>"],
|
|
@@ -47,16 +57,17 @@ export const COMMAND_HELP = {
|
|
|
47
57
|
exits: [["0", "no certain credential outside the key dirs"], ["1", "at least one certain credential"], ["2", "usage error"]],
|
|
48
58
|
},
|
|
49
59
|
review: {
|
|
50
|
-
usage: ["seisin review [--all] [--min <n>]"],
|
|
51
|
-
values: ["--min"],
|
|
52
|
-
flags: [["--all", "every row, not the first ten per section"], ["--min <n>", "denials before a place counts as repeated (default 3)"]
|
|
53
|
-
|
|
60
|
+
usage: ["seisin review [--all] [--min <n>] [--since <date|duration>]"],
|
|
61
|
+
values: ["--min", "--since"],
|
|
62
|
+
flags: [["--all", "every row, not the first ten per section"], ["--min <n>", "denials before a place counts as repeated (default 3)"],
|
|
63
|
+
["--since <when>", "only what was recorded since a date (2026-10-01) or a duration back from now (7d, 12h, 2w)"]],
|
|
64
|
+
examples: ["seisin review", "seisin review --since 7d", "seisin review --min 5 --all"],
|
|
54
65
|
exits: [["0", "no role denied repeatedly"], ["1", "a role was denied repeatedly"], ["2", "usage error"]],
|
|
55
66
|
},
|
|
56
67
|
walls: {
|
|
57
|
-
usage: ["seisin walls <role> [--all] [--since <
|
|
68
|
+
usage: ["seisin walls <role> [--all] [--since <when>] [--min <n>]"],
|
|
58
69
|
values: ["--since", "--min"],
|
|
59
|
-
flags: [["--all", "include walls not hit in the last few runs"], ["--since <
|
|
70
|
+
flags: [["--all", "include walls not hit in the last few runs"], ["--since <when>", "only denials since a date (2026-09-01) or a duration back from now (7d)"],
|
|
60
71
|
["--min <n>", "times denied before it is a wall (default 2)"]],
|
|
61
72
|
examples: ["seisin walls frontend", "seisin walls frontend --since 2026-09-01 --min 3"],
|
|
62
73
|
exits: [["0", "no walls"], ["1", "walls listed"], ["2", "usage error"]],
|
|
@@ -69,17 +80,19 @@ export const COMMAND_HELP = {
|
|
|
69
80
|
exits: [["0", "wired, or already wired"], ["2", "usage error, or settings.json is not valid JSON"]],
|
|
70
81
|
},
|
|
71
82
|
requests: {
|
|
72
|
-
usage: ["seisin requests"],
|
|
73
|
-
flags: [],
|
|
74
|
-
notes: ["#n is a position and shifts as requests are settled; the id under each one does not."
|
|
75
|
-
|
|
83
|
+
usage: ["seisin requests [--json]"],
|
|
84
|
+
flags: [["--json", "the queue as data, each request with its handoff decision — the shape seisin_requests returns"]],
|
|
85
|
+
notes: ["#n is a position and shifts as requests are settled; the id under each one does not.",
|
|
86
|
+
"The line under each request says where it goes, decided from the policy as it stands: handed to its owner, or a person decides."],
|
|
87
|
+
examples: ["seisin requests", "seisin requests --json"],
|
|
76
88
|
exits: [["0", "nothing pending"], ["1", "requests pending"], ["2", "usage error"]],
|
|
77
89
|
},
|
|
78
90
|
grant: {
|
|
79
|
-
usage: ['seisin grant <n|id> [--reason "…"]'],
|
|
91
|
+
usage: ['seisin grant <n|id> [--file] [--reason "…"]'],
|
|
80
92
|
values: ["--reason"],
|
|
81
|
-
flags: [['--reason "…"', "recorded beside the grant in the policy"]
|
|
82
|
-
|
|
93
|
+
flags: [['--reason "…"', "recorded beside the grant in the policy"],
|
|
94
|
+
["--file", "grant the file that was asked for, not its folder (a write). Claude Code cannot edit a lone file: its temporary lands beside it"]],
|
|
95
|
+
examples: ["seisin grant 1", "seisin grant 2 --file", "seisin grant 'frontend:write:docs' --reason \"owns the docs now\""],
|
|
83
96
|
exits: [["0", "granted, or already granted"], ["2", "no such request, or it cannot be granted"]],
|
|
84
97
|
},
|
|
85
98
|
decline: {
|
|
@@ -131,7 +144,14 @@ export const COMMAND_HELP = {
|
|
|
131
144
|
exits: [["0", "always — the hook explains, the kernel enforces"]],
|
|
132
145
|
},
|
|
133
146
|
};
|
|
134
|
-
|
|
147
|
+
|
|
148
|
+
/** `seisin help limits`, as text: LIMITS lives in inspect/limits.js, which `inspect()` also returns. */
|
|
149
|
+
export function renderLimits(C) {
|
|
150
|
+
const lines = ["", `${C.b}what seisin cannot do, in any repo${C.off}`, ""];
|
|
151
|
+
for (const l of LIMITS) lines.push(` ${C.b}${l.headline}${C.off}`, ` ${l.detail}`, "");
|
|
152
|
+
lines.push(` ${C.dim}seisin check lists what is wrong with this repo's policy${C.off}`, "", "");
|
|
153
|
+
return lines.join("\n");
|
|
154
|
+
}
|
|
135
155
|
|
|
136
156
|
/** The flag words a command accepts, for the unknown-flag check. */
|
|
137
157
|
export function flagsOf(name) {
|
package/src/hook.js
CHANGED
|
@@ -17,10 +17,11 @@
|
|
|
17
17
|
* What escapes this file goes unexplained, not unblocked.
|
|
18
18
|
*/
|
|
19
19
|
import { toRepoRelative } from "./paths.js";
|
|
20
|
-
import { explain, normalize } from "./owners.js";
|
|
20
|
+
import { explain, normalize, privateOwner } from "./owners.js";
|
|
21
21
|
import { append, logPath } from "./log.js";
|
|
22
|
-
import {
|
|
23
|
-
import { record, requestsPath } from "./requests.js";
|
|
22
|
+
import { hitsOf, againWords } from "./walls.js";
|
|
23
|
+
import { record, requestsPath, keyOf } from "./requests.js";
|
|
24
|
+
import { decideHandoff, toAgent, queuedLine, isStopped, ELSEWHERE } from "./handoff.js";
|
|
24
25
|
|
|
25
26
|
/** Tools whose input names a file directly. */
|
|
26
27
|
const FILE_TOOLS = {
|
|
@@ -38,7 +39,19 @@ const FILE_TOOLS = {
|
|
|
38
39
|
* matcher as a regular expression and an unanchored `Write` matches
|
|
39
40
|
* `TodoWrite`.
|
|
40
41
|
*/
|
|
41
|
-
export const TOOL_MATCHER = `^(${[...Object.keys(FILE_TOOLS), "Bash"].join("|")}|mcp__.*)$`;
|
|
42
|
+
export const TOOL_MATCHER = `^(${[...Object.keys(FILE_TOOLS), "Bash", "apply_patch"].join("|")}|mcp__.*)$`;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The files a Codex `apply_patch` call touches, from the patch's own headers.
|
|
46
|
+
*
|
|
47
|
+
* Codex sends its hooks the same event Claude Code does — `tool_name: "Bash"`
|
|
48
|
+
* with `tool_input.command` for the shell — but its file edits arrive as
|
|
49
|
+
* `apply_patch`, with the whole patch in `tool_input.command` (measured
|
|
50
|
+
* 2026-09-30, codex 0.150.1). The headers are a fixed grammar, so unlike a
|
|
51
|
+
* shell line they can be read exactly: Add, Update and Delete name the file,
|
|
52
|
+
* and `Move to` names where an update lands.
|
|
53
|
+
*/
|
|
54
|
+
const PATCH_HEADER = /^\*\*\* (?:Add File|Update File|Delete File|Move to): (.+?)\s*$/gm;
|
|
42
55
|
|
|
43
56
|
/**
|
|
44
57
|
* Paths a shell command is going to write.
|
|
@@ -89,6 +102,11 @@ export function targetsOf(tool, input = {}) {
|
|
|
89
102
|
if (typeof tool === "string" && tool.startsWith("mcp__")) {
|
|
90
103
|
return [{ action: "use", tool, server: mcpServer(tool) }];
|
|
91
104
|
}
|
|
105
|
+
if (tool === "apply_patch") {
|
|
106
|
+
const body = typeof input.command === "string" ? input.command : typeof input.patch === "string" ? input.patch : "";
|
|
107
|
+
const paths = [...new Set([...body.matchAll(PATCH_HEADER)].map((m) => m[1]))];
|
|
108
|
+
return paths.map((path) => ({ action: "write", path }));
|
|
109
|
+
}
|
|
92
110
|
if (tool !== "Bash" || typeof input.command !== "string") return [];
|
|
93
111
|
|
|
94
112
|
const out = [];
|
|
@@ -162,14 +180,10 @@ function kindOf(config, path) {
|
|
|
162
180
|
* servers live elsewhere — and naming a tool the agent may not hold costs it a
|
|
163
181
|
* turn to find out. An agent that does hold the tools discovers them the normal
|
|
164
182
|
* way; that is what their descriptions are for.
|
|
183
|
+
*
|
|
184
|
+
* The words are handoff.js's queuedLine: a route ends on the role it goes to,
|
|
185
|
+
* everything else on a person.
|
|
165
186
|
*/
|
|
166
|
-
const QUEUED = "Already queued for a person to answer — retrying or waiting will not move it.";
|
|
167
|
-
|
|
168
|
-
/** 4 -> "fourth". Past a handful the numeral reads better than the word. */
|
|
169
|
-
function ordinal(n) {
|
|
170
|
-
const words = ["", "first", "second", "third", "fourth", "fifth", "sixth", "seventh", "eighth", "ninth", "tenth"];
|
|
171
|
-
return words[n] ?? `${n}th`;
|
|
172
|
-
}
|
|
173
187
|
|
|
174
188
|
/**
|
|
175
189
|
* Decides and records one tool call.
|
|
@@ -178,7 +192,7 @@ function ordinal(n) {
|
|
|
178
192
|
* the same decisions and returns none of them, so a policy can be written from
|
|
179
193
|
* what a real run did instead of from what someone imagined it would do.
|
|
180
194
|
*/
|
|
181
|
-
export function decide(config, role, event, { observe = false, now = append, ask = record } = {}) {
|
|
195
|
+
export function decide(config, role, event, { observe = false, now = append, ask = record, chain = null, run = process.env.SEISIN_RUN_ID ?? null } = {}) {
|
|
182
196
|
const targets = targetsOf(event.tool_name, event.tool_input);
|
|
183
197
|
const file = logPath(config.root);
|
|
184
198
|
const queue = requestsPath(config.root);
|
|
@@ -190,7 +204,7 @@ export function decide(config, role, event, { observe = false, now = append, ask
|
|
|
190
204
|
// gets to it, so counting afterwards came out one behind there.
|
|
191
205
|
let before = null;
|
|
192
206
|
const priorOnce = (action, target) => {
|
|
193
|
-
if (before === null) before =
|
|
207
|
+
if (before === null) before = hitsOf(file, role, action, target, run);
|
|
194
208
|
};
|
|
195
209
|
|
|
196
210
|
for (const t of targets) {
|
|
@@ -225,9 +239,12 @@ export function decide(config, role, event, { observe = false, now = append, ask
|
|
|
225
239
|
// of a markdown file, the agent burned its turns unable to look at what it
|
|
226
240
|
// had been asked to edit, and the log showed a tidy `denied` that looked
|
|
227
241
|
// like the system working.
|
|
228
|
-
|
|
242
|
+
// The one exception: another role's private folder, which the kernel shuts
|
|
243
|
+
// to reading as well, so the hook says so before the read is tried.
|
|
244
|
+
const shutRead = t.action === "read" && kind !== "key" && privateOwner(config, rel);
|
|
245
|
+
if (t.action === "read" && kind !== "key" && (!shutRead || shutRead.role === role)) continue;
|
|
229
246
|
|
|
230
|
-
const v = explain(config, role, kind === "key" ? "read" : "write", rel);
|
|
247
|
+
const v = explain(config, role, kind === "key" || shutRead ? "read" : "write", rel);
|
|
231
248
|
if (!observe && !v.allowed) priorOnce(kind === "key" ? "read" : t.action, rel);
|
|
232
249
|
|
|
233
250
|
now(file, {
|
|
@@ -243,14 +260,15 @@ export function decide(config, role, event, { observe = false, now = append, ask
|
|
|
243
260
|
// A denial already carries everything a request needs, so leave one behind:
|
|
244
261
|
// the refusal stops being a dead end and becomes something a person can act
|
|
245
262
|
// on in one command. Observing records nothing to grant — there was no
|
|
246
|
-
// denial to answer. See docs/
|
|
263
|
+
// denial to answer. See docs/requests.md.
|
|
247
264
|
//
|
|
248
265
|
// Except a `never_writes` refusal. That one is the policy saying no on
|
|
249
266
|
// purpose; a request for it asks a person to undo a subtraction somebody
|
|
250
267
|
// wrote, and approving it is how the subtraction disappears unnoticed.
|
|
251
268
|
// Nor a protected file: no grant opens one (the kernel keeps refusing it),
|
|
252
269
|
// which is why `seisin run` never queued those either (intake.js).
|
|
253
|
-
|
|
270
|
+
// Nor another role's private folder: no grant opens it either.
|
|
271
|
+
if (!observe && !v.allowed && !v.neverWrites && !v.gitMetadata && !v.protected && !v.privateTo)
|
|
254
272
|
ask(queue, { role, action: kind === "key" ? "read" : t.action, target: rel, owners: v.owners ?? [] });
|
|
255
273
|
|
|
256
274
|
// The action as it was RECORDED, not as the tool named it: a read of a key
|
|
@@ -265,7 +283,6 @@ export function decide(config, role, event, { observe = false, now = append, ask
|
|
|
265
283
|
|
|
266
284
|
const denied = verdicts.find((v) => !v.allowed);
|
|
267
285
|
if (!denied) return { decision: null, logged: verdicts.length };
|
|
268
|
-
const wasRead = denied.kind === "key";
|
|
269
286
|
|
|
270
287
|
/**
|
|
271
288
|
* How many times this exact denial has already been handed to this role.
|
|
@@ -282,10 +299,23 @@ export function decide(config, role, event, { observe = false, now = append, ask
|
|
|
282
299
|
* on the first — a counter that says "1×" on every first refusal is noise on
|
|
283
300
|
* the turn where the sentence is already doing its job.
|
|
284
301
|
*/
|
|
285
|
-
const times = (before ?? 0) + 1;
|
|
286
|
-
const
|
|
287
|
-
|
|
288
|
-
|
|
302
|
+
const times = (before?.total ?? 0) + 1;
|
|
303
|
+
const inRun = before?.inRun == null ? null : before.inRun + 1;
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Whose it is and where the work goes, as the queue will say it: decided by
|
|
307
|
+
* handoff.js for the one path this sentence is about, in the run's handoff
|
|
308
|
+
* chain when it has one. Only for a denial that files a request — the
|
|
309
|
+
* branches above say why nothing was queued, which is not a handoff.
|
|
310
|
+
*/
|
|
311
|
+
const queued = !denied.neverWrites && !denied.gitMetadata && !denied.protected && !denied.privateTo && !denied.mcp;
|
|
312
|
+
const handoff = queued
|
|
313
|
+
? decideHandoff({ request: { role, action: denied.action, target: denied.target }, config, chain })
|
|
314
|
+
: null;
|
|
315
|
+
// A denied write that nothing will ever grant is still a change not to
|
|
316
|
+
// make under another path; a denied read is not a change at all.
|
|
317
|
+
const elsewhere = denied.action === "write" ? ` ${ELSEWHERE}` : "";
|
|
318
|
+
const again = againWords({ times, inRun });
|
|
289
319
|
|
|
290
320
|
// The hook advises; it does not enforce. Returning `deny` here stops the call
|
|
291
321
|
// early and — the part that matters — hands the agent a sentence it can act
|
|
@@ -300,16 +330,15 @@ export function decide(config, role, event, { observe = false, now = append, ask
|
|
|
300
330
|
// The sentence has to name a next step, not just a refusal. An agent
|
|
301
331
|
// told "no" retries; an agent told whose it is asks, or moves on.
|
|
302
332
|
denied.neverWrites
|
|
303
|
-
? `${denied.reason}. Do not ask for it: nothing was queued, because the answer is already written down.` + again
|
|
304
|
-
: denied.gitMetadata || denied.protected
|
|
305
|
-
? `${denied.reason}. Nothing was queued.` + again
|
|
333
|
+
? `${denied.reason}. Do not ask for it: nothing was queued, because the answer is already written down.` + elsewhere + again
|
|
334
|
+
: denied.gitMetadata || denied.protected || denied.privateTo
|
|
335
|
+
? `${denied.reason}. Nothing was queued.` + elsewhere + again
|
|
306
336
|
: denied.mcp
|
|
307
337
|
? `${denied.reason}. Nothing was queued: which MCP servers a role may load is set in its mcp list, a policy change a person makes, not something they grant from the queue.` + again
|
|
308
|
-
: (denied.
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
: `${denied.reason} (seisin)`) + again + " " + QUEUED,
|
|
338
|
+
: toAgent(handoff, { target: denied.target, role, key: keyOf({ role, action: denied.action, target: denied.target }), again }) +
|
|
339
|
+
// Where a chain stops, the sentence to pass on already says the
|
|
340
|
+
// request is waiting, and is the last thing the agent reads.
|
|
341
|
+
(isStopped(handoff) ? "" : " " + queuedLine(handoff)),
|
|
313
342
|
},
|
|
314
343
|
};
|
|
315
344
|
}
|
package/src/index.d.ts
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
export type KeyMode = "env" | "run-file" | "inject";
|
|
2
|
+
export type ReadMode = "all" | "territory";
|
|
3
|
+
export type Isolation = false | "credentials" | "home";
|
|
4
|
+
export type Action = "read" | "write" | "mcp";
|
|
5
|
+
|
|
6
|
+
export interface KeyEntry {
|
|
7
|
+
kind: "file" | "ref";
|
|
8
|
+
raw: string;
|
|
9
|
+
scheme?: string;
|
|
10
|
+
ref?: string;
|
|
11
|
+
name?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface RolePolicy {
|
|
15
|
+
name: string;
|
|
16
|
+
writes: string[];
|
|
17
|
+
writesDeclared: string[];
|
|
18
|
+
keys: string[];
|
|
19
|
+
keyEntries: KeyEntry[];
|
|
20
|
+
keyMode: KeyMode | null;
|
|
21
|
+
env: string[];
|
|
22
|
+
network: string[] | null;
|
|
23
|
+
neverWrites: string[];
|
|
24
|
+
neverWritesDeclared: string[];
|
|
25
|
+
/** Folders only this role reads and writes; also part of `writes`. */
|
|
26
|
+
private: string[];
|
|
27
|
+
reads: string[];
|
|
28
|
+
toolchain: string[];
|
|
29
|
+
verify: string[] | null;
|
|
30
|
+
localBinding: boolean;
|
|
31
|
+
localPorts: number[];
|
|
32
|
+
mcp: string[] | null;
|
|
33
|
+
trustd: boolean;
|
|
34
|
+
controlFiles: string[];
|
|
35
|
+
/** False for `handoff = false`: the role owns its territory but is never handed work. */
|
|
36
|
+
handoff: boolean;
|
|
37
|
+
[key: string]: unknown;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface KeyProvider {
|
|
41
|
+
name: string;
|
|
42
|
+
command: string[];
|
|
43
|
+
mode: KeyMode | null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface SeisinConfig {
|
|
47
|
+
root: string;
|
|
48
|
+
path: string;
|
|
49
|
+
keyDirs: string[];
|
|
50
|
+
keyProviders: Record<string, KeyProvider>;
|
|
51
|
+
allowedDomains: string[];
|
|
52
|
+
runtimeWrites?: string[];
|
|
53
|
+
isolate: Isolation;
|
|
54
|
+
read: ReadMode;
|
|
55
|
+
redact: boolean | undefined;
|
|
56
|
+
scanIgnore: string[];
|
|
57
|
+
protect: Record<string, unknown>;
|
|
58
|
+
roles: Record<string, RolePolicy>;
|
|
59
|
+
[key: string]: unknown;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface Decision {
|
|
63
|
+
allowed: boolean;
|
|
64
|
+
owners: string[];
|
|
65
|
+
reason: string;
|
|
66
|
+
[key: string]: unknown;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface SandboxSettings {
|
|
70
|
+
network: {
|
|
71
|
+
allowedDomains?: string[];
|
|
72
|
+
deniedDomains: string[];
|
|
73
|
+
allowUnixSockets: string[];
|
|
74
|
+
allowLocalBinding: boolean;
|
|
75
|
+
};
|
|
76
|
+
filesystem: {
|
|
77
|
+
denyRead: string[];
|
|
78
|
+
allowRead: string[];
|
|
79
|
+
allowWrite: string[];
|
|
80
|
+
denyWrite: string[];
|
|
81
|
+
[key: string]: unknown;
|
|
82
|
+
};
|
|
83
|
+
[key: string]: unknown;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface Wall {
|
|
87
|
+
action: string;
|
|
88
|
+
target: string;
|
|
89
|
+
times: number;
|
|
90
|
+
reason: string;
|
|
91
|
+
owners: string[];
|
|
92
|
+
firstAt: string;
|
|
93
|
+
lastAt: string;
|
|
94
|
+
/** Refused as expected — the agent program's own housekeeping, not friction. */
|
|
95
|
+
expected?: { kind: "plugins"; hint: string };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface ScanHit {
|
|
99
|
+
file: string;
|
|
100
|
+
line?: number;
|
|
101
|
+
shape: string;
|
|
102
|
+
level: "certain" | "review" | "named";
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface ScanResult {
|
|
106
|
+
hits: ScanHit[];
|
|
107
|
+
skipped: Record<string, number>;
|
|
108
|
+
nestedPaths: string[];
|
|
109
|
+
truncated: boolean;
|
|
110
|
+
omitted: number;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export const CONFIG_NAME: "seisin.toml";
|
|
114
|
+
export const STATE_DIR: ".seisin";
|
|
115
|
+
export const LOG_NAME: "log.jsonl";
|
|
116
|
+
|
|
117
|
+
export function findConfig(from?: string): string | null;
|
|
118
|
+
export function loadConfig(path: string, text?: string): SeisinConfig;
|
|
119
|
+
export function parseToml(text: string): Record<string, unknown>;
|
|
120
|
+
|
|
121
|
+
export function covers(pattern: string, path: string): boolean;
|
|
122
|
+
export function ownersOf(config: SeisinConfig, path: string): string[];
|
|
123
|
+
export function keyHolders(config: SeisinConfig, key: string): string[];
|
|
124
|
+
export function explain(config: SeisinConfig, role: string, action: Action, target: string, cwd?: string): Decision;
|
|
125
|
+
export function explainFileRead(config: SeisinConfig, role: string, path: string, credentialHomes?: string[]): Decision;
|
|
126
|
+
export function readTarget(config: SeisinConfig, target: string, cwd?: string): { key: boolean; target: string };
|
|
127
|
+
|
|
128
|
+
export function settingsFor(config: SeisinConfig, role: string, spool?: string | null, observe?: boolean,
|
|
129
|
+
options?: { agent?: string; program?: string }): SandboxSettings;
|
|
130
|
+
|
|
131
|
+
export function parseKey(entry: string): KeyEntry;
|
|
132
|
+
export function defaultName(ref: string): string;
|
|
133
|
+
export function entriesOf(role: Pick<RolePolicy, "keys" | "keyEntries">): KeyEntry[];
|
|
134
|
+
export const MODES: readonly KeyMode[];
|
|
135
|
+
|
|
136
|
+
export function walls(config: SeisinConfig, role: string, options: {
|
|
137
|
+
file: string; entries?: Record<string, unknown>[] | null; min?: number; since?: string | null;
|
|
138
|
+
limit?: number; fresh?: boolean; ask?: typeof explain;
|
|
139
|
+
}): Wall[];
|
|
140
|
+
export function wasted(walls: Wall[]): number;
|
|
141
|
+
|
|
142
|
+
export function inspect(config: SeisinConfig, role?: string | null, where?: string): Record<string, unknown>;
|
|
143
|
+
export function sharedPaths(config: SeisinConfig, roles?: RolePolicy[]): string[];
|
|
144
|
+
export function scan(root: string, protectedDirs?: string[], ignore?: string[], limit?: number,
|
|
145
|
+
options?: { roots?: string[] | null }): ScanResult;
|
|
146
|
+
|
|
147
|
+
export interface LogEntry {
|
|
148
|
+
role?: string;
|
|
149
|
+
action?: string;
|
|
150
|
+
target?: string;
|
|
151
|
+
verdict?: string;
|
|
152
|
+
at?: string;
|
|
153
|
+
[key: string]: unknown;
|
|
154
|
+
}
|
|
155
|
+
export function readLog(file: string, options?: {
|
|
156
|
+
role?: string; verdict?: string; since?: string; limit?: number;
|
|
157
|
+
}): LogEntry[];
|
|
158
|
+
export function logPath(root: string): string;
|
|
159
|
+
export function observed(entries: LogEntry[]): Map<string, { writes: Set<string>; keys: Set<string> }>;
|
|
160
|
+
export function generalise(paths: string[]): string[];
|
|
161
|
+
|
|
162
|
+
export function pendingRequests(file: string, options?: { includeSettled?: boolean; keyDirs?: string[] | null; readOnly?: boolean }): unknown[];
|
|
163
|
+
export function requestsPath(root: string): string;
|
|
164
|
+
export function grantFor(request: { action: string; target: string }, keyDir?: string | null): string;
|
|
165
|
+
|
|
166
|
+
/** Where a denied write goes, decided from the policy as it stands. Nothing executes it. */
|
|
167
|
+
export type HandoffType = "route" | "human" | "resolved" | "cycle" | "depth" | "throttled";
|
|
168
|
+
export interface HandoffDecision {
|
|
169
|
+
type: HandoffType;
|
|
170
|
+
/** The owners as the policy says now — for a key, the roles that declare it. */
|
|
171
|
+
owners: string[];
|
|
172
|
+
/** The role a route goes to; for cycle/depth/throttled, the one it would have gone to. */
|
|
173
|
+
role?: string;
|
|
174
|
+
/** For `human`: why a person decides. */
|
|
175
|
+
reason?: "unowned" | "ambiguous" | "no-receiver" | "key" | "read";
|
|
176
|
+
/** The owners that take handed work, when a `handoff = false` role left some out. */
|
|
177
|
+
receivers?: string[];
|
|
178
|
+
/** For `resolved`. */
|
|
179
|
+
resolution?: "policy_changed";
|
|
180
|
+
/** For a route: the receiver's depth in the handoff chain, and every role the chain has visited. */
|
|
181
|
+
depth?: number;
|
|
182
|
+
visited?: string[];
|
|
183
|
+
max?: number;
|
|
184
|
+
limit?: "senderChains" | "receiverConcurrent";
|
|
185
|
+
/** The handoff chain this decision extends: the id of the request that started it. */
|
|
186
|
+
chainId: string | null;
|
|
187
|
+
/** Hash of the policy file the decision was made against. */
|
|
188
|
+
revision: string | null;
|
|
189
|
+
}
|
|
190
|
+
export interface HandoffChain { id: string | null; depth: number; visited: string[] }
|
|
191
|
+
export interface QueuedRequest {
|
|
192
|
+
key: string; role: string; action: "read" | "write"; target: string; grant: string;
|
|
193
|
+
owners: string[]; ownersThen?: string[]; declared?: boolean;
|
|
194
|
+
times: number; first: string; last: string; state: string;
|
|
195
|
+
handoff: HandoffDecision;
|
|
196
|
+
/** Present when the request was asked by a run started with `--handoff`. */
|
|
197
|
+
chain?: HandoffChain;
|
|
198
|
+
/** What whatever starts roles last recorded about it (recordHandoff). */
|
|
199
|
+
attempt?: { outcome: string; role?: string; limit?: string; max?: number; reason?: string; depth?: number; chainId?: string; at: string };
|
|
200
|
+
stale?: { runs: number; since: string };
|
|
201
|
+
}
|
|
202
|
+
export const MAX_DEPTH: number;
|
|
203
|
+
export function decideHandoff(input: {
|
|
204
|
+
request: { role: string; target: string; action?: string; key?: string };
|
|
205
|
+
config: SeisinConfig | { roles: Record<string, unknown> };
|
|
206
|
+
chain?: Partial<HandoffChain> | null;
|
|
207
|
+
limits?: { depth?: number; senderChains?: number; receiverConcurrent?: number };
|
|
208
|
+
load?: { senderOpenChains?: number; receiverRunning?: number };
|
|
209
|
+
revision?: string | null;
|
|
210
|
+
}): HandoffDecision;
|
|
211
|
+
export function requestQueue(config: SeisinConfig, history?: LogEntry[] | null): QueuedRequest[];
|
|
212
|
+
export function recordHandoff(file: string, key: string, decision: Partial<HandoffDecision> & { type: string }): boolean;
|
|
213
|
+
|
|
214
|
+
export function targetsOf(tool: string, input?: Record<string, unknown>): unknown[];
|
|
215
|
+
export function decide(config: SeisinConfig, role: string, event: Record<string, unknown>, options?: {
|
|
216
|
+
observe?: boolean; now?: (...args: unknown[]) => unknown; ask?: (...args: unknown[]) => unknown;
|
|
217
|
+
}): Record<string, unknown>;
|
package/src/index.js
CHANGED
|
@@ -19,11 +19,10 @@ export { findConfig, loadConfig, parseToml } from "./config.js";
|
|
|
19
19
|
export { CONFIG_NAME, STATE_DIR, LOG_NAME } from "./layout.js";
|
|
20
20
|
|
|
21
21
|
/** Who owns a path, and the sentence to hand a denied agent. */
|
|
22
|
-
export { covers, ownersOf, keyHolders, explain } from "./owners.js";
|
|
22
|
+
export { covers, ownersOf, keyHolders, explain, explainFileRead, readTarget } from "./owners.js";
|
|
23
23
|
|
|
24
|
-
/** A policy, resolved into sandbox settings
|
|
25
|
-
export { settingsFor
|
|
26
|
-
export { buildEnv, BASE as BASE_ENV, DEFAULTS as DEFAULT_ENV } from "./env.js";
|
|
24
|
+
/** A policy, resolved into sandbox settings. */
|
|
25
|
+
export { settingsFor } from "./srt.js";
|
|
27
26
|
|
|
28
27
|
/**
|
|
29
28
|
* Keys that are references, and how a resolved value reaches a role.
|
|
@@ -32,16 +31,16 @@ export { buildEnv, BASE as BASE_ENV, DEFAULTS as DEFAULT_ENV } from "./env.js";
|
|
|
32
31
|
* knowing whether an entry is a path or a reference; `resolveRef` is not,
|
|
33
32
|
* because resolving is something seisin does and not something you ask it.
|
|
34
33
|
*/
|
|
35
|
-
export { parseKey, defaultName, entriesOf, MODES
|
|
34
|
+
export { parseKey, defaultName, entriesOf, MODES } from "./keys.js";
|
|
36
35
|
|
|
37
36
|
/** What a role keeps being denied, recomputed against the policy as it stands. */
|
|
38
|
-
export { walls, wasted
|
|
37
|
+
export { walls, wasted } from "./walls.js";
|
|
39
38
|
|
|
40
39
|
/** The report `check` prints, without the printing. */
|
|
41
40
|
export { inspect, sharedPaths } from "./inspect.js";
|
|
42
41
|
|
|
43
42
|
/** Credential-shaped content outside the declared key directories. */
|
|
44
|
-
export { scan
|
|
43
|
+
export { scan } from "./scan.js";
|
|
45
44
|
|
|
46
45
|
/** The append-only record, and what can be learned from it. */
|
|
47
46
|
export { read as readLog, logPath, observed, generalise } from "./log.js";
|
|
@@ -53,12 +52,21 @@ export { read as readLog, logPath, observed, generalise } from "./log.js";
|
|
|
53
52
|
* proposal are things a caller — including an MCP server — legitimately does.
|
|
54
53
|
* `settle` and `applyGrant` are NOT exported: granting is not a tool call, and
|
|
55
54
|
* the surface should make that hard to get wrong rather than merely document
|
|
56
|
-
* it. See docs/
|
|
55
|
+
* it. See docs/requests.md.
|
|
57
56
|
*/
|
|
58
|
-
export { pending as pendingRequests, requestsPath, grantFor } from "./requests.js";
|
|
57
|
+
export { pending as pendingRequests, requestsPath, grantFor, queueOf as requestQueue, recordHandoff } from "./requests.js";
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Where a denied write goes — decided, never executed.
|
|
61
|
+
*
|
|
62
|
+
* `requestQueue(config)` is the pending queue with each request's `handoff`
|
|
63
|
+
* decision against the policy as it stands. Something that starts roles reads
|
|
64
|
+
* it, starts `seisin run <handoff.role> --handoff <id>`, and may record with
|
|
65
|
+
* `recordHandoff` that it held one back; `decideHandoff` is there for the
|
|
66
|
+
* limits only that caller knows (how many chains a role has open, how many of
|
|
67
|
+
* the receiver are running). None of them grants anything.
|
|
68
|
+
*/
|
|
69
|
+
export { decideHandoff, MAX_DEPTH } from "./handoff.js";
|
|
59
70
|
|
|
60
71
|
/** One PreToolUse decision. */
|
|
61
72
|
export { decide, targetsOf } from "./hook.js";
|
|
62
|
-
|
|
63
|
-
/** Masking a role's own secrets on the way out. */
|
|
64
|
-
export { secretsOf, redactor } from "./redact.js";
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { join, isAbsolute } from "node:path";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* How Codex is signed in on this machine: "chatgpt", "apikey", or null.
|
|
7
|
+
*
|
|
8
|
+
* Read from `auth.json` in `CODEX_HOME` or `~/.codex`, by the parent, which
|
|
9
|
+
* `check` is. Only the shape is looked at — which fields are set — and
|
|
10
|
+
* nothing from the file is kept or printed.
|
|
11
|
+
*/
|
|
12
|
+
export function codexSignIn(env = process.env) {
|
|
13
|
+
const home = env.CODEX_HOME && isAbsolute(env.CODEX_HOME) ? env.CODEX_HOME : join(homedir(), ".codex");
|
|
14
|
+
let auth;
|
|
15
|
+
try { auth = JSON.parse(readFileSync(join(home, "auth.json"), "utf8")); } catch { return null; }
|
|
16
|
+
if (!auth || typeof auth !== "object") return null;
|
|
17
|
+
if (typeof auth.OPENAI_API_KEY === "string" && auth.OPENAI_API_KEY) return "apikey";
|
|
18
|
+
if (auth.tokens && typeof auth.tokens === "object" && auth.tokens.refresh_token) return "chatgpt";
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A role that looks set up for OpenAI and would leave Codex hanging.
|
|
24
|
+
*
|
|
25
|
+
* Signed in with ChatGPT, Codex talks to `chatgpt.com` for the model and to
|
|
26
|
+
* `auth.openai.com` to renew the sign-in — never to `api.openai.com` (measured
|
|
27
|
+
* 2026-09-30, codex 0.150.1: 0 connections). Without `chatgpt.com` it does not
|
|
28
|
+
* fail: it retries without end and prints nothing, which reads as an agent
|
|
29
|
+
* thinking. And the proxy's refusal does not reach seisin's log.
|
|
30
|
+
*
|
|
31
|
+
* `check` does not know which CLI a role runs, so the signal is the two facts
|
|
32
|
+
* it can see: this machine's Codex is signed in with ChatGPT, and the role's
|
|
33
|
+
* list names something under `openai.com` — a list written for OpenAI — but
|
|
34
|
+
* not `chatgpt.com`. A role whose `env` passes `OPENAI_API_KEY` is left out:
|
|
35
|
+
* it is set up for the API, where `api.openai.com` is right. Wrong costs a
|
|
36
|
+
* sentence, never a run, the same trade as the model-endpoint warning above.
|
|
37
|
+
*/
|
|
38
|
+
export function codexSignInWarnings(config, roles, env = process.env) {
|
|
39
|
+
if (codexSignIn(env) !== "chatgpt") return [];
|
|
40
|
+
const reaches = (list, host) => list.some((d) => d === "*" || d === host ||
|
|
41
|
+
(d.startsWith("*.") && host.endsWith(d.slice(1))));
|
|
42
|
+
const out = [];
|
|
43
|
+
for (const r of roles) {
|
|
44
|
+
const list = r.network ?? config.allowedDomains ?? [];
|
|
45
|
+
if (!list.some((d) => /(^|\.)openai\.com$/i.test(d))) continue;
|
|
46
|
+
if ((r.env ?? []).includes("OPENAI_API_KEY")) continue;
|
|
47
|
+
const missing = ["chatgpt.com", "auth.openai.com"].filter((h) => !reaches(list, h));
|
|
48
|
+
if (!missing.length) continue;
|
|
49
|
+
out.push({
|
|
50
|
+
kind: "codex-chatgpt-endpoint",
|
|
51
|
+
headline: `${r.name} allows ${list.filter((d) => /openai\.com$/i.test(d)).join(" ")} but not ${missing.join(" ")}`,
|
|
52
|
+
detail:
|
|
53
|
+
"Codex on this machine is signed in with ChatGPT, and with that sign-in it talks to " +
|
|
54
|
+
"chatgpt.com for the model and to auth.openai.com to renew the sign-in, never to " +
|
|
55
|
+
"api.openai.com. " +
|
|
56
|
+
(missing.includes("chatgpt.com")
|
|
57
|
+
? "Without chatgpt.com a Codex run does not fail: it retries without end and prints " +
|
|
58
|
+
"nothing, and the proxy's denial does not reach seisin's log. "
|
|
59
|
+
: "Without auth.openai.com a Codex run works until the sign-in has to be renewed. ") +
|
|
60
|
+
`If ${r.name} runs Codex, add ${missing.join(" and ")}; if it does not, this is not about it.`,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
return out;
|
|
64
|
+
}
|