seisin 0.2.0 → 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/config.js CHANGED
@@ -12,6 +12,7 @@ import { readFileSync, existsSync } from "node:fs";
12
12
  import { resolve, dirname, join } from "node:path";
13
13
  import { CONFIG_NAME } from "./layout.js";
14
14
  import { parseKey, MODES, checkNames, BUILTIN } from "./keys.js";
15
+ import { readNotify } from "./notify.js";
15
16
 
16
17
  export { CONFIG_NAME } from "./layout.js";
17
18
 
@@ -171,6 +172,19 @@ function readValue(value, lineNo) {
171
172
  expectItem = true; i++; continue;
172
173
  }
173
174
  if (!expectItem) bad(`missing comma before ${JSON.stringify(body.slice(i, i + 12))}`);
175
+ // Whole numbers, for `local_ports`. Digits only: no sign, no decimal, no
176
+ // underscore — a port is none of those, and every other key still
177
+ // refuses a number through asArray.
178
+ const digits = /^\d+/.exec(body.slice(i));
179
+ if (digits) {
180
+ const after = body[i + digits[0].length];
181
+ if (after !== undefined && after !== "," && after !== " " && after !== "\t")
182
+ bad(`${JSON.stringify(body.slice(i, i + 12))} is not a whole number`);
183
+ items.push(Number(digits[0]));
184
+ i += digits[0].length;
185
+ expectItem = false;
186
+ continue;
187
+ }
174
188
  if (c !== '"') bad(`array items must be double-quoted, found ${JSON.stringify(body.slice(i, i + 12))}`);
175
189
 
176
190
  const end = body.indexOf('"', i + 1);
@@ -193,7 +207,9 @@ function readValue(value, lineNo) {
193
207
  return items;
194
208
  }
195
209
 
196
- bad("value must be a string, an array of strings, or a boolean");
210
+ if (/^\d+$/.test(v)) return Number(v);
211
+
212
+ bad("value must be a string, an array of strings, a whole number, or a boolean");
197
213
  }
198
214
 
