seisin 0.6.2 → 0.6.3

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 CHANGED
@@ -855,7 +855,7 @@ A **provider** is a command with a `{ref}` placeholder, so adding 1Password, Bit
855
855
  confined process. Nothing degrades: a provider that fails stops the run.
856
856
 
857
857
  A key reaches the role as a variable (`key_mode = "env"`), as a file in the run's private
858
- directory (`"run-file"`), or, on Linux, not at all: with `"inject"` the agent holds a
858
+ directory (`"run-file"`), or not at all (on Linux, and on macOS under tartaros): with `"inject"` the agent holds a
859
859
  placeholder and the sandbox swaps in the real value only on the way to the hosts you list for
860
860
  it. That costs terminating TLS to those hosts, which `seisin check` names, and a host that
861
861
  stores what it is sent can still hand the value back — use a least-scope token.
@@ -1037,7 +1037,7 @@ the same kernel or workspace, and which one you want depends on what you are pro
1037
1037
 
1038
1038
  ## Status
1039
1039
 
1040
- **1101 tests on macOS, 1094 passing and 7 platform skips** (0.6.2, 2026-10-26, local); in CI the
1040
+ **1103 tests on macOS, 1096 passing and 7 platform skips** (after 0.6.3, 2026-10-26, local); in CI the
1041
1041
  same day, on `ubuntu-latest` **1100, 1071 passing and 29 platform skips**, and on macOS 1101,
1042
1042
  1092 passing and 9 skips, Node 18/20/22/24, with the 26 core boundary tests skipping none. On 2026-10-12, 91 of the suite's tests
1043
1043
  needed `@anthropic-ai/sandbox-runtime` installed — counted by running it with the runtime
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "seisin",
3
- "version": "0.6.2",
3
+ "version": "0.6.3",
4
4
  "description": "A permission layer for AI coding agents: role-scoped folders and keys, enforced by the OS, and when it denies something it tells you whose file it was.",
5
5
  "keywords": [
6
6
  "ai-agents",
@@ -45,7 +45,7 @@
45
45
  "@anthropic-ai/sandbox-runtime": "0.0.81"
46
46
  },
47
47
  "optionalDependencies": {
48
- "@carlostapiacl/tartaros": "0.1.0-alpha.15"
48
+ "@carlostapiacl/tartaros": "0.1.0-alpha.16"
49
49
  },
