fix(#829): enforce repository-wide runtime launch choke point
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,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 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 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, both Claudex dispatch modes, PRDY, QA remediation, coord, orchestrator, and fleet starts converge on broker register-before-exec; Claudex additionally installs the mandatory all-tools hook inside its preserved isolated config and fails closed on unsafe settings.
|
||||||
|
- The permanent `check-runtime-launches.py` suite/CI guard scans production source for direct literal, absolute-path, process-API, command-array, and dynamic Claude/Pi launches. It has no bypass allowlist: an unrecognized launch form fails CI until routed through the common boundary.
|
||||||
- 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.
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,28 @@ A receipt is only a future promotion prerequisite. It is not an obedience, resid
|
|||||||
|
|
||||||
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.
|
||||||
|
|
||||||
|
## Runtime-launch choke-point and permanent guard
|
||||||
|
|
||||||
|
Every repository-owned Claude/Pi launch entry converges on `launch-runtime.py`, either directly or through `mosaic` → `execLeaseGatedRuntime`. PRDY init/update and QA remediation invoke the wrapper directly so their existing prompts, dangerous-permission flags, working directory, and environment survive without skipping broker registration. `@mosaicstack/coord` rewrites direct Claude commands to `mosaic claude` and rejects unknown custom Claude launchers.
|
||||||
|
|
||||||
|
`check-runtime-launches.py` is the permanent completeness guard. It scans production shell, TypeScript/JavaScript, Python, and data launch definitions under `packages/`, `apps/`, `plugins/`, and `tools/`; direct literal, absolute-path, process-API, and dynamic runtime launches fail. It is mandatory in `@mosaicstack/mosaic`'s test script, so root CI fails when a future entry does not name the wrapper, `execLeaseGatedRuntime`, or a gated `mosaic` launch. Synthetic scanner tests prove representative bypass forms are rejected, while real-socket tests prove PRDY init/update and QA receive broker sessions and deny an unverified mutator.
|
||||||
|
|
||||||
|
The live inventory is emitted by:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 packages/mosaic/framework/tools/lease-broker/check-runtime-launches.py --root . --json
|
||||||
|
```
|
||||||
|
|
||||||
|
| Production launch family | Gated entries |
|
||||||
|
| ------------------------------------------------------ | ------------: |
|
||||||
|
| `@mosaicstack/coord` default/configured Claude command | 2 |
|
||||||
|
| Fleet runtime start | 1 |
|
||||||
|
| QA remediation + generated QA command | 2 |
|
||||||
|
| Orchestrator command construction/session launches | 3 |
|
||||||
|
| PRDY init/update | 2 |
|
||||||
|
| Mosaic Claude/Pi/Claudex adapter and wrapper boundary | 4 |
|
||||||
|
| **Total** | **14 / 14** |
|
||||||
|
|
||||||
## Assurance boundary
|
## 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.
|
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.
|
||||||
|
|||||||
@@ -17,7 +17,15 @@ export MOSAIC_LEASE_BROKER_SOCKET=/run/user/1000/mosaic-lease/broker.sock
|
|||||||
mosaic claude # or: mosaic claudex, mosaic yolo claudex, 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. 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.
|
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. PRDY init/update, QA remediation, coord, orchestrator, and fleet launchers also converge on this boundary. Broker registration failure, unsafe isolated settings, or missing identity denies launch/tool execution fail-closed; broker timeout/unavailability and malformed replies also block tools.
|
||||||
|
|
||||||
|
Run the permanent launch inventory locally with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 packages/mosaic/framework/tools/lease-broker/check-runtime-launches.py --root .
|
||||||
|
```
|
||||||
|
|
||||||
|
The same check runs in the Mosaic package test suite and therefore in root CI. Any direct Claude/Pi binary launch must be replaced with `launch-runtime.py`, `execLeaseGatedRuntime`, or the gated `mosaic` runtime command; do not add static allowlist exceptions.
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
|
|||||||
@@ -89,3 +89,15 @@ Carried authority chain also verified/read for the locked T-B gate contract: SPE
|
|||||||
- Mechanical repository sweep found direct executing Claude entries in PRDY init, PRDY update, QA remediation, and `@mosaicstack/coord` task launch. It also found a direct Claude command rendered into the QA report template and documentation examples. Existing Mosaic CLI Claude/Pi/Claudex, orchestrator session-run, and fleet starts already reach the gated boundary.
|
- Mechanical repository sweep found direct executing Claude entries in PRDY init, PRDY update, QA remediation, and `@mosaicstack/coord` task launch. It also found a direct Claude command rendered into the QA report template and documentation examples. Existing Mosaic CLI Claude/Pi/Claudex, orchestrator session-run, and fleet starts already reach the gated boundary.
|
||||||
- Elevated hard requirements: ship a permanent suite/CI guard that scans production source and fails on any direct Claude/Pi launch; route every executing entry through one common gated wrapper; add real-broker RED/GREEN tests for PRDY init/update and QA; preserve each environment and denial behavior; independently measure all new executable coverage at ≥85%.
|
- Elevated hard requirements: ship a permanent suite/CI guard that scans production source and fails on any direct Claude/Pi launch; route every executing entry through one common gated wrapper; add real-broker RED/GREEN tests for PRDY init/update and QA; preserve each environment and denial behavior; independently measure all new executable coverage at ≥85%.
|
||||||
- Round-3 plan: first commit RED behavioral and scanner-contract tests; then add one framework `launch-runtime.sh` choke-point over `launch-runtime.py`, make Mosaic CLI and shell launchers use it, make coord route through `mosaic`, and wire the permanent guard into package tests. Update all discovered operator-facing direct-launch examples so the scanner inventory remains complete.
|
- Round-3 plan: first commit RED behavioral and scanner-contract tests; then add one framework `launch-runtime.sh` choke-point over `launch-runtime.py`, make Mosaic CLI and shell launchers use it, make coord route through `mosaic`, and wire the permanent guard into package tests. Update all discovered operator-facing direct-launch examples so the scanner inventory remains complete.
|
||||||
|
|
||||||
|
### Round-3 outcome
|
||||||
|
|
||||||
|
- RED commit `7f3418fa`: PRDY init, PRDY update, and QA remediation all reached the fake Claude binary without a broker session even when the configured socket did not exist; the permanent-guard contract initially failed because its executable was absent, then failed against the five discovered direct entries (four executing plus the QA command template).
|
||||||
|
- Choke-point decision: a new shell layer was unnecessary. Every executing repository entry now converges directly or through `mosaic`/`execLeaseGatedRuntime` on the existing single `launch-runtime.py` register-then-exec wrapper. PRDY and QA preserve their working directories, prompts, flags, logging pipe, and environment. Coord rewrites direct Claude commands to `mosaic claude` and rejects unknown custom Claude launchers fail-closed.
|
||||||
|
- Permanent guard: `packages/mosaic/framework/tools/lease-broker/check-runtime-launches.py`, invoked by `packages/mosaic/package.json` `test:framework-shell` and therefore root `pnpm test`/CI. It scans production code under `packages/`, `apps/`, `plugins/`, and `tools/` and rejects literal, absolute-path, process-API, command-array, and dynamic Claude/Pi launch forms. Synthetic bypass tests are permanent at `runtime_launch_guard_unittest.py`.
|
||||||
|
- Mechanical inventory: **14 gated / 14 total** — coord 2, fleet 1, QA 2, orchestrator 3, PRDY 2, Mosaic Claude/Pi/Claudex adapter/boundary 4. No verification-layer fallback or follow-up issue is needed because the single code-level wrapper was achieved.
|
||||||
|
- Real-socket behavioral evidence: PRDY init, PRDY update, and QA remediation each fail before runtime execution when the broker is absent; with the broker present they receive a broker-minted 64-hex session and the unverified `Bash` authorization exits 2. Claudex normal/YOLO and the broker state machine remain GREEN.
|
||||||
|
- Fresh branch coverage: `launch-runtime.py` **18/18 = 100%**; `mutator-gate.py` **22/22 = 100%**; permanent guard **36/38 = 95%**; `daemon.py` WI-2 delta **35/40 = 87.5%**. All attributable executable statement coverage is at least 98%.
|
||||||
|
- Fresh focused suites: broker + mutator real-socket acceptance `49/49`; persistence `10/10`; launcher/gate branch suite `13/13`; permanent guard suite `7/7`; coord `19/19`.
|
||||||
|
- Fresh full repository suite: `43/43` Turbo tasks; `@mosaicstack/mosaic` `72/72` files and `1,384/1,384` tests. Root typecheck `42/42`, lint `23/23`, format, and diff checks GREEN.
|
||||||
|
- PR #837 remains open and unmerged. Terra CODE and Opus SECREV must both rerun from zero on the exact round-3 head before coordinator-owned merge authorization.
|
||||||
|
|||||||
41
packages/coord/src/__tests__/runtime-launch-gate.test.ts
Normal file
41
packages/coord/src/__tests__/runtime-launch-gate.test.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { resolveLaunchCommand } from '../runner.js';
|
||||||
|
|
||||||
|
describe('coord consequential-runtime launch gate', () => {
|
||||||
|
it('routes default and direct configured Claude commands through mosaic', () => {
|
||||||
|
expect(resolveLaunchCommand('claude', 'continue', undefined)).toEqual([
|
||||||
|
'mosaic',
|
||||||
|
'claude',
|
||||||
|
'-p',
|
||||||
|
'continue',
|
||||||
|
]);
|
||||||
|
expect(resolveLaunchCommand('claude', 'continue', ['claude', '-p', '{prompt}'])).toEqual([
|
||||||
|
'mosaic',
|
||||||
|
'claude',
|
||||||
|
'-p',
|
||||||
|
'continue',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves an already-gated Claude command and rejects unknown launchers', () => {
|
||||||
|
expect(
|
||||||
|
resolveLaunchCommand('claude', 'continue', ['mosaic', 'yolo', 'claude', '{prompt}']),
|
||||||
|
).toEqual(['mosaic', 'yolo', 'claude', 'continue']);
|
||||||
|
expect(() => resolveLaunchCommand('claude', 'continue', ['custom-launcher'])).toThrow(
|
||||||
|
/must use `mosaic claude`/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not change the out-of-scope Codex command contract', () => {
|
||||||
|
expect(resolveLaunchCommand('codex', 'continue', undefined)).toEqual([
|
||||||
|
'codex',
|
||||||
|
'-p',
|
||||||
|
'continue',
|
||||||
|
]);
|
||||||
|
expect(resolveLaunchCommand('codex', 'continue', ['codex', '{prompt}'])).toEqual([
|
||||||
|
'codex',
|
||||||
|
'continue',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -179,32 +179,41 @@ function buildContinuationPrompt(params: {
|
|||||||
`3. Read \`${mission.scratchpadFile}\` for session history and decisions`,
|
`3. Read \`${mission.scratchpadFile}\` for session history and decisions`,
|
||||||
`4. Read \`${mission.tasksFile}\` for current task state`,
|
`4. Read \`${mission.tasksFile}\` for current task state`,
|
||||||
'5. `git pull --rebase` to sync latest changes',
|
'5. `git pull --rebase` to sync latest changes',
|
||||||
`6. Launch runtime with \`${runtime} -p\``,
|
`6. Launch runtime with \`mosaic ${runtime} -p\``,
|
||||||
`7. Continue execution from task **${taskId}**`,
|
`7. Continue execution from task **${taskId}**`,
|
||||||
'8. Follow Two-Phase Completion Protocol',
|
'8. Follow Two-Phase Completion Protocol',
|
||||||
`9. You are the SOLE writer of \`${mission.tasksFile}\``,
|
`9. You are the SOLE writer of \`${mission.tasksFile}\``,
|
||||||
].join('\n');
|
].join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveLaunchCommand(
|
export function resolveLaunchCommand(
|
||||||
runtime: 'claude' | 'codex',
|
runtime: 'claude' | 'codex',
|
||||||
prompt: string,
|
prompt: string,
|
||||||
configuredCommand: string[] | undefined,
|
configuredCommand: string[] | undefined,
|
||||||
): string[] {
|
): string[] {
|
||||||
if (configuredCommand === undefined || configuredCommand.length === 0) {
|
if (configuredCommand === undefined || configuredCommand.length === 0) {
|
||||||
return [runtime, '-p', prompt];
|
return runtime === 'claude' ? ['mosaic', 'claude', '-p', prompt] : [runtime, '-p', prompt];
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasPromptPlaceholder = configuredCommand.some((value) => value === '{prompt}');
|
const hasPromptPlaceholder = configuredCommand.some((value) => value === '{prompt}');
|
||||||
const withInterpolation = configuredCommand.map((value) =>
|
const withInterpolation = configuredCommand.map((value) =>
|
||||||
value === '{prompt}' ? prompt : value,
|
value === '{prompt}' ? prompt : value,
|
||||||
);
|
);
|
||||||
|
const command = hasPromptPlaceholder ? withInterpolation : [...withInterpolation, prompt];
|
||||||
|
|
||||||
if (hasPromptPlaceholder) {
|
if (runtime !== 'claude') return command;
|
||||||
return withInterpolation;
|
if (
|
||||||
|
command[0] === 'mosaic' &&
|
||||||
|
(command[1] === 'claude' || (command[1] === 'yolo' && command[2] === 'claude'))
|
||||||
|
) {
|
||||||
|
return command;
|
||||||
}
|
}
|
||||||
|
if (command[0] === 'claude') {
|
||||||
return [...withInterpolation, prompt];
|
return ['mosaic', 'claude', ...command.slice(1)];
|
||||||
|
}
|
||||||
|
throw new Error(
|
||||||
|
'Custom Claude task commands must use `mosaic claude` so lease registration cannot be bypassed.',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function writeAtomicJson(filePath: string, payload: unknown): Promise<void> {
|
async function writeAtomicJson(filePath: string, payload: unknown): Promise<void> {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
When spawning workers, include skill loading in the kickstart:
|
When spawning workers, include skill loading in the kickstart:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
claude -p "Read ~/.config/mosaic/skills/nestjs-best-practices/SKILL.md then implement..."codex exec "Read ~/.config/mosaic/skills/nestjs-best-practices/SKILL.md then implement..."
|
mosaic claude -p "Read ~/.config/mosaic/skills/nestjs-best-practices/SKILL.md then implement..."codex exec "Read ~/.config/mosaic/skills/nestjs-best-practices/SKILL.md then implement..."
|
||||||
```
|
```
|
||||||
|
|
||||||
#### **MANDATORY**
|
#### **MANDATORY**
|
||||||
|
|||||||
@@ -0,0 +1,217 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Fail CI when production code launches Claude/Pi outside the lease gate."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Final, NamedTuple, Sequence
|
||||||
|
|
||||||
|
SCANNED_ROOTS: Final = ("packages", "apps", "plugins", "tools")
|
||||||
|
SCANNED_SUFFIXES: Final = {
|
||||||
|
".bash",
|
||||||
|
".cjs",
|
||||||
|
".js",
|
||||||
|
".json",
|
||||||
|
".mjs",
|
||||||
|
".py",
|
||||||
|
".sh",
|
||||||
|
".ts",
|
||||||
|
".tsx",
|
||||||
|
".yaml",
|
||||||
|
".yml",
|
||||||
|
".zsh",
|
||||||
|
}
|
||||||
|
SKIPPED_DIRECTORIES: Final = {
|
||||||
|
".git",
|
||||||
|
".next",
|
||||||
|
".turbo",
|
||||||
|
"coverage",
|
||||||
|
"dist",
|
||||||
|
"node_modules",
|
||||||
|
}
|
||||||
|
|
||||||
|
# A launch line is gated only when it names the common wrapper, the TypeScript
|
||||||
|
# adapter that invokes that wrapper, or the `mosaic` runtime command that reaches
|
||||||
|
# the adapter. Merely mentioning a session ID or hook is not sufficient.
|
||||||
|
GATED_PATTERNS: Final = (
|
||||||
|
re.compile(r"\bexecLeaseGatedRuntime\s*\("),
|
||||||
|
re.compile(r"\blaunch-runtime\.(?:sh|py)\b"),
|
||||||
|
re.compile(
|
||||||
|
r"(?:\bexec\s+|\b(?:LAUNCH_COMMAND|launch_cmd)\s*=\s*\(?|\becho\s+[\"'])"
|
||||||
|
r"mosaic\s+(?:yolo\s+)?(?:claude|pi|claudex|[\"']?\$\{?runtime\}?[\"']?|[\"']?\$MOSAIC_AGENT_RUNTIME[\"']?)\b"
|
||||||
|
),
|
||||||
|
re.compile(r"\[\s*[\"']mosaic[\"']\s*,\s*(?:[\"']yolo[\"']\s*,\s*)?(?:runtime|[\"'](?:claude|pi|claudex)[\"'])"),
|
||||||
|
)
|
||||||
|
|
||||||
|
DIRECT_PATTERNS: Final = (
|
||||||
|
# Shell/process command forms, including here-doc command examples that an
|
||||||
|
# operator could execute verbatim.
|
||||||
|
re.compile(r"^\s*(?:claude|pi)(?:\s|$)"),
|
||||||
|
re.compile(r"(?:^|[;&|]\s*|\bexec\s+|\bcommand\s+)(?:claude|pi)\s+(?:-p\b|--dangerously\b|--print\b)"),
|
||||||
|
re.compile(r"\bexec\s+(?:claude|pi)(?:\s|$)"),
|
||||||
|
re.compile(r"\bexec\s+(?:/[^\s/]+)+/(?:claude|pi)(?:\s|$)"),
|
||||||
|
re.compile(r"\bexec\s+[\"']?\$(?:\{?runtime\}?|MOSAIC_AGENT_RUNTIME)\b"),
|
||||||
|
# JS/TS and Python process APIs with a literal runtime binary.
|
||||||
|
re.compile(
|
||||||
|
r"\b(?:spawn|spawnSync|exec|execSync|execFile|execFileSync|execv|execvp|execvpe|Popen|run|call|system|check_call|check_output)\s*\(\s*(?:\[\s*)?[\"'](?:claude|pi)(?:[\"']|\s)"
|
||||||
|
),
|
||||||
|
re.compile(
|
||||||
|
r"\b(?:spawn|spawnSync|exec|execSync|execFile|execFileSync|execv|execvp|execvpe|Popen|run|call|system|check_call|check_output)\s*\(\s*(?:\[\s*)?[\"'](?:/[^\"'/]+)+/(?:claude|pi)[\"']"
|
||||||
|
),
|
||||||
|
# Launch-command arrays and the prior @mosaicstack/coord dynamic default.
|
||||||
|
re.compile(r"\b(?:spawn|spawnSync|exec|execSync|execFile|execFileSync)\s*\(\s*runtime\b"),
|
||||||
|
re.compile(r"\b(?:command|launchCommand|LAUNCH_COMMAND)\s*=\s*(?:\(|\[)\s*[\"']?(?:claude|pi)\b"),
|
||||||
|
re.compile(r"\b(?:command|launchCommand|LAUNCH_COMMAND)\s*=\s*(?:\(|\[)\s*[\"']?\$(?:\{?runtime\}?|MOSAIC_AGENT_RUNTIME)\b"),
|
||||||
|
re.compile(r"\breturn\s*\[\s*runtime\s*,\s*[\"'](?:-p|--dangerously)"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class LaunchSite(NamedTuple):
|
||||||
|
path: Path
|
||||||
|
line_number: int
|
||||||
|
line: str
|
||||||
|
classification: str
|
||||||
|
|
||||||
|
|
||||||
|
def is_test_path(path: Path) -> bool:
|
||||||
|
name = path.name.lower()
|
||||||
|
return (
|
||||||
|
"__tests__" in path.parts
|
||||||
|
or ".spec." in name
|
||||||
|
or ".test." in name
|
||||||
|
or name.endswith("_unittest.py")
|
||||||
|
or name.startswith("test-")
|
||||||
|
or name.startswith("test_")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def is_gated_line(line: str) -> bool:
|
||||||
|
return any(pattern.search(line) for pattern in GATED_PATTERNS)
|
||||||
|
|
||||||
|
|
||||||
|
def is_direct_line(line: str) -> bool:
|
||||||
|
return any(pattern.search(line) for pattern in DIRECT_PATTERNS)
|
||||||
|
|
||||||
|
|
||||||
|
def scan_text(path: Path, source: str) -> list[LaunchSite]:
|
||||||
|
violations: list[LaunchSite] = []
|
||||||
|
gated_continuation = False
|
||||||
|
for line_number, line in enumerate(source.splitlines(), start=1):
|
||||||
|
stripped = line.strip()
|
||||||
|
if not stripped or stripped.startswith(("#", "//", "*")):
|
||||||
|
gated_continuation = False
|
||||||
|
continue
|
||||||
|
gated = is_gated_line(line) or gated_continuation
|
||||||
|
if is_direct_line(line) and not gated:
|
||||||
|
violations.append(LaunchSite(path, line_number, line.rstrip(), "direct"))
|
||||||
|
gated_continuation = gated and line.rstrip().endswith("\\")
|
||||||
|
return violations
|
||||||
|
|
||||||
|
|
||||||
|
def source_files(root: Path):
|
||||||
|
for relative_root in SCANNED_ROOTS:
|
||||||
|
search_root = root / relative_root
|
||||||
|
if not search_root.is_dir():
|
||||||
|
continue
|
||||||
|
for path in search_root.rglob("*"):
|
||||||
|
if not path.is_file() or path.suffix.lower() not in SCANNED_SUFFIXES:
|
||||||
|
continue
|
||||||
|
if any(part in SKIPPED_DIRECTORIES for part in path.parts):
|
||||||
|
continue
|
||||||
|
if is_test_path(path):
|
||||||
|
continue
|
||||||
|
yield path
|
||||||
|
|
||||||
|
|
||||||
|
def scan_repository(root: Path) -> list[LaunchSite]:
|
||||||
|
violations: list[LaunchSite] = []
|
||||||
|
for path in source_files(root):
|
||||||
|
try:
|
||||||
|
source = path.read_text(encoding="utf-8")
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
violations.append(LaunchSite(path, 0, "non-UTF-8 source", "unscannable"))
|
||||||
|
continue
|
||||||
|
violations.extend(scan_text(path.relative_to(root), source))
|
||||||
|
return violations
|
||||||
|
|
||||||
|
|
||||||
|
def inventory_repository(root: Path) -> list[LaunchSite]:
|
||||||
|
inventory: list[LaunchSite] = []
|
||||||
|
for path in source_files(root):
|
||||||
|
try:
|
||||||
|
lines = path.read_text(encoding="utf-8").splitlines()
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
inventory.append(LaunchSite(path.relative_to(root), 0, "non-UTF-8 source", "unscannable"))
|
||||||
|
continue
|
||||||
|
gated_continuation = False
|
||||||
|
for line_number, line in enumerate(lines, start=1):
|
||||||
|
stripped = line.strip()
|
||||||
|
if not stripped or stripped.startswith(("#", "//", "*")):
|
||||||
|
gated_continuation = False
|
||||||
|
continue
|
||||||
|
line_is_gated = is_gated_line(line)
|
||||||
|
gated = line_is_gated or gated_continuation
|
||||||
|
if line_is_gated and not stripped.startswith("function execLeaseGatedRuntime"):
|
||||||
|
inventory.append(
|
||||||
|
LaunchSite(path.relative_to(root), line_number, line.rstrip(), "gated")
|
||||||
|
)
|
||||||
|
elif is_direct_line(line) and not gated:
|
||||||
|
inventory.append(
|
||||||
|
LaunchSite(path.relative_to(root), line_number, line.rstrip(), "direct")
|
||||||
|
)
|
||||||
|
gated_continuation = gated and line.rstrip().endswith("\\")
|
||||||
|
return inventory
|
||||||
|
|
||||||
|
|
||||||
|
def format_violation(violation: LaunchSite) -> str:
|
||||||
|
return f"{violation.path}:{violation.line_number}: {violation.classification}: {violation.line.strip()}"
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: Sequence[str] | None = None) -> int:
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("--root", type=Path, default=Path.cwd())
|
||||||
|
parser.add_argument("--json", action="store_true")
|
||||||
|
arguments = parser.parse_args(argv)
|
||||||
|
root = arguments.root.resolve()
|
||||||
|
inventory = inventory_repository(root)
|
||||||
|
violations = [site for site in inventory if site.classification != "gated"]
|
||||||
|
|
||||||
|
if arguments.json:
|
||||||
|
print(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"gated": sum(site.classification == "gated" for site in inventory),
|
||||||
|
"total": len(inventory),
|
||||||
|
"sites": [
|
||||||
|
{
|
||||||
|
"path": str(site.path),
|
||||||
|
"line": site.line_number,
|
||||||
|
"classification": site.classification,
|
||||||
|
"source": site.line.strip(),
|
||||||
|
}
|
||||||
|
for site in inventory
|
||||||
|
],
|
||||||
|
},
|
||||||
|
sort_keys=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
for site in inventory:
|
||||||
|
print(format_violation(site))
|
||||||
|
print(
|
||||||
|
f"runtime launch inventory: "
|
||||||
|
f"{len(inventory) - len(violations)} gated/{len(inventory)} total"
|
||||||
|
)
|
||||||
|
|
||||||
|
if violations:
|
||||||
|
print("ungated consequential runtime launch detected", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -32,7 +32,7 @@ Claude:
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"worker": {
|
"worker": {
|
||||||
"command_template": "claude -p \"Execute task {task_id}: {task_title}\""
|
"command_template": "mosaic claude -p \"Execute task {task_id}: {task_title}\""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -86,7 +86,8 @@ echo ""
|
|||||||
|
|
||||||
cd "$PROJECT"
|
cd "$PROJECT"
|
||||||
if [[ "$RUNTIME_CMD" == "claude" ]]; then
|
if [[ "$RUNTIME_CMD" == "claude" ]]; then
|
||||||
exec claude --dangerously-skip-permissions --append-system-prompt "$SYSTEM_PROMPT" "$KICKOFF"
|
exec python3 "$SCRIPT_DIR/../lease-broker/launch-runtime.py" --runtime claude -- \
|
||||||
|
claude --dangerously-skip-permissions --append-system-prompt "$SYSTEM_PROMPT" "$KICKOFF"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ "$RUNTIME_CMD" == "codex" ]]; then
|
if [[ "$RUNTIME_CMD" == "codex" ]]; then
|
||||||
|
|||||||
@@ -74,7 +74,8 @@ echo ""
|
|||||||
|
|
||||||
cd "$PROJECT"
|
cd "$PROJECT"
|
||||||
if [[ "$RUNTIME_CMD" == "claude" ]]; then
|
if [[ "$RUNTIME_CMD" == "claude" ]]; then
|
||||||
exec claude --dangerously-skip-permissions --append-system-prompt "$SYSTEM_PROMPT" "$KICKOFF"
|
exec python3 "$SCRIPT_DIR/../lease-broker/launch-runtime.py" --runtime claude -- \
|
||||||
|
claude --dangerously-skip-permissions --append-system-prompt "$SYSTEM_PROMPT" "$KICKOFF"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ "$RUNTIME_CMD" == "codex" ]]; then
|
if [[ "$RUNTIME_CMD" == "codex" ]]; then
|
||||||
|
|||||||
@@ -190,7 +190,7 @@ Pending QA validation
|
|||||||
This report was created by the QA automation hook.
|
This report was created by the QA automation hook.
|
||||||
To process this report, run:
|
To process this report, run:
|
||||||
\`\`\`bash
|
\`\`\`bash
|
||||||
claude -p "Use Task tool to launch universal-qa-agent for report: $REPORT_PATH"
|
python3 ~/.config/mosaic/tools/lease-broker/launch-runtime.py --runtime claude -- claude -p "Use Task tool to launch universal-qa-agent for report: $REPORT_PATH"
|
||||||
\`\`\`
|
\`\`\`
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
|
|||||||
@@ -58,8 +58,8 @@ ACTIONS_PATH="$IN_PROGRESS_DIR/$ACTIONS_FILE"
|
|||||||
|
|
||||||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Starting remediation: $ACTIONS_PATH" | tee -a "$LOG_FILE"
|
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Starting remediation: $ACTIONS_PATH" | tee -a "$LOG_FILE"
|
||||||
|
|
||||||
# Trigger remediation agent
|
# Trigger remediation agent through the authenticated lease-broker choke-point.
|
||||||
claude -p "Use Task tool to launch auto-remediation-agent for:
|
python3 "$(dirname "$0")/../lease-broker/launch-runtime.py" --runtime claude -- claude -p "Use Task tool to launch auto-remediation-agent for:
|
||||||
- Remediation Report: $IN_PROGRESS_DIR/$(basename "$REPORT_FILE")
|
- Remediation Report: $IN_PROGRESS_DIR/$(basename "$REPORT_FILE")
|
||||||
- Actions File: $ACTIONS_PATH
|
- Actions File: $ACTIONS_PATH
|
||||||
- Max Iterations: 5
|
- Max Iterations: 5
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
"lint": "eslint src",
|
"lint": "eslint src",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"test": "vitest run --passWithNoTests && pnpm run test:framework-shell",
|
"test": "vitest run --passWithNoTests && pnpm run test:framework-shell",
|
||||||
"test:framework-shell": "bash framework/tools/codex/test-pr-diff-context.sh"
|
"test:framework-shell": "python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mosaicstack/brain": "workspace:*",
|
"@mosaicstack/brain": "workspace:*",
|
||||||
|
|||||||
@@ -4,8 +4,15 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import importlib.util
|
import importlib.util
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import runpy
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
|
from contextlib import redirect_stderr, redirect_stdout
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
|
||||||
MOSAIC_ROOT = Path(__file__).parents[2]
|
MOSAIC_ROOT = Path(__file__).parents[2]
|
||||||
@@ -26,6 +33,15 @@ class RuntimeLaunchGuardTest(unittest.TestCase):
|
|||||||
"typescript.ts": "spawn('pi', ['--print', prompt]);\n",
|
"typescript.ts": "spawn('pi', ['--print', prompt]);\n",
|
||||||
"python.py": "subprocess.run(['claude', '-p', prompt])\n",
|
"python.py": "subprocess.run(['claude', '-p', prompt])\n",
|
||||||
"dynamic.ts": "return [runtime, '-p', prompt];\n",
|
"dynamic.ts": "return [runtime, '-p', prompt];\n",
|
||||||
|
"plain-shell.sh": 'claude "$prompt"\n',
|
||||||
|
"node-exec.ts": 'exec("claude --print hello");\n',
|
||||||
|
"command-array.ts": "const launchCommand = ['pi', '--print', prompt];\n",
|
||||||
|
"python-system.py": 'os.system("claude -p prompt")\n',
|
||||||
|
"dynamic-shell.sh": 'exec "$runtime" "$prompt"\n',
|
||||||
|
"dynamic-spawn.ts": 'spawn(runtime, args);\n',
|
||||||
|
"dynamic-command.sh": 'LAUNCH_COMMAND=("$MOSAIC_AGENT_RUNTIME" --print)\n',
|
||||||
|
"absolute-shell.sh": 'exec /usr/local/bin/claude -p prompt\n',
|
||||||
|
"absolute-spawn.ts": "spawn('/opt/bin/pi', args);\n",
|
||||||
}
|
}
|
||||||
for filename, source in cases.items():
|
for filename, source in cases.items():
|
||||||
with self.subTest(filename=filename):
|
with self.subTest(filename=filename):
|
||||||
@@ -51,6 +67,68 @@ class RuntimeLaunchGuardTest(unittest.TestCase):
|
|||||||
"\n".join(GUARD.format_violation(violation) for violation in violations),
|
"\n".join(GUARD.format_violation(violation) for violation in violations),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_repository_walk_skips_tests_build_outputs_and_reports_unscannable_source(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
root = Path(directory)
|
||||||
|
production = root / "packages/example/src/launch.sh"
|
||||||
|
production.parent.mkdir(parents=True)
|
||||||
|
production.write_text("exec claude -p prompt\n")
|
||||||
|
(production.parent / "launch.spec.ts").write_text("spawn('pi', [])\n")
|
||||||
|
dist = root / "packages/example/dist/launch.js"
|
||||||
|
dist.parent.mkdir(parents=True)
|
||||||
|
dist.write_text("exec('claude -p prompt')\n")
|
||||||
|
ignored_suffix = production.parent / "notes.txt"
|
||||||
|
ignored_suffix.write_text("claude -p prompt\n")
|
||||||
|
invalid = production.parent / "invalid.py"
|
||||||
|
invalid.write_bytes(b"\xff\xfe")
|
||||||
|
|
||||||
|
violations = GUARD.scan_repository(root)
|
||||||
|
formatted = [GUARD.format_violation(item) for item in violations]
|
||||||
|
self.assertEqual(len(violations), 2)
|
||||||
|
self.assertTrue(any("launch.sh:1: direct" in item for item in formatted))
|
||||||
|
self.assertTrue(any("invalid.py:0: unscannable" in item for item in formatted))
|
||||||
|
self.assertFalse(any("spec" in item or "dist" in item or "notes" in item for item in formatted))
|
||||||
|
|
||||||
|
inventory = GUARD.inventory_repository(root)
|
||||||
|
self.assertEqual({item.classification for item in inventory}, {"direct", "unscannable"})
|
||||||
|
|
||||||
|
def test_main_emits_machine_inventory_and_fails_on_a_direct_site(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
root = Path(directory)
|
||||||
|
source = root / "packages/example/launch.sh"
|
||||||
|
source.parent.mkdir(parents=True)
|
||||||
|
source.write_text("exec claude -p prompt\n")
|
||||||
|
stdout = io.StringIO()
|
||||||
|
stderr = io.StringIO()
|
||||||
|
with redirect_stdout(stdout), redirect_stderr(stderr):
|
||||||
|
result = GUARD.main(["--root", str(root), "--json"])
|
||||||
|
payload = json.loads(stdout.getvalue())
|
||||||
|
self.assertEqual(result, 1)
|
||||||
|
self.assertEqual(payload["gated"], 0)
|
||||||
|
self.assertEqual(payload["total"], 1)
|
||||||
|
self.assertIn("ungated consequential runtime", stderr.getvalue())
|
||||||
|
|
||||||
|
def test_main_text_mode_reports_a_green_gated_inventory(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
root = Path(directory)
|
||||||
|
source = root / "packages/example/launch.sh"
|
||||||
|
source.parent.mkdir(parents=True)
|
||||||
|
source.write_text("exec mosaic yolo claude prompt\n")
|
||||||
|
stdout = io.StringIO()
|
||||||
|
with redirect_stdout(stdout):
|
||||||
|
result = GUARD.main(["--root", str(root)])
|
||||||
|
self.assertEqual(result, 0)
|
||||||
|
self.assertIn("1 gated/1 total", stdout.getvalue())
|
||||||
|
|
||||||
|
def test_script_entrypoint_uses_current_directory_default(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
root = Path(directory)
|
||||||
|
(root / "packages").mkdir()
|
||||||
|
with patch.object(sys, "argv", [str(GUARD_PATH)]), patch("pathlib.Path.cwd", return_value=root):
|
||||||
|
with redirect_stdout(io.StringIO()), self.assertRaises(SystemExit) as raised:
|
||||||
|
runpy.run_path(str(GUARD_PATH), run_name="__main__")
|
||||||
|
self.assertEqual(raised.exception.code, 0)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -6,7 +6,10 @@ from __future__ import annotations
|
|||||||
import importlib.util
|
import importlib.util
|
||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
|
import runpy
|
||||||
import socket
|
import socket
|
||||||
|
import sys
|
||||||
import unittest
|
import unittest
|
||||||
from contextlib import redirect_stderr
|
from contextlib import redirect_stderr
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -97,6 +100,17 @@ class LaunchRuntimeTest(unittest.TestCase):
|
|||||||
self.assertEqual(environment["MOSAIC_LEASE_RUNTIME"], "claude")
|
self.assertEqual(environment["MOSAIC_LEASE_RUNTIME"], "claude")
|
||||||
self.assertEqual(environment["PRESERVED"], "yes")
|
self.assertEqual(environment["PRESERVED"], "yes")
|
||||||
|
|
||||||
|
def test_command_without_separator_is_forwarded_unchanged(self) -> None:
|
||||||
|
executed: list[tuple[str, list[str], dict[str, str]]] = []
|
||||||
|
result = LAUNCHER.main(
|
||||||
|
["--runtime", "pi", "pi", "--print", "hello"],
|
||||||
|
environ={"MOSAIC_LEASE_BROKER_SOCKET": "/broker"},
|
||||||
|
request=lambda *_args: {"ok": True, "session_id": "f" * 64},
|
||||||
|
execute=lambda *args: executed.append(args),
|
||||||
|
)
|
||||||
|
self.assertEqual(result, 0)
|
||||||
|
self.assertEqual(executed[0][0:2], ("pi", ["pi", "--print", "hello"]))
|
||||||
|
|
||||||
def test_missing_command_is_usage_error(self) -> None:
|
def test_missing_command_is_usage_error(self) -> None:
|
||||||
with redirect_stderr(io.StringIO()):
|
with redirect_stderr(io.StringIO()):
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
@@ -185,6 +199,31 @@ class LaunchRuntimeTest(unittest.TestCase):
|
|||||||
self.assertEqual(fake.shutdown_how, socket.SHUT_WR)
|
self.assertEqual(fake.shutdown_how, socket.SHUT_WR)
|
||||||
|
|
||||||
|
|
||||||
|
class ExecutableEntrypointTest(unittest.TestCase):
|
||||||
|
def test_launcher_entrypoint_returns_usage_without_a_command(self) -> None:
|
||||||
|
with patch.object(
|
||||||
|
sys,
|
||||||
|
"argv",
|
||||||
|
[str(TOOLS_DIR / "launch-runtime.py"), "--runtime", "claude"],
|
||||||
|
), redirect_stderr(io.StringIO()), self.assertRaises(SystemExit) as raised:
|
||||||
|
runpy.run_path(str(TOOLS_DIR / "launch-runtime.py"), run_name="__main__")
|
||||||
|
self.assertEqual(raised.exception.code, 64)
|
||||||
|
|
||||||
|
def test_gate_entrypoint_denies_when_identity_environment_is_absent(self) -> None:
|
||||||
|
class Stdin:
|
||||||
|
buffer = io.BytesIO(b'{"tool_name":"Bash"}')
|
||||||
|
|
||||||
|
with patch.object(
|
||||||
|
sys,
|
||||||
|
"argv",
|
||||||
|
[str(TOOLS_DIR / "mutator-gate.py"), "--runtime", "claude"],
|
||||||
|
), patch.object(sys, "stdin", Stdin()), patch.dict(
|
||||||
|
os.environ, {}, clear=True
|
||||||
|
), redirect_stderr(io.StringIO()), self.assertRaises(SystemExit) as raised:
|
||||||
|
runpy.run_path(str(TOOLS_DIR / "mutator-gate.py"), run_name="__main__")
|
||||||
|
self.assertEqual(raised.exception.code, 2)
|
||||||
|
|
||||||
|
|
||||||
class MutatorGateTest(unittest.TestCase):
|
class MutatorGateTest(unittest.TestCase):
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def environment() -> dict[str, str]:
|
def environment() -> dict[str, str]:
|
||||||
|
|||||||
Reference in New Issue
Block a user