seisin 0.1.1 → 0.3.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/src/grants.js ADDED
@@ -0,0 +1,239 @@
1
+ /**
2
+ * What a line of policy grants on disk, as paths.
3
+ *
4
+ * Pulled out of srt.js, which used to hold both halves: translating a policy
5
+ * into paths, and assembling the runtime's settings object out of them. The
6
+ * first half has a second reader now — surface.js asks "what can ANY role
7
+ * write?" to decide what the parent must not trust — and a module that needs
8
+ * the answer should not have to import the settings builder to get it.
9
+ *
10
+ * No policy decisions live here. Every function answers "which path does this
11
+ * mean", and the callers decide what to do with it.
12
+ */
13
+ import { join } from "node:path";
14
+ import { homedir, tmpdir } from "node:os";
15
+ import { realpathSync, lstatSync } from "node:fs";
16
+ import { createHash } from "node:crypto";
17
+
18
+ /**
19
+ * What every agent needs to write no matter which role it is.
20
+ *
21
+ * Found by running a real cell config through the sandbox: territory alone
22
+ * looks correct and is unusable. An agent writes its session state under its
23
+ * own config directory and its tools write scratch files to the temp dir, so a
24
+ * policy of "your folders and nothing else" stops the agent before it starts.
25
+ *
26
+ * The list is not Claude-shaped by accident and it was Claude-shaped by
27
+ * mistake: the first version named `~/.claude` and `~/.codex` and stopped
28
+ * there, so the first agent that was neither — opencode, which logs to
29
+ * `~/.local/share/opencode` — died on startup with `FileSystem.open`. Hence the
30
+ * XDG directories, which is where a CLI that follows convention puts its state.
31
+ *
32
+ * `~/.config` is deliberately NOT here. That is where credentials live —
33
+ * `~/.config/gh/hosts.yml` holds a GitHub token — and while reads outside the
34
+ * declared key directories are open anyway, letting an agent WRITE there is a
35
+ * different thing. A CLI that needs it can be granted it by name.
36
+ *
37
+ * These are grants, so they are listed rather than assumed: `seisin check`
38
+ * prints them, and `[runtime] writes = []` turns them off for anyone who wants
39
+ * to find out the hard way. Note what is NOT here — the home directory, the
40
+ * shell profile, anything under the repo. Scratch space is not a back door.
41
+ */
42
+ /**
43
+ * Where a role's home and scratch live when `[runtime] isolate = true`.
44
+ *
45
+ * Under the state directory but outside the repo tree the roles write, so one
46
+ * role cannot reach another's. Kept out of `.seisin/` inside the repo because
47
+ * that whole directory is denyWrite now.
48
+ */
49
+ /**
50
+ * Short on purpose, and the reason is the same one spool.js already names: a
51
+ * unix socket path is capped near 104 bytes on macOS and the failure when it is
52
+ * too long is an unhelpful EINVAL.
53
+ *
54
+ * This file forgot that one layer down. The runtime creates its multiplexing
55
+ * socket INSIDE the role's home — `<home>/tmp/srt-mux-<pid>-0.sock`, 25 bytes —
56
+ * and the name here used to be `seisin-home-` plus 16 characters. On macOS
57
+ * `tmpdir()` is 48 bytes on its own, so the total cleared 104 for **every role
58
+ * name**, `qa` included: `isolate = true` did not misbehave, it failed to start.
59
+ * Measured, and it is why `homeFits` below exists rather than a comment.
60
+ *
61
+ * Eleven bytes instead of twenty-eight leaves room for a role name up to
62
+ * {@link MAX_ROLE_FOR_HOME} characters.
63
+ */
64
+ export function roleHomeRoot(config) {
65
+ /**
66
+ * A hash of the whole path, not a slice of it.
67
+ *
68
+ * This was `base64url(root).slice(-16)` — the TAIL of the encoded path, which
69
+ * is the tail of the path itself. Two checkouts that end the same way get the
70
+ * same id: `/Users/ana/dev/proyecto` and `/Users/bob/dev/proyecto` collide, and
71
+ * so do `/home/a/work/api` and `/home/b/work/api`. A collision here is not a
72
+ * cosmetic clash — both repos' role `dev` would share one HOME, which is the
73
+ * session token of one handed to the other. Measured on three of four ordinary
74
+ * pairs; shortening the slice to fit the socket limit would have made it more
75
+ * likely, not less.
76
+ */
77
+ const id = createHash("sha256").update(config.root).digest("base64url").slice(0, 8);
78
+ return join(tmpdir(), `sn-${id}`);
79
+ }
80
+
81
+ /** What the runtime appends inside the role's home, at its longest. */
82
+ const SRT_SOCKET_TAIL = "/tmp/srt-mux-999999-0.sock".length;
83
+
84
+ /** The platform's cap on a unix socket path. macOS is the tight one. */
85
+ export const SOCKET_MAX = 104;
86
+
87
+ /**
88
+ * Whether a role's isolated home leaves room for the runtime's socket.
89
+ *
90
+ * Returns the room left over, negative when it does not fit. Callers refuse
91
+ * rather than let the runtime fail with EINVAL and no explanation — a boundary
92
+ * that cannot start should say so in its own words.
93
+ */
94
+ export function homeFits(config, role) {
95
+ return SOCKET_MAX - (roleHome(config, role).length + SRT_SOCKET_TAIL);
96
+ }
97
+
98
+ export function roleHome(config, role) {
99
+ return join(roleHomeRoot(config), role);
100
+ }
101
+
102
+ /**
103
+ * Where credentials live in a home directory, denied at both isolate levels.
104
+ *
105
+ * Denies rather than a read allowlist, and the reasoning is above: a
106
+ * default-deny read set has to enumerate every library, interpreter and cache a
107
+ * toolchain touches, gets one wrong, and fails as an unexplainable crash inside
108
+ * the agent. Naming the places credentials actually live is narrower than the
109
+ * ideal and holds up.
110
+ *
111
+ * `~/.config` is here whole rather than `~/.config/gh`: it is where a growing
112
+ * number of CLIs keep their tokens, and listing them one by one is the
113
+ * enumeration this list exists to avoid. A tool that needs a directory under it
114
+ * can be granted it by name.
115
+ */
116
+ export const CREDENTIAL_HOMES = [
117
+ // The order is not arbitrary: `check` names the first few, so the ones a
118
+ // reader recognises instantly go first. Everything in the list is denied
119
+ // either way.
120
+ "~/.ssh", "~/.aws", "~/.npmrc", "~/.config",
121
+ "~/.gnupg", "~/.kube", "~/.docker", "~/.netrc", "~/.git-credentials",
122
+ ];
123
+
124
+ export const RUNTIME_WRITES = [
125
+ "~/.claude", "~/.codex", // the CLIs that keep state under their own name
126
+ "~/.local/share", "~/.local/state", // XDG data and state: where most others log
127
+ "~/.cache", "$TMPDIR", "/tmp",
128
+ ];
129
+
130
+ /**
131
+ * `~` and `$TMPDIR` are the only expansions; everything else is a literal path.
132
+ *
133
+ * Symlinks are then resolved, and that is not a nicety. On macOS `/tmp` is a
134
+ * link to `/private/tmp`, and the sandbox enforces on the destination — so a
135
+ * grant written as `/tmp` grants exactly nothing, silently. Measured: with the
136
+ * literal path, writing to /tmp inside the sandbox failed while the policy
137
+ * looked correct on screen.
138
+ */
139
+ /** Is this exact path a symlink? False if it is not on disk. */
140
+ export function isLink(p) {
141
+ try {
142
+ return lstatSync(p).isSymbolicLink();
143
+ } catch {
144
+ return false;
145
+ }
146
+ }
147
+
148
+ /** The path with symlinks followed, or the path itself if it is not on disk. */
149
+ export function realOrSelf(p) {
150
+ try {
151
+ return realpathSync(p);
152
+ } catch {
153
+ return p;
154
+ }
155
+ }
156
+
157
+ export function expand(p) {
158
+ let out = p;
159
+ if (p === "$TMPDIR") out = tmpdir();
160
+ else if (p === "~" || p.startsWith("~/")) out = join(homedir(), p.slice(2));
161
+ try {
162
+ return realpathSync(out);
163
+ } catch {
164
+ return out; // not on disk yet; hand the literal through rather than drop it
165
+ }
166
+ }
167
+
168
+ /**
169
+ * `allowWrite` takes directories, not globs: the OS grants a subtree, it does
170
+ * not pattern-match. `src/api/**` and `src/api/` mean the same thing to the
171
+ * kernel, so the trailing glob is trimmed rather than passed through, where it
172
+ * would be read as a literal directory named `**` and silently grant nothing.
173
+ */
174
+ export function toWritePath(glob, key = "writes") {
175
+ if (!EXPRESSIBLE(glob)) throw new Error(unexpressible(glob, key));
176
+ if (glob === "**") return ".";
177
+ return glob.replace(/\/\*\*$/, "") || ".";
178
+ }
179
+
180
+ /**
181
+ * A write pattern the kernel can be given without changing its meaning.
182
+ *
183
+ * Exactly two shapes qualify: a literal path, and a subtree ending in `/**`.
184
+ * Both map onto what allowWrite actually is — a prefix — so the policy, the
185
+ * explanation and the enforcement stay the same sentence.
186
+ *
187
+ * Everything else is refused, and the one that made this necessary is `src/*`.
188
+ * ownersOf() reads it as one level, because `*` compiles to `[^/]*`. The old
189
+ * translation trimmed the `/*` and handed the kernel `src`, which is the whole
190
+ * subtree. So `seisin explain` said denied and the write landed — measured, not
191
+ * theorised. The document was tighter than the boundary, which is the one
192
+ * direction a permission tool must never fail in.
193
+ *
194
+ * There is no exact translation to find later: Seatbelt and bubblewrap grant
195
+ * prefixes, and "one level down" is not a prefix. Refusing is not a placeholder
196
+ * here, it is the answer.
197
+ */
198
+ const WILD = /[*?[\]]/; // owners.js treats all four as wildcards
199
+ const EXPRESSIBLE = (g) =>
200
+ g === "**" || // the whole repo
201
+ !WILD.test(g) || // a literal path
202
+ (g.endsWith("/**") && !WILD.test(g.slice(0, -3))); // a subtree, wildcard-free above it
203
+
204
+ function unexpressible(glob, key = "writes") {
205
+ return `${key} = "${glob}" cannot be enforced as written.\n` +
206
+ ` The sandbox grants a path and everything under it — there is no way to say ` +
207
+ `"one level deep".\n` +
208
+ ` Use "${glob.replace(/\/\*$/, "")}/**" for the whole subtree, or name the files.\n` +
209
+ ` Refusing rather than widening: the old behaviour granted the subtree while ` +
210
+ `seisin reported the narrow pattern.`;
211
+ }
212
+
213
+ /**
214
+ * Every directory a role may write, absolute, as its settings will say it.
215
+ *
216
+ * Observe mode is not a role property — any role can be run with it — and it
217
+ * grants the whole repo, so `observe: true` answers for that case.
218
+ *
219
+ * `.seisin/` is deliberately NOT here. It used to be, because the hook runs
220
+ * inside the box and has to record what it decided — which made the log and
221
+ * the queue writable by the process they are a record of. The hook now sends
222
+ * its lines to the parent over a socket and the parent holds the file. See
223
+ * spool.js.
224
+ *
225
+ * Isolated: this role's own home and scratch, and nothing shared. Otherwise:
226
+ * the real ~/.claude, ~/.cache and /tmp, which every role shares — convenient,
227
+ * and the reason `isolate` exists.
228
+ */
229
+ export function writePathsOf(config, role, { observe = false } = {}) {
230
+ const abs = (p) => (p.startsWith("/") ? p : join(config.root, p));
231
+ const level = config.isolate === true ? "home" : config.isolate;
232
+ const home = level === "home" ? roleHome(config, role.name) : null;
233
+ return [
234
+ ...(observe ? [abs(".")] : role.writes.map((g) => abs(toWritePath(g)))),
235
+ ...(home
236
+ ? [home, join(home, ".config"), join(home, ".local"), join(home, ".cache"), join(home, "tmp")]
237
+ : (config.runtimeWrites ?? RUNTIME_WRITES).map(expand)),
238
+ ];
239
+ }
package/src/hook.js CHANGED
@@ -16,8 +16,10 @@
16
16
  * is a minor one here, because the enforcer is the kernel and it is not fooled.
