fix(#829): gate claudex and close branch coverage gaps
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:
@@ -6,7 +6,7 @@
|
|||||||
- 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 whole-class authorization denies every consequential, unknown, and custom tool while UNVERIFIED; it does not inspect shell strings or trust wrapper selection.
|
- WI-2 whole-class authorization denies every consequential, unknown, and custom tool while UNVERIFIED; it does not inspect shell strings or trust wrapper selection. First-class Claude/Pi and both Claudex dispatch modes use broker register-before-exec; Claudex additionally installs the mandatory all-tools hook inside its preserved isolated config and fails closed on unsafe settings.
|
||||||
- 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.
|
- 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.
|
- 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.
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Whole mutator-class lease gate
|
# 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.
|
WI-2 adds the framework-native authorization boundary for Claude (including the supported Claudex overlay) 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
|
## Default-deny policy
|
||||||
|
|
||||||
@@ -30,6 +30,7 @@ A receipt is only a future promotion prerequisite. It is not an obedience, resid
|
|||||||
`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.
|
`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.
|
- Claude installs `mutator-gate.py` as an all-tools (`.*`) `PreToolUse` hook.
|
||||||
|
- `mosaic claudex` and `mosaic yolo claudex` preserve their isolated `CLAUDE_CONFIG_DIR`, merge the mandatory hook into that isolated `settings.json`, and use the same register-before-exec launcher. Malformed or symlinked isolated settings deny launch.
|
||||||
- Pi invokes the same executable from its `tool_call` handler.
|
- 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.
|
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.
|
||||||
|
|||||||
@@ -10,14 +10,14 @@ 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:
|
Before launching Claude, Claudex, or Pi, export the socket path; `mosaic` then runs the runtime through the packaged register-and-exec wrapper:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
export MOSAIC_LEASE_BROKER_SOCKET=/run/user/1000/mosaic-lease/broker.sock
|
export MOSAIC_LEASE_BROKER_SOCKET=/run/user/1000/mosaic-lease/broker.sock
|
||||||
mosaic claude # or: mosaic pi
|
mosaic claude # or: mosaic claudex, mosaic yolo claudex, 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.
|
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. Claudex retains its isolated proxy environment and config directory; Mosaic merges the mandatory all-tools hook into that isolated `settings.json` before invoking the same wrapper. Broker registration failure, unsafe isolated settings, or missing identity denies launch/tool execution fail-closed; broker timeout/unavailability and malformed replies also block tools.
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
|
|||||||
@@ -71,3 +71,14 @@ Carried authority chain also verified/read for the locked T-B gate contract: SPE
|
|||||||
- BLOCKER 1 verified: first-class Claude/Pi route through `execLeaseGatedRuntime`, while the supported Claudex path preserves isolation but directly invokes `claude`; it therefore registers no anchor, injects no lease session, and the isolated config has no guaranteed all-tools gate hook.
|
- BLOCKER 1 verified: first-class Claude/Pi route through `execLeaseGatedRuntime`, while the supported Claudex path preserves isolation but directly invokes `claude`; it therefore registers no anchor, injects no lease session, and the isolated config has no guaranteed all-tools gate hook.
|
||||||
- BLOCKER 2 accepted: prior 88% was aggregate evidence. Remediation must produce independently measured branch coverage of at least 85% for each new executable (`launch-runtime.py`, `mutator-gate.py`, and daemon delta evidence), including successful exec-boundary collection and validation/error branches.
|
- BLOCKER 2 accepted: prior 88% was aggregate evidence. Remediation must produce independently measured branch coverage of at least 85% for each new executable (`launch-runtime.py`, `mutator-gate.py`, and daemon delta evidence), including successful exec-boundary collection and validation/error branches.
|
||||||
- Remediation discipline: RED tests first; preserve the reviewed-good broker lock/state-transition ordering; update PR #837 on the same branch; no self-review or merge.
|
- Remediation discipline: RED tests first; preserve the reviewed-good broker lock/state-transition ordering; update PR #837 on the same branch; no self-review or merge.
|
||||||
|
|
||||||
|
### Remediation evidence
|
||||||
|
|
||||||
|
- RED commit `046896c6`: both `mosaic claudex` and `mosaic yolo claudex` behavioral probes exited 1 because the direct path supplied neither a broker session nor the isolated all-tools hook; branch-focused Python tests failed on the absent injectable boundaries. Fresh WI-2 daemon-delta instrumentation also failed the ≥85% branch gate at 75%.
|
||||||
|
- GREEN: Claudex now exposes only `execLeaseGated`, passes the preserved isolated proxy environment through the shared register-before-exec wrapper, and merges the exact `.*` mutator hook into isolated `settings.json` with mode `0600`. Missing broker/identity, malformed or symlinked settings, and an unverified consequential tool all fail closed. The broker transition/lock implementation was not changed.
|
||||||
|
- Behavioral regression: normal and YOLO Claudex both receive a 64-hex broker session, retain their mode-specific arguments, observe the exact all-tools hook, and receive status 2 for unverified `Bash`.
|
||||||
|
- Independent branch coverage: `launch-runtime.py` 16/18 = **89%** (statements 98%); `mutator-gate.py` 21/22 = **95%** (statements 99%); `daemon.py` WI-2 delta 35/40 = **88%** (whole-file branch 80%, statements 90%).
|
||||||
|
- Fresh focused real-socket coverage run: WI-1 + WI-2 acceptance `46/46`; persistence `10/10`; branch unit suite `10/10`.
|
||||||
|
- Fresh full repository suite: `43/43` Turbo tasks; `@mosaicstack/mosaic` `72/72` files and `1,381/1,381` tests.
|
||||||
|
- Fresh root gates: typecheck `42/42`; lint `23/23`; format and `git diff --check` GREEN.
|
||||||
|
- PR #837 remains open and unmerged. Terra CODE and Opus SECREV must both rerun from zero on the exact remediated head before coordinator-owned merge authorization.
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import json
|
|||||||
import os
|
import os
|
||||||
import socket
|
import socket
|
||||||
import sys
|
import sys
|
||||||
|
from collections.abc import Callable, Mapping, Sequence
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Final
|
from typing import Final
|
||||||
|
|
||||||
@@ -36,11 +37,17 @@ def broker_request(socket_path: Path, request: dict[str, object]) -> dict[str, o
|
|||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main(
|
||||||
|
argv: Sequence[str] | None = None,
|
||||||
|
*,
|
||||||
|
environ: Mapping[str, str] | None = None,
|
||||||
|
request: Callable[[Path, dict[str, object]], dict[str, object]] = broker_request,
|
||||||
|
execute: Callable[[str, list[str], dict[str, str]], object] = os.execvpe,
|
||||||
|
) -> int:
|
||||||
parser = argparse.ArgumentParser()
|
parser = argparse.ArgumentParser()
|
||||||
parser.add_argument("--runtime", required=True, choices=("claude", "pi"))
|
parser.add_argument("--runtime", required=True, choices=("claude", "pi"))
|
||||||
parser.add_argument("command", nargs=argparse.REMAINDER)
|
parser.add_argument("command", nargs=argparse.REMAINDER)
|
||||||
arguments = parser.parse_args()
|
arguments = parser.parse_args(argv)
|
||||||
command = arguments.command
|
command = arguments.command
|
||||||
if command and command[0] == "--":
|
if command and command[0] == "--":
|
||||||
command = command[1:]
|
command = command[1:]
|
||||||
@@ -48,12 +55,13 @@ def main() -> int:
|
|||||||
print("lease-gated runtime command is required", file=sys.stderr)
|
print("lease-gated runtime command is required", file=sys.stderr)
|
||||||
return 64
|
return 64
|
||||||
|
|
||||||
|
source_environment = os.environ if environ is None else environ
|
||||||
try:
|
try:
|
||||||
socket_path = Path(os.environ["MOSAIC_LEASE_BROKER_SOCKET"])
|
socket_path = Path(source_environment["MOSAIC_LEASE_BROKER_SOCKET"])
|
||||||
generation = int(os.environ.get("MOSAIC_RUNTIME_GENERATION", "1"))
|
generation = int(source_environment.get("MOSAIC_RUNTIME_GENERATION", "1"))
|
||||||
if generation < 0:
|
if generation < 0:
|
||||||
raise ValueError("invalid generation")
|
raise ValueError("invalid generation")
|
||||||
reply = broker_request(
|
reply = request(
|
||||||
socket_path,
|
socket_path,
|
||||||
{
|
{
|
||||||
"action": "register_anchor",
|
"action": "register_anchor",
|
||||||
@@ -72,15 +80,16 @@ def main() -> int:
|
|||||||
print("Mosaic lease broker registration failed; runtime launch denied.", file=sys.stderr)
|
print("Mosaic lease broker registration failed; runtime launch denied.", file=sys.stderr)
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
environment = dict(os.environ)
|
environment = dict(source_environment)
|
||||||
environment["MOSAIC_LEASE_SESSION_ID"] = session_id
|
environment["MOSAIC_LEASE_SESSION_ID"] = session_id
|
||||||
environment["MOSAIC_RUNTIME_GENERATION"] = str(generation)
|
environment["MOSAIC_RUNTIME_GENERATION"] = str(generation)
|
||||||
environment["MOSAIC_LEASE_RUNTIME"] = arguments.runtime
|
environment["MOSAIC_LEASE_RUNTIME"] = arguments.runtime
|
||||||
try:
|
try:
|
||||||
os.execvpe(command[0], command, environment)
|
execute(command[0], command, environment)
|
||||||
except OSError:
|
except OSError:
|
||||||
print("Mosaic lease-gated runtime exec failed.", file=sys.stderr)
|
print("Mosaic lease-gated runtime exec failed.", file=sys.stderr)
|
||||||
return 1
|
return 1
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -8,8 +8,9 @@ import json
|
|||||||
import os
|
import os
|
||||||
import socket
|
import socket
|
||||||
import sys
|
import sys
|
||||||
|
from collections.abc import Callable, Mapping, Sequence
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Final
|
from typing import BinaryIO, Final
|
||||||
|
|
||||||
MAX_FRAME: Final = 64 * 1024
|
MAX_FRAME: Final = 64 * 1024
|
||||||
BROKER_TIMEOUT_SECONDS: Final = 1.5
|
BROKER_TIMEOUT_SECONDS: Final = 1.5
|
||||||
@@ -20,8 +21,9 @@ def deny(code: str) -> int:
|
|||||||
return 2
|
return 2
|
||||||
|
|
||||||
|
|
||||||
def read_tool_name() -> str:
|
def read_tool_name(stream: BinaryIO | None = None) -> str:
|
||||||
raw = sys.stdin.buffer.read(MAX_FRAME + 1)
|
source = sys.stdin.buffer if stream is None else stream
|
||||||
|
raw = source.read(MAX_FRAME + 1)
|
||||||
if len(raw) > MAX_FRAME:
|
if len(raw) > MAX_FRAME:
|
||||||
raise ValueError("INVALID_GATE_INPUT")
|
raise ValueError("INVALID_GATE_INPUT")
|
||||||
value = json.loads(raw)
|
value = json.loads(raw)
|
||||||
@@ -56,19 +58,26 @@ def broker_request(socket_path: Path, request: dict[str, object]) -> dict[str, o
|
|||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main(
|
||||||
|
argv: Sequence[str] | None = None,
|
||||||
|
*,
|
||||||
|
environ: Mapping[str, str] | None = None,
|
||||||
|
stream: BinaryIO | None = None,
|
||||||
|
request: Callable[[Path, dict[str, object]], dict[str, object]] = broker_request,
|
||||||
|
) -> int:
|
||||||
parser = argparse.ArgumentParser()
|
parser = argparse.ArgumentParser()
|
||||||
parser.add_argument("--runtime", required=True, choices=("claude", "pi"))
|
parser.add_argument("--runtime", required=True, choices=("claude", "pi"))
|
||||||
arguments = parser.parse_args()
|
arguments = parser.parse_args(argv)
|
||||||
|
source_environment = os.environ if environ is None else environ
|
||||||
|
|
||||||
try:
|
try:
|
||||||
tool_name = read_tool_name()
|
tool_name = read_tool_name(stream)
|
||||||
socket_value = os.environ["MOSAIC_LEASE_BROKER_SOCKET"]
|
socket_value = source_environment["MOSAIC_LEASE_BROKER_SOCKET"]
|
||||||
session_id = os.environ["MOSAIC_LEASE_SESSION_ID"]
|
session_id = source_environment["MOSAIC_LEASE_SESSION_ID"]
|
||||||
generation = int(os.environ["MOSAIC_RUNTIME_GENERATION"])
|
generation = int(source_environment["MOSAIC_RUNTIME_GENERATION"])
|
||||||
if generation < 0:
|
if generation < 0:
|
||||||
raise ValueError("INVALID_GENERATION")
|
raise ValueError("INVALID_GENERATION")
|
||||||
reply = broker_request(
|
reply = request(
|
||||||
Path(socket_value),
|
Path(socket_value),
|
||||||
{
|
{
|
||||||
"action": "authorize_tool",
|
"action": "authorize_tool",
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
import { describe, it, expect, vi } from 'vitest';
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
import { mkdtempSync, mkdirSync, symlinkSync, rmSync, lstatSync, writeFileSync } from 'node:fs';
|
import {
|
||||||
|
lstatSync,
|
||||||
|
mkdtempSync,
|
||||||
|
mkdirSync,
|
||||||
|
readFileSync,
|
||||||
|
rmSync,
|
||||||
|
symlinkSync,
|
||||||
|
writeFileSync,
|
||||||
|
} from 'node:fs';
|
||||||
import { tmpdir, homedir } from 'node:os';
|
import { tmpdir, homedir } from 'node:os';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import {
|
import {
|
||||||
@@ -14,6 +22,7 @@ import {
|
|||||||
buildClaudexEnv,
|
buildClaudexEnv,
|
||||||
buildClaudexBanner,
|
buildClaudexBanner,
|
||||||
buildClaudexContractNote,
|
buildClaudexContractNote,
|
||||||
|
ensureClaudexMutatorGateSettings,
|
||||||
runClaudexProxyGate,
|
runClaudexProxyGate,
|
||||||
launchClaudex,
|
launchClaudex,
|
||||||
type ClaudexHarnessAdapter,
|
type ClaudexHarnessAdapter,
|
||||||
@@ -36,12 +45,17 @@ function makeReport(overrides: Partial<PreflightReport> = {}): PreflightReport {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function okAdapter(overrides: Partial<ClaudexHarnessAdapter> = {}): ClaudexHarnessAdapter {
|
type TestAdapterOverrides = Partial<ClaudexHarnessAdapter> & {
|
||||||
|
exec?: (cmd: string, args: string[], env: NodeJS.ProcessEnv) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function okAdapter(overrides: TestAdapterOverrides = {}): ClaudexHarnessAdapter {
|
||||||
|
const { exec, ...adapterOverrides } = overrides;
|
||||||
return {
|
return {
|
||||||
harnessPreflight: () => {},
|
harnessPreflight: () => {},
|
||||||
composePrompt: () => '# Composed Claude contract',
|
composePrompt: () => '# Composed Claude contract',
|
||||||
exec: () => {},
|
execLeaseGated: adapterOverrides.execLeaseGated ?? ((args, env) => exec?.('claude', args, env)),
|
||||||
...overrides,
|
...adapterOverrides,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -561,11 +575,51 @@ describe('runClaudexProxyGate', () => {
|
|||||||
|
|
||||||
// ─── launch orchestration (fail-closed ordering) ──────────────────────────────
|
// ─── launch orchestration (fail-closed ordering) ──────────────────────────────
|
||||||
|
|
||||||
|
describe('ensureClaudexMutatorGateSettings', () => {
|
||||||
|
it('does not accept a lookalike hook and preserves isolated settings', () => {
|
||||||
|
const root = mkdtempSync(join(tmpdir(), 'claudex-gate-settings-'));
|
||||||
|
try {
|
||||||
|
writeFileSync(
|
||||||
|
join(root, 'settings.json'),
|
||||||
|
JSON.stringify({
|
||||||
|
preserved: true,
|
||||||
|
hooks: {
|
||||||
|
PreToolUse: [
|
||||||
|
{
|
||||||
|
matcher: '.*',
|
||||||
|
hooks: [{ type: 'command', command: 'echo lease-broker/mutator-gate.py' }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
ensureClaudexMutatorGateSettings(root);
|
||||||
|
|
||||||
|
const settings = JSON.parse(readFileSync(join(root, 'settings.json'), 'utf8')) as {
|
||||||
|
preserved: boolean;
|
||||||
|
hooks: { PreToolUse: Array<{ hooks: Array<{ command: string }> }> };
|
||||||
|
};
|
||||||
|
const commands = settings.hooks.PreToolUse.flatMap((entry) =>
|
||||||
|
entry.hooks.map((hook) => hook.command),
|
||||||
|
);
|
||||||
|
expect(settings.preserved).toBe(true);
|
||||||
|
expect(commands).toContain(
|
||||||
|
'python3 ~/.config/mosaic/tools/lease-broker/mutator-gate.py --runtime claude',
|
||||||
|
);
|
||||||
|
expect(lstatSync(join(root, 'settings.json')).mode & 0o777).toBe(0o600);
|
||||||
|
} finally {
|
||||||
|
rmSync(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('launchClaudex', () => {
|
describe('launchClaudex', () => {
|
||||||
const baseDeps = {
|
const baseDeps = {
|
||||||
baseEnv: {},
|
baseEnv: {},
|
||||||
proxyGate: () => Promise.resolve({ ok: true, report: makeReport(), problems: [] }),
|
proxyGate: () => Promise.resolve({ ok: true, report: makeReport(), problems: [] }),
|
||||||
resolveConfigDir: () => '/home/agent/.config/mosaic/claudex/home',
|
resolveConfigDir: () => '/home/agent/.config/mosaic/claudex/home',
|
||||||
|
prepareConfig: () => {},
|
||||||
log: () => {},
|
log: () => {},
|
||||||
errorLog: () => {},
|
errorLog: () => {},
|
||||||
fail: (() => {
|
fail: (() => {
|
||||||
|
|||||||
@@ -27,7 +27,14 @@
|
|||||||
* unit-testable without spawning Claude Code or touching a real config dir.
|
* unit-testable without spawning Claude Code or touching a real config dir.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { lstatSync, mkdirSync, realpathSync } from 'node:fs';
|
import {
|
||||||
|
chmodSync,
|
||||||
|
lstatSync,
|
||||||
|
mkdirSync,
|
||||||
|
readFileSync,
|
||||||
|
realpathSync,
|
||||||
|
writeFileSync,
|
||||||
|
} from 'node:fs';
|
||||||
import { homedir } from 'node:os';
|
import { homedir } from 'node:os';
|
||||||
import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
||||||
import {
|
import {
|
||||||
@@ -278,6 +285,77 @@ export function buildClaudexEnv(
|
|||||||
return env;
|
return env;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const CLAUDEX_MUTATOR_GATE_COMMAND =
|
||||||
|
'python3 ~/.config/mosaic/tools/lease-broker/mutator-gate.py --runtime claude';
|
||||||
|
|
||||||
|
const CLAUDEX_MUTATOR_GATE_HOOK = {
|
||||||
|
matcher: '.*',
|
||||||
|
hooks: [
|
||||||
|
{
|
||||||
|
type: 'command',
|
||||||
|
command: CLAUDEX_MUTATOR_GATE_COMMAND,
|
||||||
|
timeout: 3,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Install the mandatory all-tools gate in Claudex's isolated config without
|
||||||
|
* discarding any existing isolated settings. Malformed settings and symlinked
|
||||||
|
* settings files fail closed rather than launching with uncertain hook state.
|
||||||
|
*/
|
||||||
|
export function ensureClaudexMutatorGateSettings(configDir: string): void {
|
||||||
|
mkdirSync(configDir, { recursive: true, mode: 0o700 });
|
||||||
|
const settingsPath = join(configDir, 'settings.json');
|
||||||
|
let settings: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (lstatSync(settingsPath).isSymbolicLink()) {
|
||||||
|
throw new Error('claudex: isolated settings.json must not be a symlink (fail closed).');
|
||||||
|
}
|
||||||
|
const parsed: unknown = JSON.parse(readFileSync(settingsPath, 'utf8'));
|
||||||
|
if (!isRecord(parsed)) {
|
||||||
|
throw new Error('claudex: isolated settings.json must contain a JSON object (fail closed).');
|
||||||
|
}
|
||||||
|
settings = parsed;
|
||||||
|
} catch (err) {
|
||||||
|
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hooksValue = settings['hooks'];
|
||||||
|
if (hooksValue !== undefined && !isRecord(hooksValue)) {
|
||||||
|
throw new Error('claudex: isolated settings hooks must be an object (fail closed).');
|
||||||
|
}
|
||||||
|
const hooks = hooksValue ?? {};
|
||||||
|
const preToolUseValue = hooks['PreToolUse'];
|
||||||
|
if (preToolUseValue !== undefined && !Array.isArray(preToolUseValue)) {
|
||||||
|
throw new Error('claudex: isolated PreToolUse hooks must be an array (fail closed).');
|
||||||
|
}
|
||||||
|
const preToolUse = preToolUseValue ?? [];
|
||||||
|
const gatePresent = preToolUse.some(
|
||||||
|
(entry) =>
|
||||||
|
isRecord(entry) &&
|
||||||
|
entry['matcher'] === '.*' &&
|
||||||
|
Array.isArray(entry['hooks']) &&
|
||||||
|
entry['hooks'].some(
|
||||||
|
(hook) =>
|
||||||
|
isRecord(hook) &&
|
||||||
|
hook['type'] === 'command' &&
|
||||||
|
hook['command'] === CLAUDEX_MUTATOR_GATE_COMMAND,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (!gatePresent) preToolUse.unshift(CLAUDEX_MUTATOR_GATE_HOOK);
|
||||||
|
|
||||||
|
hooks['PreToolUse'] = preToolUse;
|
||||||
|
settings['hooks'] = hooks;
|
||||||
|
writeFileSync(settingsPath, `${JSON.stringify(settings, null, 2)}\n`, { mode: 0o600 });
|
||||||
|
chmodSync(settingsPath, 0o600);
|
||||||
|
}
|
||||||
|
|
||||||
// ─── EXPERIMENTAL classification (P4) ─────────────────────────────────────────
|
// ─── EXPERIMENTAL classification (P4) ─────────────────────────────────────────
|
||||||
|
|
||||||
/** Console banner shown at launch. Contains no token material by construction. */
|
/** Console banner shown at launch. Contains no token material by construction. */
|
||||||
@@ -396,8 +474,8 @@ export interface ClaudexHarnessAdapter {
|
|||||||
harnessPreflight: () => void;
|
harnessPreflight: () => void;
|
||||||
/** Compose the full Claude runtime contract (== `composeContract('claude')`). */
|
/** Compose the full Claude runtime contract (== `composeContract('claude')`). */
|
||||||
composePrompt: () => string;
|
composePrompt: () => string;
|
||||||
/** Replace the current process with `claude` using the composed env. */
|
/** Register the Claude parent with the broker, then exec with the same PID. */
|
||||||
exec: (cmd: string, args: string[], env: NodeJS.ProcessEnv) => void;
|
execLeaseGated: (args: string[], env: NodeJS.ProcessEnv) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LaunchClaudexDeps {
|
export interface LaunchClaudexDeps {
|
||||||
@@ -406,6 +484,7 @@ export interface LaunchClaudexDeps {
|
|||||||
resolveConfigDir?: () => string;
|
resolveConfigDir?: () => string;
|
||||||
models?: () => ClaudexModels;
|
models?: () => ClaudexModels;
|
||||||
buildEnv?: (base: NodeJS.ProcessEnv, opts: BuildClaudexEnvOptions) => NodeJS.ProcessEnv;
|
buildEnv?: (base: NodeJS.ProcessEnv, opts: BuildClaudexEnvOptions) => NodeJS.ProcessEnv;
|
||||||
|
prepareConfig?: (configDir: string) => void;
|
||||||
log?: (message: string) => void;
|
log?: (message: string) => void;
|
||||||
errorLog?: (message: string) => void;
|
errorLog?: (message: string) => void;
|
||||||
fail?: (code: number) => never;
|
fail?: (code: number) => never;
|
||||||
@@ -431,6 +510,7 @@ export async function launchClaudex(
|
|||||||
const resolveConfigDir = deps.resolveConfigDir ?? (() => resolveClaudexConfigDir(baseEnv));
|
const resolveConfigDir = deps.resolveConfigDir ?? (() => resolveClaudexConfigDir(baseEnv));
|
||||||
const models = deps.models ?? (() => resolveClaudexModels(baseEnv));
|
const models = deps.models ?? (() => resolveClaudexModels(baseEnv));
|
||||||
const buildEnv = deps.buildEnv ?? buildClaudexEnv;
|
const buildEnv = deps.buildEnv ?? buildClaudexEnv;
|
||||||
|
const prepareConfig = deps.prepareConfig ?? ensureClaudexMutatorGateSettings;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Harness readiness first (claude on PATH, mosaic home, sequential-thinking).
|
// Harness readiness first (claude on PATH, mosaic home, sequential-thinking).
|
||||||
@@ -447,6 +527,7 @@ export async function launchClaudex(
|
|||||||
// Compose the isolated launch env (guard throws → caught below, fail closed).
|
// Compose the isolated launch env (guard throws → caught below, fail closed).
|
||||||
const resolvedModels = models();
|
const resolvedModels = models();
|
||||||
const configDir = resolveConfigDir();
|
const configDir = resolveConfigDir();
|
||||||
|
prepareConfig(configDir);
|
||||||
const env = buildEnv(baseEnv, { configDir, models: resolvedModels });
|
const env = buildEnv(baseEnv, { configDir, models: resolvedModels });
|
||||||
const prompt = `${adapter.composePrompt()}\n\n${buildClaudexContractNote(resolvedModels)}`;
|
const prompt = `${adapter.composePrompt()}\n\n${buildClaudexContractNote(resolvedModels)}`;
|
||||||
|
|
||||||
@@ -454,7 +535,7 @@ export async function launchClaudex(
|
|||||||
|
|
||||||
const cliArgs = yolo ? ['--dangerously-skip-permissions'] : [];
|
const cliArgs = yolo ? ['--dangerously-skip-permissions'] : [];
|
||||||
cliArgs.push('--append-system-prompt', prompt, ...args);
|
cliArgs.push('--append-system-prompt', prompt, ...args);
|
||||||
adapter.exec('claude', cliArgs, env);
|
adapter.execLeaseGated(cliArgs, env);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
errorLog(
|
errorLog(
|
||||||
`[mosaic] claudex launch aborted: ${err instanceof Error ? err.message : String(err)}`,
|
`[mosaic] claudex launch aborted: ${err instanceof Error ? err.message : String(err)}`,
|
||||||
|
|||||||
@@ -814,12 +814,16 @@ function defaultLeaseBrokerSocket(env: NodeJS.ProcessEnv = process.env): string
|
|||||||
return join('/run/user', String(uid), 'mosaic-lease', 'broker.sock');
|
return join('/run/user', String(uid), 'mosaic-lease', 'broker.sock');
|
||||||
}
|
}
|
||||||
|
|
||||||
function execLeaseGatedRuntime(runtime: 'claude' | 'pi', args: string[]): void {
|
function execLeaseGatedRuntime(
|
||||||
|
runtime: 'claude' | 'pi',
|
||||||
|
args: string[],
|
||||||
|
baseEnv: NodeJS.ProcessEnv = process.env,
|
||||||
|
): void {
|
||||||
const launcher = resolveTool('lease-broker', 'launch-runtime.py');
|
const launcher = resolveTool('lease-broker', 'launch-runtime.py');
|
||||||
execRuntime('python3', [launcher, '--runtime', runtime, '--', runtime, ...args], {
|
execRuntime('python3', [launcher, '--runtime', runtime, '--', runtime, ...args], {
|
||||||
...process.env,
|
...baseEnv,
|
||||||
MOSAIC_LEASE_BROKER_SOCKET: defaultLeaseBrokerSocket(),
|
MOSAIC_LEASE_BROKER_SOCKET: defaultLeaseBrokerSocket(baseEnv),
|
||||||
MOSAIC_RUNTIME_GENERATION: process.env['MOSAIC_RUNTIME_GENERATION'] ?? '1',
|
MOSAIC_RUNTIME_GENERATION: baseEnv['MOSAIC_RUNTIME_GENERATION'] ?? '1',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -856,7 +860,7 @@ function launchClaudexProduction(args: string[], yolo: boolean): void {
|
|||||||
checkSequentialThinking('claude');
|
checkSequentialThinking('claude');
|
||||||
},
|
},
|
||||||
composePrompt: () => buildRuntimePrompt('claude'),
|
composePrompt: () => buildRuntimePrompt('claude'),
|
||||||
exec: (cmd, cmdArgs, env) => execRuntime(cmd, cmdArgs, env),
|
execLeaseGated: (cmdArgs, env) => execLeaseGatedRuntime('claude', cmdArgs, env),
|
||||||
};
|
};
|
||||||
void launchClaudex(args, yolo, adapter);
|
void launchClaudex(args, yolo, adapter);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -244,6 +244,52 @@ describe('whole mutator-class lease gate', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('lease and tool validation failures remain fail-closed at the broker boundary', async () => {
|
||||||
|
const { socket } = await startBroker();
|
||||||
|
const sessionId = await register(socket);
|
||||||
|
const baseRequest = {
|
||||||
|
action: 'begin_verification',
|
||||||
|
session_id: sessionId,
|
||||||
|
runtime_generation: 1,
|
||||||
|
runtime: 'claude',
|
||||||
|
ttl_seconds: 300,
|
||||||
|
binding: binding(),
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(await request(socket, { ...baseRequest, runtime: 'codex' })).toMatchObject({
|
||||||
|
ok: false,
|
||||||
|
code: 'INVALID_RUNTIME',
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
await request(socket, { ...baseRequest, binding: { ...binding(), h_source: 'bad' } }),
|
||||||
|
).toMatchObject({
|
||||||
|
ok: false,
|
||||||
|
code: 'INVALID_BINDING',
|
||||||
|
});
|
||||||
|
expect(await request(socket, { ...baseRequest, ttl_seconds: 0 })).toMatchObject({
|
||||||
|
ok: false,
|
||||||
|
code: 'INVALID_LEASE_TTL',
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
await request(socket, {
|
||||||
|
action: 'authorize_tool',
|
||||||
|
session_id: sessionId,
|
||||||
|
runtime_generation: 1,
|
||||||
|
runtime: 'codex',
|
||||||
|
tool_name: 'Read',
|
||||||
|
}),
|
||||||
|
).toMatchObject({ ok: false, code: 'INVALID_RUNTIME' });
|
||||||
|
expect(
|
||||||
|
await request(socket, {
|
||||||
|
action: 'authorize_tool',
|
||||||
|
session_id: sessionId,
|
||||||
|
runtime_generation: 1,
|
||||||
|
runtime: 'claude',
|
||||||
|
tool_name: 'x'.repeat(257),
|
||||||
|
}),
|
||||||
|
).toMatchObject({ ok: false, code: 'INVALID_TOOL' });
|
||||||
|
});
|
||||||
|
|
||||||
test('observer revocation and monotonic TTL expiry deny the next mutator', async () => {
|
test('observer revocation and monotonic TTL expiry deny the next mutator', async () => {
|
||||||
const { socket } = await startBroker();
|
const { socket } = await startBroker();
|
||||||
const sessionId = await register(socket);
|
const sessionId = await register(socket);
|
||||||
@@ -400,9 +446,7 @@ raise SystemExit(0 if len(session_id) == 64 and hook_present and denied else 1)
|
|||||||
const adapter = {
|
const adapter = {
|
||||||
harnessPreflight: () => {},
|
harnessPreflight: () => {},
|
||||||
composePrompt: () => '# composed Claude contract',
|
composePrompt: () => '# composed Claude contract',
|
||||||
// Pre-remediation Claudex uses this direct boundary and therefore bypasses registration.
|
// Claudex exposes only the shared register-before-exec boundary.
|
||||||
exec: run,
|
|
||||||
// The remediated orchestration must select this shared register-before-exec boundary.
|
|
||||||
execLeaseGated: (args: string[], env: NodeJS.ProcessEnv) =>
|
execLeaseGated: (args: string[], env: NodeJS.ProcessEnv) =>
|
||||||
run('python3', [launcherPath, '--runtime', 'claude', '--', 'claude', ...args], env),
|
run('python3', [launcherPath, '--runtime', 'claude', '--', 'claude', ...args], env),
|
||||||
} as unknown as ClaudexHarnessAdapter;
|
} as unknown as ClaudexHarnessAdapter;
|
||||||
@@ -438,8 +482,8 @@ raise SystemExit(0 if len(session_id) == 64 and hook_present and denied else 1)
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(execution).toBeDefined();
|
expect(execution).toBeDefined();
|
||||||
expect(execution!.status, execution!.stderr).toBe(0);
|
expect(execution!.status, String(execution!.stderr)).toBe(0);
|
||||||
expect(JSON.parse(execution!.stdout)).toEqual({
|
expect(JSON.parse(String(execution!.stdout))).toEqual({
|
||||||
session_id: expect.stringMatching(/^[a-f0-9]{64}$/),
|
session_id: expect.stringMatching(/^[a-f0-9]{64}$/),
|
||||||
hook_present: true,
|
hook_present: true,
|
||||||
denied: true,
|
denied: true,
|
||||||
|
|||||||
Reference in New Issue
Block a user