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/keys.js
CHANGED
|
@@ -28,10 +28,10 @@
|
|
|
28
28
|
* still reaches the process and the agent can still read it. That is a strict
|
|
29
29
|
* improvement over a plaintext file — the secret stops living on disk, the
|
|
30
30
|
* policy holds a reviewable reference instead of a path, and every resolution
|
|
31
|
-
* is logged without its value — but it is not "the agent never sees it". That
|
|
32
|
-
* is `inject`,
|
|
33
|
-
*
|
|
34
|
-
*
|
|
31
|
+
* is logged without its value (`event: "key"`, see commands/run.js) — but it is not "the agent never sees it". That
|
|
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.
|
|
35
35
|
*
|
|
36
36
|
* ## Why the value is never written down
|
|
37
37
|
*
|
|
@@ -41,6 +41,7 @@
|
|
|
41
41
|
*/
|
|
42
42
|
import { spawnSync } from "node:child_process";
|
|
43
43
|
import { readFileSync } from "node:fs";
|
|
44
|
+
import { fileURLToPath } from "node:url";
|
|
44
45
|
import { resolve, isAbsolute, delimiter } from "node:path";
|
|
45
46
|
import { BASE, DEFAULTS } from "./env.js";
|
|
46
47
|
import { SOCK_ENV } from "./spool.js";
|
|
@@ -197,7 +198,8 @@ const unterminate = (v) => v.replace(/\r?\n$/, "");
|
|
|
197
198
|
function fromJson(obj, key, ref) {
|
|
198
199
|
let v = Object.prototype.hasOwnProperty.call(obj, key) ? obj[key] : undefined;
|
|
199
200
|
if (v === undefined && key.includes(".")) {
|
|
200
|
-
v = key.split(".").reduce((o, k) =>
|
|
201
|
+
v = key.split(".").reduce((o, k) =>
|
|
202
|
+
(o && typeof o === "object" && Object.hasOwn(o, k) ? o[k] : undefined), obj);
|
|
201
203
|
}
|
|
202
204
|
if (v === undefined) {
|
|
203
205
|
const top = Object.keys(obj).slice(0, 8).join(", ");
|
|
@@ -251,7 +253,7 @@ function dotEnvValue(raw, ref, key) {
|
|
|
251
253
|
const rest = v.slice(end + 1).trim();
|
|
252
254
|
if (rest && !rest.startsWith("#"))
|
|
253
255
|
throw new Error(
|
|
254
|
-
`key "${ref}": ${key}= has text after its closing quote
|
|
256
|
+
`key "${ref}": ${key}= has text after its closing quote.\n` +
|
|
255
257
|
` Loaders disagree on what that value is. Quote the whole value.`);
|
|
256
258
|
const inner = v.slice(1, end);
|
|
257
259
|
// Inside double quotes dotenv turns \n and \r into the characters — the
|
|
@@ -316,7 +318,7 @@ function fromDotEnv(text, key, ref) {
|
|
|
316
318
|
* differently depending on where you were standing when you typed the command
|
|
317
319
|
* is a key that works in your shell and fails in the agent's.
|
|
318
320
|
*/
|
|
319
|
-
export function readFileRef(entry, root = ".") {
|
|
321
|
+
export function readFileRef(entry, root = ".", { exact = false } = {}) {
|
|
320
322
|
const hash = entry.ref.lastIndexOf("#");
|
|
321
323
|
const path = hash === -1 ? entry.ref : entry.ref.slice(0, hash);
|
|
322
324
|
const key = hash === -1 ? null : entry.ref.slice(hash + 1);
|
|
@@ -331,7 +333,11 @@ export function readFileRef(entry, root = ".") {
|
|
|
331
333
|
}
|
|
332
334
|
let value;
|
|
333
335
|
if (key === null) {
|
|
334
|
-
|
|
336
|
+
// `exact` (run-file) hands the file over byte for byte: the file is the
|
|
337
|
+
// value, and an ssh key without its last newline is an invalid ssh key.
|
|
338
|
+
if (unterminate(text) === "")
|
|
339
|
+
throw new Error(`key "${entry.raw}": ${full} is empty. An empty credential is not a credential.`);
|
|
340
|
+
value = exact ? text : unterminate(text);
|
|
335
341
|
} else {
|
|
336
342
|
// Sniffed, not taken from the extension. The file that motivated this was
|
|
337
343
|
// named `.txt` and the one before it `.env`; the name is a hint and the
|
|
@@ -354,8 +360,8 @@ export function readFileRef(entry, root = ".") {
|
|
|
354
360
|
* - **What seisin itself injects.** `SEISIN_ROLE` is how the hook inside the
|
|
355
361
|
* sandbox learns which role it is. A key called that would let a policy tell
|
|
356
362
|
* the hook it is somebody else — privilege confusion written in the one
|
|
357
|
-
* file that is supposed to prevent it. Same for the config path
|
|
358
|
-
* audit socket.
|
|
363
|
+
* file that is supposed to prevent it. Same for the config path, the
|
|
364
|
+
* audit socket and the handoff chain.
|
|
359
365
|
* - **What the child needs to be a child.** `keys = ["keychain://path"]`
|
|
360
366
|
* derives `PATH`, and the measured result is the sandbox failing with
|
|
361
367
|
* `env: node: No such file or directory` — a config mistake that reads
|
|
@@ -367,13 +373,15 @@ export function readFileRef(entry, root = ".") {
|
|
|
367
373
|
* catches it and nobody debugs it from inside a sandbox.
|
|
368
374
|
*/
|
|
369
375
|
export const RESERVED_ENV = new Set([
|
|
370
|
-
|
|
376
|
+
// The last is handoff.js's CHAIN_ENV, spelled out: handoff.js reaches this
|
|
377
|
+
// module through owners.js, and importing it back would be a cycle.
|
|
378
|
+
"SEISIN_ROLE", "SEISIN_CONFIG", SOCK_ENV, "SEISIN_HANDOFF_CHAIN",
|
|
371
379
|
...BASE, ...Object.keys(DEFAULTS),
|
|
372
380
|
]);
|
|
373
381
|
|
|
374
382
|
/**
|
|
375
383
|
* Every delivered name a role's keys would claim — the variable, and for
|
|
376
|
-
* `
|
|
384
|
+
* `run-file` the `_FILE` that carries the path.
|
|
377
385
|
*
|
|
378
386
|
* Checked as a set because two keys resolving to one name is the other half of
|
|
379
387
|
* the same failure: `keys = ["T=a://x", "T=b://y"]` used to deliver the second
|
|
@@ -417,7 +425,7 @@ export function entriesOf(role) {
|
|
|
417
425
|
/**
|
|
418
426
|
* The three ways a resolved value can reach a role.
|
|
419
427
|
*
|
|
420
|
-
* The middle one is called `
|
|
428
|
+
* The middle one is called `run-file` and not `file`, which looks like a
|
|
421
429
|
* cosmetic choice and is not. The runtime has a field named
|
|
422
430
|
* `credentials.files`, it is the one that looks like seisin's `keys` (both take
|
|
423
431
|
* paths), and on macOS it does **not** mask — it makes the file unreadable, so
|
|
@@ -427,12 +435,40 @@ export function entriesOf(role) {
|
|
|
427
435
|
* runtime. A mode called `file` sitting ten centimetres from a runtime field
|
|
428
436
|
* called `files` that fails that way is a trap with a date on it.
|
|
429
437
|
*
|
|
430
|
-
* `
|
|
431
|
-
*
|
|
432
|
-
*
|
|
433
|
-
*
|
|
438
|
+
* `run-file` says what happens: the value lands in a file in the run directory
|
|
439
|
+
* (rundir.js) and goes when the run does, which `file` does not say at all. It
|
|
440
|
+
* was `scratch` until 0.6.0, from when the file sat in shared scratch space;
|
|
441
|
+
* since the 2026-09-23 fix it has not, and a name that says the place it left
|
|
442
|
+
* is the leak that fix closed, said again. RETIRED_MODES refuses the old name.
|
|
434
443
|
*/
|
|
435
|
-
export const MODES = ["env", "
|
|
444
|
+
export const MODES = ["env", "run-file", "inject"];
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* Mode names that were renamed, and the name now. Refused with that sentence
|
|
448
|
+
* wherever a mode is read (config.js for the policy, modeOf for a role built by
|
|
449
|
+
* hand), never read as the new one: a policy says one thing in one word.
|
|
450
|
+
*/
|
|
451
|
+
export const RETIRED_MODES = { scratch: "run-file" };
|
|
452
|
+
|
|
453
|
+
/** The error for a mode that is not one, with the rename when there was one. */
|
|
454
|
+
export function unknownMode(where, setting, mode) {
|
|
455
|
+
const now = Object.hasOwn(RETIRED_MODES, mode) ? RETIRED_MODES[mode] : null;
|
|
456
|
+
if (now)
|
|
457
|
+
return new Error(
|
|
458
|
+
`${where} ${setting} = "${mode}" was renamed "${now}" in seisin 0.6.0 — the file is ` +
|
|
459
|
+
`written to the run directory, not to scratch.\n` +
|
|
460
|
+
` Change it to ${setting} = "${now}". Nothing else changes: same file, same _FILE variable.`);
|
|
461
|
+
return new Error(
|
|
462
|
+
`${where} ${setting} = "${mode}" is not a delivery mode. Known: ${MODES.join(", ")}.` +
|
|
463
|
+
// The one wrong answer named, because it is the word everybody
|
|
464
|
+
// reaches for and it is one letter from a runtime field that fails
|
|
465
|
+
// silently. Guessing costs a debugging session; saying so costs a line.
|
|
466
|
+
(mode === "file"
|
|
467
|
+
? `\n "file" is not one of them on purpose — the runtime has a "credentials.files" that ` +
|
|
468
|
+
`takes paths and, on macOS, makes them unreadable instead of masking. The mode that ` +
|
|
469
|
+
`hands a role a file is "run-file".`
|
|
470
|
+
: ""));
|
|
471
|
+
}
|
|
436
472
|
|
|
437
473
|
/**
|
|
438
474
|
* How a role receives its reference keys, and where that is written.
|
|
@@ -453,9 +489,18 @@ export const MODES = ["env", "scratch", "inject"];
|
|
|
453
489
|
* because the two available answers differ in what the agent can walk away
|
|
454
490
|
* with, and picking one for somebody is picking how much a leak costs them.
|
|
455
491
|
*/
|
|
492
|
+
/**
|
|
493
|
+
* The delivery mode a role declares for an entry — the role's `key_mode` first,
|
|
494
|
+
* else the provider's `mode`, else null. The precedence rule (role over
|
|
495
|
+
* provider) lives here so {@link modeOf} and {@link injectKeysOf} cannot drift.
|
|
496
|
+
*/
|
|
497
|
+
function declaredMode(config, role, entry) {
|
|
498
|
+
return role.keyMode ?? config.keyProviders?.[entry.scheme]?.mode ?? null;
|
|
499
|
+
}
|
|
500
|
+
|
|
456
501
|
export function modeOf(config, role, entry) {
|
|
457
502
|
const provider = config.keyProviders?.[entry.scheme];
|
|
458
|
-
const mode = role
|
|
503
|
+
const mode = declaredMode(config, role, entry);
|
|
459
504
|
if (mode === null) {
|
|
460
505
|
// A built-in scheme has no `[keys.providers.…]` table to put a default in —
|
|
461
506
|
// declaring one is refused, because a scheme cannot mean two things. So
|
|
@@ -468,31 +513,125 @@ export function modeOf(config, role, entry) {
|
|
|
468
513
|
throw new Error(
|
|
469
514
|
`roles.${role.name}: key "${entry.raw}" has no delivery mode.\n` +
|
|
470
515
|
` Declare one beside the key: [roles.${role.name}] key_mode = "env".${second}\n` +
|
|
471
|
-
` "env" passes the value as ${entry.name}; "
|
|
516
|
+
` "env" passes the value as ${entry.name}; "run-file" writes it to a file in the ` +
|
|
472
517
|
`run's private directory, grants that one path, and removes it when the run ends.`);
|
|
473
518
|
}
|
|
474
|
-
if (mode === "inject")
|
|
475
|
-
|
|
476
|
-
`roles.${role.name}: key_mode = "inject" is not implemented.\n` +
|
|
477
|
-
` It is the one mode where the agent never sees the value, and it is not free: the ` +
|
|
478
|
-
`runtime only masks a credential when the role's TLS is terminated with a CA of ` +
|
|
479
|
-
`seisin's own — MITM over all of that role's traffic, not just the host holding the ` +
|
|
480
|
-
`secret. It is refused rather than half-available.\n` +
|
|
481
|
-
` Use "env" or "file" today. See docs/decisions.md.`);
|
|
482
|
-
if (!MODES.includes(mode))
|
|
483
|
-
throw new Error(
|
|
484
|
-
`roles.${role.name}: key_mode = "${mode}" is not a delivery mode. Known: ${MODES.join(", ")}.` +
|
|
485
|
-
// The one wrong answer named, because it is the word everybody
|
|
486
|
-
// reaches for and it is one letter from a runtime field that fails
|
|
487
|
-
// silently. Guessing costs a debugging session; saying so costs a line.
|
|
488
|
-
(mode === "file"
|
|
489
|
-
? `\n "file" is not one of them on purpose — the runtime has a "credentials.files" that ` +
|
|
490
|
-
`takes paths and, on macOS, makes them unreadable instead of masking. The mode that ` +
|
|
491
|
-
`hands a role a file is "scratch".`
|
|
492
|
-
: ""));
|
|
519
|
+
if (mode === "inject") injectGate(role);
|
|
520
|
+
if (!MODES.includes(mode)) throw unknownMode(`roles.${role.name}:`, "key_mode", mode);
|
|
493
521
|
return mode;
|
|
494
522
|
}
|
|
495
523
|
|
|
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.
|
|
533
|
+
*/
|
|
534
|
+
function injectGate(role) {
|
|
535
|
+
if (process.platform === "linux") return;
|
|
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.`);
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/**
|
|
545
|
+
* The role's `inject` keys as `{ entry, name, hosts }`, the single source both
|
|
546
|
+
* the settings (srt.js) and `check` read. Throws when the policy is internally
|
|
547
|
+
* inconsistent — an inject key with no `inject_hosts` entry, or an
|
|
548
|
+
* `inject_hosts` entry naming a key that is not injected — because either one
|
|
549
|
+
* is a masked credential that silently injects nowhere, or a host list that
|
|
550
|
+
* guards nothing. Platform is NOT checked here: this answers "what does the
|
|
551
|
+
* policy say", and {@link injectGate} answers "can we run it here".
|
|
552
|
+
*/
|
|
553
|
+
export function injectKeysOf(config, role) {
|
|
554
|
+
const refs = entriesOf(role).filter((e) => e.kind === "ref");
|
|
555
|
+
const inject = refs.filter((e) => declaredMode(config, role, e) === "inject");
|
|
556
|
+
const hosts = role.injectHosts ?? {};
|
|
557
|
+
const out = inject.map((entry) => {
|
|
558
|
+
const h = hosts[entry.name];
|
|
559
|
+
if (!h)
|
|
560
|
+
throw new Error(
|
|
561
|
+
`roles.${role.name}: key "${entry.raw}" is injected but has no hosts.\n` +
|
|
562
|
+
` A masked credential needs the host its token authenticates to:\n` +
|
|
563
|
+
` [roles.${role.name}.inject_hosts]\n ${entry.name} = ["api.example.com"]`);
|
|
564
|
+
return { entry, name: entry.name, hosts: h };
|
|
565
|
+
});
|
|
566
|
+
const named = new Set(out.map((c) => c.name));
|
|
567
|
+
for (const name of Object.keys(hosts))
|
|
568
|
+
if (!named.has(name))
|
|
569
|
+
throw new Error(
|
|
570
|
+
`roles.${role.name}.inject_hosts.${name}: no injected key delivers ${name}.\n` +
|
|
571
|
+
` inject_hosts names the hosts a masked credential may reach; ${name} is not one. ` +
|
|
572
|
+
`Add a key that delivers it with key_mode = "inject", or remove the entry.`);
|
|
573
|
+
return out;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/**
|
|
577
|
+
* How long a provider's stdout is still read after it exits.
|
|
578
|
+
*
|
|
579
|
+
* Bytes written just before exit may still be in the pipe. A descendant that
|
|
580
|
+
* kept the pipe open is not the provider, and its silence is not an answer
|
|
581
|
+
* worth waiting a minute for.
|
|
582
|
+
*/
|
|
583
|
+
export const PROVIDER_DRAIN_MS = 250;
|
|
584
|
+
|
|
585
|
+
const HELPER = fileURLToPath(new URL("./provider-run.js", import.meta.url));
|
|
586
|
+
|
|
587
|
+
/**
|
|
588
|
+
* Run the provider through provider-run.js, which returns when the provider
|
|
589
|
+
* exits rather than when every holder of its stdout lets go, and stops it with
|
|
590
|
+
* SIGKILL at the deadline. The outer call has a deadline of its own, a little
|
|
591
|
+
* later, so a helper that never answers cannot hold the run either.
|
|
592
|
+
*/
|
|
593
|
+
function spawnProvider(argv, { env, timeoutMs }) {
|
|
594
|
+
const r = spawnSync(process.execPath,
|
|
595
|
+
[HELPER, String(timeoutMs), String(PROVIDER_MAX_BYTES), String(PROVIDER_DRAIN_MS), ...argv], {
|
|
596
|
+
encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], env,
|
|
597
|
+
timeout: timeoutMs + 5_000, killSignal: "SIGKILL", maxBuffer: PROVIDER_MAX_BYTES * 2 + 4096,
|
|
598
|
+
});
|
|
599
|
+
let got = null;
|
|
600
|
+
try { got = JSON.parse(r.stdout ?? ""); } catch {}
|
|
601
|
+
if (!got) return { status: null, stdout: "", errBytes: 0, timedOut: true };
|
|
602
|
+
if (got.error) return { error: new Error(`spawn ${got.error}`) };
|
|
603
|
+
return {
|
|
604
|
+
status: got.status, stdout: Buffer.from(got.out, "base64").toString("utf8"),
|
|
605
|
+
errBytes: got.errBytes, timedOut: got.timedOut, tooBig: got.tooBig,
|
|
606
|
+
};
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/** What a runner of the caller's own returned, in the shape spawnProvider returns. */
|
|
610
|
+
function normalise(r) {
|
|
611
|
+
return {
|
|
612
|
+
...r,
|
|
613
|
+
timedOut: r.error?.code === "ETIMEDOUT",
|
|
614
|
+
tooBig: r.error?.code === "ENOBUFS",
|
|
615
|
+
errBytes: Buffer.byteLength(r.stderr ?? ""),
|
|
616
|
+
};
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/**
|
|
620
|
+
* The value as a provider or a file gave it, refused if it cannot be delivered.
|
|
621
|
+
*
|
|
622
|
+
* A NUL byte cannot be in an environment variable, and Node's refusal to set
|
|
623
|
+
* one quotes the whole string — the value, in the terminal. Checked here, by a
|
|
624
|
+
* message that does not carry it.
|
|
625
|
+
*/
|
|
626
|
+
function deliverable(entry, value) {
|
|
627
|
+
if (value.includes("\0"))
|
|
628
|
+
throw new Error(
|
|
629
|
+
`key "${entry.raw}": the value contains a NUL byte, which cannot be delivered.\n` +
|
|
630
|
+
` The value is not shown. A credential with a NUL in it is binary; store it encoded ` +
|
|
631
|
+
`(base64) and decode it where it is used.`);
|
|
632
|
+
return value;
|
|
633
|
+
}
|
|
634
|
+
|
|
496
635
|
/**
|
|
497
636
|
* Run a provider and hand back what it printed.
|
|
498
637
|
*
|
|
@@ -509,14 +648,19 @@ export function modeOf(config, role, entry) {
|
|
|
509
648
|
* cannot measure and says ok lies more than one that says no. A role that
|
|
510
649
|
* starts without the credential it declared fails later, further away, and
|
|
511
650
|
* looking like something else.
|
|
512
|
-
* - **The value is not in the error.**
|
|
513
|
-
*
|
|
514
|
-
*
|
|
515
|
-
*
|
|
651
|
+
* - **The value is not in the error.** Neither stream of a failed provider is
|
|
652
|
+
* shown. Taking stdout out of stderr was not enough: a provider that printed
|
|
653
|
+
* the value only on stderr, or printed it beside other text on stdout, got
|
|
654
|
+
* it into the terminal through the error meant to explain it. The error
|
|
655
|
+
* says the exit status, how much was written to stderr, and the command to
|
|
656
|
+
* run by hand to read it.
|
|
657
|
+
*
|
|
658
|
+
* `exact` hands the value back as printed. Without it exactly one trailing
|
|
659
|
+
* newline comes off, which is right for a variable and wrong for a file.
|
|
516
660
|
*/
|
|
517
|
-
export function resolveRef(entry, provider, { run = spawnSync, root = ".", config = null, env = process.env } = {}) {
|
|
661
|
+
export function resolveRef(entry, provider, { run = spawnSync, root = ".", config = null, env = process.env, exact = false, timeoutMs = PROVIDER_TIMEOUT_MS } = {}) {
|
|
518
662
|
if (provider?.builtin === "file" || (provider === undefined && entry.scheme === "file"))
|
|
519
|
-
return readFileRef(entry, root);
|
|
663
|
+
return deliverable(entry, readFileRef(entry, root, { exact }));
|
|
520
664
|
const argv = provider.command.map((part) => part.replaceAll("{ref}", entry.ref));
|
|
521
665
|
/**
|
|
522
666
|
* Found where no role can write, and handed the same PATH.
|
|
@@ -530,43 +674,46 @@ export function resolveRef(entry, provider, { run = spawnSync, root = ".", confi
|
|
|
530
674
|
* caller's own, the argv is handed over as given: finding the program is part
|
|
531
675
|
* of spawning it, and whoever replaced the spawn owns that too.
|
|
532
676
|
*/
|
|
533
|
-
const opts = { encoding: "utf8", timeout:
|
|
677
|
+
const opts = { encoding: "utf8", timeout: timeoutMs, killSignal: "SIGKILL", maxBuffer: PROVIDER_MAX_BYTES };
|
|
534
678
|
if (config && run === spawnSync) {
|
|
535
679
|
argv[0] = resolveExecutable(argv[0], config, env);
|
|
536
680
|
opts.env = { ...env, PATH: trustedPath(config, env).join(delimiter) };
|
|
537
681
|
}
|
|
538
|
-
const r = run
|
|
539
|
-
|
|
682
|
+
const r = run === spawnSync
|
|
683
|
+
? spawnProvider(argv, { env: opts.env, timeoutMs })
|
|
684
|
+
: normalise(run(argv[0], argv.slice(1), opts));
|
|
685
|
+
if (r.timedOut)
|
|
540
686
|
throw new Error(
|
|
541
687
|
`key "${entry.raw}": the ${entry.scheme} provider (${argv[0]}) did not answer in ` +
|
|
542
|
-
`${
|
|
688
|
+
`${timeoutMs / 1000} s and was killed (SIGKILL).\n` +
|
|
543
689
|
` A provider that waits on a prompt nobody sees would otherwise hold the run forever.`);
|
|
690
|
+
if (r.tooBig)
|
|
691
|
+
throw new Error(
|
|
692
|
+
`key "${entry.raw}": the ${entry.scheme} provider (${argv[0]}) printed more than ` +
|
|
693
|
+
`${PROVIDER_MAX_BYTES / 1024 / 1024} MiB on stdout and was stopped.\n` +
|
|
694
|
+
` A credential is bytes, not megabytes: check that the command prints one value.`);
|
|
544
695
|
if (r.error)
|
|
545
696
|
throw new Error(
|
|
546
697
|
`key "${entry.raw}": could not run the ${entry.scheme} provider (${argv[0]}): ${r.error.message}`);
|
|
547
698
|
if (r.status !== 0) {
|
|
548
|
-
// Whatever it printed on stdout is taken out of stderr before stderr is
|
|
549
|
-
// shown: a provider that echoes the secret to both and then fails would
|
|
550
|
-
// otherwise put it in the terminal through the error meant to explain it.
|
|
551
|
-
const leaked = (r.stdout ?? "").trim();
|
|
552
|
-
const clean = leaked.length >= 4 ? (r.stderr ?? "").split(leaked).join("‹redacted›") : (r.stderr ?? "");
|
|
553
|
-
const said = clean.trim().split("\n").slice(0, 3).map((l) => l.slice(0, 200)).join("\n ");
|
|
554
699
|
throw new Error(
|
|
555
|
-
`key "${entry.raw}": the ${entry.scheme} provider exited ${r.status ?? "on a signal"}
|
|
556
|
-
(
|
|
557
|
-
|
|
700
|
+
`key "${entry.raw}": the ${entry.scheme} provider exited ${r.status ?? "on a signal"}` +
|
|
701
|
+
(r.errBytes ? ` and wrote ${r.errBytes} bytes to stderr` : "") + `.\n` +
|
|
702
|
+
` What it printed is not shown, because a provider can print the value. To read it, ` +
|
|
703
|
+
`run it yourself: ${argv.join(" ")}\n` +
|
|
704
|
+
` Nothing is substituted for a key that did not resolve — the run stops here rather ` +
|
|
558
705
|
`than starting a role without a credential it declared.`);
|
|
559
706
|
}
|
|
707
|
+
const printed = r.stdout ?? "";
|
|
560
708
|
// One trailing newline is the shell's, not the secret's: `security -w` and
|
|
561
709
|
// `op read` both add one, and a token with a newline welded to it fails
|
|
562
710
|
// authentication in a way that looks like a wrong token.
|
|
563
|
-
|
|
564
|
-
if (value === "")
|
|
711
|
+
if (unterminate(printed) === "")
|
|
565
712
|
throw new Error(
|
|
566
713
|
`key "${entry.raw}": the ${entry.scheme} provider succeeded and printed nothing.\n` +
|
|
567
714
|
` An empty credential is not a credential. Check the reference — most providers exit 0 ` +
|
|
568
715
|
`for a name they do not have.`);
|
|
569
|
-
return
|
|
716
|
+
return deliverable(entry, exact ? printed : unterminate(printed));
|
|
570
717
|
}
|
|
571
718
|
|
|
572
719
|
/**
|
|
@@ -576,12 +723,21 @@ export function resolveRef(entry, provider, { run = spawnSync, root = ".", confi
|
|
|
576
723
|
* provider runs. A config with three bad references should say so once, and it
|
|
577
724
|
* should say so without asking anyone's keychain for a password first.
|
|
578
725
|
*/
|
|
579
|
-
export function resolveKeys(config, role, opts = {}) {
|
|
726
|
+
export function resolveKeys(config, role, { record = () => {}, ...opts } = {}) {
|
|
580
727
|
opts = { root: config.root ?? ".", config, ...opts };
|
|
581
|
-
const refs = role.
|
|
728
|
+
const refs = entriesOf(role).filter((e) => e.kind === "ref");
|
|
582
729
|
const plan = refs.map((entry) => ({ entry, mode: modeOf(config, role, entry) }));
|
|
583
|
-
return plan.map((p) =>
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
730
|
+
return plan.map((p) => {
|
|
731
|
+
// `run-file` writes a file, and a file is handed over as the provider gave
|
|
732
|
+
// it; `env` drops the one trailing newline a shell adds. See resolveRef.
|
|
733
|
+
let value;
|
|
734
|
+
try {
|
|
735
|
+
value = resolveRef(p.entry, config.keyProviders[p.entry.scheme], { ...opts, exact: p.mode === "run-file" });
|
|
736
|
+
} catch (e) {
|
|
737
|
+
record({ entry: p.entry, mode: p.mode, ok: false });
|
|
738
|
+
throw e;
|
|
739
|
+
}
|
|
740
|
+
record({ entry: p.entry, mode: p.mode, ok: true });
|
|
741
|
+
return { ...p, value };
|
|
742
|
+
});
|
|
587
743
|
}
|
package/src/kinds.js
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
* clock, so it is the same answer on every screen that asks. It never changes
|
|
20
20
|
* what is allowed; it changes what is said about a refusal.
|
|
21
21
|
*/
|
|
22
|
+
import { homedir } from "node:os";
|
|
22
23
|
import { CREDENTIAL_HOMES } from "./grants.js";
|
|
23
24
|
|
|
24
25
|
/** Every kind, in the order a reader should deal with them, and the one line each says. */
|
|
@@ -205,3 +206,44 @@ export function kindsOf(targets, opts = {}) {
|
|
|
205
206
|
}
|
|
206
207
|
return out;
|
|
207
208
|
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* A refusal that is expected, and why — apart from the kinds above.
|
|
212
|
+
*
|
|
213
|
+
* The kinds name a path no role owns and the move that fits it. This is the
|
|
214
|
+
* other case: a path the agent program itself reaches for from inside the
|
|
215
|
+
* sandbox, that the boundary is right to refuse, and that no move fixes because
|
|
216
|
+
* nothing is wrong. Claude Code checks for plugin updates on start and writes
|
|
217
|
+
* the new version into `~/.claude/plugins/cache`; under seisin that write comes
|
|
218
|
+
* from inside the run and the kernel says no. Plugins run outside the sandbox,
|
|
219
|
+
* and they update there, from the Claude Code you start without seisin.
|
|
220
|
+
*
|
|
221
|
+
* Shown as a wall or as "denied repeatedly", it read as a role stuck on
|
|
222
|
+
* something to grant — and the only grant that would quiet it is a write into
|
|
223
|
+
* what Claude Code runs outside the sandbox. So every screen says it apart, as
|
|
224
|
+
* expected, and none counts it as friction. It changes no verdict.
|
|
225
|
+
*
|
|
226
|
+
* Only the cache: the rest of `~/.claude/plugins` (which plugins are installed,
|
|
227
|
+
* which marketplaces are trusted) is not housekeeping, and a write there stays
|
|
228
|
+
* an ordinary protected refusal, said in full.
|
|
229
|
+
*/
|
|
230
|
+
export const EXPECTED = {
|
|
231
|
+
plugins:
|
|
232
|
+
"Claude Code updating its plugins from inside the sandbox: plugins run outside it and update there, so the denial is expected — nothing to grant",
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* `{ kind, hint }` when the refusal of `target` is expected, else null.
|
|
237
|
+
*
|
|
238
|
+
* `target` as the log keeps it: absolute outside the repository. The home and
|
|
239
|
+
* `CLAUDE_CONFIG_DIR` are parameters so a test does not depend on the machine.
|
|
240
|
+
*/
|
|
241
|
+
export function expectedOf(target, { home = homedir(), env = process.env } = {}) {
|
|
242
|
+
const t = String(target ?? "").replace(/\\/g, "/");
|
|
243
|
+
const roots = [
|
|
244
|
+
env.CLAUDE_CONFIG_DIR,
|
|
245
|
+
home && `${home.replace(/\/+$/, "")}/.claude`,
|
|
246
|
+
"~/.claude",
|
|
247
|
+
].filter(Boolean).map((r) => `${String(r).replace(/\/+$/, "")}/plugins/cache`);
|
|
248
|
+
return roots.some((r) => t === r || t.startsWith(r + "/")) ? { kind: "plugins", hint: EXPECTED.plugins } : null;
|
|
249
|
+
}
|
package/src/lines.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/** Bounded newline framing for the audit channel and the stdio MCP server. */
|
|
2
|
+
export function lineReader(consume, { maxBytes = 1_000_000, onTooLong } = {}) {
|
|
3
|
+
let buffer = "", bytes = 0, discarding = false;
|
|
4
|
+
return (chunk) => {
|
|
5
|
+
let from = 0;
|
|
6
|
+
for (;;) {
|
|
7
|
+
const nl = chunk.indexOf("\n", from);
|
|
8
|
+
const part = nl === -1 ? chunk.slice(from) : chunk.slice(from, nl);
|
|
9
|
+
if (!discarding) {
|
|
10
|
+
bytes += Buffer.byteLength(part);
|
|
11
|
+
if (bytes > maxBytes) {
|
|
12
|
+
buffer = ""; bytes = 0; discarding = true;
|
|
13
|
+
onTooLong?.();
|
|
14
|
+
} else buffer += part;
|
|
15
|
+
}
|
|
16
|
+
if (nl === -1) return;
|
|
17
|
+
// Discard the entire oversized frame, including any later chunks, up
|
|
18
|
+
// to its newline. Its suffix is never a fresh request. A burst of many
|
|
19
|
+
// valid frames, however, is not one oversized frame.
|
|
20
|
+
if (!discarding && buffer.trim()) consume(buffer);
|
|
21
|
+
buffer = ""; bytes = 0; discarding = false;
|
|
22
|
+
from = nl + 1;
|
|
23
|
+
if (from === chunk.length) return;
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
}
|
package/src/log.js
CHANGED
|
@@ -306,7 +306,12 @@ export function alive(pid) {
|
|
|
306
306
|
*/
|
|
307
307
|
function reapable(lock, staleMs) {
|
|
308
308
|
let seen, mtime;
|
|
309
|
-
try { seen = readFileSync(lock, "utf8"); mtime = statSync(lock).mtimeMs; }
|
|
309
|
+
try { seen = readFileSync(lock, "utf8"); mtime = statSync(lock).mtimeMs; }
|
|
310
|
+
catch (e) {
|
|
311
|
+
// Only a disappeared lock is an immediate retry. An unreadable lock or
|
|
312
|
+
// a directory at this name otherwise spins forever, bypassing deadline.
|
|
313
|
+
return e.code === "ENOENT";
|
|
314
|
+
}
|
|
310
315
|
const pid = Number(seen.split(" ")[0]);
|
|
311
316
|
const named = Number.isInteger(pid) && pid > 0;
|
|
312
317
|
// A named holder that is alive is waited on, however long it takes: its age
|
|
@@ -499,7 +504,7 @@ export function read(file, { role, verdict, since, limit = 0 } = {}) {
|
|
|
499
504
|
}
|
|
500
505
|
const got = [];
|
|
501
506
|
for (const e of readEntries(seg)) {
|
|
502
|
-
if (e.event === "rotated") continue; //
|
|
507
|
+
if (e.event === "rotated") continue; // an internal marker, not activity
|
|
503
508
|
if (role && e.role !== role) continue;
|
|
504
509
|
if (verdict && e.verdict !== verdict) continue;
|
|
505
510
|
if (since && e.at < since) continue;
|