feat(#829): enforce whole mutator-class lease gate
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
This commit is contained in:
@@ -4,7 +4,8 @@
|
|||||||
|
|
||||||
- [Internal broker protocol](architecture/lease-broker-protocol.md) — kernel identity, ancestry and generation invariants, framed requests, responses, and persisted cycle bindings.
|
- [Internal broker protocol](architecture/lease-broker-protocol.md) — kernel identity, ancestry and generation invariants, framed requests, responses, and persisted cycle bindings.
|
||||||
- [Broker operations](guides/lease-broker-operations.md) — protected paths, startup, fail-closed recovery posture, distinct-principal deployment, and residual risk.
|
- [Broker operations](guides/lease-broker-operations.md) — protected paths, startup, fail-closed recovery posture, distinct-principal deployment, and residual risk.
|
||||||
- [WI-1 security notes](architecture/lease-broker-security.md) — threat-boundary summary and coordinator review requirements.
|
- [Lease-broker security notes](architecture/lease-broker-security.md) — identity, whole-class authorization, threat boundaries, and coordinator review requirements.
|
||||||
|
- [Whole mutator-class gate](architecture/mutator-class-gate.md) — default-deny policy, revoke-first/promote-last state machine, TTL, runtime adapters, and T-B/T-C assurance boundary.
|
||||||
|
|
||||||
## CLI and skill management
|
## CLI and skill management
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,13 @@ Each connection carries exactly one UTF-8 JSON object followed by one newline, c
|
|||||||
- `authenticate`: `action`, broker-minted `session_id`, non-negative `runtime_generation`.
|
- `authenticate`: `action`, broker-minted `session_id`, non-negative `runtime_generation`.
|
||||||
- `mint_token`: authenticated identity plus `binding` containing exactly `compaction_epoch`, `request_epoch`, `h_source`, `h_payload`, and `schema_version`.
|
- `mint_token`: authenticated identity plus `binding` containing exactly `compaction_epoch`, `request_epoch`, `h_source`, `h_payload`, and `schema_version`.
|
||||||
- `consume_token`: authenticated identity plus `token`.
|
- `consume_token`: authenticated identity plus `token`.
|
||||||
|
- `begin_verification`: authenticated identity, runtime (`claude` or `pi`), cycle `binding`, and a TTL no greater than 300 seconds. The broker revokes existing authority first, enters `PENDING_VERIFICATION`, and returns a single-use promotion token.
|
||||||
|
- `promote_lease`: authenticated identity plus the exact pending promotion token. The broker commits token consumption before making `VERIFIED` visible.
|
||||||
|
- `revoke_lease`: authenticated observer signal; deletes pending tokens and makes the session `UNVERIFIED` immediately.
|
||||||
|
- `authorize_tool`: authenticated identity, runtime, and exact runtime-reported tool name. The broker returns an explicit allow/deny decision from the whole-class policy and current lease.
|
||||||
|
|
||||||
A higher generation for the same anchor atomically replaces the stored incarnation and deletes all prior tokens for that session. A lower generation is stale. Tokens are 256-bit values from the operating-system cryptographic RNG and are single use. At most 256 pending tokens may be persisted; another mint fails with `TOKEN_CAPACITY` before mutation. Successful consumption deletes the token, while a replay still fails with `TOKEN_REPLAY`. Live v1 token records retain the existing `consumed: false` schema. Their persisted binding is the WI-1 substrate for later receipt work; WI-1 does not implement receipts, promotion, hooks, payload builders, mutator gates, or recovery.
|
A higher generation for the same anchor atomically replaces the stored incarnation and deletes all prior tokens and lease authority for that session. A lower generation is stale. Tokens are 256-bit values from the operating-system cryptographic RNG and are single use. At most 256 pending tokens may be persisted; another mint fails with `TOKEN_CAPACITY` before mutation. Successful consumption deletes the token, while a replay still fails with `TOKEN_REPLAY`. Live v1 token records retain the existing `consumed: false` schema.
|
||||||
|
|
||||||
|
VERIFIED leases are volatile and monotonic-time bounded: broker restart, generation change, explicit observer revocation, or expiry returns the session to `UNVERIFIED`. `begin_verification` always revokes before minting a new prerequisite. `promote_lease` is valid only from the matching pending cycle; persistence failure rolls token and lease state back, while post-rename durability uncertainty terminates the broker. The WI-1 token is the atomic promotion prerequisite substrate. A later receipt implementation must satisfy that prerequisite but cannot replace the mechanical mutator gate as safety authority.
|
||||||
|
|
||||||
State replacement serializes and enforces the 4 MiB maximum before opening a temporary file, then uses a mode-`0600` temporary file, `fsync`, atomic rename, and parent-directory `fsync`. Every broker mutation snapshots the prior v1 state. A commit failure before rename restores that snapshot and leaves durable state unchanged. A failure after rename makes durability uncertain, so the store is poisoned without rolling memory back and the daemon terminates rather than serving with divergent state. Existing state is opened without following symlinks, must be a bounded regular file at mode `0600`, and is fully schema- and invariant-validated before use. Persisted tokens must be unconsumed, match their session's current generation, and remain within the 256-token cap. Session identity is uniquely keyed by `(anchor_pid,anchor_starttime)`; duplicate logical sessions for one anchor refuse startup. State integrity or mode failures refuse startup. The daemon does not log session IDs or tokens.
|
State replacement serializes and enforces the 4 MiB maximum before opening a temporary file, then uses a mode-`0600` temporary file, `fsync`, atomic rename, and parent-directory `fsync`. Every broker mutation snapshots the prior v1 state. A commit failure before rename restores that snapshot and leaves durable state unchanged. A failure after rename makes durability uncertain, so the store is poisoned without rolling memory back and the daemon terminates rather than serving with divergent state. Existing state is opened without following symlinks, must be a bounded regular file at mode `0600`, and is fully schema- and invariant-validated before use. Persisted tokens must be unconsumed, match their session's current generation, and remain within the 256-token cap. Session identity is uniquely keyed by `(anchor_pid,anchor_starttime)`; duplicate logical sessions for one anchor refuse startup. State integrity or mode failures refuse startup. The daemon does not log session IDs or tokens.
|
||||||
|
|||||||
@@ -6,6 +6,8 @@
|
|||||||
- Session IDs and cycle tokens use the OS cryptographic RNG. `Math.random` and model output are not token sources.
|
- Session IDs and cycle tokens use the OS cryptographic RNG. `Math.random` and model output are not token sources.
|
||||||
- Framing and persistence failures fail closed. Sensitive tokens are not logged.
|
- Framing and persistence failures fail closed. Sensitive tokens are not logged.
|
||||||
- Built-in `0700`/`0600` filesystem modes provide same-principal hardening only, not socket authenticity against the same UID. WI-1 provides no distinct-principal isolation. That stronger deployment requires an external protected proxy, ACL, or service boundary, and the boundary must preserve authenticated client identity for the broker's `SO_PEERCRED` and ancestry authorization rather than substituting a shared proxy identity.
|
- Built-in `0700`/`0600` filesystem modes provide same-principal hardening only, not socket authenticity against the same UID. WI-1 provides no distinct-principal isolation. That stronger deployment requires an external protected proxy, ACL, or service boundary, and the boundary must preserve authenticated client identity for the broker's `SO_PEERCRED` and ancestry authorization rather than substituting a shared proxy identity.
|
||||||
- WI-2+ security surfaces—receipts, promotion transactions, mutator gates, hooks, payload construction, and recovery—are explicitly out of scope.
|
- WI-2 whole-class authorization denies every consequential, unknown, and custom tool while UNVERIFIED; it does not inspect shell strings or trust wrapper selection.
|
||||||
|
- WI-2 promotion consumes a WI-1 cycle token before VERIFIED becomes visible. Observer revocation, runtime-generation replacement, broker restart, and monotonic TTL expiry remove authority.
|
||||||
|
- Receipt observation, payload construction, compaction observers, and constrained recovery implementation remain later surfaces. A receipt can become a promotion prerequisite but is never the safety mechanism.
|
||||||
|
|
||||||
Coordinator security review must rerun the real socket/peercred acceptance suite on an unrestricted Linux runner and obtain the mandated independent Opus-SECREV review before integration.
|
Coordinator security review must rerun the real socket/peercred and mutator-gate acceptance suites on an unrestricted Linux runner and obtain the mandated independent Opus-SECREV review before integration.
|
||||||
|
|||||||
39
docs/architecture/mutator-class-gate.md
Normal file
39
docs/architecture/mutator-class-gate.md
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
# Whole mutator-class lease gate
|
||||||
|
|
||||||
|
WI-2 adds the framework-native authorization boundary for Claude and Pi. Every runtime-reported tool name reaches the lease broker before execution. The gate classifies capabilities by the whole tool class; it never parses a Bash command to decide whether that particular string looks read-only.
|
||||||
|
|
||||||
|
## Default-deny policy
|
||||||
|
|
||||||
|
While a session is not VERIFIED, only these exact classes are allowed:
|
||||||
|
|
||||||
|
- Claude: `Read`, `Grep`, `Glob`, `Ls`, `Find`
|
||||||
|
- Pi: `read`, `grep`, `find`, `ls`
|
||||||
|
- Both runtimes: the fixed `mosaic_context_recover` primitive
|
||||||
|
|
||||||
|
Every other built-in, unknown tool, and custom/MCP tool is consequential by default and is denied. This includes Claude `Bash`, `Edit`, `Write`, and `NotebookEdit`, plus Pi `bash`, `edit`, and `write`. A compromised model therefore cannot bypass Mosaic wrappers by selecting raw `git`, `curl`, `kubectl`, provider, deployment, or filesystem commands inside a generic mutator—the generic mutator itself is blocked before its input executes.
|
||||||
|
|
||||||
|
## Broker-owned transition order
|
||||||
|
|
||||||
|
The authenticated broker is the sole lease writer:
|
||||||
|
|
||||||
|
1. `begin_verification` revokes existing authority and pending tokens first, then records `PENDING_VERIFICATION` and mints one WI-1 single-use promotion token bound to the exact cycle.
|
||||||
|
2. `promote_lease` accepts only that session/generation/binding/token combination.
|
||||||
|
3. Token consumption commits before the volatile lease becomes VERIFIED. Promotion is last and cannot be reached directly from UNVERIFIED.
|
||||||
|
4. `revoke_lease`, a runtime-generation increase, broker restart, or monotonic expiry removes mutator authority.
|
||||||
|
|
||||||
|
The initial TTL is capped at the ratified 300-second maximum. A caller may request a shorter positive TTL but cannot lengthen the maximum. Dual compaction-hook miss within an unexpired lease remains the ratified bounded T-A residual; once either observer revokes or TTL expires, the next consequential tool is denied.
|
||||||
|
|
||||||
|
A receipt is only a future promotion prerequisite. It is not an obedience, residency, or safety proof and never replaces this mechanical gate.
|
||||||
|
|
||||||
|
## Runtime adapters
|
||||||
|
|
||||||
|
`launch-runtime.py` registers itself with the broker and then `exec`s Claude or Pi so PID/starttime remain the authenticated parent anchor. It exports only the broker-minted session ID and current generation to descendants.
|
||||||
|
|
||||||
|
- Claude installs `mutator-gate.py` as an all-tools (`.*`) `PreToolUse` hook.
|
||||||
|
- Pi invokes the same executable from its `tool_call` handler.
|
||||||
|
|
||||||
|
The executable submits the runtime's actual tool name to `authorize_tool`. Missing identity, malformed input/reply, timeout, broker unavailability, or denial exits with status 2 and blocks fail-closed.
|
||||||
|
|
||||||
|
## Assurance boundary
|
||||||
|
|
||||||
|
This closes T-A after an observer fires or lease expiry and T-B for in-runtime tool calls. Hook/extension absence, a runtime executing outside the gated launcher, ptrace/same-UID broker replacement, and other fully rotted behavior remain T-C. Server-side branch protection and required PR review/CI remain the irreducible line for protected repository mutations.
|
||||||
@@ -10,9 +10,18 @@ python3 "$MOSAIC_HOME/tools/lease-broker/daemon.py" \
|
|||||||
|
|
||||||
The broker refuses an existing parent directory whose mode is not exactly `0700`, an existing state file not at `0600`, corrupt/incompatible state, or an already-existing socket path. After bind it sets the socket to `0600`. It never silently unlinks a pre-existing socket. On normal termination it unlinks only the socket inode it created, so it does not remove a replacement path.
|
The broker refuses an existing parent directory whose mode is not exactly `0700`, an existing state file not at `0600`, corrupt/incompatible state, or an already-existing socket path. After bind it sets the socket to `0600`. It never silently unlinks a pre-existing socket. On normal termination it unlinks only the socket inode it created, so it does not remove a replacement path.
|
||||||
|
|
||||||
|
Before launching Claude or Pi, export the socket path; `mosaic` then runs the runtime through the packaged register-and-exec wrapper:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export MOSAIC_LEASE_BROKER_SOCKET=/run/user/1000/mosaic-lease/broker.sock
|
||||||
|
mosaic claude # or: mosaic pi
|
||||||
|
```
|
||||||
|
|
||||||
|
The wrapper obtains a broker-minted session ID and `exec`s the runtime without changing its PID/starttime anchor. The all-tools Claude `PreToolUse` hook and Pi `tool_call` handler inherit that identity. Broker registration failure denies runtime launch; missing identity, broker timeout/unavailability, and malformed replies block tools fail-closed.
|
||||||
|
|
||||||
Clients must complete the request boundary before waiting for a reply. After sending the single JSON object and its terminating newline, the client **MUST half-close the socket's write side** (`shutdown(SHUT_WR)` in POSIX clients; `socket.end()` in Node) and only then await the response. Merely calling `write()` and waiting is invalid: the broker waits for EOF to enforce the exact-one-frame contract and fails closed at its one-second deadline. Do not replace `end()` with `write()` in client helpers. A delayed second frame remains malformed and is rejected.
|
Clients must complete the request boundary before waiting for a reply. After sending the single JSON object and its terminating newline, the client **MUST half-close the socket's write side** (`shutdown(SHUT_WR)` in POSIX clients; `socket.end()` in Node) and only then await the response. Merely calling `write()` and waiting is invalid: the broker waits for EOF to enforce the exact-one-frame contract and fails closed at its one-second deadline. Do not replace `end()` with `write()` in client helpers. A delayed second frame remains malformed and is rejected.
|
||||||
|
|
||||||
There is no automated recovery workflow in WI-1. After a crash, preserve the protected state file and restart only after verifying that no broker owns the socket. A leftover socket requires an operator to verify the owning service is stopped and remove that exact socket deliberately. Corrupt, oversized, symlinked, or non-regular state fails closed; do not overwrite it. Preserve it for incident review and establish new state only through an explicit operational decision, which invalidates prior sessions and tokens.
|
There is no automated recovery workflow yet. `mosaic_context_recover` is reserved as the only unverified mutator class, but its fixed payload/receipt implementation lands in a later WI. After a crash, preserve the protected state file and restart only after verifying that no broker owns the socket. Restart intentionally clears all volatile VERIFIED leases. A leftover socket requires an operator to verify the owning service is stopped and remove that exact socket deliberately. Corrupt, oversized, symlinked, or non-regular state fails closed; do not overwrite it. Preserve it for incident review and establish new state only through an explicit operational decision, which invalidates prior sessions and tokens.
|
||||||
|
|
||||||
## Security posture
|
## Security posture
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,27 @@ Carried authority chain also verified/read for the locked T-B gate contract: SPE
|
|||||||
- Initial lease TTL is capped at ratified 300 seconds; callers may only shorten it.
|
- Initial lease TTL is capped at ratified 300 seconds; callers may only shorten it.
|
||||||
- Working budget assumption: 35K tokens; reduce documentation/refactor breadth before touching locked scope if pressure rises.
|
- Working budget assumption: 35K tokens; reduce documentation/refactor breadth before touching locked scope if pressure rises.
|
||||||
|
|
||||||
|
## Progress and verification
|
||||||
|
|
||||||
|
- RED #1: all 5 initial real-socket contract tests failed on WI-1 with `UNKNOWN_ACTION` or missing adapter behavior.
|
||||||
|
- RED #2: register-before-exec runtime test failed because `launch-runtime.py` did not exist.
|
||||||
|
- GREEN: broker-owned volatile lease state, 300-second maximum monotonic TTL, WI-1 token-backed promotion, all-tools runtime gate, Claude/Pi wiring, and register-before-exec launcher delivered without changing WI-1 peercred/ancestry authority.
|
||||||
|
- Focused broker + gate acceptance: `43/43` GREEN.
|
||||||
|
- Instrumented Python coverage: `88%` total — daemon `89%`, register/exec launcher `86%`, runtime gate `86%`.
|
||||||
|
- Full repository suite: `43/43` Turbo tasks GREEN; `@mosaicstack/mosaic` `72/72` files and `1,377/1,377` tests GREEN.
|
||||||
|
- Root typecheck: `42/42`; lint: `23/23`; format check and `git diff --check`: GREEN.
|
||||||
|
- No author self-review was run. Exact-head terra CODE and Opus SECREV remain coordinator-owned gates.
|
||||||
|
|
||||||
|
## Acceptance mapping
|
||||||
|
|
||||||
|
| Acceptance criterion | Evidence |
|
||||||
|
| --- | --- |
|
||||||
|
| No consequential mutator succeeds while UNVERIFIED after observer revoke or TTL | `observer revocation and monotonic TTL expiry deny the next mutator` real-socket acceptance test |
|
||||||
|
| T-B compromised-tool bypass is covered by the whole gate | `T-B raw and custom mutator tools are default-denied without shell parsing` across Claude/Pi built-ins, raw Bash class, MCP/custom/unknown tools |
|
||||||
|
| Revoke-first / promote-last is structural | `revoke-first and promote-last structurally bracket mutator authority`; direct promotion rejected, pending remains denied, token consumption commits before VERIFIED |
|
||||||
|
| Consume WI-1 auth/lease substrate | All transitions and decisions traverse merged peercred/ancestry authentication; promotion consumes the exact WI-1 CSPRNG cycle token |
|
||||||
|
| M1 Claude + Pi | Claude `.*` PreToolUse and Pi `tool_call` invoke the same broker gate; register-before-exec test proves broker-minted parent identity reaches runtime descendants |
|
||||||
|
|
||||||
## Locked discipline
|
## Locked discipline
|
||||||
|
|
||||||
- Re-verify and read the four authority artifacts before design or code.
|
- Re-verify and read the four authority artifacts before design or code.
|
||||||
|
|||||||
@@ -2,6 +2,16 @@
|
|||||||
"model": "opus",
|
"model": "opus",
|
||||||
"hooks": {
|
"hooks": {
|
||||||
"PreToolUse": [
|
"PreToolUse": [
|
||||||
|
{
|
||||||
|
"matcher": ".*",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "python3 ~/.config/mosaic/tools/lease-broker/mutator-gate.py --runtime claude",
|
||||||
|
"timeout": 3
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"matcher": "Write|Edit|MultiEdit",
|
"matcher": "Write|Edit|MultiEdit",
|
||||||
"hooks": [
|
"hooks": [
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import { execSync, spawnSync } from 'node:child_process';
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
const MOSAIC_HOME = process.env['MOSAIC_HOME'] ?? join(homedir(), '.config', 'mosaic');
|
const MOSAIC_HOME = process.env['MOSAIC_HOME'] ?? join(homedir(), '.config', 'mosaic');
|
||||||
|
const MUTATOR_GATE = join(MOSAIC_HOME, 'tools', 'lease-broker', 'mutator-gate.py');
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Helpers
|
// Helpers
|
||||||
@@ -106,6 +107,23 @@ function nowIso(): string {
|
|||||||
return new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
|
return new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function checkPiMutatorGate(toolName: string): { block: true; reason: string } | undefined {
|
||||||
|
const result = spawnSync('python3', [MUTATOR_GATE, '--runtime', 'pi'], {
|
||||||
|
input: `${JSON.stringify({ tool_name: toolName })}\n`,
|
||||||
|
encoding: 'utf8',
|
||||||
|
timeout: 2_000,
|
||||||
|
env: process.env,
|
||||||
|
});
|
||||||
|
if (result.status === 0) return undefined;
|
||||||
|
const detail = String(result.stderr ?? '')
|
||||||
|
.trim()
|
||||||
|
.split('\n')[0];
|
||||||
|
return {
|
||||||
|
block: true,
|
||||||
|
reason: detail || 'BLOCKED: Mosaic mutator gate is unavailable or the lease is UNVERIFIED.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Mission detection
|
// Mission detection
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -250,6 +268,11 @@ export default function register(pi: ExtensionAPI) {
|
|||||||
let hbModel: string | null = null;
|
let hbModel: string | null = null;
|
||||||
let hbTimer: ReturnType<typeof setInterval> | null = null;
|
let hbTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
// ── Whole mutator-class authorization gate ────────────────────────────
|
||||||
|
// Every Pi tool, including unknown/custom tools, reaches the broker-backed
|
||||||
|
// class gate before execution. Broker/script failure blocks fail-closed.
|
||||||
|
pi.on('tool_call', async (event) => checkPiMutatorGate(event.toolName));
|
||||||
|
|
||||||
// ── Session Start ─────────────────────────────────────────────────────
|
// ── Session Start ─────────────────────────────────────────────────────
|
||||||
pi.on('session_start', async (_event, ctx) => {
|
pi.on('session_start', async (_event, ctx) => {
|
||||||
sessionCwd = process.cwd();
|
sessionCwd = process.cwd();
|
||||||
|
|||||||
@@ -24,9 +24,19 @@ MAX_FRAME: Final = 64 * 1024
|
|||||||
MAX_STATE: Final = 4 * 1024 * 1024
|
MAX_STATE: Final = 4 * 1024 * 1024
|
||||||
MAX_PENDING_TOKENS: Final = 256
|
MAX_PENDING_TOKENS: Final = 256
|
||||||
MAX_IN_FLIGHT_CONNECTIONS: Final = 16
|
MAX_IN_FLIGHT_CONNECTIONS: Final = 16
|
||||||
|
MAX_LEASE_TTL_SECONDS: Final = 300
|
||||||
STATE_VERSION: Final = 1
|
STATE_VERSION: Final = 1
|
||||||
CONNECTION_DEADLINE_SECONDS: Final = 1.0
|
CONNECTION_DEADLINE_SECONDS: Final = 1.0
|
||||||
HEX_256_LENGTH: Final = 64
|
HEX_256_LENGTH: Final = 64
|
||||||
|
LEASE_UNVERIFIED: Final = "UNVERIFIED"
|
||||||
|
LEASE_PENDING: Final = "PENDING_VERIFICATION"
|
||||||
|
LEASE_PENDING_PROMOTION: Final = "PENDING_PROMOTION"
|
||||||
|
LEASE_VERIFIED: Final = "VERIFIED"
|
||||||
|
READ_ONLY_TOOLS: Final = {
|
||||||
|
"claude": frozenset({"Read", "Grep", "Glob", "Ls", "Find"}),
|
||||||
|
"pi": frozenset({"read", "grep", "find", "ls"}),
|
||||||
|
}
|
||||||
|
RECOVERY_TOOL: Final = "mosaic_context_recover"
|
||||||
|
|
||||||
|
|
||||||
class BrokerFailure(Exception):
|
class BrokerFailure(Exception):
|
||||||
@@ -265,6 +275,9 @@ class StateStore:
|
|||||||
class Broker:
|
class Broker:
|
||||||
def __init__(self, store: StateStore) -> None:
|
def __init__(self, store: StateStore) -> None:
|
||||||
self.store = store
|
self.store = store
|
||||||
|
# VERIFIED authority is deliberately volatile: broker restart revokes all
|
||||||
|
# leases while preserving WI-1 identity and pending-token integrity.
|
||||||
|
self.leases: dict[str, dict[str, object]] = {}
|
||||||
|
|
||||||
def authenticate(self, peer_pid: int, request: dict[str, object]) -> tuple[str, dict[str, object]]:
|
def authenticate(self, peer_pid: int, request: dict[str, object]) -> tuple[str, dict[str, object]]:
|
||||||
session_id = request.get("session_id")
|
session_id = request.get("session_id")
|
||||||
@@ -289,7 +302,7 @@ class Broker:
|
|||||||
raise BrokerFailure("STALE_GENERATION")
|
raise BrokerFailure("STALE_GENERATION")
|
||||||
if generation > current_generation:
|
if generation > current_generation:
|
||||||
session["runtime_generation"] = generation
|
session["runtime_generation"] = generation
|
||||||
self.revoke_session_tokens(session_id)
|
self.revoke_session_authority(session_id)
|
||||||
return session_id, session
|
return session_id, session
|
||||||
|
|
||||||
def session_for_anchor(
|
def session_for_anchor(
|
||||||
@@ -310,19 +323,59 @@ class Broker:
|
|||||||
]:
|
]:
|
||||||
del tokens[token_value]
|
del tokens[token_value]
|
||||||
|
|
||||||
|
def revoke_session_authority(self, session_id: str) -> None:
|
||||||
|
self.revoke_session_tokens(session_id)
|
||||||
|
session = self.store.sessions().get(session_id)
|
||||||
|
generation = session.get("runtime_generation") if isinstance(session, dict) else None
|
||||||
|
self.leases[session_id] = {
|
||||||
|
"state": LEASE_UNVERIFIED,
|
||||||
|
"runtime_generation": generation,
|
||||||
|
}
|
||||||
|
|
||||||
|
def mint_token(
|
||||||
|
self,
|
||||||
|
session_id: str,
|
||||||
|
generation: int,
|
||||||
|
binding: dict[str, object],
|
||||||
|
) -> str:
|
||||||
|
if len(self.store.tokens()) >= MAX_PENDING_TOKENS:
|
||||||
|
raise BrokerFailure("TOKEN_CAPACITY")
|
||||||
|
token = secrets.token_hex(32)
|
||||||
|
self.store.tokens()[token] = {
|
||||||
|
"session_id": session_id,
|
||||||
|
"runtime_generation": generation,
|
||||||
|
"binding": copy.deepcopy(binding),
|
||||||
|
"consumed": False,
|
||||||
|
}
|
||||||
|
return token
|
||||||
|
|
||||||
|
def finish_promotion(self, session_id: str) -> None:
|
||||||
|
lease = self.leases.get(session_id)
|
||||||
|
if not isinstance(lease, dict) or lease.get("state") != LEASE_PENDING_PROMOTION:
|
||||||
|
raise BrokerFailure("STATE_INTEGRITY")
|
||||||
|
lease["state"] = LEASE_VERIFIED
|
||||||
|
|
||||||
def handle(self, peer: tuple[int, int, int], request: dict[str, object]) -> dict[str, object]:
|
def handle(self, peer: tuple[int, int, int], request: dict[str, object]) -> dict[str, object]:
|
||||||
if self.store.poisoned:
|
if self.store.poisoned:
|
||||||
raise StateCommitUncertain()
|
raise StateCommitUncertain()
|
||||||
previous = copy.deepcopy(self.store.value)
|
previous = copy.deepcopy(self.store.value)
|
||||||
|
previous_leases = copy.deepcopy(self.leases)
|
||||||
try:
|
try:
|
||||||
response = self._handle(peer, request)
|
response = self._handle(peer, request)
|
||||||
if self.store.value != previous:
|
if self.store.value != previous:
|
||||||
self.store.commit()
|
self.store.commit()
|
||||||
|
if request.get("action") == "promote_lease":
|
||||||
|
session_id = request.get("session_id")
|
||||||
|
if not isinstance(session_id, str):
|
||||||
|
raise BrokerFailure("INVALID_IDENTITY")
|
||||||
|
self.finish_promotion(session_id)
|
||||||
|
response["state"] = LEASE_VERIFIED
|
||||||
return response
|
return response
|
||||||
except StateCommitUncertain:
|
except StateCommitUncertain:
|
||||||
raise
|
raise
|
||||||
except Exception:
|
except Exception:
|
||||||
self.store.value = previous
|
self.store.value = previous
|
||||||
|
self.leases = previous_leases
|
||||||
raise
|
raise
|
||||||
|
|
||||||
def _handle(self, peer: tuple[int, int, int], request: dict[str, object]) -> dict[str, object]:
|
def _handle(self, peer: tuple[int, int, int], request: dict[str, object]) -> dict[str, object]:
|
||||||
@@ -351,7 +404,7 @@ class Broker:
|
|||||||
raise BrokerFailure("STALE_GENERATION")
|
raise BrokerFailure("STALE_GENERATION")
|
||||||
if generation > current_generation:
|
if generation > current_generation:
|
||||||
session["runtime_generation"] = generation
|
session["runtime_generation"] = generation
|
||||||
self.revoke_session_tokens(session_id)
|
self.revoke_session_authority(session_id)
|
||||||
return {"ok": True, "session_id": session_id, "peer": {"pid": peer_pid, "uid": peer_uid, "gid": peer_gid, "starttime": anchor["starttime"]}}
|
return {"ok": True, "session_id": session_id, "peer": {"pid": peer_pid, "uid": peer_uid, "gid": peer_gid, "starttime": anchor["starttime"]}}
|
||||||
if action == "authenticate":
|
if action == "authenticate":
|
||||||
self.authenticate(peer_pid, request)
|
self.authenticate(peer_pid, request)
|
||||||
@@ -361,15 +414,7 @@ class Broker:
|
|||||||
binding = request.get("binding")
|
binding = request.get("binding")
|
||||||
if not valid_binding(binding):
|
if not valid_binding(binding):
|
||||||
raise BrokerFailure("INVALID_BINDING")
|
raise BrokerFailure("INVALID_BINDING")
|
||||||
if len(self.store.tokens()) >= MAX_PENDING_TOKENS:
|
token = self.mint_token(session_id, request["runtime_generation"], binding)
|
||||||
raise BrokerFailure("TOKEN_CAPACITY")
|
|
||||||
token = secrets.token_hex(32)
|
|
||||||
self.store.tokens()[token] = {
|
|
||||||
"session_id": session_id,
|
|
||||||
"runtime_generation": request["runtime_generation"],
|
|
||||||
"binding": binding,
|
|
||||||
"consumed": False,
|
|
||||||
}
|
|
||||||
return {"ok": True, "token": token}
|
return {"ok": True, "token": token}
|
||||||
if action == "consume_token":
|
if action == "consume_token":
|
||||||
session_id, _ = self.authenticate(peer_pid, request)
|
session_id, _ = self.authenticate(peer_pid, request)
|
||||||
@@ -379,6 +424,112 @@ class Broker:
|
|||||||
raise BrokerFailure("TOKEN_REPLAY")
|
raise BrokerFailure("TOKEN_REPLAY")
|
||||||
del self.store.tokens()[token_value]
|
del self.store.tokens()[token_value]
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
if action == "begin_verification":
|
||||||
|
session_id, _ = self.authenticate(peer_pid, request)
|
||||||
|
runtime = request.get("runtime")
|
||||||
|
binding = request.get("binding")
|
||||||
|
ttl_seconds = request.get("ttl_seconds", MAX_LEASE_TTL_SECONDS)
|
||||||
|
if runtime not in READ_ONLY_TOOLS:
|
||||||
|
raise BrokerFailure("INVALID_RUNTIME")
|
||||||
|
if not valid_binding(binding):
|
||||||
|
raise BrokerFailure("INVALID_BINDING")
|
||||||
|
if (
|
||||||
|
type(ttl_seconds) is not int
|
||||||
|
or ttl_seconds <= 0
|
||||||
|
or ttl_seconds > MAX_LEASE_TTL_SECONDS
|
||||||
|
):
|
||||||
|
raise BrokerFailure("INVALID_LEASE_TTL")
|
||||||
|
# Revoke-first is a broker operation, not advisory adapter order.
|
||||||
|
self.revoke_session_authority(session_id)
|
||||||
|
token = self.mint_token(session_id, request["runtime_generation"], binding)
|
||||||
|
self.leases[session_id] = {
|
||||||
|
"state": LEASE_PENDING,
|
||||||
|
"runtime": runtime,
|
||||||
|
"runtime_generation": request["runtime_generation"],
|
||||||
|
"binding": copy.deepcopy(binding),
|
||||||
|
"promotion_token": token,
|
||||||
|
"ttl_seconds": ttl_seconds,
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"state": LEASE_PENDING,
|
||||||
|
"promotion_token": token,
|
||||||
|
}
|
||||||
|
if action == "promote_lease":
|
||||||
|
session_id, _ = self.authenticate(peer_pid, request)
|
||||||
|
promotion_token = request.get("promotion_token")
|
||||||
|
lease = self.leases.get(session_id)
|
||||||
|
if not isinstance(lease, dict) or lease.get("state") != LEASE_PENDING:
|
||||||
|
raise BrokerFailure("INVALID_LEASE_TRANSITION")
|
||||||
|
expected_token = lease.get("promotion_token")
|
||||||
|
if (
|
||||||
|
not isinstance(promotion_token, str)
|
||||||
|
or not isinstance(expected_token, str)
|
||||||
|
or not secrets.compare_digest(promotion_token, expected_token)
|
||||||
|
):
|
||||||
|
raise BrokerFailure("PROMOTION_TOKEN_MISMATCH")
|
||||||
|
token = self.store.tokens().get(promotion_token)
|
||||||
|
if (
|
||||||
|
not isinstance(token, dict)
|
||||||
|
or token.get("session_id") != session_id
|
||||||
|
or token.get("runtime_generation") != request.get("runtime_generation")
|
||||||
|
or token.get("binding") != lease.get("binding")
|
||||||
|
or token.get("consumed") is not False
|
||||||
|
):
|
||||||
|
raise BrokerFailure("PROMOTION_TOKEN_INVALID")
|
||||||
|
del self.store.tokens()[promotion_token]
|
||||||
|
lease["state"] = LEASE_PENDING_PROMOTION
|
||||||
|
lease["expires_at"] = time.monotonic() + int(lease["ttl_seconds"])
|
||||||
|
# handle() commits token consumption before finish_promotion() makes
|
||||||
|
# VERIFIED externally visible: promote-last by construction.
|
||||||
|
return {"ok": True, "state": LEASE_PENDING_PROMOTION}
|
||||||
|
if action == "revoke_lease":
|
||||||
|
session_id, _ = self.authenticate(peer_pid, request)
|
||||||
|
self.revoke_session_authority(session_id)
|
||||||
|
return {"ok": True, "state": LEASE_UNVERIFIED}
|
||||||
|
if action == "authorize_tool":
|
||||||
|
session_id, _ = self.authenticate(peer_pid, request)
|
||||||
|
runtime = request.get("runtime")
|
||||||
|
tool_name = request.get("tool_name")
|
||||||
|
if runtime not in READ_ONLY_TOOLS:
|
||||||
|
raise BrokerFailure("INVALID_RUNTIME")
|
||||||
|
if not isinstance(tool_name, str) or not tool_name or len(tool_name) > 256:
|
||||||
|
raise BrokerFailure("INVALID_TOOL")
|
||||||
|
lease = self.leases.get(session_id)
|
||||||
|
state = lease.get("state") if isinstance(lease, dict) else LEASE_UNVERIFIED
|
||||||
|
if tool_name in READ_ONLY_TOOLS[runtime] or tool_name == RECOVERY_TOOL:
|
||||||
|
return {"ok": True, "decision": "allow", "state": state}
|
||||||
|
if not isinstance(lease, dict) or lease.get("state") != LEASE_VERIFIED:
|
||||||
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"code": "MUTATOR_UNVERIFIED",
|
||||||
|
"decision": "deny",
|
||||||
|
"state": LEASE_UNVERIFIED,
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
lease.get("runtime") != runtime
|
||||||
|
or lease.get("runtime_generation") != request.get("runtime_generation")
|
||||||
|
):
|
||||||
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"code": "MUTATOR_UNVERIFIED",
|
||||||
|
"decision": "deny",
|
||||||
|
"state": LEASE_UNVERIFIED,
|
||||||
|
}
|
||||||
|
expires_at = lease.get("expires_at")
|
||||||
|
if not isinstance(expires_at, (int, float)) or time.monotonic() >= expires_at:
|
||||||
|
self.revoke_session_authority(session_id)
|
||||||
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"code": "LEASE_EXPIRED",
|
||||||
|
"decision": "deny",
|
||||||
|
"state": LEASE_UNVERIFIED,
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"decision": "allow",
|
||||||
|
"state": LEASE_VERIFIED,
|
||||||
|
}
|
||||||
raise BrokerFailure("UNKNOWN_ACTION")
|
raise BrokerFailure("UNKNOWN_ACTION")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Register a runtime parent with the lease broker, then exec without changing PID."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import socket
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Final
|
||||||
|
|
||||||
|
MAX_FRAME: Final = 64 * 1024
|
||||||
|
BROKER_TIMEOUT_SECONDS: Final = 1.5
|
||||||
|
|
||||||
|
|
||||||
|
def broker_request(socket_path: Path, request: dict[str, object]) -> dict[str, object]:
|
||||||
|
payload = (json.dumps(request, separators=(",", ":")) + "\n").encode()
|
||||||
|
response = bytearray()
|
||||||
|
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as connection:
|
||||||
|
connection.settimeout(BROKER_TIMEOUT_SECONDS)
|
||||||
|
connection.connect(str(socket_path))
|
||||||
|
connection.sendall(payload)
|
||||||
|
connection.shutdown(socket.SHUT_WR)
|
||||||
|
while len(response) <= MAX_FRAME:
|
||||||
|
chunk = connection.recv(min(4096, MAX_FRAME + 1 - len(response)))
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
response.extend(chunk)
|
||||||
|
if len(response) > MAX_FRAME or not response.endswith(b"\n"):
|
||||||
|
raise ValueError("invalid broker reply")
|
||||||
|
value = json.loads(response)
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
raise ValueError("invalid broker reply")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("--runtime", required=True, choices=("claude", "pi"))
|
||||||
|
parser.add_argument("command", nargs=argparse.REMAINDER)
|
||||||
|
arguments = parser.parse_args()
|
||||||
|
command = arguments.command
|
||||||
|
if command and command[0] == "--":
|
||||||
|
command = command[1:]
|
||||||
|
if not command:
|
||||||
|
print("lease-gated runtime command is required", file=sys.stderr)
|
||||||
|
return 64
|
||||||
|
|
||||||
|
try:
|
||||||
|
socket_path = Path(os.environ["MOSAIC_LEASE_BROKER_SOCKET"])
|
||||||
|
generation = int(os.environ.get("MOSAIC_RUNTIME_GENERATION", "1"))
|
||||||
|
if generation < 0:
|
||||||
|
raise ValueError("invalid generation")
|
||||||
|
reply = broker_request(
|
||||||
|
socket_path,
|
||||||
|
{
|
||||||
|
"action": "register_anchor",
|
||||||
|
"runtime_generation": generation,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
session_id = reply.get("session_id")
|
||||||
|
if (
|
||||||
|
reply.get("ok") is not True
|
||||||
|
or not isinstance(session_id, str)
|
||||||
|
or len(session_id) != 64
|
||||||
|
or any(character not in "0123456789abcdef" for character in session_id)
|
||||||
|
):
|
||||||
|
raise ValueError("registration refused")
|
||||||
|
except (KeyError, ValueError, OSError, json.JSONDecodeError):
|
||||||
|
print("Mosaic lease broker registration failed; runtime launch denied.", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
environment = dict(os.environ)
|
||||||
|
environment["MOSAIC_LEASE_SESSION_ID"] = session_id
|
||||||
|
environment["MOSAIC_RUNTIME_GENERATION"] = str(generation)
|
||||||
|
environment["MOSAIC_LEASE_RUNTIME"] = arguments.runtime
|
||||||
|
try:
|
||||||
|
os.execvpe(command[0], command, environment)
|
||||||
|
except OSError:
|
||||||
|
print("Mosaic lease-gated runtime exec failed.", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
91
packages/mosaic/framework/tools/lease-broker/mutator-gate.py
Normal file
91
packages/mosaic/framework/tools/lease-broker/mutator-gate.py
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Runtime-neutral whole mutator-class gate backed by the Mosaic lease broker."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import socket
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Final
|
||||||
|
|
||||||
|
MAX_FRAME: Final = 64 * 1024
|
||||||
|
BROKER_TIMEOUT_SECONDS: Final = 1.5
|
||||||
|
|
||||||
|
|
||||||
|
def deny(code: str) -> int:
|
||||||
|
print(f"BLOCKED: Mosaic mutator gate denied this tool ({code}).", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
|
||||||
|
def read_tool_name() -> str:
|
||||||
|
raw = sys.stdin.buffer.read(MAX_FRAME + 1)
|
||||||
|
if len(raw) > MAX_FRAME:
|
||||||
|
raise ValueError("INVALID_GATE_INPUT")
|
||||||
|
value = json.loads(raw)
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
raise ValueError("INVALID_GATE_INPUT")
|
||||||
|
tool_name = value.get("tool_name")
|
||||||
|
if not isinstance(tool_name, str) or not tool_name or len(tool_name) > 256:
|
||||||
|
raise ValueError("INVALID_GATE_INPUT")
|
||||||
|
return tool_name
|
||||||
|
|
||||||
|
|
||||||
|
def broker_request(socket_path: Path, request: dict[str, object]) -> dict[str, object]:
|
||||||
|
payload = (json.dumps(request, separators=(",", ":")) + "\n").encode()
|
||||||
|
if len(payload) > MAX_FRAME:
|
||||||
|
raise ValueError("INVALID_GATE_INPUT")
|
||||||
|
response = bytearray()
|
||||||
|
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as connection:
|
||||||
|
connection.settimeout(BROKER_TIMEOUT_SECONDS)
|
||||||
|
connection.connect(str(socket_path))
|
||||||
|
connection.sendall(payload)
|
||||||
|
connection.shutdown(socket.SHUT_WR)
|
||||||
|
while len(response) <= MAX_FRAME:
|
||||||
|
chunk = connection.recv(min(4096, MAX_FRAME + 1 - len(response)))
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
response.extend(chunk)
|
||||||
|
if len(response) > MAX_FRAME or not response.endswith(b"\n"):
|
||||||
|
raise ValueError("INVALID_BROKER_REPLY")
|
||||||
|
value = json.loads(response)
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
raise ValueError("INVALID_BROKER_REPLY")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("--runtime", required=True, choices=("claude", "pi"))
|
||||||
|
arguments = parser.parse_args()
|
||||||
|
|
||||||
|
try:
|
||||||
|
tool_name = read_tool_name()
|
||||||
|
socket_value = os.environ["MOSAIC_LEASE_BROKER_SOCKET"]
|
||||||
|
session_id = os.environ["MOSAIC_LEASE_SESSION_ID"]
|
||||||
|
generation = int(os.environ["MOSAIC_RUNTIME_GENERATION"])
|
||||||
|
if generation < 0:
|
||||||
|
raise ValueError("INVALID_GENERATION")
|
||||||
|
reply = broker_request(
|
||||||
|
Path(socket_value),
|
||||||
|
{
|
||||||
|
"action": "authorize_tool",
|
||||||
|
"session_id": session_id,
|
||||||
|
"runtime_generation": generation,
|
||||||
|
"runtime": arguments.runtime,
|
||||||
|
"tool_name": tool_name,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except (KeyError, ValueError, OSError, json.JSONDecodeError):
|
||||||
|
return deny("GATE_UNAVAILABLE")
|
||||||
|
|
||||||
|
if reply.get("ok") is True and reply.get("decision") == "allow":
|
||||||
|
return 0
|
||||||
|
code = reply.get("code")
|
||||||
|
return deny(code if isinstance(code, str) else "MUTATOR_UNVERIFIED")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -115,7 +115,7 @@ function auditClaudeSettings(): SettingsAudit {
|
|||||||
// Check required hooks
|
// Check required hooks
|
||||||
const hooks = settings['hooks'] as Record<string, unknown[]> | undefined;
|
const hooks = settings['hooks'] as Record<string, unknown[]> | undefined;
|
||||||
|
|
||||||
const requiredPreToolUse = ['prevent-memory-write.sh'];
|
const requiredPreToolUse = ['mutator-gate.py', 'prevent-memory-write.sh'];
|
||||||
const requiredPostToolUse = ['qa-hook-stdin.sh', 'typecheck-hook.sh'];
|
const requiredPostToolUse = ['qa-hook-stdin.sh', 'typecheck-hook.sh'];
|
||||||
|
|
||||||
const preHooks = (hooks?.['PreToolUse'] ?? []) as Array<Record<string, unknown>>;
|
const preHooks = (hooks?.['PreToolUse'] ?? []) as Array<Record<string, unknown>>;
|
||||||
@@ -763,7 +763,7 @@ function launchRuntime(runtime: RuntimeName, args: string[], yolo: boolean): nev
|
|||||||
cliArgs.push(...args);
|
cliArgs.push(...args);
|
||||||
}
|
}
|
||||||
console.log(`[mosaic] Launching ${label}${modeStr}${missionStr}...`);
|
console.log(`[mosaic] Launching ${label}${modeStr}${missionStr}...`);
|
||||||
execRuntime('claude', cliArgs);
|
execLeaseGatedRuntime('claude', cliArgs);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -798,7 +798,7 @@ function launchRuntime(runtime: RuntimeName, args: string[], yolo: boolean): nev
|
|||||||
cliArgs.push(...args);
|
cliArgs.push(...args);
|
||||||
}
|
}
|
||||||
console.log(`[mosaic] Launching ${label}${modeStr}${missionStr}...`);
|
console.log(`[mosaic] Launching ${label}${modeStr}${missionStr}...`);
|
||||||
execRuntime('pi', cliArgs);
|
execLeaseGatedRuntime('pi', cliArgs);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -806,6 +806,23 @@ function launchRuntime(runtime: RuntimeName, args: string[], yolo: boolean): nev
|
|||||||
process.exit(0); // Unreachable but satisfies never
|
process.exit(0); // Unreachable but satisfies never
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function defaultLeaseBrokerSocket(env: NodeJS.ProcessEnv = process.env): string {
|
||||||
|
if (env['MOSAIC_LEASE_BROKER_SOCKET']) return env['MOSAIC_LEASE_BROKER_SOCKET'];
|
||||||
|
const runtimeDir = env['XDG_RUNTIME_DIR'];
|
||||||
|
if (runtimeDir) return join(runtimeDir, 'mosaic-lease', 'broker.sock');
|
||||||
|
const uid = typeof process.getuid === 'function' ? process.getuid() : 0;
|
||||||
|
return join('/run/user', String(uid), 'mosaic-lease', 'broker.sock');
|
||||||
|
}
|
||||||
|
|
||||||
|
function execLeaseGatedRuntime(runtime: 'claude' | 'pi', args: string[]): void {
|
||||||
|
const launcher = resolveTool('lease-broker', 'launch-runtime.py');
|
||||||
|
execRuntime('python3', [launcher, '--runtime', runtime, '--', runtime, ...args], {
|
||||||
|
...process.env,
|
||||||
|
MOSAIC_LEASE_BROKER_SOCKET: defaultLeaseBrokerSocket(),
|
||||||
|
MOSAIC_RUNTIME_GENERATION: process.env['MOSAIC_RUNTIME_GENERATION'] ?? '1',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** exec into the runtime, replacing the current process. */
|
/** exec into the runtime, replacing the current process. */
|
||||||
function execRuntime(cmd: string, args: string[], env: NodeJS.ProcessEnv = process.env): void {
|
function execRuntime(cmd: string, args: string[], env: NodeJS.ProcessEnv = process.env): void {
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user