199
215
  /**
@@ -354,6 +370,15 @@ export function loadConfig(path) {
354
370
  throw new Error(
355
371
  `${path}: roles.${name}: key_mode = "${keyMode}" is not a delivery mode. ` +
356
372
  `Known: ${MODES.join(", ")}.`);
373
+ const neverWrites = readNeverWrites(own(r, "never_writes"), `${path}: roles.${name}.never_writes`);
374
+ const localBinding = own(r, "local_binding");
375
+ const trustd = own(r, "trustd");
376
+ if (trustd !== undefined && typeof trustd !== "boolean")
377
+ throw new Error(`${path}: roles.${name}.trustd must be true or false, not ${JSON.stringify(trustd)}`);
378
+ if (localBinding !== undefined && typeof localBinding !== "boolean")
379
+ throw new Error(`${path}: roles.${name}.local_binding must be true or false, not ${JSON.stringify(localBinding)}`);
380
+ const localPorts = readLocalPorts(own(r, "local_ports"), `${path}: roles.${name}.local_ports`);
381
+ const mcp = readMcp(own(r, "mcp"), `${path}: roles.${name}.mcp`);
357
382
  out.roles[name] = {
358
383
  name,
359
384
  /**
@@ -374,8 +399,67 @@ export function loadConfig(path) {
374
399
  keyMode: keyMode ?? null,
375
400
  env: asArray(own(r, "env"), `roles.${name}.env`),
376
401
  network: own(r, "network") === undefined ? null : asArray(own(r, "network"), `roles.${name}.network`),
402
+ /**
403
+ * What this role may never write, even inside its own `writes`.
404
+ *
405
+ * A subtraction, and the only one the format has. It exists for the case
406
+ * a territory glob cannot say: `repo/**` minus `repo/.git/index.lock`, for
407
+ * a role that works in a worktree and has its own index. Without it the
408
+ * alternative is enumerating every top-level entry of the repo but `.git`
409
+ * — a list that goes stale the day somebody adds a directory.
410
+ *
411
+ * Per role and never global: a global deny on `.git/index.lock` breaks
412
+ * every role that legitimately commits in the shared checkout. Absent
413
+ * means exactly what it meant before the key existed.
414
+ */
415
+ // Sidecars too: `data/app.sqlite` minus its `-wal` is not a subtraction.
416
+ // A fabricated -wal is applied to the database the next time it opens.
417
+ neverWrites: withSidecars(neverWrites),
418
+ neverWritesDeclared: neverWrites,
419
+ /**
420
+ * Whether this role may listen on a local port — a dev server, the
421
+ * backend an end-to-end test drives, a test database.
422
+ *
423
+ * Off unless the role says so, and per role because most roles never
424
+ * start a server: an agent that edits docs has no business accepting
425
+ * connections. Wider than the name: the runtime also lets the role listen
426
+ * on every interface and connect to every localhost port. See `check`.
427
+ */
428
+ localBinding: localBinding === true,
429
+ /**
430
+ * The ports on this machine the role may connect to, and no others.
431
+ *
432
+ * The narrow form of reaching a local service. `local_binding` opens
433
+ * every port on localhost (macOS), which includes whatever control API
434
+ * is listening there without a password. This names the ports instead,
435
+ * and the runtime's proxy enforces the list — the same allowlist the
436
+ * domains go through. See srt.js for what that costs: the client has to
437
+ * go through the proxy, which HTTP clients do and a MySQL driver does not.
438
+ */
439
+ localPorts,
440
+ /**
441
+ * The MCP servers this role may load, by name, or null when the policy
442
+ * does not say. `[]` is "none". seisin declares this and does not
443
+ * enforce it: MCP servers are started by the agent's CLI, so the launcher
444
+ * builds the CLI's MCP config from this list (Claude Code:
445
+ * `--mcp-config` with only these + `--strict-mcp-config`).
446
+ */
447
+ mcp,
448
+ /**
449
+ * Whether this role may reach com.apple.trustd.agent (macOS). Needed by
450
+ * Dart/Flutter and by Go built before 1.27 to verify TLS at all; Go 1.27+
451
+ * does without it through the SSL_CERT_FILE that env.js sets. Off unless
452
+ * the role says so. See srt.js for what it costs.
453
+ */
454
+ trustd: trustd === true,
455
+ // Kept so `check` can name a misspelt key instead of ignoring it. An
456
+ // unknown key in a role table used to be dropped silently, and for a
457
+ // subtraction that is failing open: `never_write` would read as a rule
458
+ // and subtract nothing.
459
+ unknownKeys: Object.keys(r).filter((k) => !ROLE_KEYS.includes(k)),
377
460
  };
378
461
  }
462
+ out.notify = readNotify(own(parsed, "notify"), { path, root: out.root, keyDirs, roles: out.roles, own });
379
463
  return out;
380
464
  }
381
465
 
@@ -466,9 +550,73 @@ export function withSidecars(paths) {
466
550
  return [...new Set(out)];
467
551
  }
468
552
 