50
50
  "scripts": {
51
51
  "test": "node --test test/*.test.js",
package/src/canary.js CHANGED
@@ -130,6 +130,16 @@ export async function runCanary({ srt, wrap = null, settingsFile, settings, env,
130
130
  }
131
131
 
132
132
  if (timedOut) failed.push(`the canary did not finish in ${timeoutMs / 1000}s`);
133
+ // tartaros refuses a run whose real value another process exposes (its exec
134
+ // record, which the agent can read; on macOS no sandbox hides it). That is not
135
+ // a boundary to fix in the policy, and its own advice names a flag seisin does
136
+ // not pass, so it is said apart, as the variable and the process.
137
+ const exposed = [...stderr.matchAll(/tartaros: secret exposed: the value of (\S+) in pid (\d+) \(([^)]*)\)/g)]
138
+ .map(([, name, pid, proc]) => ({ name, pid: Number(pid), proc }));
139
+ if (exposed.length) {
140
+ for (const p of [secret, outside, insideDir, shared]) if (p) try { rmSync(p, { force: true, recursive: p === insideDir }); } catch {}
141
+ return { ok: false, exposed, failed: exposed.map((e) => `${e.name}'s real value is readable in pid ${e.pid} (${e.proc})`), ms: Date.now() - t0 };
142
+ }
133
143
  if (!output.includes(`ALIVE-${nonce}`)) {
134
144
  // The runtime's own words, when it said any: a sandbox that refuses to
135
145
  // start usually says why (measured: a path the runtime read as a broken
@@ -496,6 +496,14 @@ export async function run(config, argv) {
496
496
  event: "canary", role, run: theRun.id.slice(0, 8), ...trace, agent: agent ?? null, confined: false, reasons: probe.failed,
497
497
  ...(sandbox.name === DEFAULT_SANDBOX ? {} : { sandbox: sandbox.name }),
498
498
  });
499
+ if (probe.exposed) {
500
+ err(`${C.red}seisin: ${role} was NOT started — another process holds a real value the agent would see:${C.off}\n` +
501
+ probe.failed.map((f) => ` - ${f}\n`).join("") +
502
+ ` A process's environment and arguments are readable by every process of your user (on macOS no sandbox ` +
503
+ `hides them), so masking would hide nothing. Usually a shell that exported the token, and what it started. ` +
504
+ `Unset it there or end that process, and keep the value only where seisin's key points (docs/keys.md).\n`);
505
+ process.exit(CANARY_EXIT);
506
+ }
499
507
  err(`${C.red}seisin: ${role} was NOT started — the sandbox failed its canary:${C.off}\n` +
500
508
  probe.failed.map((f) => ` - ${f}\n`).join("") +
501
509
  ` The policy as generated does not hold on this machine, so no agent runs under it. ` +
@@ -11,6 +11,7 @@
11
11
  * price printed next to the thing it buys.
12
12
  */
13
13
  import { injectKeysOf } from "../keys.js";
14
+ import { sandboxesOf } from "../sandboxes/index.js";
14
15
  import { coversHost as covers } from "../srt.js";
15
16
 
16
17
  export function injectWarnings(config, roles) {
@@ -27,14 +28,24 @@ export function injectWarnings(config, roles) {
27
28
  if (inject.length === 0) continue;
28
29
  const allowed = r.network ?? config.allowedDomains ?? [];
29
30
 
30
- if (process.platform !== "linux")
31
+ // Which sandbox masks is the sandbox's to say (sandboxes/, `masks`): on macOS
32
+ // only tartaros does, so a provider list without it cannot run the role here.
33
+ const names = sandboxesOf(config).map((s) => s.name);
34
+ if (process.platform === "darwin" && !names.includes("tartaros"))
31
35
  out.push({
32
36
  kind: "inject-platform",
33
37
  role: r.name,
34
- headline: `${r.name}: key_mode = "inject" is not available on ${process.platform} — it is Linux-only for now`,
35
- detail: `On this machine the role will not run. Masked injection on macOS is tracked upstream ` +
36
- `(anthropics/sandbox-runtime#593) and enables once that lands. Run this role on Linux, or ` +
37
- `use "env"/"run-file" here.`,
38
+ headline: `${r.name}: key_mode = "inject" needs tartaros on macOS — [sandbox] provider is ${names.join(", ")}`,
39
+ detail: `On macOS sandbox-runtime does not mask credentials yet (anthropics/sandbox-runtime#593), so ` +
40
+ `this role will not run here. Add tartaros: [sandbox] provider = ["tartaros", "srt"]. Or run it on ` +
41
+ `Linux, or use "env"/"run-file" here.`,
42
+ });
43
+ else if (process.platform !== "linux" && process.platform !== "darwin")
44
+ out.push({
45
+ kind: "inject-platform",
46
+ role: r.name,
47
+ headline: `${r.name}: key_mode = "inject" is not available on ${process.platform}`,
48
+ detail: `No sandbox seisin runs here masks credentials. Run this role on Linux or macOS, or use "env"/"run-file".`,
38
49
  });
39
50
 
40
51
  for (const c of inject) {
package/src/keys.js CHANGED
@@ -30,8 +30,8 @@
30
30
  * policy holds a reviewable reference instead of a path, and every resolution
31
31
  * is logged without its value (`event: "key"`, see commands/run.js) — but it is not "the agent never sees it". That
32
32
  * is `inject`, which costs terminating the role's TLS with a CA of your own and
33
- * is Linux-only for now (see `injectGate` and `injectKeysOf` below); off Linux
34
- * `modeOf` holds it, with the reason in the message.
33
+ * needs a sandbox that masks credentials: srt on Linux, tartaros on Linux and
34
+ * macOS (see `injectGate` below, and `masks` on each sandbox).
35
35
  *
36
36
  * ## Why the value is never written down
37
37
  *
@@ -522,23 +522,22 @@ export function modeOf(config, role, entry) {
522
522
  }
523
523
 
524
524
  /**
525
- * `inject` is the mode where the agent never sees the value: the runtime hands
526
- * the sandbox a sentinel and substitutes the real value on egress to the
527
- * credential's declared hosts. It is Linux-only in this version — masked
528
- * delivery is validated on the Linux backend; the macOS path is not enabled
529
- * yet and is tracked upstream, where the fit for it is a credential computed
530
- * per run rather than read from the environment
531
- * (anthropics/sandbox-runtime#593). Until that lands, macOS `inject` is held
532
- * rather than offered, with the alternatives in the message.
525
+ * `inject` is the mode where the agent never sees the value: the sandbox hands
526
+ * the agent a sentinel and substitutes the real value on egress to the
527
+ * credential's declared hosts. Which sandbox can do that is the sandbox's to
528
+ * say (`masks` in sandboxes/): srt on Linux, tartaros on Linux and macOS
529
+ * (srt's macOS path is tracked upstream, anthropics/sandbox-runtime#593). A
530
+ * sandbox that does not mask is never handed the role, because the real value
531
+ * is in the environment it is started with. Here only the platforms where no
532
+ * sandbox masks are refused outright.
533
533
  */
534
534
  function injectGate(role) {
535
- if (process.platform === "linux") return;
535
+ if (process.platform === "linux" || process.platform === "darwin") return;
536
536
  throw new Error(
537
- `roles.${role.name}: key_mode = "inject" is not available on ${process.platform} yet.\n` +
538
- ` It is Linux-only for now; masked injection on macOS is tracked upstream ` +
539
- `(anthropics/sandbox-runtime#593) and enables once that lands.\n` +
540
- ` Use "env" or "run-file" here, or run this role on Linux. ` +
541
- `See https://github.com/carlostapiacl/seisin/blob/main/docs/decisions.md.`);
537
+ `roles.${role.name}: key_mode = "inject" is not available on ${process.platform}.\n` +
538
+ ` No sandbox seisin runs here masks credentials: srt does on Linux, tartaros on Linux and macOS.\n` +
539
+ ` Use "env" or "run-file" here, or run this role on Linux or macOS. ` +
540
+ `See https://github.com/carlostapiacl/seisin/blob/main/docs/keys.md.`);
542
541
  }
543
542
 
544
543
  /**
@@ -85,6 +85,9 @@ export function commandSandbox(name, command, check = null, { tagsDenials = fals
85
85
  assess,
86
86
  unmet: (settings, ctx) => assess(settings, ctx).unmet,
87
87
  wrap: (bin, file, _settings, inner) => [bin, ...withPolicy(command.slice(1), file), ...inner],
88
+ // A sandbox of your own is not known to mask credentials, and it is started
89
+ // with the real value in its environment: an inject role is never its.
90
+ masks: () => `masked credentials (key_mode = "inject"): ${name} is not known to mask them, and it would start with the real value in its environment`,
88
91
  // Declared, not detected: `tags_denials = true` says its Seatbelt denials
89
92
  // carry sandbox-runtime's tag (docs/sandboxes.md). Without it, on macOS,
90
93
  // none of the kernel's refusals under it reach the log.
@@ -71,10 +71,14 @@ export function weigh(config, settings, { platform = process.platform, role = nu
71
71
  // `browsers`: a Mach service to register. srt's schema has no field for it
72
72
  // (srt-mach.mjs adds it); tartaros takes it as network.allowMachRegister
73
73
  // (adapt). A sandbox that is given it neither way cannot hold the role.
74
+ // `inject`: the real value is in the environment the sandbox starts with,
75
+ // so only one that masks credentials may be handed the role.
76
+ const masked = (own.credentials?.envVars ?? []).length > 0;
77
+ const masking = masked ? (sandbox.masks ? sandbox.masks(platform) : `masked credentials: ${sandbox.name} does not mask them`) : null;
74
78
  const browser = register.length && !sandbox.machRegister
75
79
  ? [`a Mach service to register (${register.join(", ")}, for browsers): ${sandbox.name} is not given it, only srt and tartaros are`]
76
80
  : [];
77
- return { sandbox, bin, unmet: [...unmet, ...browser], warnings, missing: bin ? null : sandbox.missing() };
81
+ return { sandbox, bin, unmet: [...(masking ? [masking] : []), ...unmet, ...browser], warnings, missing: bin ? null : sandbox.missing() };
78
82
  });
79
83
  const chosen = options.find((o) => o.bin && o.unmet.length === 0) ?? null;
80
84
  return { chosen, options };
@@ -150,6 +150,9 @@ export const srt = {
150
150
  locate: () => resolveSrt(),
151
151
  missing: () => `sandbox runtime not found. ${installHint()}`,
152
152
  unmet: () => [],
153
+ // Masked credentials: on Linux; on macOS not yet (anthropics/sandbox-runtime#593).
154
+ masks: (platform) => platform === "linux" ? null
155
+ : `masked credentials (key_mode = "inject") on ${platform}: sandbox-runtime does not mask them there yet (anthropics/sandbox-runtime#593); tartaros does`,
153
156
  // `--` before the command, and it is not cosmetic. Without it the runtime's
154
157
  // own argument parser reaches into what the agent was invoked with and eats
155
158
  // anything resembling one of its flags: `claude --settings`, `claude -c`.
@@ -95,6 +95,9 @@ export const tartaros = {
95
95
  locate: () => resolveTartaros(),
96
96
  missing: () => `tartaros not found: neither ${TARTAROS_PACKAGE}, which installs with seisin unless optional dependencies are left out, nor tartaros on PATH.`,
97
97
  asksFile: true,
98
+ // Masked credentials: tartaros says whether it masks them, through explain
99
+ // (0.1.0-alpha.16 and later do; earlier ones refuse the field).
100
+ masks: () => null,
98
101
  adapt: tartarosSettings,
99
102
  machRegister: true,
100
103
  assess: (_settings, { bin, file, platform } = {}) => tartarosAssess(bin, file, { platform }),