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 +2 -2
- package/package.json +2 -2
- package/src/canary.js +10 -0
- package/src/commands/run.js +8 -0
- package/src/inspect/inject.js +16 -5
- package/src/keys.js +15 -16
- package/src/sandboxes/command.js +3 -0
- package/src/sandboxes/index.js +5 -1
- package/src/sandboxes/srt.js +3 -0
- package/src/sandboxes/tartaros.js +3 -0
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
|
|
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
|
-
**
|
|
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.
|
|
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.
|
|
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
|
package/src/commands/run.js
CHANGED
|
@@ -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. ` +
|
package/src/inspect/inject.js
CHANGED
|
@@ -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
|
-
|
|
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"
|
|
35
|
-
detail: `On
|
|
36
|
-
`
|
|
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
|
-
*
|
|
34
|
-
* `
|
|
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
|
|
526
|
-
* the
|
|
527
|
-
* credential's declared hosts.
|
|
528
|
-
*
|
|
529
|
-
*
|
|
530
|
-
*
|
|
531
|
-
*
|
|
532
|
-
*
|
|
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}
|
|
538
|
-
`
|
|
539
|
-
`
|
|
540
|
-
`
|
|
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
|
/**
|
package/src/sandboxes/command.js
CHANGED
|
@@ -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.
|
package/src/sandboxes/index.js
CHANGED
|
@@ -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 };
|
package/src/sandboxes/srt.js
CHANGED
|
@@ -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 }),
|