553
+ /** The keys a `[roles.<name>]` table can hold. Anything else is reported by `check`. */
554
+ export const ROLE_KEYS = ["writes", "keys", "key_mode", "env", "network", "never_writes", "local_binding", "local_ports", "mcp", "trustd"];
555
+
556
+ /**
557
+ * `mcp`: names of MCP servers, as the CLI's config calls them. Absent is null,
558
+ * not [], so a policy that never mentions MCP changes nothing for a launcher
559
+ * that reads it; `mcp = []` is the explicit "none".
560
+ */
561
+ function readMcp(value, where) {
562
+ if (value === undefined) return null;
563
+ const list = asArray(value, where);
564
+ const out = [];
565
+ for (const n of list) {
566
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(n))
567
+ throw new Error(`${where}: ${JSON.stringify(n)} is not an MCP server name. ` +
568
+ `Use the name from the CLI's config, for example mcp = ["playwright"].`);
569
+ if (!out.includes(n)) out.push(n);
570
+ }
571
+ return out;
572
+ }
573
+
574
+ /**
575
+ * `local_ports`, refused rather than guessed when it is not a list of ports.
576
+ *
577
+ * Refused at load for the same reason as `never_writes`: a port list that loads
578
+ * with a typo in it opens a port nobody meant, or silently fails to open the
579
+ * one they did. A string is accepted when it is only digits, because TOML
580
+ * written by hand says `"8001"` as often as `8001`.
581
+ */
582
+ function readLocalPorts(value, where) {
583
+ if (value === undefined) return [];
584
+ const list = Array.isArray(value) ? value : [value];
585
+ const out = [];
586
+ for (const v of list) {
587
+ const n = typeof v === "string" && /^\d+$/.test(v) ? Number(v) : v;
588
+ if (!Number.isInteger(n) || n < 1 || n > 65535)
589
+ throw new Error(`${where}: ${JSON.stringify(v)} is not a port. Write numbers from 1 to 65535, ` +
590
+ `for example local_ports = [8001, 8081].`);
591
+ if (!out.includes(n)) out.push(n);
592
+ }
593
+ return out;
594
+ }
595
+
596
+ /**
597
+ * `never_writes`, refused rather than guessed when it cannot mean what it says.
598
+ *
599
+ * Refused at load, not warned about: this is a list of things a role must not
600
+ * do, and a malformed entry that loads anyway fails open. An absolute path or a
601
+ * `..` would point outside the repo the rest of the policy is written against,
602
+ * so it cannot be the subtraction the person meant.
603
+ */
604
+ function readNeverWrites(value, where) {
605
+ const list = asArray(value, where);
606
+ for (const g of list) {
607
+ if (typeof g !== "string" || g.trim() === "")
608
+ throw new Error(`${where}: every entry must be a non-empty string`);
609
+ if (g.startsWith("/"))
610
+ throw new Error(`${where}: "${g}" is absolute. never_writes is relative to the repo root, like writes.`);
611
+ if (g.split("/").includes(".."))
612
+ throw new Error(`${where}: "${g}" contains "..". never_writes names paths inside the repo.`);
613
+ }
614
+ return list;
615
+ }
616
+
469
617
  function asArray(value, where) {
470
618
  if (value === undefined) return [];
471
619
  if (typeof value === "string") return [value];
472
- if (Array.isArray(value)) return value;
620
+ if (Array.isArray(value) && value.every((v) => typeof v === "string")) return value;
473
621
  throw new Error(`${where}: expected a string or an array of strings`);
474
622
  }
