@brutalsystems/muster 0.7.12 → 0.7.14
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 +264 -5
- package/dist/claude-policy.js +0 -2
- package/dist/config.js +64 -1
- package/dist/enforcement.js +5 -0
- package/dist/guard.js +103 -0
- package/dist/identity/claude.js +14 -1
- package/dist/identity-auth.js +66 -0
- package/dist/identity-cli.js +88 -0
- package/dist/identity-copy.js +124 -0
- package/dist/identity-store.js +101 -0
- package/dist/log.js +82 -4
- package/dist/mcp-tools.js +134 -0
- package/dist/muster.js +72 -96
- package/dist/registry.js +15 -2
- package/dist/requester-policy.js +106 -0
- package/dist/requester.js +59 -0
- package/dist/run.js +290 -47
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -413,11 +413,270 @@ No user-level runtime configuration is rewritten, and workspace-trust dialogs
|
|
|
413
413
|
are never accepted automatically.
|
|
414
414
|
|
|
415
415
|
Launch intent is fsynced to `~/.muster/launches.jsonl` before a runtime starts;
|
|
416
|
-
ready/failure outcomes follow.
|
|
417
|
-
requester, runtime, kind and host.
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
416
|
+
ready/failure outcomes follow. For a local requester the log contains the
|
|
417
|
+
**full prompt**, cwd, requester, runtime, kind and host. A non-local requester
|
|
418
|
+
gets metadata-only logging instead — no prompt text and no environment values,
|
|
419
|
+
see [Requester profiles and enrolments](#requester-profiles-and-enrolments)
|
|
420
|
+
below. Registry and task outputs also live under `~/.muster`, with private
|
|
421
|
+
file permissions. An abandoned `registry.lock` fails closed: verify no Muster
|
|
422
|
+
operation is running before removing that directory. There is no automatic
|
|
423
|
+
time-based lock theft. `launches.jsonl` rotates at 8 MiB: the file is renamed
|
|
424
|
+
to `launches.jsonl.1`, replacing any previous generation, and a fresh file is
|
|
425
|
+
opened at mode `0600`. One prior generation is kept; this bounds the file
|
|
426
|
+
without adding a retention policy nobody asked for.
|
|
427
|
+
|
|
428
|
+
### Containment levels
|
|
429
|
+
|
|
430
|
+
`--level` names a legal `--permissions` / `--sandbox` pair. It is a spelling,
|
|
431
|
+
not a second policy path, and naming both a level and either flag is an error.
|
|
432
|
+
|
|
433
|
+
| Level | Equivalent to |
|
|
434
|
+
| ------ | ------------------------------------------------------------------------------------- |
|
|
435
|
+
| `read` | `--permissions deny --sandbox read-only` |
|
|
436
|
+
| `work` | `--permissions auto --sandbox workspace-write` |
|
|
437
|
+
| `open` | `--permissions bypass --sandbox full-access` (still requires `allow_dangerous_flags`) |
|
|
438
|
+
|
|
439
|
+
`deny` with `workspace-write` or `full-access` has no level and stays available
|
|
440
|
+
through the flags.
|
|
441
|
+
|
|
442
|
+
Every launch also records how strongly its containment was imposed:
|
|
443
|
+
|
|
444
|
+
| `enforcement` | Meaning |
|
|
445
|
+
| ------------- | ---------------------------------------------------------------------------- |
|
|
446
|
+
| `kernel` | Codex or Claude Code with an OS sandbox configured and active. |
|
|
447
|
+
| `tool-policy` | OpenCode — rules the agent is asked to respect, with no kernel enforcement. |
|
|
448
|
+
| `none` | `full-access` — the sandbox is deliberately disabled. |
|
|
449
|
+
|
|
450
|
+
### Requester profiles and enrolments
|
|
451
|
+
|
|
452
|
+
A non-local caller — one reached through a transport such as Tin Can rather
|
|
453
|
+
than the local CLI or a local MCP client — is identified by an `(authority,
|
|
454
|
+
subject)` tuple supplied by whatever already authenticated it. That tuple must
|
|
455
|
+
be enrolled in `config.toml` and bound to a profile, or the request is
|
|
456
|
+
refused. Two additions to `configSchema`, following the existing record style
|
|
457
|
+
of `[projects]`, `[mcp_servers]` and `[plugins]`:
|
|
458
|
+
|
|
459
|
+
```toml
|
|
460
|
+
[requester_profiles.research]
|
|
461
|
+
allowed_roots = ["/Users/mikewilliams/Source/brutalsystems"]
|
|
462
|
+
level = "read"
|
|
463
|
+
min_enforcement = "kernel" # "tool-policy" to admit OpenCode here
|
|
464
|
+
env_allow = ["GH_TOKEN"]
|
|
465
|
+
env_defaults = { PATH = "/usr/bin:/bin", LANG = "en_US.UTF-8" }
|
|
466
|
+
identities = ["research"] # bounds which identities this requester may name
|
|
467
|
+
|
|
468
|
+
[[requesters]]
|
|
469
|
+
authority = "example.peer.v1"
|
|
470
|
+
subject = "<opaque authenticated key id>"
|
|
471
|
+
label = "m4pro"
|
|
472
|
+
profile = "research"
|
|
473
|
+
```
|
|
474
|
+
|
|
475
|
+
`[[requesters]]` is an array of tables rather than a record keyed by
|
|
476
|
+
`"authority:subject"`, so no separator can be ambiguous inside a subject. Two
|
|
477
|
+
enrolments with the same `(authority, subject)` are a configuration error
|
|
478
|
+
rather than a precedence rule. `label` is optional and never read for policy.
|
|
479
|
+
|
|
480
|
+
`allowed_roots` is **required** on a profile and must be non-empty — it is the
|
|
481
|
+
one key with no safe default, since the global `allowed_roots` defaults to
|
|
482
|
+
empty and empty is read as permitting anything the account can reach. Every
|
|
483
|
+
other key defaults to its narrowest value: `level` defaults to `read` and
|
|
484
|
+
excludes `open` (no remote request may disable the sandbox), `min_enforcement`
|
|
485
|
+
defaults to `kernel` (the default admits Codex and Claude Code; `tool-policy`
|
|
486
|
+
is what admits OpenCode), and `env_allow`/`env_defaults` default to empty.
|
|
487
|
+
Where a key appears in both, `env_defaults` wins — a profile that pins a value
|
|
488
|
+
and also allows the key means the value it pinned, not the accepting shell's. A
|
|
489
|
+
profile written as nothing but a required `allowed_roots` is therefore the
|
|
490
|
+
most restrictive profile expressible.
|
|
491
|
+
|
|
492
|
+
`identities` bounds which named identities (see [Agent
|
|
493
|
+
identities](#agent-identities) above) a non-local requester may launch under,
|
|
494
|
+
and it also defaults to its narrowest value — empty:
|
|
495
|
+
|
|
496
|
+
- **Empty or omitted** refuses any identity the request names — the launch
|
|
497
|
+
falls back to lending the operator's own ambient account, same as before
|
|
498
|
+
identities existed.
|
|
499
|
+
- **Exactly one** makes that identity the default: a request naming no
|
|
500
|
+
identity gets it anyway, and a request naming it explicitly gets the same
|
|
501
|
+
launch.
|
|
502
|
+
- **Several** require the request to choose: a request naming none of them is
|
|
503
|
+
refused rather than guessing, and a request naming one outside the list is
|
|
504
|
+
refused too.
|
|
505
|
+
|
|
506
|
+
A local requester has no profile and no ceiling here either — it may name any
|
|
507
|
+
identity that exists, exactly as it may reach any root.
|
|
508
|
+
|
|
509
|
+
A request naming no level resolves to its profile's level — never to the
|
|
510
|
+
receiving machine's own `config.permissions` / `config.sandbox` defaults,
|
|
511
|
+
which would let an unconfigured local default hand a remote caller more than
|
|
512
|
+
its profile grants.
|
|
513
|
+
|
|
514
|
+
**The `env_allow` trap.** A non-local launch's environment is composed from
|
|
515
|
+
nothing — an allowlist, not the accepting shell's environment filtered by a
|
|
516
|
+
denylist. That means a remote profile must list in `env_allow` (or supply in
|
|
517
|
+
`env_defaults`) everything the runtime's own toolchain needs — not just what
|
|
518
|
+
the agent's task needs. A missing entry does not present as a policy refusal;
|
|
519
|
+
it presents as a **broken runtime** — a launch that starts and then fails in
|
|
520
|
+
ways that look like a bug in Codex, Claude Code or OpenCode rather than a
|
|
521
|
+
missing environment variable. This is exactly the diagnosis-confusing failure
|
|
522
|
+
shape the rest of this feature avoids, so test a first remote profile with a
|
|
523
|
+
trivial launch before relying on it.
|
|
524
|
+
|
|
525
|
+
**`env_allow` is not the whole environment.** Two configured things still
|
|
526
|
+
contribute variables read from the accepting process's unfiltered environment,
|
|
527
|
+
exactly as for a local launch. A selected MCP server contributes its own declared
|
|
528
|
+
`env_vars` and its `bearer_token_env_var`. And on every OpenCode launch, each
|
|
529
|
+
configured `[opencode.provider]` contributes its API key, because the provider
|
|
530
|
+
configuration handed to the runtime is built from them — so a remote OpenCode
|
|
531
|
+
launch reaches this machine's model providers even with `env_allow = []`. That is the same principle applied
|
|
532
|
+
one level up: a configured credential authenticates, one that merely happens to
|
|
533
|
+
be present does not. It does mean a remote request may not choose which servers
|
|
534
|
+
or plugins run: naming `mcp` or `plugin` is refused as not expressible
|
|
535
|
+
remotely, and a remote launch gets this machine's configured `default_mcp` and
|
|
536
|
+
`default_plugins`. To withhold a credential from a remote requester, withhold
|
|
537
|
+
the server — a profile cannot withhold the variable alone. Provider keys cannot
|
|
538
|
+
be withheld per requester at all today; a machine that must not lend its model
|
|
539
|
+
providers should not enrol a remote requester for OpenCode.
|
|
540
|
+
|
|
541
|
+
**What this does and does not defend against.** Muster does not authenticate
|
|
542
|
+
its caller. A trusted local invoker that has already authenticated a remote
|
|
543
|
+
peer passes along the provenance it established, and Muster applies least
|
|
544
|
+
privilege to that provenance. This is not a defence against a compromised
|
|
545
|
+
same-user process: anything able to exec Muster can already rewrite the very
|
|
546
|
+
`config.toml` it reads, including its own enrolment and profile. The value is
|
|
547
|
+
in what a legitimately-authenticated remote peer is confined to, not in
|
|
548
|
+
resisting an attacker who is already running as the same local user.
|
|
549
|
+
|
|
550
|
+
**Stop and output are asymmetric.** A local operator may stop or read the
|
|
551
|
+
output of any launch on the machine, as before. A non-local requester may
|
|
552
|
+
`stop` or read `output` for only the launches it asked for — an attempt to act
|
|
553
|
+
on another requester's launch is refused. An entry with no recorded requester
|
|
554
|
+
(older data, or a local launch) reads as local and is therefore protected the
|
|
555
|
+
same as any other local launch. `list` is not scoped this way: every caller
|
|
556
|
+
sees every launch on the machine, so the asymmetry covers acting on a launch,
|
|
557
|
+
not seeing that it exists.
|
|
558
|
+
|
|
559
|
+
## Agent identities
|
|
560
|
+
|
|
561
|
+
An **identity** is a named, pre-authenticated template configuration for one
|
|
562
|
+
agent, stored under `~/.muster/identities/<name>` at mode `0700`. Launching
|
|
563
|
+
under one makes a launch state which account it runs as instead of silently
|
|
564
|
+
inheriting whatever the environment happened to imply.
|
|
565
|
+
|
|
566
|
+
```sh
|
|
567
|
+
muster setup-identity --identity work --agent claude --token-env CLAUDE_WORK_TOKEN
|
|
568
|
+
muster setup-identity --identity ci-bot --agent codex
|
|
569
|
+
muster setup-identity --identity research --agent opencode
|
|
570
|
+
```
|
|
571
|
+
|
|
572
|
+
`--token-env` names the environment variable holding a Claude Code token (from
|
|
573
|
+
`claude setup-token`) — never the token itself. It applies to Claude **only**,
|
|
574
|
+
and Claude **requires** it: Claude's keychain credential is keyed to the
|
|
575
|
+
template directory's own path, and a launch runs from a per-launch copy whose
|
|
576
|
+
path hashes to a different keychain service name, so a token is the only thing
|
|
577
|
+
that can authenticate a Claude identity launch. `muster run --identity` refuses
|
|
578
|
+
a Claude identity without one rather than spawning an agent that cannot log in.
|
|
579
|
+
Codex and OpenCode authenticate from an auth file inside the template, so
|
|
580
|
+
`--token-env` is refused for them — it could only ever add a way for the launch
|
|
581
|
+
to fail.
|
|
582
|
+
|
|
583
|
+
`setup-identity` creates the template (or reuses an existing one for the same
|
|
584
|
+
agent — it refuses to repurpose one that belongs to a different agent),
|
|
585
|
+
prints the **one-time login** for that agent to run yourself, and reports
|
|
586
|
+
whether a credential is already configured:
|
|
587
|
+
|
|
588
|
+
```sh
|
|
589
|
+
CLAUDE_CONFIG_DIR=~/.muster/identities/work claude setup-token # then export it
|
|
590
|
+
CODEX_HOME=~/.muster/identities/ci-bot codex login
|
|
591
|
+
OPENCODE_CONFIG_DIR=~/.muster/identities/research opencode auth login
|
|
592
|
+
```
|
|
593
|
+
|
|
594
|
+
Muster never performs that login itself — each is an interactive OAuth flow,
|
|
595
|
+
and a command that appeared to do it for you would be hiding a credential
|
|
596
|
+
prompt. Run the printed command, complete the flow, then check the result:
|
|
597
|
+
|
|
598
|
+
```sh
|
|
599
|
+
muster identities
|
|
600
|
+
```
|
|
601
|
+
|
|
602
|
+
Launch under a named identity with `--identity`:
|
|
603
|
+
|
|
604
|
+
```sh
|
|
605
|
+
muster run claude --identity work --cwd ~/projects/thing --prompt 'Ship it'
|
|
606
|
+
```
|
|
607
|
+
|
|
608
|
+
At launch, Muster copies a declared subset of that identity's files into a
|
|
609
|
+
fresh per-launch directory under `~/.muster/identities-live/<launchId>/` and
|
|
610
|
+
points the runtime's own configuration-location variable
|
|
611
|
+
(`CLAUDE_CONFIG_DIR`, `CODEX_HOME`, or `OPENCODE_CONFIG_DIR` +
|
|
612
|
+
`XDG_DATA_HOME`) at that copy — never at the template itself, so one launch
|
|
613
|
+
can never mutate another's credential.
|
|
614
|
+
|
|
615
|
+
The copy lives only as long as the launch. `muster stop` removes it directly,
|
|
616
|
+
and otherwise it is removed by the **first `muster list` after the entry
|
|
617
|
+
reaches a terminal status** (`exited`, `failed`, `stopped`, `unknown`). There
|
|
618
|
+
is no prune operation — an earlier draft of this documentation said the copy
|
|
619
|
+
was retained until the entry was pruned, and that was never what was built.
|
|
620
|
+
This matters for two reasons: an agent writes its own session transcript into
|
|
621
|
+
its configuration directory, so that transcript goes with the copy moments
|
|
622
|
+
after the launch ends (capture it while the launch is still running if you
|
|
623
|
+
need it), and resume/fork (#32) cannot assume a copy is still there.
|
|
624
|
+
|
|
625
|
+
Launching without `--identity` sets none of this: behaviour is exactly as
|
|
626
|
+
before, using the ambient environment.
|
|
627
|
+
|
|
628
|
+
Session discovery follows the same relocated path, so a Claude identity's
|
|
629
|
+
sessions are found even though `CLAUDE_CONFIG_DIR` no longer points at
|
|
630
|
+
`~/.claude` — this was previously refused outright as an unsupported
|
|
631
|
+
configuration.
|
|
632
|
+
|
|
633
|
+
Five things worth stating plainly, because each is a sharp edge:
|
|
634
|
+
|
|
635
|
+
1. **`muster identities` reports *configured*, never *valid*.** A keychain
|
|
636
|
+
item, an auth file or a set variable proves a credential exists — not that
|
|
637
|
+
it still works. Tokens expire and are revoked; there is no way to check
|
|
638
|
+
that without exercising the credential, and `identities` does not do that.
|
|
639
|
+
2. **The identity store holds credentials at rest** for Codex and OpenCode,
|
|
640
|
+
because their own logins write auth files directly into it. Directories are
|
|
641
|
+
`0700`, and deleting an identity deletes a credential — there is no
|
|
642
|
+
separate secret to revoke elsewhere first. Claude Code is the exception:
|
|
643
|
+
its credential lives in the macOS keychain (scoped to the identity's
|
|
644
|
+
directory path), or is supplied by `--token-env`, never as a file in the
|
|
645
|
+
identity directory.
|
|
646
|
+
3. **Use `claude setup-token` with `--token-env`, not `ANTHROPIC_API_KEY`.**
|
|
647
|
+
The OAuth token from `setup-token` bills the account's Claude subscription.
|
|
648
|
+
An `ANTHROPIC_API_KEY` silently moves the same account onto pay-per-token
|
|
649
|
+
API billing instead — same agent, very different bill, no error either way.
|
|
650
|
+
4. **A Claude identity's token is briefly written to disk during a launch.**
|
|
651
|
+
The tmux host serialises the whole launch specification — environment
|
|
652
|
+
included — to a temporary `launch.json` at mode `0600` and removes it once
|
|
653
|
+
the launch is recorded. That is the same treatment every other launch
|
|
654
|
+
variable gets, not special handling for this one, but it does mean a
|
|
655
|
+
long-lived subscription token passes through disk; a launch whose host
|
|
656
|
+
temp directory leaks (a crash before cleanup, a misconfigured `TMPDIR`)
|
|
657
|
+
leaves it there.
|
|
658
|
+
5. **A requester profile with no `identities` lends the operator's own
|
|
659
|
+
account** to that requester, because the launch falls back to the ambient
|
|
660
|
+
environment exactly as it did before identities existed. That is preserved
|
|
661
|
+
for compatibility, not a recommendation — an operator should set
|
|
662
|
+
`identities` on any requester profile whose access they actually care
|
|
663
|
+
about bounding.
|
|
664
|
+
|
|
665
|
+
A Claude launch under an identity also marks the launch's working directory
|
|
666
|
+
trusted *inside that launch's own copy* of the configuration, so a brand-new
|
|
667
|
+
directory starts as a registered session without anyone accepting a trust
|
|
668
|
+
dialog. This is distinct from the ordinary Claude prerequisite described
|
|
669
|
+
above: it only ever writes to the per-launch copy, never to the operator's own
|
|
670
|
+
`~/.claude.json`, and only applies to launches under an identity. A launch
|
|
671
|
+
without `--identity` still requires the operator to have trusted the directory
|
|
672
|
+
themselves beforehand.
|
|
673
|
+
|
|
674
|
+
Automated tests use fake runtimes, so they prove the plumbing — the copy, the
|
|
675
|
+
variables, the trust flag, the lifecycle — but not that a real agent starts
|
|
676
|
+
authenticated from a copy. See
|
|
677
|
+
[docs/identity-verification.md](./docs/identity-verification.md) for the
|
|
678
|
+
manual checklist that covers that, and for which agents have actually been
|
|
679
|
+
verified.
|
|
421
680
|
|
|
422
681
|
## Optional tools for launched agents
|
|
423
682
|
|
package/dist/claude-policy.js
CHANGED
|
@@ -6,8 +6,6 @@ import { promisify } from "node:util";
|
|
|
6
6
|
/** Claude's CLI cannot report all effective enterprise policy before starting.
|
|
7
7
|
* Refuse detected managed sources rather than pretending inline settings win. */
|
|
8
8
|
export async function assertClaudePolicy(env) {
|
|
9
|
-
if (env.CLAUDE_CONFIG_DIR)
|
|
10
|
-
throw new Error("Custom CLAUDE_CONFIG_DIR is unsupported: Tin Can discovers ~/.claude/sessions");
|
|
11
9
|
const home = env.HOME ?? homedir();
|
|
12
10
|
const system = process.platform === "darwin"
|
|
13
11
|
? "/Library/Application Support/ClaudeCode"
|
package/dist/config.js
CHANGED
|
@@ -6,13 +6,60 @@ import { z } from "zod";
|
|
|
6
6
|
import { mcpName, mcpServerSchema } from "./mcp.js";
|
|
7
7
|
import { pluginName, pluginSchema } from "./plugins.js";
|
|
8
8
|
import { openCodeSectionSchema } from "./providers.js";
|
|
9
|
+
import { identityNameSchema } from "./identity-store.js";
|
|
10
|
+
import { authoritySchema, subjectSchema } from "./requester.js";
|
|
11
|
+
import { RequesterConfigError } from "./requester-policy.js";
|
|
12
|
+
/**
|
|
13
|
+
* What a remote requester is permitted here. Every default is the narrowest
|
|
14
|
+
* value, so the shortest profile is the most restrictive one.
|
|
15
|
+
*
|
|
16
|
+
* `allowed_roots` is the one key with no safe default: the global
|
|
17
|
+
* `allowed_roots` defaults to empty, which `assertAllowedWorkspace` reads as
|
|
18
|
+
* permitting anything the account can reach, so inheriting it would hand an
|
|
19
|
+
* unconfigured profile the whole filesystem.
|
|
20
|
+
*
|
|
21
|
+
* `level` excludes `open` deliberately — no remote request may disable the
|
|
22
|
+
* sandbox, and a config naming it should fail loudly rather than be checked for
|
|
23
|
+
* at launch.
|
|
24
|
+
*
|
|
25
|
+
* `min_enforcement` is the one key whose default excludes a runtime rather than
|
|
26
|
+
* narrowing a permission: `kernel` admits Codex and Claude Code, and
|
|
27
|
+
* `tool-policy` is what admits OpenCode.
|
|
28
|
+
*/
|
|
29
|
+
export const requesterProfileSchema = z
|
|
30
|
+
.object({
|
|
31
|
+
allowed_roots: z.array(z.string().min(1)).min(1),
|
|
32
|
+
level: z.enum(["read", "work"]).default("read"),
|
|
33
|
+
min_enforcement: z.enum(["kernel", "tool-policy"]).default("kernel"),
|
|
34
|
+
env_allow: z.array(z.string().min(1)).default([]),
|
|
35
|
+
/**
|
|
36
|
+
* Baseline overrides, e.g. a PATH that does not depend on the accepting
|
|
37
|
+
* shell. It wins over the source environment for every key it sets,
|
|
38
|
+
* `env_allow` entries included: a profile that pins a value and also allows
|
|
39
|
+
* the key meant the value it pinned.
|
|
40
|
+
*/
|
|
41
|
+
env_defaults: z.record(z.string().min(1), z.string()).default({}),
|
|
42
|
+
/** Which identities this requester may launch under. Empty refuses any. */
|
|
43
|
+
identities: z.array(identityNameSchema).default([]),
|
|
44
|
+
})
|
|
45
|
+
.strict();
|
|
46
|
+
/** Binding an authenticated `(authority, subject)` to a profile. The grant. */
|
|
47
|
+
export const requesterEnrolmentSchema = z
|
|
48
|
+
.object({
|
|
49
|
+
authority: authoritySchema,
|
|
50
|
+
subject: subjectSchema,
|
|
51
|
+
label: z.string().min(1).max(200).optional(),
|
|
52
|
+
profile: z.string().min(1),
|
|
53
|
+
})
|
|
54
|
+
.strict();
|
|
9
55
|
export const configSchema = z
|
|
10
56
|
.object({
|
|
11
57
|
host: z.enum(["auto", "tmux", "pty", "macos-terminal"]).default("auto"),
|
|
12
58
|
launch_timeout_sec: z.number().finite().positive().max(600).default(30),
|
|
13
59
|
max_concurrent: z.number().int().positive().max(1000).default(4),
|
|
14
60
|
allowed_roots: z.array(z.string().min(1)).default([]),
|
|
15
|
-
projects: z
|
|
61
|
+
projects: z
|
|
62
|
+
.record(z.string().regex(/^[a-zA-Z0-9_-]+$/), z.string().min(1))
|
|
16
63
|
.default({}),
|
|
17
64
|
permissions: z.enum(["auto", "deny", "bypass"]).default("deny"),
|
|
18
65
|
sandbox: z
|
|
@@ -29,6 +76,10 @@ export const configSchema = z
|
|
|
29
76
|
plugins: z.record(pluginName, pluginSchema).default({}),
|
|
30
77
|
opencode: openCodeSectionSchema,
|
|
31
78
|
allow_dangerous_flags: z.boolean().default(false),
|
|
79
|
+
requester_profiles: z
|
|
80
|
+
.record(z.string().regex(/^[a-zA-Z0-9_-]+$/), requesterProfileSchema)
|
|
81
|
+
.default({}),
|
|
82
|
+
requesters: z.array(requesterEnrolmentSchema).default([]),
|
|
32
83
|
})
|
|
33
84
|
.strict();
|
|
34
85
|
export const musterHome = () => join(homedir(), ".muster");
|
|
@@ -42,6 +93,18 @@ export async function loadConfig(home = musterHome()) {
|
|
|
42
93
|
throw e;
|
|
43
94
|
}
|
|
44
95
|
const result = configSchema.parse(value);
|
|
96
|
+
const seen = new Set();
|
|
97
|
+
for (const e of result.requesters) {
|
|
98
|
+
// The spec lists both of these under RequesterConfigError, and the error
|
|
99
|
+
// model exists so a transport maps to stable codes without matching on
|
|
100
|
+
// message text. A plain Error is indistinguishable from a parse failure.
|
|
101
|
+
if (!result.requester_profiles[e.profile])
|
|
102
|
+
throw new RequesterConfigError(`Requester ${e.authority}/${e.subject} names unknown profile ${e.profile}. Configured: ${Object.keys(result.requester_profiles).join(", ") || "none"}`);
|
|
103
|
+
const key = [e.authority, e.subject].join("\0");
|
|
104
|
+
if (seen.has(key))
|
|
105
|
+
throw new RequesterConfigError(`Duplicate requester enrolment for ${e.authority}/${e.subject}`);
|
|
106
|
+
seen.add(key);
|
|
107
|
+
}
|
|
45
108
|
if ((["danger-full-access", "full-access"].includes(result.sandbox) ||
|
|
46
109
|
result.permissions === "bypass") &&
|
|
47
110
|
!result.allow_dangerous_flags)
|
package/dist/guard.js
CHANGED
|
@@ -4,6 +4,7 @@ import { realpath } from "node:fs/promises";
|
|
|
4
4
|
import { isAbsolute, relative, resolve } from "node:path";
|
|
5
5
|
import { mcpName, claudeMcpConfig } from "./mcp.js";
|
|
6
6
|
import { pluginName } from "./plugins.js";
|
|
7
|
+
import { RequesterConfigError } from "./requester-policy.js";
|
|
7
8
|
export const runSchema = z
|
|
8
9
|
.object({
|
|
9
10
|
runtime: z.enum(["codex", "claude", "opencode"]),
|
|
@@ -28,11 +29,48 @@ export const runSchema = z
|
|
|
28
29
|
.string()
|
|
29
30
|
.regex(/^[a-zA-Z0-9_-]+$/)
|
|
30
31
|
.optional(),
|
|
32
|
+
/** Name of a stored identity to launch under. See src/identity-store.ts. */
|
|
33
|
+
identity: z
|
|
34
|
+
.string()
|
|
35
|
+
.regex(/^[a-zA-Z0-9_-]+$/)
|
|
36
|
+
.optional(),
|
|
31
37
|
requestKey: z.string().min(1).max(200).optional(),
|
|
38
|
+
level: z.enum(["read", "work", "open"]).optional(),
|
|
32
39
|
args: z.array(z.string()).default([]),
|
|
33
40
|
})
|
|
34
41
|
.strict();
|
|
42
|
+
/**
|
|
43
|
+
* A level is a spelling of a legal `permissions` + `sandbox` pair, not a second
|
|
44
|
+
* policy path — `test/level.test.ts` asserts the equivalence for all three.
|
|
45
|
+
* The two legal combinations no level names, `deny` + `workspace-write` and
|
|
46
|
+
* `deny` + `full-access`, stay reachable through the flags.
|
|
47
|
+
*/
|
|
48
|
+
export const LEVELS = {
|
|
49
|
+
read: { permissions: "deny", sandbox: "read-only" },
|
|
50
|
+
work: { permissions: "auto", sandbox: "workspace-write" },
|
|
51
|
+
open: { permissions: "bypass", sandbox: "full-access" },
|
|
52
|
+
};
|
|
53
|
+
/**
|
|
54
|
+
* Checked against raw input before parsing, like `project` and `cwd`: once
|
|
55
|
+
* defaults have been applied a caller's value is indistinguishable from one
|
|
56
|
+
* Muster chose.
|
|
57
|
+
*/
|
|
58
|
+
export function assertLevelExclusive(input) {
|
|
59
|
+
if (typeof input !== "object" || input === null)
|
|
60
|
+
return;
|
|
61
|
+
const o = input;
|
|
62
|
+
if (o.level !== undefined &&
|
|
63
|
+
(o.permissions !== undefined || o.sandbox !== undefined))
|
|
64
|
+
throw new Error("use level or permissions/sandbox, not both");
|
|
65
|
+
}
|
|
35
66
|
export function resolvePermissions(req, config) {
|
|
67
|
+
if (req.level) {
|
|
68
|
+
const pair = { ...LEVELS[req.level] };
|
|
69
|
+
if ((pair.permissions === "bypass" || pair.sandbox === "full-access") &&
|
|
70
|
+
!config.allow_dangerous_flags)
|
|
71
|
+
throw new Error("bypass or full-access requires allow_dangerous_flags=true in config.toml");
|
|
72
|
+
return pair;
|
|
73
|
+
}
|
|
36
74
|
const permissions = req.permissions ?? config.permissions;
|
|
37
75
|
const configuredSandbox = req.sandbox ?? config.sandbox;
|
|
38
76
|
const sandbox = configuredSandbox === "danger-full-access"
|
|
@@ -108,6 +146,12 @@ export function paramsFingerprint(req, permissions, mcp, plugins) {
|
|
|
108
146
|
[...mcp].sort(),
|
|
109
147
|
[...plugins].sort(),
|
|
110
148
|
req.model ?? null,
|
|
149
|
+
// Hashed because it selects which account's credentials the agent
|
|
150
|
+
// holds. Without it, one request key reused across two identities
|
|
151
|
+
// dedupes: the caller asks for account-b and is handed the launch
|
|
152
|
+
// already running under account-a, reported as success. A reused key
|
|
153
|
+
// with different parameters must be refused, not answered.
|
|
154
|
+
req.identity ?? null,
|
|
111
155
|
req.args,
|
|
112
156
|
]))
|
|
113
157
|
.digest("hex");
|
|
@@ -209,3 +253,62 @@ export function launchEnv(env) {
|
|
|
209
253
|
const blocked = /^(CLAUDECODE|CLAUDE_CODE_|CODEX_THREAD_ID$|TMUX$|TMUX_PANE$|MCP_)/;
|
|
210
254
|
return Object.fromEntries(Object.entries(env).filter(([k, v]) => typeof v === "string" && !blocked.test(k)));
|
|
211
255
|
}
|
|
256
|
+
/**
|
|
257
|
+
* What a composed environment must contain to launch at all. `LANG` is here
|
|
258
|
+
* deliberately: an empty or absent `LANG` has broken every tmux launch on the
|
|
259
|
+
* remote hosts before, and a composed environment is exactly where it goes
|
|
260
|
+
* missing.
|
|
261
|
+
*/
|
|
262
|
+
export const BASELINE_ENV = [
|
|
263
|
+
"PATH",
|
|
264
|
+
"HOME",
|
|
265
|
+
"USER",
|
|
266
|
+
"SHELL",
|
|
267
|
+
"TMPDIR",
|
|
268
|
+
"TERM",
|
|
269
|
+
"LANG",
|
|
270
|
+
];
|
|
271
|
+
/**
|
|
272
|
+
* The base environment handed to the launched agent — distinct from the
|
|
273
|
+
* environment Muster's own subprocesses run in, which stays denylist-filtered
|
|
274
|
+
* from the parent because it is Muster's own machinery on its own machine and
|
|
275
|
+
* never reaches an agent.
|
|
276
|
+
*
|
|
277
|
+
* Local requesters keep the denylist: the user's own machine, the user's own
|
|
278
|
+
* credentials, long-standing documented behaviour. Remote requesters get an
|
|
279
|
+
* allowlist composed from nothing, because the alternative hands an agent
|
|
280
|
+
* whatever happened to be in the environment of the process that accepted the
|
|
281
|
+
* request — a set nobody chose.
|
|
282
|
+
*/
|
|
283
|
+
export function agentBaseEnv(requester, profile, sourceEnv) {
|
|
284
|
+
if (requester.kind === "local")
|
|
285
|
+
return launchEnv(sourceEnv);
|
|
286
|
+
if (!profile)
|
|
287
|
+
throw new RequesterConfigError("a remote requester reached environment composition without a profile");
|
|
288
|
+
const env = {};
|
|
289
|
+
// Allow-listed variables come from the accepting environment.
|
|
290
|
+
for (const key of profile.env_allow) {
|
|
291
|
+
const value = sourceEnv[key];
|
|
292
|
+
if (typeof value === "string")
|
|
293
|
+
env[key] = value;
|
|
294
|
+
}
|
|
295
|
+
// The baseline a launch cannot run without, from the profile where it sets one.
|
|
296
|
+
for (const key of BASELINE_ENV) {
|
|
297
|
+
const value = profile.env_defaults[key] ?? sourceEnv[key];
|
|
298
|
+
if (typeof value === "string" && value.length)
|
|
299
|
+
env[key] = value;
|
|
300
|
+
}
|
|
301
|
+
// `env_defaults` wins over everything above, and is delivered even for a key
|
|
302
|
+
// that is neither baseline nor allow-listed. A profile that sets a value meant
|
|
303
|
+
// the value it chose; dropping it would leave the profile reading as though it
|
|
304
|
+
// supplies something it never delivers, and pinning `PATH` while handing over
|
|
305
|
+
// the accepting shell's raw one is precisely the mismatch this feature exists
|
|
306
|
+
// to remove.
|
|
307
|
+
for (const [key, value] of Object.entries(profile.env_defaults))
|
|
308
|
+
if (typeof value === "string" && value.length)
|
|
309
|
+
env[key] = value;
|
|
310
|
+
for (const required of ["PATH", "LANG"])
|
|
311
|
+
if (!env[required])
|
|
312
|
+
throw new RequesterConfigError(`composed environment for this requester has no ${required}; set it under the profile's [env_defaults] table`);
|
|
313
|
+
return env;
|
|
314
|
+
}
|
package/dist/identity/claude.js
CHANGED
|
@@ -5,8 +5,21 @@ import { readFile, access } from "node:fs/promises";
|
|
|
5
5
|
import { join } from "node:path";
|
|
6
6
|
import { homedir } from "node:os";
|
|
7
7
|
import { descendants } from "./processes.js";
|
|
8
|
+
/**
|
|
9
|
+
* Where Claude Code records its sessions. `CLAUDE_CONFIG_DIR` relocates the whole
|
|
10
|
+
* configuration directory, sessions included, so discovery must follow it —
|
|
11
|
+
* otherwise a launch whose configuration was relocated records its session
|
|
12
|
+
* somewhere Muster never looks, and resolution fails at the deadline rather than
|
|
13
|
+
* reporting anything useful.
|
|
14
|
+
*/
|
|
15
|
+
export function claudeSessionsDir(env) {
|
|
16
|
+
const configured = env.CLAUDE_CONFIG_DIR;
|
|
17
|
+
if (typeof configured === "string" && configured.length)
|
|
18
|
+
return join(configured, "sessions");
|
|
19
|
+
return join(env.HOME ?? homedir(), ".claude", "sessions");
|
|
20
|
+
}
|
|
8
21
|
export async function resolveClaude(root, env, deadline) {
|
|
9
|
-
const dir =
|
|
22
|
+
const dir = claudeSessionsDir(env);
|
|
10
23
|
const matches = [];
|
|
11
24
|
for (const pid of await descendants(root, deadline)) {
|
|
12
25
|
if (Date.now() >= deadline)
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { execFile } from "node:child_process";
|
|
3
|
+
import { readFile } from "node:fs/promises";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { promisify } from "node:util";
|
|
6
|
+
import { identityPath } from "./identity-store.js";
|
|
7
|
+
const run = promisify(execFile);
|
|
8
|
+
/**
|
|
9
|
+
* Claude Code stores its credential in the macOS keychain under a service name
|
|
10
|
+
* derived from the configuration directory's PATH, which is why a copied
|
|
11
|
+
* directory cannot authenticate: it hashes to a name that does not exist.
|
|
12
|
+
*/
|
|
13
|
+
export function claudeKeychainService(configDir) {
|
|
14
|
+
return ("Claude Code-credentials-" +
|
|
15
|
+
createHash("sha256").update(configDir).digest("hex").slice(0, 8));
|
|
16
|
+
}
|
|
17
|
+
async function keychainHas(service) {
|
|
18
|
+
try {
|
|
19
|
+
await run("security", ["find-generic-password", "-s", service]);
|
|
20
|
+
return true;
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
async function readsAsJson(file) {
|
|
27
|
+
try {
|
|
28
|
+
JSON.parse(await readFile(file, "utf8"));
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
export async function identityAuthState(home, name, meta, env, probe = keychainHas) {
|
|
36
|
+
const dir = identityPath(home, name);
|
|
37
|
+
const no = (detail) => ({ state: "not-configured", detail });
|
|
38
|
+
const yes = (detail) => ({ state: "configured", detail });
|
|
39
|
+
if (meta.agent === "claude") {
|
|
40
|
+
if (meta.token_env)
|
|
41
|
+
return env[meta.token_env]
|
|
42
|
+
? yes(`token variable ${meta.token_env} is set`)
|
|
43
|
+
: no(`token variable ${meta.token_env} is not set`);
|
|
44
|
+
// A keychain item for the TEMPLATE's path proves the template is usable
|
|
45
|
+
// where it sits, and nothing more. A launch never points Claude at the
|
|
46
|
+
// template: it copies it, and the copy's path hashes to a DIFFERENT service
|
|
47
|
+
// name with no fallback (see claudeKeychainService above). So the keychain
|
|
48
|
+
// route cannot produce a launch, and reporting "configured" for it would
|
|
49
|
+
// promise one that dies at its deadline. `token_env` is the only route a
|
|
50
|
+
// Claude identity can launch under, so its absence is not-configured
|
|
51
|
+
// regardless of what the keychain holds.
|
|
52
|
+
return no((await probe(claudeKeychainService(dir)))
|
|
53
|
+
? "a keychain item exists for this identity's template path, but a launch runs from a copy whose path hashes to a different service name — mint a token with `claude setup-token` and name the variable with --token-env"
|
|
54
|
+
: "no credential for a launch — mint a token with `claude setup-token` and name the variable with --token-env");
|
|
55
|
+
}
|
|
56
|
+
// Codex keeps a flat JSON auth file. OpenCode splits config from data: its
|
|
57
|
+
// auth file lives under a data root the binary itself always suffixes with
|
|
58
|
+
// "opencode" (join(XDG_DATA_HOME, "opencode", "auth.json") — confirmed
|
|
59
|
+
// against the installed 1.18.32 binary and the real ~/.local/share/opencode
|
|
60
|
+
// layout; see identity-copy.ts and task-4-report.md). The identity template
|
|
61
|
+
// mirrors that shape under data/opencode/auth.json.
|
|
62
|
+
const file = meta.agent === "opencode" ? join("data", "opencode", "auth.json") : "auth.json";
|
|
63
|
+
return (await readsAsJson(join(dir, file)))
|
|
64
|
+
? yes(`${file} is present`)
|
|
65
|
+
: no(`${file} is missing or unreadable — log in for this identity`);
|
|
66
|
+
}
|