17
17
  * What escapes this file goes unexplained, not unblocked.
18
18
  */
19
+ import { toRepoRelative } from "./paths.js";
19
20
  import { explain, normalize } from "./owners.js";
20
21
  import { append, logPath } from "./log.js";
22
+ import { timesHit } from "./walls.js";
21
23
  import { record, requestsPath } from "./requests.js";
22
24
 
23
25
  /** Tools whose input names a file directly. */
@@ -118,6 +120,12 @@ function kindOf(config, path) {
118
120
  */
119
121
  const QUEUED = "Already queued for a person to answer — retrying or waiting will not move it.";
120
122
 
123
+ /** 4 -> "fourth". Past a handful the numeral reads better than the word. */
124
+ function ordinal(n) {
125
+ const words = ["", "first", "second", "third", "fourth", "fifth", "sixth", "seventh", "eighth", "ninth", "tenth"];
126
+ return words[n] ?? `${n}th`;
127
+ }
128
+
121
129
  /**
122
130
  * Decides and records one tool call.
123
131
  *
@@ -132,7 +140,7 @@ export function decide(config, role, event, { observe = false, now = append, ask
132
140
  const verdicts = [];
133
141
 
134
142
  for (const t of targets) {
135
- const rel = t.path.startsWith(config.root + "/") ? t.path.slice(config.root.length + 1) : t.path;
143
+ const rel = toRepoRelative(config, t.path);
136
144
  const kind = kindOf(config, rel);
137
145
 
138
146
  // Reading an ordinary file is never a policy question. Territory divides
@@ -161,10 +169,19 @@ export function decide(config, role, event, { observe = false, now = append, ask
161
169
  // the refusal stops being a dead end and becomes something a person can act
162
170
  // on in one command. Observing records nothing to approve — there was no
163
171
  // denial to answer. See docs/permission-requests.md.
164
- if (!observe && !v.allowed)
172
+ //
173
+ // Except a `never_writes` refusal. That one is the policy saying no on
174
+ // purpose; a request for it asks a person to undo a subtraction somebody
175
+ // wrote, and approving it is how the subtraction disappears unnoticed.
176
+ if (!observe && !v.allowed && !v.neverWrites && !v.gitMetadata)
165
177
  ask(queue, { role, action: kind === "key" ? "read" : t.action, target: rel, owners: v.owners ?? [] });
166
178
 
167
- verdicts.push({ ...v, target: rel, kind });
179
+ // The action as it was RECORDED, not as the tool named it: a read of a key
180
+ // is logged as "read" and a write is "write", and the counter below looks
181
+ // the entry up by that pair. Carrying the tool's word here instead would
182
+ // make every lookup miss, silently — the refusal would simply never
183
+ // mention that it had been given before.
184
+ verdicts.push({ ...v, target: rel, kind, action: kind === "key" ? "read" : t.action });
168
185
  }
169
186
 
170
187
  if (observe) return { decision: null, logged: verdicts.length };
@@ -173,6 +190,25 @@ export function decide(config, role, event, { observe = false, now = append, ask
173
190
  if (!denied) return { decision: null, logged: verdicts.length };
174
191
  const wasRead = denied.kind === "key";
175
192
 
193
+ /**
194
+ * How many times this exact denial has already been handed to this role.
195
+ *
196
+ * The sentence below is correct every time and that has not been enough:
197
+ * measured on a real team, 25% of all denials were a repeat of something the
198
+ * same role had already been refused, one of them nineteen times. Correct
199
+ * and *heard* are different properties, and only the first was being
200
+ * measured. So the refusal carries its own history: the second time, it says
201
+ * it is the second time.
202
+ *
203
+ * Counted from the log that was just written, so the count includes this
204
+ * attempt. Silent on the first — a counter that says "1×" on every first
205
+ * refusal is noise on the turn where the sentence is already doing its job.
206
+ */
207
+ const before = timesHit(logPath(config.root), role, denied.action, denied.target);
208
+ const again = before > 1
209
+ ? ` You have been denied this ${before} times now; it is not going to work on the ${ordinal(before + 1)} try.`
210
+ : "";
211
+
176
212
  // The hook advises; it does not enforce. Returning `deny` here stops the call
177
213
  // early and — the part that matters — hands the agent a sentence it can act
178
214
  // on, instead of an errno it can only retry.
@@ -185,11 +221,15 @@ export function decide(config, role, event, { observe = false, now = append, ask
185
221
  permissionDecisionReason:
186
222
  // The sentence has to name a next step, not just a refusal. An agent
187
223
  // told "no" retries; an agent told whose it is asks, or moves on.
188
- (denied.owners?.length
224
+ denied.neverWrites
225
+ ? `${denied.reason}. Do not ask for it: nothing was queued, because the answer is already written down.` + again
226
+ : denied.gitMetadata
227
+ ? `${denied.reason}. Nothing was queued.` + again
228
+ : (denied.owners?.length
189
229
  ? wasRead
190
230
  ? `${denied.target} is declared for ${denied.owners.join(", ")}, not ${role}. Ask for what you need from it rather than reading the key.`
191
231
  : `${denied.target} belongs to ${denied.owners.join(", ")}. It is not ${role}'s to change — hand it over rather than working around it.`
192
- : `${denied.reason} (seisin)`) + " " + QUEUED,
232
+ : `${denied.reason} (seisin)`) + again + " " + QUEUED,
193
233
  },
194
234
  };
195
235
  }
package/src/index.js CHANGED
@@ -25,6 +25,18 @@ export { covers, ownersOf, keyHolders, explain } from "./owners.js";
25
25
  export { settingsFor, RUNTIME_WRITES, expand } from "./srt.js";
26
26
  export { buildEnv, BASE as BASE_ENV, DEFAULTS as DEFAULT_ENV } from "./env.js";
27
27
 
28
+ /**
29
+ * Keys that are references, and how a resolved value reaches a role.
30
+ *
31
+ * `parseKey` and `MODES` are here because reading somebody's policy means
32
+ * knowing whether an entry is a path or a reference; `resolveRef` is not,
33
+ * because resolving is something seisin does and not something you ask it.
34
+ */
35
+ export { parseKey, defaultName, entriesOf, MODES, RESERVED_ENV } from "./keys.js";
36
+
37
+ /** What a role keeps being refused, recomputed against the policy as it stands. */
38
+ export { walls, wasted, MIN_HITS } from "./walls.js";
39
+
28
40
  /** The report `check` prints, without the printing. */
29
41
  export { inspect, sharedPaths } from "./inspect.js";
30
42