@@ -0,0 +1,172 @@
1
+ /**
2
+ * The refusal the agent never heard, said after the fact.
3
+ *
4
+ * PreToolUse sees the path a tool call names, and that is all it sees. What the
5
+ * kernel refuses on its own — a child process, `/bin/rm` inside a script, a
6
+ * path the regex did not catch — reaches the agent as `Operation not permitted`
7
+ * with no path and no reason, and it retries. Measured on a real portfolio from
8
+ * 2026-09-14 to 2026-09-22: 5,617 refusals, all of them from the kernel, 72%
9
+ * the same role hitting the same path again.
10
+ *
11
+ * Two hooks close that, the shape nono uses for its sandbox diagnostics:
12
+ *
13
+ * - after a tool call fails (PostToolUseFailure, and PostToolUse for Bash,
14
+ * which reports a failed command as a result): read this role's kernel
15
+ * refusals from the last moments and hand the agent the same sentence the
16
+ * PreToolUse hook would have — whose it is, how many times, and that it
17
+ * should hand it over rather than retry. The kernel's line reaches the log
18
+ * about 30 ms after the refusal (measured inside the box), so this reads it.
19
+ * - when a session starts, resumes or is compacted (SessionStart): the role's
20
+ * territory and what it keeps being refused, before the first refusal —
21
+ * and right after compaction, which is when an agent forgets what it was
22
+ * already told.
23
+ *
24
+ * What it does not do, on purpose: suggest widening. nono's message offers
25
+ * `--allow /path`; that assumes the profile was too narrow. seisin assumes the
26
+ * file is somebody else's, and the way out is handing it to them.
27
+ *
28
+ * On Linux the runtime's refusals cannot be read from outside (violations.js),
29
+ * so the after-the-fact half finds nothing there and stays quiet.
30
+ */
31
+ import { closeSync, existsSync, openSync, readSync, statSync } from "node:fs";
32
+ import { explain } from "./owners.js";
33
+ import { timesHit, walls } from "./walls.js";
34
+ import { collapseSidecars } from "./render.js";
35
+
36
+ /** What a refused write or read looks like in a tool's output. A trigger to look, not a verdict. */
37
+ export const DENIAL_SIGNS = /Operation not permitted|EPERM|EACCES|Permission denied|Read-only file system/i;
38
+
39
+ /**
40
+ * Refusals the runtime's profile makes that never reach the log, recognised by
41
+ * what the failing program printed.
42
+ *
43
+ * The log holds what the kernel refused on a path or a port. A Mach service
44
+ * registration is neither: the profile refuses it, the program dies with its
45
+ * own words, and nothing in `.seisin/` says why. Each entry here is one of those
46
+ * signatures, measured, with the sentence that sends the agent to the fix
47
+ * instead of to a retry. Kept to exact signatures — a guess here would be an
48
+ * explanation for a failure seisin did not cause.
49
+ */
50
+ export const KNOWN_REFUSALS = [
51
+ {
52
+ // Measured 2026-09-23 with Playwright's Chromium under seisin on macOS. It
53
+ // cost a team an afternoon: the error names a Mach port, not a file.
54
+ sign: /bootstrap_check_in\s+org\.chromium\.Chromium\.MachPortRendezvousServer\S*:\s*Permission denied \(1100\)/,
55
+ say: "Chromium could not register its Mach port: the sandbox does not allow it, and no grant " +
56
+ "changes that. Launch it with --single-process (Playwright: launchOptions.args) and one worker.",
57
+ },
58
+ ];
59
+
60
+ /** The known refusals whose signature is in this output. */
61
+ export function knownRefusals(evidence) {
62
+ return KNOWN_REFUSALS.filter((k) => k.sign.test(evidence)).map((k) => k.say);
63
+ }
64
+
65
+ /** How far back a refusal still belongs to the call that just failed. */
66
+ const WINDOW_MS = 120_000;
67
+ /** Read only the end of the log: this runs on every failed command. */
68
+ const TAIL_BYTES = 512 * 1024;
69
+ /** How many paths to name. More than this is a wall of text nobody acts on. */
70
+ const MAX_NAMED = 3;
71
+
72
+ function tail(file) {
73
+ if (!existsSync(file)) return [];
74
+ const { size } = statSync(file);
75
+ const from = Math.max(0, size - TAIL_BYTES);
76
+ const fd = openSync(file, "r");
77
+ try {
78
+ const buf = Buffer.alloc(size - from);
79
+ readSync(fd, buf, 0, buf.length, from);
80
+ const lines = buf.toString("utf8").split("\n");
81
+ if (from > 0) lines.shift(); // the first line is cut in half
82
+ const out = [];
83
+ for (const l of lines) {
84
+ if (!l.trim()) continue;
85
+ try { out.push(JSON.parse(l)); } catch { /* a line being written */ }
86
+ }
87
+ return out;
88
+ } finally {
89
+ closeSync(fd);
90
+ }
91
+ }
92
+
93
+ /** This role's refusals by the kernel since `sinceMs`, one per path, newest first. */
94
+ export function recentKernelDenials(file, role, sinceMs) {
95
+ const since = new Date(sinceMs).toISOString();
96
+ const seen = new Map();
97
+ for (const e of tail(file).reverse()) {
98
+ if (e.role !== role || e.verdict !== "denied" || e.source !== "kernel") continue;
99
+ if (!e.at || e.at < since || !e.target) continue;
100
+ const k = `${e.action}\u0000${e.target}`;
101
+ if (!seen.has(k)) seen.set(k, e);
102
+ }
103
+ return [...seen.values()];
104
+ }
105
+
106
+ /** The sentence for one refusal, in the words the PreToolUse hook uses. */
107
+ function sentence(config, role, file, e) {
108
+ const action = e.kind === "key" ? "read" : e.action === "read" || e.action === "connect" ? e.action : "write";
109
+ const v = explain(config, role, action, e.target);
110
+ if (v.allowed) return null; // the policy moved since: nothing to say
111
+ const times = timesHit(file, role, action, e.target);
112
+ const again = times > 1 ? ` Refused ${times} times now; the next try ends the same way.` : "";
113
+ if (v.neverWrites) return `${v.reason}. Do not ask for it.${again}`;
114
+ if (v.owners?.length)
115
+ return `${e.target} belongs to ${v.owners.join(", ")}. It is not ${role}'s to change — ` +
116
+ `hand it over rather than working around it.${again}`;
117
+ return `${e.target}: ${v.reason}.${again}`;
118
+ }
119
+
120
+ const PREFACE =
121
+ "seisin — the sandbox this agent runs in — refused part of that. This is not a Unix permission " +
122
+ "or a macOS privacy prompt: chmod, sudo or a different path to the same file will not change it.";
123
+
124
+ /**
125
+ * After a tool call failed. Returns the hook output, or null to say nothing.
126
+ *
127
+ * `wait` retries the read a few times: the kernel's line lands ~30 ms after the
128
+ * refusal, and a loaded machine can be slower than the hook.
129
+ */
130
+ export async function afterTool(config, role, event, { file, now = Date.now, wait = [0, 100, 300] } = {}) {
131
+ const evidence = JSON.stringify({ r: event.tool_response, e: event.error, o: event.tool_output });
132
+ if (!DENIAL_SIGNS.test(evidence)) return null;
133
+ // JSON escapes what the program printed; the signatures are written against the text.
134
+ const known = knownRefusals(evidence.replace(/\\n/g, "\n").replace(/\\"/g, '"'));
135
+ let found = [];
136
+ for (const ms of wait) {
137
+ if (ms) await new Promise((ok) => setTimeout(ok, ms));
138
+ found = recentKernelDenials(file, role, now() - WINDOW_MS);
139
+ if (found.length) break;
140
+ }
141
+ const lines = [...known, ...found.map((e) => sentence(config, role, file, e)).filter(Boolean)].slice(0, MAX_NAMED);
142
+ if (!lines.length) return null;
143
+ const total = known.length + found.length;
144
+ const more = total > MAX_NAMED ? ` (and ${total - MAX_NAMED} more — \`seisin walls ${role}\`)` : "";
145
+ return {
146
+ hookSpecificOutput: {
147
+ hookEventName: event.hook_event_name,
148
+ additionalContext: [PREFACE, ...lines.map((l) => `- ${l}`)].join("\n") + more,
149
+ },
150
+ };
151
+ }
152
+
153
+ /** When a session starts, resumes or is compacted: the map, before the first wall. */
154
+ export function atSessionStart(config, role, event, { file } = {}) {
155
+ const r = config.roles[role];
156
+ const writes = collapseSidecars(r.writesDeclared ?? r.writes);
157
+ const lines = [
158
+ `seisin: this session runs as the role "${role}". The kernel enforces it; nothing inside can widen it.`,
159
+ `You may write: ${writes.length ? writes.join(", ") : "nothing"}. Reading the rest of the repo is fine.`,
160
+ ];
161
+ if ((r.neverWritesDeclared ?? r.neverWrites ?? []).length)
162
+ lines.push(`Never, even inside that: ${(r.neverWritesDeclared ?? r.neverWrites).join(", ")}.`);
163
+ if (r.keys.length) lines.push(`Keys you hold: ${r.keys.join(", ")}.`);
164
+ const w = file ? walls(config, role, { file, limit: 5, fresh: true }) : [];
165
+ if (w.length) {
166
+ lines.push("Already refused more than once, and still refused — do not try again, hand it over:");
167
+ for (const x of w)
168
+ lines.push(`- ${x.action} ${x.target} (${x.times}×) — ${x.owners?.length ? `belongs to ${x.owners.join(", ")}` : x.reason}`);
169
+ }
170
+ lines.push("A file outside that is somebody else's: say whose it is and hand it over, rather than working around it.");
171
+ return { hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: lines.join("\n") } };
172
+ }
package/src/env.js CHANGED
@@ -10,6 +10,9 @@
10
10
  * unless it is named. The base list below is what a command needs to be a
11
11
  * command at all, and it deliberately contains no credential-shaped names.
12
12
  */
13
+ import { existsSync } from "node:fs";
14
+
15
+ const SYSTEM_BUNDLE = "/etc/ssl/cert.pem";
13
16
 
14
17
  /**
15
18
  * What survives with no policy at all.
@@ -57,7 +60,34 @@ export const BASE = [
57
60
  * A role that wants the old behaviour names `GIT_OPTIONAL_LOCKS` in its `env`
58
61
  * and sets it in the parent — the loop below then overwrites this.
59
62
  */
60
- export const DEFAULTS = { GIT_OPTIONAL_LOCKS: "0" };
63
+ export const DEFAULTS = {
64
+ GIT_OPTIONAL_LOCKS: "0",
65
+ /**
66
+ * Node's built-in `fetch()` ignores HTTP_PROXY/HTTPS_PROXY unless told
67
+ * otherwise, so inside the box it tries DNS directly and dies with ENOTFOUND
68
+ * while `npm`, which reads the proxy itself, works. Measured on Node 24.2:
69
+ * ENOTFOUND without it, 200 with it. It only makes Node use the proxy the
70
+ * sandbox already routes everything through — nothing is widened. Older
71
+ * Nodes ignore the variable.
72
+ */
73
+ NODE_USE_ENV_PROXY: "1",
74
+ /**
75
+ * The system's CA bundle, when there is one at this path (macOS ships it).
76
+ *
77
+ * Go 1.27+ verifies against SSL_CERT_FILE instead of asking the system when
78
+ * the variable is set ("the native Go verifier is used", go1.27 notes), and
79
+ * asking the system means com.apple.trustd.agent, which the sandbox closes.
80
+ * Measured: `go get` fails with `x509: OSStatus -26276` without it and works
81
+ * with it, with trustd still shut. Binaries built with Go before 1.27 (gh
82
+ * 2.89 is 1.26) ignore it; for those, and for Dart, see `trustd = true`.
83
+ *
84
+ * Only if the file exists: on a system without it, pointing every TLS client
85
+ * at a missing file would break the ones that work today. And a parent that
86
+ * sets its own SSL_CERT_FILE or SSL_CERT_DIR — a corporate CA — keeps it;
87
+ * see buildEnv.
88
+ */
89
+ ...(existsSync(SYSTEM_BUNDLE) ? { SSL_CERT_FILE: SYSTEM_BUNDLE } : {}),
90
+ };
61
91
 
62
92
  /**
63
93
  * Names that must never ride along, even if a role asks for them by pattern.
@@ -87,8 +117,31 @@ export function buildEnv(parent, role, extra = []) {
87
117
  for (const [name, value] of Object.entries(parent)) {
88
118
  if (!wanted.has(name)) { dropped.push(name); continue; }
89
119
  if (NEVER.test(name) && !(role.env ?? []).includes(name)) { dropped.push(name); continue; }
120
+ // Not even when named: whoever can read the notify URL can send the person
121
+ // a fake "approve #3". Only the parent uses it (notify.js).
122
+ if (name === "SEISIN_NOTIFY_URL") { dropped.push(name); continue; }
90
123
  env[name] = value;
91
124
  }
125
+ // A trust store the parent chose is a decision about who to trust, not an
126
+ // incidental variable: it crosses whether or not the role named it, and it
127
+ // replaces the default rather than sitting next to it. They are paths, never
128
+ // secrets. A role that names SSL_CERT_FILE while the parent has none opts out
129
+ // of the default.
130
+ // A parent that already says whether Node should use the proxy is answered
131
+ // as it asked — `=0` included — rather than overwritten by the default.
132
+ if (parent.NODE_USE_ENV_PROXY !== undefined) env.NODE_USE_ENV_PROXY = parent.NODE_USE_ENV_PROXY;
133
+ // A role with `trustd = true` can ask the system verifier, which sees the
134
+ // Keychain — corporate CAs included — and /etc/ssl/cert.pem does not. Setting
135
+ // the bundle there would make Go 1.27+ skip the verifier the role was given
136
+ // trustd for, and fail behind a corporate proxy (review of 2026-09-22, #7).
137
+ if (role.trustd === true) delete env.SSL_CERT_FILE;
138
+ if (parent.SSL_CERT_FILE || parent.SSL_CERT_DIR) {
139
+ delete env.SSL_CERT_FILE;
140
+ for (const k of ["SSL_CERT_FILE", "SSL_CERT_DIR"])
141
+ if (parent[k]) { env[k] = parent[k]; const i = dropped.indexOf(k); if (i !== -1) dropped.splice(i, 1); }
142
+ } else if ((role.env ?? []).includes("SSL_CERT_FILE")) {
143
+ delete env.SSL_CERT_FILE;
144
+ }
92
145
  // A role may name a variable the parent does not have. That is not an error —
93
146
  // it is a policy written ahead of the environment, which is the right order.
94
147
  return { env, dropped: dropped.sort() };