From 39fd4e3a8cbfe801ef8d701229a64fcb36108f33 Mon Sep 17 00:00:00 2001 From: Mother Seara Date: Thu, 10 Sep 2026 18:58:38 +0900 Subject: [PATCH 1/2] feat: standalone Yeoul workspaces with guarded CLI and MCP tasks --- .github/workflows/ci.yml | 18 +- CHANGELOG.md | 14 + README.md | 37 +- README_KO.md | 32 +- bin/arc-close | 4 +- bin/arc-list | 8 +- bin/arc-open | 9 +- bin/loop-guard | 1 + bin/verify_core.py | 3 + docs/INTEGRITY.md | 11 + docs/RUNTIME_CONTRACT.md | 203 +++++++++++ docs/WORKSPACE_GUIDE.ko.md | 115 ++++++ mcp/README.md | 30 +- mcp/pyproject.toml | 3 +- mcp/tests/test_product.py | 237 ++++++++++++ mcp/tests/test_runtime_contract.py | 335 +++++++++++++++++ mcp/yeoul_mcp/__init__.py | 2 +- mcp/yeoul_mcp/product.py | 21 ++ mcp/yeoul_mcp/runtime.py | 435 ++++++++++++++++++++++ mcp/yeoul_mcp/server.py | 84 ++++- mcp/yeoul_mcp/workspace.py | 562 +++++++++++++++++++++++++++++ setup/install.sh | 14 +- setup/mcp-servers.json | 5 +- tests/test_hardening.py | 4 +- 24 files changed, 2112 insertions(+), 75 deletions(-) create mode 100644 docs/RUNTIME_CONTRACT.md create mode 100644 docs/WORKSPACE_GUIDE.ko.md create mode 100644 mcp/tests/test_product.py create mode 100644 mcp/tests/test_runtime_contract.py create mode 100644 mcp/yeoul_mcp/product.py create mode 100644 mcp/yeoul_mcp/runtime.py create mode 100644 mcp/yeoul_mcp/workspace.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 69626bd..e5452ea 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -66,11 +66,11 @@ jobs: tools = [n for n in dir(server) if n in ( 'yeoul_new','arc_open','arc_ticket','loop_guard_init','loop_guard_tick', 'arc_close','build_handoff','ralph_gate_check','arc_prereg','verify_gate', - 'status','arc_list')] - assert len(tools) == 12, tools + 'status','arc_list','workspace_prepare','workspace_execute','workspace_tasks')] + assert len(tools) == 15, tools # ASCII only: the em-dash renders as "?" on the Windows console, which reads like # a mojibake defect in the log when nothing is actually wrong. - print("yeoul-mcp OK - 12 tools") + print("yeoul-mcp OK - 15 tools") PY - name: _run subprocess contract (stdin never inherited, UTF-8 pinned) run: python mcp/tests/test_run_contract.py @@ -78,7 +78,12 @@ jobs: # The import check above proves the module loads; it never reads the handshake. This # step launches the server and reads what it actually says on the wire. run: python mcp/tests/test_serverinfo_version.py - - name: Install compatible Mirror contract for integration tests + - name: Managed runtime permissions, receipts, process locks and real stdio + run: python mcp/tests/test_runtime_contract.py + - name: Independent product CLI, approvals, recovery and stdio reconnection + # Standalone suite runs FIRST: Mirror must not be required to use Yeoul. + run: python mcp/tests/test_product.py + - name: Install optional Mirror contract for integration tests run: pip install "git+https://github.com/mirror-stack/mirror-stack-mcp@v0.2.14" - name: CLI/MCP parity and real Mirror result contract run: python mcp/tests/test_surface_contract.py @@ -97,6 +102,11 @@ jobs: spec = Path(tmp)/'projects'/'installed-smoke'/'design'/'spec.md' assert '**Goal**' in spec.read_text(encoding='utf-8') PY + - name: Installed product CLI and MCP outside the checkout + shell: bash + run: | + cd "$RUNNER_TEMP" + PRODUCT_TEST_INSTALLED=1 python "$GITHUB_WORKSPACE/mcp/tests/test_product.py" - name: Source archive builds a self-contained wheel too shell: bash run: | diff --git a/CHANGELOG.md b/CHANGELOG.md index be250ec..5fbdba7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,20 @@ All notable changes to this project are documented here. +## [0.4.0] — 2026-09-10 + +- Independent `yeoul` setup/new/status/doctor/connect/serve CLI with observe, + discuss and develop modes; Mirror installation is now explicit opt-in. +- CLI/MCP task preparation, durable replay, workspace permissions and cooperative + cross-process locks. Three new workspace MCP tools; twelve business tools retained. +- Operator-only audited recovery, retired interrupted IDs, reviewed and hash-pinned + verification baselines, config history and explicit external read-ledger grants. +- Managed closures do not invoke an external action recorder. Separate product roots + are supported; no LaneStack dependency or cross-product transaction is implied. +- Atomic arc directory allocation, no silent loop-state reset, and conservative + interrupted verification handling. Wheels/sdists bundle the changed harness. +- See [workspace guide](docs/WORKSPACE_GUIDE.ko.md) and [runtime contract](docs/RUNTIME_CONTRACT.md). + ## [0.3.0] — 2026-09-09 ### Integrity contracts and compatibility changes diff --git a/README.md b/README.md index e4d9479..cd9484e 100644 --- a/README.md +++ b/README.md @@ -13,29 +13,32 @@ with integrity gates that resist self-deception and premature closure.** It is t on top of a measurement-discipline primitive (pre-registration + a tamper-evident ledger): the primitive checks recorded evidence and integrity; Yeoul answers *"how do I run a disciplined idea loop end to end?"* -> **v0.3.0: integrity-contract hardening.** See the [changelog](CHANGELOG.md) and -> [trust boundaries, installation and migration guide](docs/INTEGRITY.md). +> **v0.4.0: standalone workspace product.** Yeoul is independent of Mirror and LaneStack. +> See the [workspace guide (Korean)](docs/WORKSPACE_GUIDE.ko.md), +> [changelog](CHANGELOG.md) and [integrity boundaries](docs/INTEGRITY.md). + +For stdio MCP permissions, durable retries and cross-process locking, see the +[runtime contract](docs/RUNTIME_CONTRACT.md). Managed mode is opt-in with +`YEOUL_MCP_ROOT`; without it, the server is a trusted local runner without managed +permission enforcement or cooperative concurrency controls. ## Install ```bash -git clone https://github.com/mirror-stack/yeoul -cd yeoul -export PATH="$PWD/bin:$PATH" # the CLI: yeoul-new, arc-open, arc-close, ralph, status, … -setup/install.sh # installs mirror-stack (sealing primitive) + yeoul-mcp, prints MCP config -``` - -Register both MCP servers with your client (Claude Desktop/Code — merge `setup/mcp-servers.json`): - -```json -{ "mcpServers": { - "mirror-stack": { "command": "mirror-stack-mcp" }, - "yeoul": { "command": "yeoul-mcp" } -} } +pip install 'git+https://github.com/mirror-stack/yeoul@v0.4.0#subdirectory=mcp' +yeoul setup ./my-discussions --mode discuss +yeoul new example --workspace ./my-discussions +yeoul doctor --workspace ./my-discussions +yeoul connect --workspace ./my-discussions ``` -mirror-stack is optional — without a recorder, discussion closes are explicitly file-only -(`setup/install.sh --no-mirror-stack`). +Add the configuration printed by `yeoul connect` to your MCP client. No existing +client config is modified. Python 3.10+ and Bash (Git Bash on Windows) are required. +The checkout installer `setup/install.sh` also installs Yeoul alone; +`--with-mirror-stack` explicitly adds the independent Mirror product. +Linked ledgers may live in separate roots. Managed closures remain file-only and +do not invoke an external recorder. Raw checkout commands remain advanced, +trusted-local interfaces outside the managed product CLI/MCP lock. ## Quickstart diff --git a/README_KO.md b/README_KO.md index 1776ccc..ddf42ea 100644 --- a/README_KO.md +++ b/README_KO.md @@ -12,29 +12,27 @@ 돌리는 파일 기반 하네스입니다.** 측정 규율 프리미티브(사전등록 + 변조 감지 원장) 위에 얹힌 *실천 계층*이에요 — 프리미티브는 기록된 증거의 무결성을 확인하고, Yeoul은 *"규율 있는 아이디어 루프를 처음부터 끝까지 어떻게 돌리나?"*에 답합니다. -> **v0.3.0: 무결성 계약 보완.** [변경 기록](CHANGELOG.md)과 -> [검증 범위·설치·기존 아크 마이그레이션 안내](docs/INTEGRITY.md)를 확인하세요. +> **v0.4.0: 독립 제품의 간편 설정·실행·복구.** 거울과 레인스택 없이 사용할 수 있습니다. +> [간편 사용 안내](docs/WORKSPACE_GUIDE.ko.md), [변경 기록](CHANGELOG.md), +> [검증 범위·기존 아크 안내](docs/INTEGRITY.md)를 확인하세요. ## 설치 ```bash -git clone https://github.com/mirror-stack/yeoul -cd yeoul -export PATH="$PWD/bin:$PATH" # CLI: yeoul-new, arc-open, arc-close, ralph, status, … -setup/install.sh # mirror-stack(봉인 프리미티브) + yeoul-mcp 설치 + MCP 설정 출력 +pip install 'git+https://github.com/mirror-stack/yeoul@v0.4.0#subdirectory=mcp' +yeoul setup ./my-discussions --mode discuss +yeoul new example --workspace ./my-discussions +yeoul doctor --workspace ./my-discussions +yeoul connect --workspace ./my-discussions ``` -두 MCP 서버를 클라이언트에 등록(Claude Desktop/Code — `setup/mcp-servers.json` 병합): - -```json -{ "mcpServers": { - "mirror-stack": { "command": "mirror-stack-mcp" }, - "yeoul": { "command": "yeoul-mcp" } -} } -``` - -mirror-stack은 선택입니다 — 기록기가 없으면 토론 종결은 파일 전용으로 명시됩니다 -(`setup/install.sh --no-mirror-stack`). +`yeoul connect`가 출력한 서버 항목을 사용 중인 MCP 클라이언트에 추가하세요. +기존 설정은 자동 수정하지 않습니다. Python 3.10+와 Bash(Windows: Git Bash)가 필요합니다. +소스 설치의 `setup/install.sh`도 여울만 설치하며, +`--with-mirror-stack`을 선택해야 거울을 함께 설치합니다. +원장은 별도 폴더에 두고 파일별 읽기 권한으로 연결할 수 있습니다. +관리 모드 종결은 외부 기록기를 실행하지 않습니다. 기존 원시 CLI·스크립트는 +고급 로컬 인터페이스이며 제품 CLI/MCP의 공통 잠금 밖에 있습니다. ## 빠른 시작 diff --git a/bin/arc-close b/bin/arc-close index b0009d1..1b31fc2 100755 --- a/bin/arc-close +++ b/bin/arc-close @@ -296,7 +296,9 @@ echo "ARC_CLOSED ${TODAY} stop=${STOP} — ${VERDICT}" >> "$ARCHIVE_DIR/$ARC/STA # which ran `am` and leaked its exit status into the close (caught by tests/test_gates.sh). SEAL_MSG='not sealed — file only (`am` CLI not on PATH; mirror-stack may still be installed)' RECORD_STATE="file-only" -if command -v am >/dev/null 2>&1; then +if [ "${YEOUL_MCP_MANAGED:-0}" = "1" ]; then + SEAL_MSG='not sealed — managed MCP disables external am recording; file only' +elif command -v am >/dev/null 2>&1; then if am record --agent yeoul --action arc-close --target "$ARC" \ --payload "{\"stop_reason\":\"${STOP}\"}" \ --content-file "$SEALED" >/dev/null 2>&1; then diff --git a/bin/arc-list b/bin/arc-list index 90ecb53..cdbaf35 100755 --- a/bin/arc-list +++ b/bin/arc-list @@ -14,11 +14,15 @@ for arg in "$@"; do --all) OPEN_ONLY=0 ;; esac done -[ -z "$ROOTS" ] && ROOTS="$PROJECTS_DIR" +if [ -z "$ROOTS" ]; then + SEARCH_ROOTS=("$PROJECTS_DIR") +else + read -r -a SEARCH_ROOTS <<< "$ROOTS" +fi field() { grep -m1 "^$1:" "$2" 2>/dev/null | sed "s/^$1:[[:space:]]*//; s/\"//g"; } -for root in $ROOTS; do +for root in "${SEARCH_ROOTS[@]}"; do [ -d "$root" ] || continue while IFS= read -r arcfile; do # 🔴 `--all` has to mean all. This skip used to run BEFORE the OPEN_ONLY test, so the flag diff --git a/bin/arc-open b/bin/arc-open index dc7ca85..83eb203 100755 --- a/bin/arc-open +++ b/bin/arc-open @@ -49,7 +49,14 @@ TS_ISO=$(date -Iseconds 2>/dev/null || date +"%Y-%m-%dT%H:%M:%S") TODAY=$(date +%Y-%m-%d) ARC="${TS}_${SLUG}" ARC_DIR="$ARCS_DIR/$ARC" -mkdir -p "$ARC_DIR/ARC" +# Reserve a unique directory atomically. Same-second calls must never overwrite an arc. +mkdir -p "$ARCS_DIR" +if ! mkdir "$ARC_DIR" 2>/dev/null; then + [ -e "$ARC_DIR" ] || { echo 'cannot create arc directory' >&2; exit 1; } + ARC_DIR="$(mktemp -d "$ARCS_DIR/${TS}_${SLUG}.XXXXXX")" + ARC="$(basename "$ARC_DIR")" +fi +mkdir "$ARC_DIR/ARC" for r in $ROLES; do mkdir -p "$ARC_DIR/tickets/$r"; done # ── role assignment ledger (relay = auto-assigned to creator, work roles = unassigned) ── diff --git a/bin/loop-guard b/bin/loop-guard index c31f370..40878de 100755 --- a/bin/loop-guard +++ b/bin/loop-guard @@ -50,6 +50,7 @@ warn_unmeasured(){ [ "$UM" -gt 0 ] && printf ' unmeasured=%s/%s-ticks' "$UM" "$R case "$CMD" in init) + [ ! -e "$STATE" ] || { echo 'guard already initialized; refusing to reset accumulated state' >&2; exit 2; } ROUND=0; MR="${MAXR:-3}"; TB="${BUDGET:-200000}"; TU=0; NP=0; UM=0; save echo "loop init: max_rounds=$MR token_budget=$TB" ;; tick) diff --git a/bin/verify_core.py b/bin/verify_core.py index 6f25dbb..1ef117a 100644 --- a/bin/verify_core.py +++ b/bin/verify_core.py @@ -74,6 +74,9 @@ def verify(todo, *, baseline=None, revert=False, require=False, timeout=120): command = VERIFY.search(line) if item and item[1] == 'x' and (command or require): rc = execute([os.environ.get('YEOUL_BASH', 'bash'), '-c', command[1]], timeout=timeout) if command else 1 + if rc == 124 or rc < 0: + print('verification interrupted; reconciliation required', file=sys.stderr) + return 124 if rc: print(f'verify failed (exit {rc}): {line.strip()}', file=sys.stderr) failed = True diff --git a/docs/INTEGRITY.md b/docs/INTEGRITY.md index 1d5846d..d181f89 100644 --- a/docs/INTEGRITY.md +++ b/docs/INTEGRITY.md @@ -80,6 +80,17 @@ baseline path is `TODO.md.verify-baseline.json`; creating an existing baseline i refused. Baseline approval is a supervisor action, not an automatic MCP tool. Criteria changes require explicit review and a new baseline path. +Ordinary failed verification commands revert their checked boxes when `--revert` +is enabled. An interrupted or timed-out command instead makes `verify-gate` +return **124**, stops later commands, and leaves the TODO unchanged for +reconciliation. A surviving checked box is **not evidence of successful +verification**. Inspect partial command effects and surviving children before +resuming; managed MCP leaves the operation pending and refuses automatic retry. +See the [stdio runtime contract](RUNTIME_CONTRACT.md) for managed permissions, +approved baseline locations, receipt recovery and concurrency limits. The baseline +paths above describe direct CLI/trusted usage; managed MCP requires its configured +baseline under `YEOUL_MCP_ROOT/.yeoul-approved/`. + Keep the standalone baseline and verification implementations outside the worker's write permissions. A directory name alone does not enforce that boundary. `--current-only` is an explicit manual diagnostic that prints its weaker scope; diff --git a/docs/RUNTIME_CONTRACT.md b/docs/RUNTIME_CONTRACT.md new file mode 100644 index 0000000..2b460e6 --- /dev/null +++ b/docs/RUNTIME_CONTRACT.md @@ -0,0 +1,203 @@ +# Yeoul stdio MCP runtime contract + +Yeoul keeps its 12 business tools and adds 3 workspace tools in v0.4.0; it delegates business decisions to the existing +harness. Projects, arcs, tickets, summaries, bindings and loop state remain files. +The MCP boundary adds permission checks, cooperative serialization and durable +operation receipts. Receipts are control metadata, not a business ledger. + +Start with the [workspace guide](WORKSPACE_GUIDE.ko.md): `yeoul setup` saves an +independent product profile; `yeoul serve` applies it. Mirror and LaneStack are optional. + +## Modes and capabilities + +Without `YEOUL_MCP_ROOT`, the server remains a **trusted local tool runner** and +prints a warning to stderr at startup. Existing calls without an operation ID keep +working. Paths and subprocesses have the server account's authority. There are no +managed permission, retry or concurrency controls in this mode. Supplying an +`operation_id` without managed mode is refused. + +Managed mode is opt-in through the server's environment: + +| Variable | Contract | +| --- | --- | +| `YEOUL_MCP_ROOT` | Explicit absolute existing workspace directory; filesystem roots, traversal and linked roots are refused. Empty is an error, not trusted mode. | +| `YEOUL_MCP_ALLOW_WRITE=1` | Enables mutations. Unset or any other value denies them. | +| `YEOUL_MCP_WRITE_TOOLS` | Optional comma-separated allowlist of mutating tool names. Unset permits all nine when writes are enabled; empty denies all; unknown names are refused. | +| `YEOUL_MCP_ALLOW_EXEC=1` | Separately enables arbitrary verification commands; still requires write permission, an allowed tool, an operation ID and an approved baseline. | +| `YEOUL_MCP_VERIFY_BASELINE` | Supervisor-configured absolute existing baseline JSON under `ROOT/.yeoul-approved/`. Required for managed verification. | +| `YEOUL_MCP_VERIFY_BASELINE_SHA256` | Hash checked at every verification. Set by product approval; optional only for legacy environment configuration. | +| `YEOUL_MCP_READ_LEDGERS` | JSON array of exact absolute ledger files, granting read-only ledger inputs, not external writes or arbitrary command authority. | + +The nine mutations are `yeoul_new`, `arc_open`, `arc_ticket`, `loop_guard_init`, +`loop_guard_tick`, `arc_close`, `arc_prereg`, `build_handoff` and `verify_gate`. +Each has an optional `operation_id: str | None` in its MCP schema for compatibility; +managed mode requires it at runtime. `verify_gate(revert=False)` is still a mutation +because verification executes commands. IDs are 1–128 ASCII letters, digits, +underscores, dots, colons or hyphens, starting with a letter or digit. + +`status`, `arc_list` and `ralph_gate_check` are read-only business operations. They +may create `.yeoul-mcp/` and its persistent lock file; they do not create receipts, +execute TODO verification, initialize loops, or write business state. Managed +Python children have bytecode caching disabled. Fixed, trusted harness subprocesses +are necessary for these tools and do not require `ALLOW_EXEC`. + +Configuration belongs to the supervisor. These switches are not per-caller +identity or role authentication: everyone using one server has its capabilities. +This change does not activate managed mode or modify client configuration. + +## Path and execution scope + +Managed relative paths resolve against the selected workspace; `workspace="."` +means `YEOUL_MCP_ROOT`, independently of the server's launch directory. Workspaces +must already exist inside the root. Names, slugs, roles and relay names use a +restricted ASCII identifier alphabet, preventing path and option injection. + +Explicit paths and `YEOUL_PROJECTS`, `YEOUL_INDEX`, `YEOUL_LEDGER`, and +`YEOUL_CLOSED_REGISTRY` paths are checked. Managed defaults stay inside the selected +workspace, including the closed-question registry. `.prereg` ledger references, +archive destinations, and paths selected through script globs are covered by a +conservative workspace scan. External ledger references require an explicit exact-file +read grant; there is no implicit exception for a nearby Mirror workspace. `arc_close` disables its optional +external `am record` hook in managed mode, even if `am` is on PATH. Its local +knowledge-index update remains part of the locked operation. + +The scan rejects symlinks, Windows reparse points/junctions, hardlinked files and +special files. `..`, control characters and ambiguous alternate path syntax are +refused. This deliberately includes unrelated linked files anywhere in the managed +workspace. The scan stops and refuses at 20,000 entries or five seconds; choose a +small dedicated workspace. The control directory is checked separately rather than +recursively scanning historical receipts. Nested managed roots are refused when +their control directories are present; provision non-overlapping roots. + +`.yeoul-mcp`, `.yeoul-approved` and `.yeoul-workspace.json` are reserved from tool-selected business paths. +On POSIX the control directory must belong to the server UID and have no group or +other permissions (created as 0700). On Windows, the supervisor must provide +equivalent restrictive ACLs; the Python stdlib cannot verify those ACLs here. + +Runtime code, Python/Bash, `YEOUL_BIN`, interpreter overrides, executable search +paths and installed dependencies must be supervisor-controlled. Managed children +remove shell startup injection variables, exported Bash functions, and Python path +injection variables. That is defense in depth, not an executable allowlist or an +OS sandbox. The worker must not be able to rewrite the runtime or its environment. + +For verification, the supervisor creates and reviews a baseline using the existing +`verify-baseline` CLI and configures its approved path. An explicit `baseline_path` +argument must match that configured file. Baseline content/path and TODO criteria +are checked by the existing verification gate before commands execute. Keep the +baseline **and test implementations outside worker write permissions**, using OS +permissions or separate identities. The reserved directory prevents ordinary MCP +path selection; it cannot constrain arbitrary shell commands. Baseline approval +does not itself validate that a command is safe. Commands run with the server +account's full filesystem/network/process authority, and may contain indirect +paths. Review their dependencies and forbid detached/background work operationally. + +## Locking and retry protocol + +Every managed call holds an exclusive workspace lock through validation, script +execution and receipt completion. The lock uses `fcntl.flock` on POSIX and +`msvcrt.locking` on Windows, plus an in-process mutex. Acquisition waits at most +five seconds. A persistent lock file is never deleted based on a PID or age. +Kernel locks release when the owning process exits. The existing `.close.lock` +directory and gate behavior are preserved. + +Mutations follow this sequence: + +1. Check current capabilities, safe paths and approval configuration under the lock. +2. Compare the operation ID with the journal in `.yeoul-mcp/`. Its SHA-256 filename + is only a safe lookup key, not authentication. The request fingerprint includes + the tool, default-expanded arguments, root, workspace and relevant Yeoul + environment; permission switches are checked separately on every call. +3. Persist an active-operation pointer, then its pending receipt, flushing both + before launching the harness. A crash between these writes leaves a missing + receipt that blocks all subsequent calls for reconciliation. JSON writes use temporary files and atomic replacement; + POSIX also fsyncs parent directories. Failure here prevents execution. +4. Persist the completed response after the harness returns an ordinary exit code. + Ordinary gate refusals are completed responses too. The initial and replayed + response use the same key order for identical MCP text content. + +Same ID and same request return the recorded completed response without rerunning +the harness. Changed arguments, tool or fingerprinted configuration return +`operation_conflict`. Current security checks still apply to replay; an archived +original path may be absent, but a newly introduced unsafe link or revoked +capability causes refusal. The response is historical, not a fresh state check. +Use a new ID for each intentional phase of two-phase `arc_close`, and for a new +attempt after editing a summary that received a completed gate refusal. + +A pending receipt is never retried automatically, even if no effect is visible. +Timeout, launch uncertainty, signal exit, failure to persist completion or process +crash can leave partial effects. They require reconciliation. A pending active +operation blocks other IDs **and read tools**, since a surviving child may still +be writing. Receipts reject duplicate keys, nonfinite JSON numbers, malformed +schemas and mismatched request fingerprints. Damaged metadata fails closed. + +The outer script deadline is 120 seconds, with a bounded five-second pipe cleanup +after timeout. POSIX kills the outer process group; Windows kills the direct child. +Nested process groups and escaped descendants may survive, including subprocesses +started by an approved verification command. Verification interruption now returns +124 immediately without executing later commands or rewriting TODO checkboxes. +Output fields are capped at 65,536 characters with `output_truncated`; capture in +memory itself is not byte-bounded. Request/context JSON is limited to 1 MiB and +receipt reads to 4 MiB. Historical receipts are not automatically pruned. + +These are cooperative local-filesystem contracts, **not exactly-once execution**. +POSIX receipt fsync improves crash durability, but business scripts do not form a +transaction with the receipt. Power loss, storage failures, restored backups and +filesystems with unreliable locking/rename semantics require reconciliation. +Windows directory-entry durability is weaker because stdlib directory fsync is +unavailable. Use one intact control directory shared by all cooperating instances. + +## Reconciliation and limits + +There is deliberately no worker-facing unlock, retry-pending, reset or receipt +editing tool. On `reconciliation_required`, stop issuing mutations and do not +invent a fresh ID to bypass the receipt. A supervisor must stop affected servers, +identify and stop surviving children, inspect the pending receipt and actual files +(including `.close.lock`, archive, index and any command effects), and restore a +consistent state from evidence. Keep the pending receipt as evidence. Only after +that investigation may the supervisor clear the active pointer to resume unrelated +work; the pending ID must remain non-retriable. Corrupt metadata needs restoration +from a trusted copy or a separately audited migration. Do not delete the lock file +while a process might hold it. + +The new `yeoul` product CLI dispatches business operations through the same boundary +as MCP. Raw checkout shell/CLI tools, older MCP servers, trusted-mode servers, other software +(including Mirror) and editors **do not participate in the Yeoul MCP lock**. +There is no cross-product transaction or shared Yeoul/Mirror lock. Concurrent +out-of-band changes, symlink swaps after validation, mutable hardlink aliases, +mount/bind aliases, and a hostile owner rewriting receipts are outside this +cooperative boundary. Protect the workspace and control directory accordingly. + +The workspace tools are `workspace_prepare` (persist a task), `workspace_execute` +(execute/replay its ID), and `workspace_tasks` (inspect). They cannot configure +permissions, approve commands or recover interrupted work. CLI-only `yeoul recover` +defaults to inspection; explicit acknowledgement requires an audit note and +stopped-children attestation. It persists a tombstone before clearing the active +pointer, including when the old receipt was never written. The old ID cannot execute +again. This records an operator judgment, not automatic proof of consistency. + +Two small harness fixes also apply to direct CLI use: `loop-guard init` refuses to +reset existing state; `arc-open` reserves its directory atomically and chooses a +unique suffix on same-second collisions. Those fixes do not make the other CLI +mutations concurrent-safe or put them under the MCP lock. + +## Verification + +Run `python mcp/tests/test_runtime_contract.py` for managed permissions, strict +receipt parsing, policy rechecks, real simultaneous processes, replay/conflict, +crash-before/after-effect, timeout, bounded lock contention, links, approved +verification, and actual stdio tool schemas/calls. The stdio test has a 30-second +deadline; a hang is a failure, not a skipped pass. In environments that block SDK +stdio/event-loop wakeups, run the same test outside that sandbox. + +Also run `python tests/test_hardening.py`, `bash tests/test_gates.sh`, and the three +existing scripts in `mcp/tests/`. Python >=3.10 and the existing `mcp/setup.py` +wheel/sdist harness bundling remain unchanged. Windows locking code needs native +Windows validation; POSIX execution cannot certify Windows behavior. + +Implementation validation on Linux/Python 3.10: 21/21 new runtime tests (including +real stdio on the host), 26/26 existing hardening tests, 72/72 shell gate checks, +5/5 CLI/MCP integration tests, 8/8 subprocess checks, and 8/8 server-version checks +passed. The existing `setup.py` built an sdist and then a wheel from that sdist; +the extracted wheel ran managed scaffolding and identical replay away from the +checkout without installation. The explicit MCP CI job now invokes the runtime +suite on its Linux/Windows matrix. Native Windows results are not claimed here. diff --git a/docs/WORKSPACE_GUIDE.ko.md b/docs/WORKSPACE_GUIDE.ko.md new file mode 100644 index 0000000..32a7071 --- /dev/null +++ b/docs/WORKSPACE_GUIDE.ko.md @@ -0,0 +1,115 @@ +# 독립 제품 사용·운영 안내 + +여울은 독립적으로 설치·설정·사용합니다. LaneStack은 선택적인 소비자일 뿐이며, +다른 제품이나 공통 폴더, LaneStack 설정 파일이 필요하지 않습니다. + +## 처음 사용 + +```bash +pip install 'git+https://github.com/mirror-stack/yeoul@v0.4.0#subdirectory=mcp' +yeoul setup ./my-discussions --mode discuss +yeoul new example --workspace ./my-discussions +yeoul status --workspace ./my-discussions +yeoul doctor --workspace ./my-discussions +yeoul connect --workspace ./my-discussions +``` + +대화형 `setup`은 폴더와 사용 방식을 묻습니다. 자동화할 때만 `--yes`를 붙이세요. +기존 파일을 덮어쓰거나 다른 제품의 설정을 변경하지 않습니다. 기본 방식은 `observe`입니다. +`doctor`는 설정·실행 전제·미처리 작업을 검사할 뿐, 업무 결과의 진실성을 인증하지 않습니다. + +- `observe`: 상태·아크·개발 자격 조회. +- `discuss`: 프로젝트·아크·티켓·루프 가드·심의 종결·개발 골격·봉인 연결. 임의 검증 명령 실행 금지. +- `develop`: 위 기능과 검증 게이트. 실행은 **별도 TODO 승인 후에만** 가능. + +`yeoul configure --workspace FOLDER --mode develop` 후, +`yeoul approve /absolute/path/to/TODO.md --workspace FOLDER`가 보여주는 +검증 명령을 운영자가 검토합니다. 승인 기준의 해시를 매 실행 때 다시 확인합니다. +검증은 `yeoul verify /absolute/path/to/TODO.md --workspace FOLDER`로 실행합니다. +이 명령은 서버 계정 권한으로 셸을 실행합니다. 승인 후에도 테스트 구현 보호와 OS 격리가 +필요합니다. 모드 변경은 이전 명령 실행 승인을 취소합니다. +Python 3.10+와 Bash가 필요하며 Windows에서는 Git Bash를 사용합니다. + +`connect`가 출력한 설정에서 필요한 서버 항목만 사용 중인 MCP 클라이언트에 추가하세요. +기존 클라이언트 설정을 자동 수정하지 않습니다. 직접 서버를 띄울 때는 +`yeoul serve --workspace FOLDER`를 사용합니다. 전송은 stdio이며 업무 상태는 파일에 남습니다. +프로필 변경·권한 취소·업그레이드 후에는 실행 중인 서버를 종료하고 다시 연결해야 합니다. + +## 정상 사용과 재시도 + +일반 명령은 작업을 준비하여 ID를 디스크에 저장하고, ID를 표준 오류에 표시한 뒤 실행합니다. +ID를 사용자가 만들 필요는 없습니다. 고급 명령은 +`yeoul run TOOL --arguments '{"key":"value"}'`입니다. + +MCP 클라이언트는 다음 공개 도구를 사용합니다. + +1. `workspace_prepare(tool, arguments)`: 업무 실행 없이 작업을 저장하고 `task_id` 반환. +2. ID를 보관한 뒤 `workspace_execute(task_id)`: 기존 안전 처리로 실행. +3. 응답이 끊기면 `workspace_tasks()`로 상태를 보고 **동일 ID**로 다시 요청. + +준비 응답 유실 시 실행하지 않은 준비 작업이 중복될 수 있습니다. 같은 내용을 고의로 두 번 +요청하는 경우도 있으므로 내용만으로 사용자 의도를 합치지 않습니다. 완료 기록이 있는 +재시도는 과거 응답을 반환합니다. 읽기 전용 작업은 처리 영수증 없이 다시 조회합니다. +업무 실패나 게이트 거부도 완료된 응답일 수 있습니다. 명시적으로 내용을 수정한 다음 +새 시도를 할 때는 새 작업을 준비하세요. 불확실한 중단을 새 ID로 우회하지 마세요. + +CLI에서는 `yeoul tasks --workspace FOLDER`, +`yeoul retry TASK_ID --workspace FOLDER`를 사용합니다. +`state=returned`는 응답 전달 완료일 뿐 검증 통과가 아닙니다. +원래 결과의 `exit_code`, `ok`, `decision`, 검증 finding을 별도로 확인해야 합니다. + +## 선택적 원장 연결 + +두 제품의 작업 폴더는 **분리해도 됩니다**. 먼저 생산자에서 원장을 만들고, +소비자에서 `yeoul link /absolute/path/to/claims.jsonl --workspace FOLDER`를 실행합니다. +원장의 해시·체인을 확인한 뒤 그 **파일 하나의 읽기 권한**만 설정합니다. +폴더 권한이나 외부 쓰기 권한은 주지 않습니다. +`yeoul unlink /absolute/path/to/claims.jsonl --workspace FOLDER`로 취소할 수 있습니다. + +이 권한은 원장 입력에만 적용됩니다. 임의 작업 폴더·출력 파일·검증 명령·네트워크 권한으로 +확대되지 않습니다. 연결했다고 업무 아크에 봉인이 자동 연결되거나 증거가 자동 출판되지도 +않습니다. 원장 생산자가 쓰는 도중 소비자가 읽는 것까지 원자적으로 묶는 교차 제품 잠금은 +없습니다. 쓰기 완료 후 검증된 원장을 읽거나 별도의 안정된 스냅샷을 사용하세요. +해시 검증은 내용의 진실·작성자 신원·외부 시각을 인증하지 않습니다. + +## 중단과 복구 + +`yeoul recover --workspace FOLDER`는 **조회만** 합니다. +작업자용 MCP에는 설정 변경·명령 승인·중단 해제 도구를 제공하지 않습니다. + +운영자가 다음을 확인해야 합니다. + +1. 영향을 받는 서버와 남아 있는 자식 프로세스를 정지. +2. 작업 폴더 전체와 처리 기록을 함께 백업. +3. 실제 변경 파일·원장·영수증을 비교해 일관된 상태인지 판단. +4. 근거를 남긴 뒤에만 아래 명령을 실행. + +```bash +yeoul recover --workspace FOLDER --acknowledge --children-stopped --note "확인한 파일과 판단 근거" +``` + +이 명령은 운영자의 확인 진술을 기록하는 것이며 프로세스 종료·업무 복구를 자동 증명하지 +않습니다. 감사 기록과 재실행 금지 표식을 먼저 저장하고 활성 중단 포인터만 제거합니다. +이전 중단 작업은 영수증이 없던 경우에도 다시 실행할 수 없습니다. +완료로 위조하지 않으며 원장 삭제·롤백·재봉인도 하지 않습니다. +메타데이터가 손상됐다면 임의 삭제하지 말고 신뢰할 수 있는 백업과 대조해야 합니다. +잠금 파일은 지우지 마세요. + +## 지원 경계와 업그레이드 + +제품 CLI의 업무 명령과 MCP 도구는 같은 guarded 함수·잠금·영수증 경로를 사용합니다. +**기존 원시 CLI/스크립트, 임의 셸 명령, 편집기, 옛 서버는 이 잠금에 참여하지 않습니다.** +제품 CLI를 지원 경로로 사용하고 다른 작성자가 동시에 파일을 바꾸지 않게 운영하세요. +프로필·처리 기록·설치 코드·승인된 테스트를 작업자가 임의 수정할 수 있다면 보호가 무력화됩니다. +실행 계정 권한, Windows ACL, 필요시 컨테이너/별도 계정·네트워크 제한은 운영자가 설정해야 합니다. + +프로필 스키마는 1입니다. 설정 변경 전 이전 프로필을 비공개 이력으로 보존하고, +동시 변경 시 오래된 설정으로 덮어쓰지 않습니다. +업그레이드는 서버 정지 → 폴더 전체 백업 → 패키지 재설치 → `doctor` → 재연결 순서입니다. +업무 원장·아크와 숨김 처리 기록을 함께 유지하세요. 설정의 절대 경로를 바꾸는 폴더 이동이나 +알 수 없는 스키마는 자동 마이그레이션하지 않습니다. 먼저 별도로 검토해야 합니다. +설치 코드에는 사용자 업무 자료를 보관하지 마세요. + +네트워크 파일시스템·서로 겹치는 루트·비협조적 작성자에 대한 동시성 보장이나, +외부 명령까지 포함한 exactly-once 트랜잭션을 주장하지 않습니다. +상세 제한은 [runtime contract](RUNTIME_CONTRACT.md)를 확인하세요. diff --git a/mcp/README.md b/mcp/README.md index c349203..673b9ae 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -5,20 +5,35 @@ wrapper that shells out to them, so a gate refusal (e.g. `arc_close` returning t `ralph_gate_check` refusing an ungated TODO) is returned as the tool result — it cannot be talked past. Composes with the [`mirror-stack`](https://github.com/mirror-stack/mirror-stack-mcp) MCP (pre-registration + -tamper-evident ledger). Register both together — see `../setup/mcp-servers.json`. +tamper-evident ledger) optionally. Neither Mirror nor LaneStack is required. +See the [standalone workspace guide](../docs/WORKSPACE_GUIDE.ko.md). ## Install ```bash -pip install git+https://github.com/mirror-stack/yeoul#subdirectory=mcp +pip install 'git+https://github.com/mirror-stack/yeoul@v0.4.0#subdirectory=mcp' ``` ## Run ```bash -yeoul-mcp # stdio server +yeoul setup ./my-discussions --mode discuss +yeoul doctor --workspace ./my-discussions +yeoul connect --workspace ./my-discussions # prints managed client config +yeoul serve --workspace ./my-discussions # stdio server ``` +For opt-in managed permissions, operation IDs, durable receipts and workspace +locking, read the [runtime contract](../docs/RUNTIME_CONTRACT.md). An existing +absolute `YEOUL_MCP_ROOT` enables managed mode; writes require +`YEOUL_MCP_ALLOW_WRITE=1` and an `operation_id`. Optional +`YEOUL_MCP_WRITE_TOOLS` restricts mutations by name. Arbitrary verification also +requires `YEOUL_MCP_ALLOW_EXEC=1` and a supervisor-configured +`YEOUL_MCP_VERIFY_BASELINE` under `ROOT/.yeoul-approved/`, protected from worker +writes. Without a root, this is trusted local mode without managed concurrency +controls. The new `yeoul` product CLI shares the MCP lock; raw checkout scripts +and older servers do not. Bare `yeoul-mcp` remains the advanced compatibility entrypoint. + v0.3.0 wheels and sdists bundle the harness and templates. Python >=3.10 and Bash (Git Bash on Windows) are required. `YEOUL_BIN` overrides the bundled scripts only when explicitly pointing to a custom checkout. No checkout is needed for the default MCP setup. @@ -28,16 +43,21 @@ when explicitly pointing to a custom checkout. No checkout is needed for the def `yeoul_new`, `arc_open`, `arc_ticket`, `loop_guard_init`, `loop_guard_tick`, `arc_close`, `arc_prereg`, `build_handoff`, `ralph_gate_check`, `verify_gate`, `status`, `arc_list`. +Three additional workspace tools bring the total to 15: `workspace_prepare`, +`workspace_execute`, `workspace_tasks`. Prepare persists a task without running +it; execute uses its stable ID; tasks inspects receipts. No setup, command approval, +or recovery mutation is exposed as a worker MCP tool. + `arc_prereg` and `verify_gate` are the two enforcement halves that used to be reachable only from the shell: linking a seal (so `arc_close` injects the kill-condition verbatim) and re-running a round's verify commands. An agent driving Yeoul purely through MCP could not run either — so neither gate held on that path. -Each returns `{exit_code, stdout, stderr}`. Always inspect `exit_code`; a delivered MCP response is not a passing gate. +Each business tool returns `{exit_code, stdout, stderr}`. Always inspect `exit_code`; a delivered MCP response is not a passing gate. A non-zero `exit_code` on `arc_close` (4 = blanks, 5 = defense/binding, 8 = record mismatch, 9 = close/archive conflict) or `ralph_gate_check` (3 = ungated item) is an enforced gate, not an error to route around. `ralph_gate_check` is read-only and calls the same CLI eligibility path. `verify_gate` -requires a supervisor-created baseline (optional `baseline_path`; default +requires a supervisor-created baseline (in trusted mode, optional `baseline_path`; default `TODO.md.verify-baseline.json`). Create it with the CLI `verify-baseline` before work. It is deliberately not a worker-facing MCP approval tool. Missing token counts in `loop_guard_tick` stay unmeasured rather than becoming zero. diff --git a/mcp/pyproject.toml b/mcp/pyproject.toml index 6f6c8bf..910d186 100644 --- a/mcp/pyproject.toml +++ b/mcp/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "yeoul-mcp" -version = "0.3.0" +version = "0.4.0" description = "Gate-enforcing MCP tools over the Yeoul harness (deliberation → pre-registration → dev loop). Composes with mirror-stack." requires-python = ">=3.10" license = { text = "Apache-2.0" } @@ -16,6 +16,7 @@ dependencies = [ ] [project.scripts] +yeoul = "yeoul_mcp.product:main" yeoul-mcp = "yeoul_mcp.server:main" [project.urls] diff --git a/mcp/tests/test_product.py b/mcp/tests/test_product.py new file mode 100644 index 0000000..18d97e4 --- /dev/null +++ b/mcp/tests/test_product.py @@ -0,0 +1,237 @@ +"""Standalone product contract. All data and approvals stay in temporary folders.""" +import hashlib +import json +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest +from unittest.mock import patch + +SOURCE = str(Path(__file__).resolve().parents[1]) +if not os.environ.get("PRODUCT_TEST_INSTALLED"): + sys.path.insert(0, SOURCE) +from yeoul_mcp.product import workspace +from yeoul_mcp.workspace import write_json +from yeoul_mcp import server +if os.environ.get("PRODUCT_TEST_INSTALLED"): + SOURCE = str(Path(server.__file__).resolve().parents[1]) +MODE, TOOL, EFFECT, COMPLETE, PACKAGE = "discuss", "yeoul_new", "projects", "complete", "yeoul_mcp" +ARGUMENTS = {"name":"example", "no_arc":True} + +class ProductContract(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory(prefix="standalone product ") + self.addCleanup(self.tmp.cleanup) + self.base = Path(self.tmp.name).resolve() + self.root = self.base / "workspace" + self.env = patch.dict(os.environ, {k:v for k,v in os.environ.items() + if not k.startswith(workspace.prefix + "_")}, clear=True) + self.env.start() + self.addCleanup(self.env.stop) + workspace.setup(self.root, MODE) + + def test_setup_preserves_data_and_refuses_reinitialize(self): + data = self.root / "business.txt" + data.write_text("preserve") + with self.assertRaises(ValueError): + workspace.setup(self.root, MODE) + self.assertEqual(data.read_text(), "preserve") + self.assertTrue(workspace.doctor(self.root)["ok"]) + connection = workspace.connection(self.root) + config = connection["mcpServers"][workspace.product] + self.assertEqual(config["args"][-1], str(self.root)) + self.assertNotIn("lane", json.dumps(config)) + + def test_prepare_then_execute_shared_with_mcp(self): + with workspace.activated(self.root): + task = server.workspace_prepare(TOOL, ARGUMENTS) + self.assertEqual(task["state"], "prepared") + self.assertFalse((self.root / EFFECT).exists()) + first = workspace.execute(self.root, task["task_id"]) + self.assertEqual(first["state"], "returned", first) + self.assertTrue((self.root / EFFECT).exists(), first) + snapshot = self.snapshot() + second = server.workspace_execute(task["task_id"]) + self.assertEqual(first, second) + self.assertEqual(snapshot, self.snapshot()) + direct = getattr(server, TOOL)(**ARGUMENTS, operation_id=task["task_id"]) + self.assertEqual(direct, first["result"]) + self.assertEqual(snapshot, self.snapshot()) + self.assertEqual(server.workspace_tasks()["tasks"][0]["state"], COMPLETE) + + def snapshot(self): + return {str(p.relative_to(self.root)): p.read_bytes() + for p in self.root.rglob("*") if p.is_file() + and workspace.control not in p.parts} + + def test_readonly_does_not_elevate_from_writable_profile(self): + with workspace.activated(self.root): + os.environ[workspace.prefix + "_MCP_ALLOW_WRITE"] = "0" + with self.assertRaises(ValueError): + server.workspace_prepare(TOOL, ARGUMENTS) + self.assertFalse((self.root / EFFECT).exists()) + + def test_changed_task_refused(self): + with workspace.activated(self.root): + task = workspace.prepare(self.root, TOOL, ARGUMENTS) + path = workspace.job_path(self.root, task["task_id"]) + job = json.loads(path.read_text()) + job["arguments"] = {} + write_json(path, job) + with self.assertRaises(ValueError): + workspace.execute(self.root, task["task_id"]) + self.assertFalse((self.root / EFFECT).exists()) + + def test_operator_metadata_cannot_be_business_target(self): + with workspace.activated(self.root): + if workspace.product == "mirror-stack": + with self.assertRaises(ValueError): + server.am_record(workspace.config_name, "u", "note", operation_id="bad") + else: + result = server.verify_gate(workspace.config_name, operation_id="bad") + self.assertNotEqual(result["exit_code"], 0) + self.assertEqual(workspace.load(self.root)["mode"], MODE) + + def test_missing_receipt_recovery_retires_old_id(self): + with workspace.activated(self.root): + task = workspace.prepare(self.root, TOOL, ARGUMENTS) + receipt_name = hashlib.sha256(task["task_id"].encode()).hexdigest() + ".json" + marker = {"receipt": receipt_name} + if workspace.product == "mirror-stack": + marker["schema"] = 1 + write_json(self.root / workspace.control / "active.json", marker) + self.assertFalse(workspace.doctor(self.root)["ok"]) + self.assertTrue(workspace.recover(self.root)["active"]["needs_attention"]) + with self.assertRaises(ValueError): + workspace.recover(self.root, True, "", True) + self.assertFalse((self.root / EFFECT).exists()) + got = workspace.execute(self.root, task["task_id"]) + self.assertEqual(got["state"], "needs_attention", got) + result = workspace.recover(self.root, True, "Inspected files; no surviving child.", True) + self.assertTrue(result["changed"]) + got = workspace.execute(self.root, task["task_id"]) + self.assertEqual(got["state"], "needs_attention", got) + self.assertFalse((self.root / EFFECT).exists()) + self.assertEqual(workspace.tasks(self.root)["tasks"][0]["state"], "retired") + new_task = workspace.prepare(self.root, TOOL, ARGUMENTS) + self.assertEqual(workspace.execute(self.root, new_task["task_id"])["state"], "returned") + + def test_mode_change_backs_up_and_revokes(self): + old = workspace.load(self.root) + workspace.configure(self.root, "observe") + self.assertEqual(workspace.load(self.root)["mode"], "observe") + history = list((self.root / workspace.control / "config-history").glob("*.json")) + self.assertTrue(any(json.loads(p.read_text()) == old for p in history)) + with workspace.activated(self.root), self.assertRaises(ValueError): + workspace.prepare(self.root, TOOL, ARGUMENTS) + with self.assertRaises(ValueError): + workspace.save_config(self.root, old, expected_revision=old["revision"]) + + def test_external_grant_exact_file_readonly_and_revocable(self): + ledger = self.base / "outside.jsonl" + body = {"prev_seal": "genesis", "claim_id": "c", "metric": "m", "kill_condition": "n < 20"} + body["seal"] = hashlib.sha256(json.dumps(body, sort_keys=True, ensure_ascii=False).encode()).hexdigest() + ledger.write_text(json.dumps(body) + "\n") + original = ledger.read_bytes() + workspace.link(self.root, ledger) + with workspace.activated(self.root): + if workspace.product == "mirror-stack": + from mirror_stack_mcp.runtime import scoped_path + self.assertEqual(scoped_path(ledger, external_read=True), str(ledger)) + server.mm_anchor(str(ledger)) + with self.assertRaises(ValueError): + server.am_record(str(ledger), "u", "note", operation_id="external-write") + else: + from yeoul_mcp.runtime import Policy + policy = Policy({}) + self.assertEqual(policy.path(str(ledger), external_read=True), ledger) + with self.assertRaises(ValueError): + policy.path(str(ledger)) + arc = self.root / "arc" + arc.mkdir() + (arc / ".prereg").write_text("c\n" + str(ledger) + "\nseal\n") + policy.validate("status", {}) + self.assertEqual(ledger.read_bytes(), original) + workspace.link(self.root, ledger, remove=True) + with workspace.activated(self.root): + if workspace.product == "mirror-stack": + with self.assertRaises(ValueError): + scoped_path(ledger, external_read=True) + else: + with self.assertRaises(ValueError): + Policy({}).path(str(ledger), external_read=True) + self.assertEqual(ledger.read_bytes(), original) + + def test_cli_outside_launch_directory(self): + env = dict(os.environ, PYTHONPATH=SOURCE, PYTHONDONTWRITEBYTECODE="1") + def cli(*args): + return subprocess.run([sys.executable, "-B", "-m", PACKAGE + ".product", *args, + "--workspace", str(self.root)], cwd=self.base, env=env, + text=True, capture_output=True, timeout=30) + self.assertEqual(cli("doctor").returncode, 0) + result = cli("run", TOOL, "--arguments", json.dumps(ARGUMENTS), "--yes") + self.assertEqual(result.returncode, 0, result.stderr + result.stdout) + task_id = json.loads(result.stdout)["task_id"] + self.assertIn("task_id=" + task_id, result.stderr) + repeated = cli("retry", task_id, "--yes") + self.assertEqual(repeated.returncode, 0, repeated.stderr) + self.assertEqual(json.loads(result.stdout), json.loads(repeated.stdout)) + + + def test_approval_pins_baseline_and_mode_change_revokes(self): + workspace.configure(self.root, "develop") + todo = self.root / "TODO.md" + todo.write_text('- [x] evidence. verify: `printf approved > verified.txt`\n') + workspace.approve(self.root, todo) + with workspace.activated(self.root): + got = server.verify_gate("TODO.md", operation_id="verify") + self.assertEqual(got["exit_code"], 0, got) + self.assertTrue((self.root / "verified.txt").exists()) + baseline = Path(os.environ["YEOUL_MCP_VERIFY_BASELINE"]) + baseline.write_text(baseline.read_text() + " ") + got = server.verify_gate("TODO.md", operation_id="tampered") + self.assertNotEqual(got["exit_code"], 0) + self.assertIn("baseline changed", got["stderr"]) + self.assertFalse(workspace.doctor(self.root)["ok"]) + workspace.configure(self.root, "discuss") + self.assertIsNone(workspace.load(self.root)["verification"]) + with workspace.activated(self.root): + self.assertEqual(os.environ["YEOUL_MCP_ALLOW_EXEC"], "0") + + def test_product_stdio_reconnect_replays_task(self): + import asyncio + from mcp import ClientSession, StdioServerParameters + from mcp.client.stdio import stdio_client + from yeoul_mcp import __version__ + async def run(): + settings = StdioServerParameters( + command=sys.executable, + args=["-B", "-m", PACKAGE + ".product", "serve", "--workspace", str(self.root)], + env=dict(os.environ, PYTHONPATH=SOURCE, PYTHONDONTWRITEBYTECODE="1")) + async def connect(task_id=None): + async with stdio_client(settings) as (reader, writer): + async with ClientSession(reader, writer) as client: + hello = await client.initialize() + self.assertEqual(hello.serverInfo.version, __version__) + names = {tool.name for tool in (await client.list_tools()).tools} + self.assertTrue({"workspace_prepare", "workspace_execute", "workspace_tasks"} <= names) + self.assertNotIn("workspace_recover", names) + if task_id is None: + reply = await client.call_tool("workspace_prepare", {"tool": TOOL, "arguments": ARGUMENTS}) + self.assertFalse(reply.isError, reply) + task_id = json.loads(reply.content[0].text)["task_id"] + result = await client.call_tool("workspace_execute", {"task_id": task_id}) + self.assertFalse(result.isError, result) + return task_id, json.loads(result.content[0].text) + task_id, first = await connect() + snapshot = self.snapshot() + _, second = await connect(task_id) + self.assertEqual(first, second) + self.assertEqual(first["state"], "returned") + self.assertEqual(snapshot, self.snapshot()) + asyncio.run(asyncio.wait_for(run(), timeout=60)) + +if __name__ == "__main__": + unittest.main() diff --git a/mcp/tests/test_runtime_contract.py b/mcp/tests/test_runtime_contract.py new file mode 100644 index 0000000..661e1fe --- /dev/null +++ b/mcp/tests/test_runtime_contract.py @@ -0,0 +1,335 @@ +"""Managed stdio boundary, durable retry receipts, and real process contention. + +Run directly with Python 3.10+; no installs or production workspace writes. +""" +import asyncio +import hashlib +import json +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import time +import unittest +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / 'mcp')) +from yeoul_mcp import runtime, server + +WORKER = ''' +import json, os, sys, time +from pathlib import Path +from yeoul_mcp import runtime +runtime.LOCK_TIMEOUT = float(os.environ.get('TEST_LOCK_TIMEOUT', '5')) +@runtime.boundary(mutating=True) +def loop_guard_tick(value=1, operation_id=None): + root = Path(os.environ['YEOUL_MCP_ROOT']) + marker = json.loads((root/'.yeoul-mcp/active.json').read_text()) + assert json.loads((root/'.yeoul-mcp'/marker['receipt']).read_text())['state'] == 'pending' + if os.environ.get('TEST_CRASH') == 'before': + os._exit(77) + count = root/'count' + n = int(count.read_text()) if count.exists() else 0 + time.sleep(0.1) + count.write_text(str(n+value)) + if os.environ.get('TEST_CRASH') == 'after': + os._exit(77) + return dict(exit_code=0, stdout=str(n+value), stderr='') +print(json.dumps(loop_guard_tick(operation_id=sys.argv[1]))) +''' + + +class RuntimeContract(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory(prefix='yeoul runtime ') + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name).resolve() + self.env = {k: v for k, v in os.environ.items() if not k.startswith('YEOUL_')} + self.env.update(YEOUL_MCP_ROOT=str(self.root), YEOUL_MCP_ALLOW_WRITE='1', + PYTHONPATH=str(ROOT / 'mcp'), PYTHONDONTWRITEBYTECODE='1') + self.environment = patch.dict(os.environ, self.env, clear=True) + self.environment.start() + self.addCleanup(self.environment.stop) + + def spawn(self, operation, extra=None, code=WORKER): + return subprocess.Popen([sys.executable, '-c', code, operation], + cwd=self.root, env=dict(self.env, **(extra or {})), + stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + + def result(self, child, expected=0): + out, err = child.communicate(timeout=30) + self.assertEqual(child.returncode, expected, out + err) + return json.loads(out) if expected == 0 else None + + def assert_denied(self, result): + self.assertNotEqual(result['exit_code'], 0, result) + + def test_explicit_root_and_denied_write(self): + for raw in ('', '.', str(self.root / 'missing'), self.root.anchor): + with self.subTest(root=raw), patch.dict(os.environ, {'YEOUL_MCP_ROOT': raw}): + self.assert_denied(server.status()) + with patch.dict(os.environ, {'YEOUL_MCP_ALLOW_WRITE': '0'}): + self.assert_denied(server.yeoul_new('p', no_arc=True, operation_id='new')) + self.assert_denied(server.yeoul_new('p', no_arc=True)) + self.assertFalse((self.root / 'projects').exists()) + + def test_paths_names_roles_and_implicit_environment(self): + for name in ('../escape', '/escape', 'x/y', 'x\\y', '--option', '$(touch bad)', 'a\nb'): + with self.subTest(name=name): + self.assert_denied(server.yeoul_new(name, no_arc=True, operation_id='invalid')) + for roles in ('analysis ../bad', 'analysis *', 'analysis;touch', 'analysis\nimpl'): + self.assert_denied(server.arc_open('x', 'arcs', roles=roles, operation_id='invalid')) + for workspace in ('..', str(self.root.parent), '.yeoul-mcp'): + self.assert_denied(server.status(workspace=workspace)) + for variable in ('YEOUL_PROJECTS', 'YEOUL_INDEX', 'YEOUL_LEDGER', 'YEOUL_CLOSED_REGISTRY'): + with self.subTest(variable=variable), patch.dict(os.environ, {variable: str(self.root.parent / 'outside')}): + self.assert_denied(server.status()) + self.assert_denied(server.arc_close(str(self.root), 'GO', operation_id='outside-archive')) + self.assertFalse((self.root / 'projects').exists()) + + def test_symlink_hardlink_and_implicit_linked_ledger(self): + arc = self.root / 'arc' + arc.mkdir() + (arc / '.prereg').write_text('claim\n' + str(self.root.parent / 'outside.jsonl') + '\nseal\n') + self.assert_denied(server.arc_close('arc', 'GO', operation_id='bad-link')) + self.assertFalse((arc / '.close.lock').exists()) + (arc / '.prereg').unlink() + target = self.root / 'target' + target.write_text('untouched') + alias = self.root / 'alias' + try: + alias.symlink_to(target) + except OSError as exc: + self.skipTest(f'symlinks unavailable: {exc}') + self.assert_denied(server.status()) + alias.unlink() + os.link(target, alias) + self.assert_denied(server.status()) + self.assertEqual(target.read_text(), 'untouched') + + def test_read_only_creates_only_lock_metadata(self): + self.assertEqual(server.status()['exit_code'], 0) + self.assertEqual(server.arc_list()['exit_code'], 0) + self.assertEqual({p.relative_to(self.root).as_posix() for p in self.root.rglob('*')}, + {'.yeoul-mcp', '.yeoul-mcp/workspace.lock'}) + with patch.object(server, '_run', side_effect=AssertionError('must not execute')): + self.assert_denied(server.verify_gate('TODO.md', revert=False, operation_id='verify')) + + def test_replay_conflict_and_guard_reset(self): + (self.root / 'arc').mkdir() + first = server.loop_guard_init('arc', operation_id='init') + self.assertEqual(first['exit_code'], 0, first) + self.assertEqual(server.loop_guard_tick('arc', tokens=5, operation_id='tick')['exit_code'], 0) + before = (self.root / 'arc/loop_state.tsv').read_bytes() + self.assertEqual(server.loop_guard_init('arc', operation_id='init'), first) + self.assertEqual((self.root / 'arc/loop_state.tsv').read_bytes(), before) + self.assertEqual(server.loop_guard_tick('arc', tokens=6, operation_id='tick')['runtime_status'], + 'operation_conflict') + self.assert_denied(server.loop_guard_init('arc', operation_id='different-init')) + self.assertEqual((self.root / 'arc/loop_state.tsv').read_bytes(), before) + + def test_concurrent_processes_serialize_and_replay_after_restart(self): + children = [self.spawn(op) for op in ('same', 'same', 'second', 'third')] + results = [self.result(child) for child in children] + self.assertTrue(all(r['exit_code'] == 0 for r in results), results) + self.assertEqual(results[0], results[1]) + self.assertEqual((self.root / 'count').read_text(), '3') + self.assertEqual(self.result(self.spawn('same')), results[0]) + self.assertEqual((self.root / 'count').read_text(), '3') + + def test_pending_before_effect_is_not_retried(self): + self.result(self.spawn('crash', {'TEST_CRASH': 'before'}), expected=77) + result = self.result(self.spawn('crash')) + self.assertEqual(result['runtime_status'], 'reconciliation_required') + self.assertFalse((self.root / 'count').exists()) + + def test_crash_after_effect_blocks_other_ids_and_reads(self): + self.result(self.spawn('crash', {'TEST_CRASH': 'after'}), expected=77) + for op in ('crash', 'fresh-id'): + self.assertEqual(self.result(self.spawn(op))['runtime_status'], 'reconciliation_required') + self.assertEqual(server.status()['runtime_status'], 'reconciliation_required') + self.assertEqual((self.root / 'count').read_text(), '1') + + def test_lock_wait_is_bounded_and_never_deletes_lock(self): + with runtime.workspace_lock(self.root): + lock = self.root / '.yeoul-mcp/workspace.lock' + inode = lock.stat().st_ino + start = time.monotonic() + result = self.result(self.spawn('busy', {'TEST_LOCK_TIMEOUT': '0.15'})) + self.assertIn('lock wait expired', result['stderr']) + self.assertLess(time.monotonic() - start, 10) + self.assertEqual(lock.stat().st_ino, inode) + self.assertEqual(self.result(self.spawn('busy'))['exit_code'], 0) + + def test_timeout_leaves_pending_and_preserves_receipt(self): + helpers = self.root / 'helpers' + helpers.mkdir() + (helpers / 'loop-guard').write_text('sleep 2\n', encoding='utf-8') + (self.root / 'arc').mkdir() + with patch.object(server, 'BIN', helpers), patch.object(server, 'RUN_TIMEOUT', 0.1): + result = server.loop_guard_tick('arc', operation_id='timeout') + self.assertEqual(result['runtime_status'], 'reconciliation_required', result) + again = server.loop_guard_tick('arc', operation_id='timeout') + self.assertEqual(again['runtime_status'], 'reconciliation_required') + receipt = self.root / '.yeoul-mcp' / (hashlib.sha256(b'timeout').hexdigest() + '.json') + self.assertEqual(json.loads(receipt.read_text())['state'], 'pending') + + def test_corrupt_receipt_and_receipt_write_failure_fail_closed(self): + self.assertEqual(self.result(self.spawn('ok'))['exit_code'], 0) + receipt = self.root / '.yeoul-mcp' / (hashlib.sha256(b'ok').hexdigest() + '.json') + receipt.write_text('{broken') + self.assert_denied(self.result(self.spawn('ok'))) + self.assert_denied(self.result(self.spawn('another'))) + self.assertEqual((self.root / 'count').read_text(), '1') + + def test_receipt_failure_prevents_child_launch(self): + with patch.object(runtime, 'write_json', side_effect=OSError('disk full')): + with patch.object(server, '_run', side_effect=AssertionError('side effect before receipt')): + self.assert_denied(server.yeoul_new('p', no_arc=True, operation_id='fail')) + self.assertFalse((self.root / 'projects').exists()) + + def test_active_marker_before_receipt_failure_blocks_new_ids(self): + real_write = runtime.write_json + + def fail_receipt(path, data): + if path.name != 'active.json': + raise OSError('interrupted between active pointer and receipt') + real_write(path, data) + + with patch.object(runtime, 'write_json', side_effect=fail_receipt): + with patch.object(server, '_run', side_effect=AssertionError('must not launch')): + result = server.yeoul_new('p', no_arc=True, operation_id='incomplete') + self.assertEqual(result['runtime_status'], 'reconciliation_required') + for op in ('incomplete', 'fresh'): + result = server.yeoul_new('p', no_arc=True, operation_id=op) + self.assertEqual(result['runtime_status'], 'reconciliation_required', result) + self.assertEqual(server.status()['runtime_status'], 'reconciliation_required') + self.assertFalse((self.root / 'projects').exists()) + + def test_verification_requires_supervisor_baseline_and_exec(self): + todo = self.root / 'TODO.md' + todo.write_text('- [x] evidence. verify: `printf approved > verified.txt`\n') + self.assert_denied(server.verify_gate('TODO.md', operation_id='verify')) + self.assertFalse((self.root / 'verified.txt').exists()) + with patch.dict(os.environ, {'YEOUL_MCP_ALLOW_EXEC': '1'}): + self.assert_denied(server.verify_gate('TODO.md', operation_id='verify')) + approved = self.root / '.yeoul-approved/baseline.json' + approved.parent.mkdir() + approved.write_text(json.dumps(dict(version=1, todo=str(todo), + text=todo.read_text().replace('[x]', '[ ]')))) + with patch.dict(os.environ, {'YEOUL_MCP_VERIFY_BASELINE': str(approved)}): + self.assert_denied(server.verify_gate('TODO.md', baseline_path='unapproved.json', + operation_id='wrong-baseline')) + self.assert_denied(server.loop_guard_init('.yeoul-approved', operation_id='protected')) + result = server.verify_gate('TODO.md', operation_id='verify') + self.assertEqual(result['exit_code'], 0, result) + self.assertEqual((self.root / 'verified.txt').read_text(), 'approved') + todo.write_text('- [x] evidence. verify: `printf changed > bypass.txt`\n') + result = server.verify_gate('TODO.md', operation_id='changed-criteria') + self.assertEqual(result['exit_code'], 3, result) + self.assertFalse((self.root / 'bypass.txt').exists()) + + def test_same_second_open_preserves_both_arcs(self): + first = server.arc_open('audit', 'arcs', operation_id='open-1') + second = server.arc_open('audit', 'arcs', operation_id='open-2') + self.assertEqual(first['exit_code'], 0, first) + self.assertEqual(second['exit_code'], 0, second) + arcs = list((self.root / 'arcs').iterdir()) + self.assertEqual(len(arcs), 2) + self.assertTrue(all((a / 'ARC' / (a.name + '.md')).is_file() for a in arcs)) + + def test_trusted_mode_without_id_remains_available(self): + os.environ.pop('YEOUL_MCP_ROOT') + os.environ.pop('YEOUL_MCP_ALLOW_WRITE') + result = server.yeoul_new('trusted', no_arc=True, workspace=str(self.root)) + self.assertEqual(result['exit_code'], 0, result) + self.assertFalse((self.root / '.yeoul-mcp').exists()) + self.assert_denied(server.yeoul_new('with-id', no_arc=True, workspace=str(self.root), + operation_id='legacy')) + + def test_allowlist_and_policy_rechecked_before_replay(self): + with patch.dict(os.environ, {'YEOUL_MCP_WRITE_TOOLS': 'yeoul_new'}): + result = server.yeoul_new('p', no_arc=True, operation_id='new') + self.assertEqual(result['exit_code'], 0, result) + self.assert_denied(server.build_handoff('p', operation_id='handoff')) + with patch.dict(os.environ, {'YEOUL_MCP_WRITE_TOOLS': ''}): + self.assert_denied(server.yeoul_new('p', no_arc=True, operation_id='new')) + with patch.dict(os.environ, {'YEOUL_MCP_WRITE_TOOLS': 'invented'}): + self.assert_denied(server.yeoul_new('p', no_arc=True, operation_id='new')) + self.assertEqual(server.yeoul_new('p', no_arc=True, operation_id='new'), result) + target = self.root / 'alias' + try: + target.symlink_to(self.root / 'projects', target_is_directory=True) + except OSError as exc: + self.skipTest(f'symlinks unavailable: {exc}') + self.assert_denied(server.yeoul_new('p', no_arc=True, operation_id='new')) + + def test_strict_receipt_parser(self): + self.assertEqual(self.result(self.spawn('strict'))['exit_code'], 0) + path = self.root / '.yeoul-mcp' / (hashlib.sha256(b'strict').hexdigest() + '.json') + original = path.read_text() + record = json.loads(original) + bad = ['[]', original.replace('"version": 1', '"version": 1, "version": 1', 1), + original.replace('"exit_code": 0', '"exit_code": NaN'), + original.replace('"exit_code": 0', '"exit_code": true'), + original.replace('"state": "complete"', '"state": "other"')] + for value in bad: + with self.subTest(value=value[:60]): + path.write_text(value) + with self.assertRaises((runtime.Refusal, ValueError)): + runtime.read_json(path) + path.write_text(original) + self.assertEqual(runtime.read_json(path), record) + + @unittest.skipUnless(os.name == 'posix', 'POSIX owner/mode contract') + def test_control_directory_owner_only(self): + control = self.root / '.yeoul-mcp' + control.mkdir(mode=0o755) + control.chmod(0o755) + self.assert_denied(server.status()) + self.assertFalse((control / 'workspace.lock').exists()) + control.chmod(0o700) + self.assertEqual(server.status()['exit_code'], 0) + + def test_replay_after_original_arc_is_moved(self): + arc = self.root / 'arc' + arc.mkdir() + first = server.loop_guard_init('arc', operation_id='init') + self.assertEqual(first['exit_code'], 0, first) + arc.rename(self.root / 'archived') + self.assertEqual(server.loop_guard_init('arc', operation_id='init'), first) + self.assertFalse(arc.exists()) + + def test_stdio_schema_and_runtime_enforcement(self): + from mcp import ClientSession, StdioServerParameters + from mcp.client.stdio import stdio_client + + async def exercise(): + params = StdioServerParameters(command=sys.executable, + args=['-c', 'from yeoul_mcp.server import main; main()'], env=self.env) + async with stdio_client(params) as (reader, writer): + async with ClientSession(reader, writer) as client: + await client.initialize() + listed = await client.list_tools() + tools = {t.name: t for t in listed.tools} + self.assertEqual(len(tools), 15) + readers = {'status', 'arc_list', 'ralph_gate_check'} + for name, tool in tools.items(): + properties = tool.inputSchema['properties'] + self.assertEqual('operation_id' in properties, + name not in readers and not name.startswith('workspace_')) + self.assertNotIn('operation_id', tool.inputSchema.get('required', [])) + response = await client.call_tool('yeoul_new', dict(name='wire', no_arc=True)) + self.assertIn('operation_id required', response.content[0].text) + args = dict(name='wire', no_arc=True, operation_id='wire-new') + first = await client.call_tool('yeoul_new', args) + again = await client.call_tool('yeoul_new', args) + self.assertEqual(first.content, again.content) + self.assertTrue((self.root / 'projects/wire').is_dir()) + asyncio.run(asyncio.wait_for(exercise(), timeout=30)) + + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/mcp/yeoul_mcp/__init__.py b/mcp/yeoul_mcp/__init__.py index da52943..262c585 100644 --- a/mcp/yeoul_mcp/__init__.py +++ b/mcp/yeoul_mcp/__init__.py @@ -1,2 +1,2 @@ """Yeoul MCP — gate-enforcing tools over the Yeoul harness.""" -__version__ = "0.3.0" +__version__ = "0.4.0" diff --git a/mcp/yeoul_mcp/product.py b/mcp/yeoul_mcp/product.py new file mode 100644 index 0000000..2959f36 --- /dev/null +++ b/mcp/yeoul_mcp/product.py @@ -0,0 +1,21 @@ +"""Independent product entrypoint; no consumer application dependency.""" +import os +from .workspace import Workspace + +workspace = Workspace( + product="yeoul", prefix="YEOUL", + control=".yeoul-mcp", package="yeoul_mcp", + modes={"observe":[],"discuss":["yeoul_new","arc_open","arc_ticket","loop_guard_tick","loop_guard_init","arc_close","build_handoff","arc_prereg"],"develop":["yeoul_new","arc_open","arc_ticket","loop_guard_tick","loop_guard_init","arc_close","build_handoff","arc_prereg","verify_gate"]}, defaults={"new":["yeoul_new","name","example"],"status":["status","workspace","."],"verify":["verify_gate","todo_path","TODO.md"]}, +) + +def root(): + value = os.environ.get("YEOUL_MCP_ROOT") + if not value: + raise ValueError("Use yeoul setup, then yeoul serve --workspace FOLDER.") + return workspace.root(value) + +def main(): + raise SystemExit(workspace.cli()) + +if __name__ == "__main__": + main() diff --git a/mcp/yeoul_mcp/runtime.py b/mcp/yeoul_mcp/runtime.py new file mode 100644 index 0000000..9d0a2ff --- /dev/null +++ b/mcp/yeoul_mcp/runtime.py @@ -0,0 +1,435 @@ +"""Cooperative MCP boundary; business state remains in the harness files. + +This is not an OS sandbox. See docs/RUNTIME_CONTRACT.md for trust and recovery. +""" +from __future__ import annotations + +from contextlib import contextmanager +from contextvars import ContextVar +from functools import wraps +import errno +import hashlib +import inspect +import json +import math +import os +from pathlib import Path +import re +import stat +import threading +import time +import uuid + +CONTEXT = ContextVar('yeoul_runtime', default=None) +LOCK_TIMEOUT = 5.0 +SCAN_LIMIT = 20000 +_THREAD_LOCK = threading.Lock() +_NAME = re.compile(r'[A-Za-z0-9][A-Za-z0-9_-]{0,127}\Z') +_ID = re.compile(r'[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}\Z') +_RESERVED = {'.yeoul-mcp', '.yeoul-approved'} +WRITE_TOOLS = {'yeoul_new', 'arc_open', 'arc_ticket', 'loop_guard_tick', 'loop_guard_init', + 'arc_close', 'build_handoff', 'arc_prereg', 'verify_gate'} + + +class Refusal(ValueError): + pass + + +def refused(message, code='permission_denied'): + return dict(exit_code=2, stdout='', stderr=message, runtime_status=code) + + +def safe_components(path): + """Reject links (including Windows junctions), special files and hardlink aliases.""" + for part in [*reversed(path.parents), path]: + try: + info = part.lstat() + except FileNotFoundError: + continue + if (stat.S_ISLNK(info.st_mode) + or getattr(info, 'st_file_attributes', 0) & 0x400): + raise Refusal(f'linked path refused: {part}') + if not (stat.S_ISREG(info.st_mode) or stat.S_ISDIR(info.st_mode)): + raise Refusal(f'special file refused: {part}') + if stat.S_ISREG(info.st_mode) and info.st_nlink > 1: + raise Refusal(f'hardlinked file refused: {part}') + + +def sync_dir(path): + # Windows stdlib cannot fsync a directory. File contents are still flushed. + if os.name == 'posix': + fd = os.open(path, os.O_RDONLY | os.O_DIRECTORY) + try: + os.fsync(fd) + finally: + os.close(fd) + + +def write_json(path, data): + safe_components(path) + temp = path.with_name('.tmp-' + uuid.uuid4().hex) + try: + with temp.open('x', encoding='utf-8') as stream: + json.dump(data, stream, sort_keys=True, ensure_ascii=False, allow_nan=False) + stream.flush() + os.fsync(stream.fileno()) + os.replace(temp, path) + sync_dir(path.parent) + finally: + temp.unlink(missing_ok=True) + + +def unique_object(pairs): + result = {} + for key, value in pairs: + if key in result: + raise Refusal('duplicate receipt key; reconciliation required') + result[key] = value + return result + + +def reject_constant(value): + raise Refusal('nonfinite receipt value; reconciliation required') + + +def finite_float(value): + result = float(value) + if not math.isfinite(result): + reject_constant(value) + return result + + +def read_json(path): + safe_components(path) + if path.stat().st_size > 4 * 1024 * 1024: + raise Refusal('oversized receipt; reconciliation required') + data = json.loads(path.read_text(encoding='utf-8'), object_pairs_hook=unique_object, + parse_constant=reject_constant, parse_float=finite_float) + valid = isinstance(data, dict) + if path.name == 'active.json': + valid = valid and set(data) == {'receipt'} and isinstance(data['receipt'], str) + valid = valid and bool(re.fullmatch(r'[0-9a-f]{64}\.json', data['receipt'])) + else: + required = {'version', 'operation_id', 'fingerprint', 'request', 'state'} + valid = valid and required <= data.keys() and data.keys() <= required | {'response'} + if valid: + valid = (type(data['version']) is int and data['version'] == 1 + and isinstance(data['operation_id'], str) and bool(_ID.fullmatch(data['operation_id'])) + and isinstance(data['fingerprint'], str) + and isinstance(data['state'], str) and data['state'] in ('pending', 'complete') + and isinstance(data['request'], dict)) + if valid: + request = data['request'] + valid = (set(request) == {'version', 'tool', 'arguments', 'root', 'cwd', 'environment'} + and type(request['version']) is int and request['version'] == 1 + and isinstance(request['tool'], str) and request['tool'] in WRITE_TOOLS + and isinstance(request['arguments'], dict) + and isinstance(request['root'], str) and isinstance(request['cwd'], str) + and isinstance(request['environment'], dict) + and all(isinstance(v, str) for v in request['environment'].values()) + and request['arguments'].get('operation_id') == data['operation_id']) + if valid: + digest = hashlib.sha256(json.dumps(request, sort_keys=True, ensure_ascii=False, + allow_nan=False).encode('utf-8')).hexdigest() + valid = (data['fingerprint'] == digest and path.name == + hashlib.sha256(data['operation_id'].encode()).hexdigest() + '.json') + if valid and data['state'] == 'complete': + response = data.get('response') + valid = (isinstance(response, dict) and {'exit_code', 'stdout', 'stderr'} <= response.keys() + and response.keys() <= {'exit_code', 'stdout', 'stderr', 'output_truncated'} + and type(response['exit_code']) is int and isinstance(response['stdout'], str) + and isinstance(response['stderr'], str) + and type(response.get('output_truncated', False)) is bool) + elif valid: + valid = 'response' not in data + if not valid: + raise Refusal('invalid receipt schema/integrity; reconciliation required') + return data + + +@contextmanager +def workspace_lock(root): + """One persistent inode, kernel locking, bounded wait; never delete a stale PID lock.""" + deadline = time.monotonic() + LOCK_TIMEOUT + if not _THREAD_LOCK.acquire(timeout=LOCK_TIMEOUT): + raise Refusal('workspace busy; lock wait expired') + try: + control = root / '.yeoul-mcp' + safe_components(control) + control.mkdir(exist_ok=True, mode=0o700) + info = control.stat() + if os.name == 'posix' and (info.st_uid != os.geteuid() or stat.S_IMODE(info.st_mode) & 0o077): + raise Refusal('control directory must be owned by the server user with mode 0700') + sync_dir(root) + path = control / 'workspace.lock' + safe_components(path) + with path.open('a+b') as stream: + if not path.stat().st_size: + stream.write(b'\0') + stream.flush() + locked = False + try: + while not locked: + try: + if os.name == 'nt': + import msvcrt + stream.seek(0) + msvcrt.locking(stream.fileno(), msvcrt.LK_NBLCK, 1) + else: + import fcntl + fcntl.flock(stream, fcntl.LOCK_EX | fcntl.LOCK_NB) + locked = True + except OSError as exc: + if exc.errno not in (errno.EACCES, errno.EAGAIN, errno.EDEADLK): + raise + if time.monotonic() >= deadline: + raise Refusal('workspace busy; lock wait expired') from exc + time.sleep(0.025) + yield control + finally: + if locked: + if os.name == 'nt': + stream.seek(0) + msvcrt.locking(stream.fileno(), msvcrt.LK_UNLCK, 1) + else: + fcntl.flock(stream, fcntl.LOCK_UN) + finally: + _THREAD_LOCK.release() + + +class Policy: + def __init__(self, values): + raw = os.environ.get('YEOUL_MCP_ROOT') + self.managed = raw is not None + self.env = dict(os.environ) + if self.managed: + if not raw or not Path(raw).is_absolute() or '..' in Path(raw).parts: + raise Refusal('YEOUL_MCP_ROOT must be an explicit absolute existing directory') + self.root = Path(raw) + safe_components(self.root) + if not self.root.is_dir(): + raise Refusal('YEOUL_MCP_ROOT must be an existing directory') + self.root = self.root.resolve() + if self.root.parent == self.root: + raise Refusal('filesystem root cannot be a managed workspace') + self.cwd = self.path(values.get('workspace', '.')) + if not self.cwd.is_dir(): + raise Refusal('workspace must be an existing directory within YEOUL_MCP_ROOT') + else: + self.cwd = Path(values.get('workspace', os.getcwd())).absolute() + self.root = Path.cwd().resolve() + + def path(self, raw, *, base=None, approved=False, external_read=False): + if not raw or any(ord(c) < 32 for c in raw) or '\\' in raw: + # Accept native Windows absolute paths, but never ambiguous relative backslash paths. + if not (os.name == 'nt' and Path(raw).is_absolute() + and not any(ord(c) < 32 for c in raw)): + raise Refusal('empty, control-character or ambiguous path refused') + path = Path(raw) + if '..' in path.parts or (os.name != 'nt' and ':' in raw): + raise Refusal('path traversal or alternate path syntax refused') + path = path if path.is_absolute() else (base or self.root) / path + try: + relative = path.relative_to(self.root) + except ValueError as exc: + if external_read: + grants = json.loads(self.env.get('YEOUL_MCP_READ_LEDGERS', '[]')) + if (not isinstance(grants, list) or len(grants) > 100 or + any(not isinstance(p, str) or not Path(p).is_absolute() for p in grants)): + raise Refusal('invalid external ledger grants') + if str(path) in grants: + safe_components(path) + if path.is_file(): + return path + raise Refusal('path outside YEOUL_MCP_ROOT') from exc + for part in relative.parts: + if ':' in part or part.endswith((' ', '.')): + raise Refusal('ambiguous path component refused') + if '.yeoul-workspace.json' in relative.parts: + raise Refusal('workspace profile is not a business target') + if any(p in _RESERVED for p in relative.parts) and not approved: + raise Refusal('reserved runtime/approval path refused') + safe_components(path) + return path + + def validate(self, tool, values): + if not self.managed: + return + for key in ('name', 'slug', 'role', 'relay'): + if key in values and not _NAME.fullmatch(values[key]): + raise Refusal(f'{key} must be 1-128 ASCII letters/digits/underscore/hyphen, starting alphanumeric') + if 'roles' in values and (not values['roles'].split(' ') or + any(not _NAME.fullmatch(role) for role in values['roles'].split(' '))): + raise Refusal('roles must be space-separated safe names') + for key, choices in [('backend', {'a', 'b', 'both'}), + ('stop', {'converged', 'falsified', 'no-progress'})]: + if key in values and values[key] not in choices: + raise Refusal(f'invalid {key}') + for key in ('arc_dir', 'arcs_dir', 'todo_path', 'ledger'): + if values.get(key): + values[key] = str(self.path(values[key], base=self.cwd, external_read=key == 'ledger')) + if tool == 'arc_close': + arc = Path(values['arc_dir']) + self.path(str(arc.parent / '_archive' / arc.name)) + if 'workspace' in values: + values['workspace'] = str(self.cwd) + # Defaults must not fall back to package paths or the server's launch cwd. + defaults = {'YEOUL_PROJECTS': 'projects', 'YEOUL_INDEX': 'KNOWLEDGE_INDEX.md', + 'YEOUL_CLOSED_REGISTRY': 'registry/closed_questions.jsonl'} + for key, default in defaults.items(): + self.env[key] = str(self.path(self.env.get(key) or default, base=self.cwd)) + if self.env.get('YEOUL_LEDGER'): + self.env['YEOUL_LEDGER'] = str(self.path(self.env['YEOUL_LEDGER'], base=self.cwd, external_read=True)) + # Fixed harness code may run; arbitrary shell commands have a separate capability. + self.env['YEOUL_MCP_MANAGED'] = '1' + self.env['PYTHONDONTWRITEBYTECODE'] = '1' + for key in ('BASH_ENV', 'ENV', 'CDPATH', 'PYTHONPATH', 'PYTHONSTARTUP', + 'SHELLOPTS', 'BASHOPTS', 'GLOBIGNORE', 'IFS'): + self.env.pop(key, None) + self.env = {k: v for k, v in self.env.items() if not k.startswith('BASH_FUNC_')} + if tool == 'verify_gate': + if self.env.get('YEOUL_MCP_ALLOW_EXEC') != '1': + raise Refusal('verification executes arbitrary commands; YEOUL_MCP_ALLOW_EXEC=1 required') + approved = self.env.get('YEOUL_MCP_VERIFY_BASELINE', '') + if not approved or not Path(approved).is_absolute(): + raise Refusal('supervisor must configure absolute YEOUL_MCP_VERIFY_BASELINE') + baseline = self.path(approved, approved=True) + if not baseline.is_relative_to(self.root / '.yeoul-approved') or not baseline.is_file(): + raise Refusal('approved baseline must be an existing file under root/.yeoul-approved') + approved_hash = self.env.get('YEOUL_MCP_VERIFY_BASELINE_SHA256') + if approved_hash is not None and hashlib.sha256(baseline.read_bytes()).hexdigest() != approved_hash: + raise Refusal('approved baseline changed; operator re-approval required') + if values.get('baseline_path') and self.path(values['baseline_path'], base=self.cwd, + approved=True) != baseline: + raise Refusal('baseline differs from supervisor-approved baseline') + values['baseline_path'] = str(baseline) + # A bounded conservative scan covers implicit script globs, archive destinations, + # .prereg references, and children of explicit paths. No symlink following. + deadline = time.monotonic() + 5 + stack, count = [self.root], 0 + while stack: + directory = stack.pop() + with os.scandir(directory) as entries: + for entry in entries: + count += 1 + if count > SCAN_LIMIT or time.monotonic() > deadline: + raise Refusal('workspace safety scan limit exceeded (20000 entries / 5 seconds)') + path = Path(entry.path) + safe_components(path) + if path == self.root / '.yeoul-mcp': + continue + if entry.is_dir(follow_symlinks=False): + if path.name == '.yeoul-mcp': + raise Refusal('overlapping managed workspace roots refused') + stack.append(path) + elif path.name == '.prereg': + if path.stat().st_size > 65536: + raise Refusal('oversized .prereg') + lines = path.read_text(encoding='utf-8').splitlines() + if len(lines) >= 2: + self.path(lines[1], base=self.cwd, external_read=True) + for ancestor in self.root.parents: + if (ancestor / '.yeoul-mcp').exists(): + raise Refusal('overlapping managed workspace roots refused') + + +def boundary(*, mutating=False): + """Preserve the actual tool signature so FastMCP exposes operation_id.""" + def decorate(function): + signature = inspect.signature(function) + + @wraps(function) + def wrapped(*args, **kwargs): + bound = signature.bind(*args, **kwargs) + bound.apply_defaults() + values = dict(bound.arguments) + operation_id = values.get('operation_id') + armed = False + checking_receipts = False + try: + policy = Policy(values) + if mutating and policy.managed: + if policy.env.get('YEOUL_MCP_ALLOW_WRITE') != '1': + raise Refusal('YEOUL_MCP_ALLOW_WRITE=1 required') + if not operation_id: + raise Refusal('operation_id required for managed mutations') + if function.__name__ == 'verify_gate' and policy.env.get('YEOUL_MCP_ALLOW_EXEC') != '1': + raise Refusal('YEOUL_MCP_ALLOW_EXEC=1 required for verification') + allowlist = policy.env.get('YEOUL_MCP_WRITE_TOOLS') + if allowlist is not None: + allowed = {name.strip() for name in allowlist.split(',') if name.strip()} + if allowed - WRITE_TOOLS or function.__name__ not in allowed: + raise Refusal('tool denied by YEOUL_MCP_WRITE_TOOLS (comma-separated tool names)') + if operation_id is not None and not _ID.fullmatch(operation_id): + raise Refusal('invalid operation_id (1-128 ASCII identifier characters)') + if not policy.managed: + if operation_id is not None: + raise Refusal('operation_id requires managed mode (YEOUL_MCP_ROOT)') + return function(**values) + request = dict(version=1, tool=function.__name__, arguments=dict(values), + root=str(policy.root), cwd=str(policy.cwd), + environment={k: v for k, v in policy.env.items() + if k.startswith('YEOUL_') and k not in + ('YEOUL_MCP_ALLOW_WRITE', 'YEOUL_MCP_ALLOW_EXEC', + 'YEOUL_MCP_WRITE_TOOLS')}) + encoded = json.dumps(request, sort_keys=True, ensure_ascii=False, allow_nan=False).encode('utf-8') + if len(encoded) > 1024 * 1024: + raise Refusal('operation arguments/context exceed 1 MiB') + fingerprint = hashlib.sha256(encoded).hexdigest() + with workspace_lock(policy.root) as control: + # Recheck current paths/approvals even for a completed replay. Paths may be + # absent after archive; validate containment and links, not business existence. + policy.validate(function.__name__, values) + checking_receipts = True + receipt = control / (hashlib.sha256(operation_id.encode()).hexdigest() + '.json') if operation_id else None + if receipt and (control / 'recovery' / receipt.name).exists(): + return refused('retired operation cannot be retried', 'reconciliation_required') + if receipt and receipt.exists(): + old = read_json(receipt) + if old['fingerprint'] != fingerprint: + return refused('operation_id already used with different arguments/context', 'operation_conflict') + if old['state'] == 'complete': + return old['response'] + return refused('operation pending/ambiguous; reconciliation required; do not retry with a new ID', + 'reconciliation_required') + active = control / 'active.json' + if active.exists(): + marker = read_json(active) + active_id = marker['receipt'] + if not re.fullmatch(r'[0-9a-f]{64}\.json', active_id): + raise Refusal('invalid active receipt; reconciliation required') + if read_json(control / active_id)['state'] != 'complete': + return refused('workspace has a pending/ambiguous operation; reconciliation required', + 'reconciliation_required') + checking_receipts = False + if receipt: + record = dict(version=1, operation_id=operation_id, fingerprint=fingerprint, + request=request, state='pending') + armed = True + # Active first: even a crash before receipt creation blocks new IDs. + # A pointer to a missing receipt requires supervisor reconciliation. + write_json(active, {'receipt': receipt.name}) + write_json(receipt, record) + token = CONTEXT.set(policy) + try: + response = function(**values) + finally: + CONTEXT.reset(token) + # A killed/timed-out child may have committed part of its work. Never retry it. + if receipt and (response['exit_code'] < 0 or response['exit_code'] >= 128 + or response['exit_code'] in (124, 127) + or response.get('runtime_status') == 'ambiguous'): + return refused('operation interrupted or launch outcome uncertain; reconciliation required', + 'reconciliation_required') + if receipt: + # Match FastMCP's text serialization on the first response and replay. + response = json.loads(json.dumps(response, sort_keys=True, ensure_ascii=False, + allow_nan=False)) + record.update(state='complete', response=response) + write_json(receipt, record) + return response + except (OSError, ValueError, KeyError, TypeError, RecursionError) as exc: + return refused(str(exc) + '; inspect pending receipts before retrying', + 'reconciliation_required' if armed or checking_receipts else 'permission_denied') + return wrapped + return decorate diff --git a/mcp/yeoul_mcp/server.py b/mcp/yeoul_mcp/server.py index 894b5b8..85c9d33 100644 --- a/mcp/yeoul_mcp/server.py +++ b/mcp/yeoul_mcp/server.py @@ -40,6 +40,9 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1])) from yeoul_mcp import __version__ mcp._mcp_server.version = __version__ +from yeoul_mcp.runtime import CONTEXT, boundary + +RUN_TIMEOUT = 120 _BUNDLED = Path(__file__).resolve().parent / "_harness" / "bin" BIN = Path(os.environ.get("YEOUL_BIN", _BUNDLED if _BUNDLED.is_dir() @@ -86,18 +89,20 @@ def _run(script: str, *args: str, cwd: str | None = None, stdin: str | None = No # 🔴 encoding: pin UTF-8. The gate strips a `←` hint before judging an answer; under a # non-UTF-8 default (CP949) the strip fails and a trivial "yes" arrives long enough to # clear the substance checks. That is a gate-integrity bug, not a display bug. - env = {**os.environ, "PYTHONUTF8": "1", "PYTHONIOENCODING": "utf-8"} + policy = CONTEXT.get() + env = {**(policy.env if policy else os.environ), "PYTHONUTF8": "1", "PYTHONIOENCODING": "utf-8"} if not script.endswith('.py'): env['YEOUL_BASH'] = interp[0] try: with subprocess.Popen( - cmd, cwd=cwd or os.getcwd(), + cmd, cwd=cwd or (str(policy.cwd) if policy else os.getcwd()), stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, encoding="utf-8", env=env, start_new_session=os.name == "posix", ) as child: try: - stdout, stderr = child.communicate(input=stdin if stdin is not None else "", timeout=120) - return {"exit_code": child.returncode, "stdout": stdout, "stderr": stderr} + stdout, stderr = child.communicate(input=stdin if stdin is not None else "", timeout=RUN_TIMEOUT) + return {"exit_code": child.returncode, "stdout": stdout[:65536], "stderr": stderr[:65536], + **({"output_truncated": True} if len(stdout) > 65536 or len(stderr) > 65536 else {})} except subprocess.TimeoutExpired: if os.name == "posix": try: @@ -106,15 +111,22 @@ def _run(script: str, *args: str, cwd: str | None = None, stdin: str | None = No pass else: child.kill() - child.communicate() + try: + child.communicate(timeout=5) + except subprocess.TimeoutExpired: + # Descendants may retain the pipes (especially on Windows). Do not wait forever. + child.stdout.close() + child.stderr.close() return {"exit_code": 124, "stdout": "", "stderr": "timeout"} except (OSError, UnicodeError) as exc: return {"exit_code": 127, "stdout": "", "stderr": str(exc)} @mcp.tool() +@boundary(mutating=True) def yeoul_new(name: str, topic: str = "", roles: str = "analysis impl repro", - backend: str = "both", no_arc: bool = False, workspace: str = ".") -> dict: + backend: str = "both", no_arc: bool = False, workspace: str = ".", + operation_id: str | None = None) -> dict: """Scaffold a project (design/ + dev/ + spec) and open a deliberation arc. Set no_arc to scaffold only.""" args = [name] if topic: @@ -126,8 +138,10 @@ def yeoul_new(name: str, topic: str = "", roles: str = "analysis impl repro", @mcp.tool() +@boundary(mutating=True) def arc_open(slug: str, arcs_dir: str, topic: str = "", roles: str = "analysis impl repro", - backend: str = "both", relay: str = "orchestrator", workspace: str = ".") -> dict: + backend: str = "both", relay: str = "orchestrator", workspace: str = ".", + operation_id: str | None = None) -> dict: """Open a deliberation arc directly under arcs_dir (thread + ticket inboxes + roster + join prompts).""" args = [slug, f"--arcs-dir={arcs_dir}", f"--roles={roles}", f"--backend={backend}", f"--relay={relay}"] if topic: @@ -136,13 +150,17 @@ def arc_open(slug: str, arcs_dir: str, topic: str = "", roles: str = "analysis i @mcp.tool() -def arc_ticket(arc_dir: str, role: str, slug: str, body: str, ref: str = "") -> dict: +@boundary(mutating=True) +def arc_ticket(arc_dir: str, role: str, slug: str, body: str, ref: str = "", + operation_id: str | None = None) -> dict: """Issue a relay ticket to a role's inbox (deliberation rules baked in). Relay-only.""" return _run("arc-ticket", arc_dir, role, slug, ref, stdin=body) @mcp.tool() -def loop_guard_tick(arc_dir: str, tokens: int | None = None, progress: bool = True) -> dict: +@boundary(mutating=True) +def loop_guard_tick(arc_dir: str, tokens: int | None = None, progress: bool = True, + operation_id: str | None = None) -> dict: """Tick the runaway guard for a round. Returns CONTINUE or STOP:max-rounds/budget/no-progress.""" args = [arc_dir, "tick", f"--progress={'yes' if progress else 'no'}"] if tokens is not None: @@ -151,14 +169,18 @@ def loop_guard_tick(arc_dir: str, tokens: int | None = None, progress: bool = Tr @mcp.tool() -def loop_guard_init(arc_dir: str, max_rounds: int = 3, token_budget: int = 200000) -> dict: +@boundary(mutating=True) +def loop_guard_init(arc_dir: str, max_rounds: int = 3, token_budget: int = 200000, + operation_id: str | None = None) -> dict: """Initialize the runaway guard (max rounds / token budget) for a loop.""" return _run("loop-guard", arc_dir, "init", f"--max-rounds={max_rounds}", f"--token-budget={token_budget}") @mcp.tool() -def arc_close(arc_dir: str, verdict: str, stop: str = "converged") -> dict: +@boundary(mutating=True) +def arc_close(arc_dir: str, verdict: str, stop: str = "converged", + operation_id: str | None = None) -> dict: """Close an arc (2-phase, GATE-ENFORCED). 1st call drafts _SUMMARY; fill the blanks, then call again to seal. Extra sections are required depending on the close: a KILL close (stop=falsified, or KILL in the verdict) gets the 🛡️ 5-check; ANY close on an arc with a linked prereg seal gets the 🔒 sealed-condition cross-check — @@ -169,19 +191,23 @@ def arc_close(arc_dir: str, verdict: str, stop: str = "converged") -> dict: @mcp.tool() -def build_handoff(name: str, workspace: str = ".") -> dict: +@boundary(mutating=True) +def build_handoff(name: str, workspace: str = ".", operation_id: str | None = None) -> dict: """Generate a dev skeleton, NOT authorization to build. Missing/negative verdicts remain manual gates.""" return _run("build-handoff", name, cwd=workspace) @mcp.tool() +@boundary() def ralph_gate_check(name: str, workspace: str = ".") -> dict: """Read-only eligibility check of ALL items, using the real CLI parser. No loop or verification runs.""" return _run("ralph", name, "--check", cwd=workspace) @mcp.tool() -def arc_prereg(arc_dir: str, claim_id: str, ledger: str = "", workspace: str = ".") -> dict: +@boundary(mutating=True) +def arc_prereg(arc_dir: str, claim_id: str, ledger: str = "", workspace: str = ".", + operation_id: str | None = None) -> dict: """Link a sealed pre-registration to an arc so arc_close injects its kill-condition VERBATIM instead of trusting an agent-typed field. Seal the claim first (mirror-stack). Without this, closes are UNSEALED.""" args = [arc_dir, claim_id] + ([ledger] if ledger else []) @@ -189,8 +215,10 @@ def arc_prereg(arc_dir: str, claim_id: str, ledger: str = "", workspace: str = " @mcp.tool() +@boundary(mutating=True) def verify_gate(todo_path: str, revert: bool = True, require_verify: bool = True, - workspace: str = ".", baseline_path: str = "") -> dict: + workspace: str = ".", baseline_path: str = "", + operation_id: str | None = None) -> dict: """Re-run the `verify:` command of every checked TODO item and revert the boxes that do not pass. This is the harness half of the dev loop — backend A (in-session) MUST call it each round, or nothing has been verified but the agent's word. Requires a supervisor-created baseline (default TODO.verify-baseline.json). @@ -202,18 +230,46 @@ def verify_gate(todo_path: str, revert: bool = True, require_verify: bool = True @mcp.tool() +@boundary() def status(workspace: str = ".", md: bool = False) -> dict: """One line per active project: name · latest arc verdict · dev TODO progress.""" return _run("status", *(["--md"] if md else []), cwd=workspace) @mcp.tool() +@boundary() def arc_list(workspace: str = ".", show_all: bool = False) -> dict: """List deliberation arcs (default: open/In-Progress only).""" return _run("arc-list", *(["--all"] if show_all else []), cwd=workspace) + +@mcp.tool() +def workspace_prepare(tool: str, arguments: dict) -> dict: + """Prepare a durable task WITHOUT executing it. Retain task_id before execute. + Reuse the same task_id after a lost execute response; never create a new task + to bypass an interrupted operation. Product modes still restrict permissions.""" + from .product import workspace, root + return workspace.prepare(root(), tool, arguments) + + +@mcp.tool() +def workspace_execute(task_id: str) -> dict: + """Execute one prepared task, or replay its recorded response. No recovery or elevation.""" + from .product import workspace, root + return workspace.execute(root(), task_id) + + +@mcp.tool() +def workspace_tasks() -> dict: + """Inspect task receipts, without executing business work or clearing pending state.""" + from .product import workspace, root + return workspace.tasks(root()) + def main(): + if 'YEOUL_MCP_ROOT' not in os.environ: + print('WARNING: Yeoul MCP trusted local mode: no managed path/permission boundary; ' + 'configure YEOUL_MCP_ROOT for managed mode. See docs/RUNTIME_CONTRACT.md.', file=sys.stderr) mcp.run() diff --git a/mcp/yeoul_mcp/workspace.py b/mcp/yeoul_mcp/workspace.py new file mode 100644 index 0000000..d438583 --- /dev/null +++ b/mcp/yeoul_mcp/workspace.py @@ -0,0 +1,562 @@ +"""Standalone workspace UX, vendored per product; stdlib only. + +No dependency on LaneStack or the other product. Operator configuration is not +a sandbox. All business operations dispatch to the same guarded functions as MCP. +""" +import argparse +from contextlib import contextmanager +import hashlib +import importlib +import importlib.util +import inspect +import json +import os +from pathlib import Path +import re +import shutil +import stat +import sys +import tempfile +import time +import uuid + +JOB_ID = re.compile(r"[a-f0-9]{32}\Z") + + +def canonical(value): + return json.dumps(value, ensure_ascii=False, sort_keys=True, allow_nan=False) + + +def checked_path(value, existing=False): + path = Path(value).absolute() + if ".." in path.parts or path.parent == path: + raise ValueError("Choose one project folder, not a filesystem root.") + for part in [*reversed(path.parents), path]: + try: + info = part.lstat() + except FileNotFoundError: + continue + if stat.S_ISLNK(info.st_mode) or getattr(info, "st_file_attributes", 0) & 0x400: + raise ValueError("Linked paths are not allowed: " + str(part)) + if not (stat.S_ISDIR(info.st_mode) or stat.S_ISREG(info.st_mode)): + raise ValueError("Special files are not allowed.") + if stat.S_ISREG(info.st_mode) and info.st_nlink != 1: + raise ValueError("Hardlinked files are not allowed.") + if existing and not path.exists(): + raise ValueError("Path does not exist: " + str(path)) + return path + + +def read_json(path): + path = checked_path(path, existing=True) + if path.stat().st_size > 8 * 1024 * 1024: + raise ValueError("JSON exceeds 8 MiB.") + def pairs(items): + result = {} + for key, value in items: + if key in result: + raise ValueError("Duplicate JSON key: " + key) + result[key] = value + return result + def constant(value): + raise ValueError("Nonfinite JSON: " + value) + result = json.loads(path.read_text(encoding="utf-8"), object_pairs_hook=pairs, + parse_constant=constant) + canonical(result) # Also rejects overflowed floats. + return result + + +def sync_dir(path): + if os.name == "posix": + fd = os.open(path, os.O_RDONLY | os.O_DIRECTORY) + try: + os.fsync(fd) + finally: + os.close(fd) + + +def write_json(path, value): + checked_path(path) + raw = canonical(value) + if len(raw.encode()) > 8 * 1024 * 1024: + raise ValueError("JSON exceeds 8 MiB.") + fd, temporary = tempfile.mkstemp(prefix=".write-", dir=path.parent) + try: + with os.fdopen(fd, "w", encoding="utf-8") as stream: + stream.write(raw + "\n") + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary, path) + sync_dir(path.parent) + finally: + if os.path.exists(temporary): + os.unlink(temporary) + + +class Workspace: + def __init__(self, product, prefix, control, package, modes, defaults): + self.product, self.prefix, self.control = product, prefix, control + self.package, self.modes, self.defaults = package, modes, defaults + self.config_name = "." + product + "-workspace.json" + + @property + def runtime(self): + return importlib.import_module(self.package + ".runtime") + + def tools(self): + server = importlib.import_module(self.package + ".server") + return {tool.name: getattr(server, tool.name) for tool in server.mcp._tool_manager.list_tools() + if not tool.name.startswith("workspace_")} + + def root(self, value): + root = checked_path(value, existing=True) + if not root.is_dir(): + raise ValueError("Workspace must be a directory.") + return root + + def load(self, root): + root = self.root(root) + config = read_json(root / self.config_name) + expected = {"schema", "product", "root", "revision", "mode", "external_ledgers", "verification"} + if (not isinstance(config, dict) or set(config) != expected or type(config["schema"]) is not int + or config["schema"] != 1 or config["product"] != self.product or config["root"] != str(root) + or config["mode"] not in self.modes or not isinstance(config["revision"], str) + or not isinstance(config["external_ledgers"], list) or len(config["external_ledgers"]) > 100 + or any(not isinstance(p, str) or not Path(p).is_absolute() for p in config["external_ledgers"]) + or (config["verification"] is not None and + (not isinstance(config["verification"], dict) or + set(config["verification"]) != {"path", "sha256"}))): + raise ValueError("Invalid or relocated workspace config; do not silently repair it.") + verification = config["verification"] + if verification is not None and (not isinstance(verification["path"], str) or + not isinstance(verification["sha256"], str)): + raise ValueError("Invalid verification approval.") + return config + + def save_config(self, root, config, initial=False, expected_revision=None): + with self.runtime.workspace_lock(root): + path = root / self.config_name + if initial and path.exists(): + raise ValueError("Already configured; use configure, not setup.") + history = root / self.control / "config-history" + checked_path(history) + history.mkdir(mode=0o700, exist_ok=True) + if path.exists(): + previous = self.load(root) + if expected_revision is not None and previous["revision"] != expected_revision: + raise ValueError("Configuration changed concurrently; reload before updating.") + write_json(history / (uuid.uuid4().hex + ".json"), previous) + write_json(path, config) + + def setup(self, folder, mode): + if mode not in self.modes: + raise ValueError("Unknown mode.") + root = checked_path(folder) + if (root / self.config_name).exists(): + raise ValueError("Already configured; no files changed.") + root.mkdir(mode=0o700, parents=True, exist_ok=True) + config = dict(schema=1, product=self.product, root=str(root), revision=uuid.uuid4().hex, + mode=mode, external_ledgers=[], verification=None) + self.save_config(root, config, initial=True) + return {"workspace": str(root), "mode": mode, "next": self.product + " doctor --workspace " + str(root)} + + def configure(self, root, mode): + config = self.load(root) + revision = config["revision"] + if mode not in self.modes: + raise ValueError("Unknown mode.") + config.update(mode=mode, revision=uuid.uuid4().hex) + # Mode changes revoke shell approval, never silently keep an old execution grant. + config["verification"] = None + self.save_config(Path(root), config, expected_revision=revision) + return config + + def environment(self, config): + root = config["root"] + allowed = self.modes[config["mode"]] + env = {self.prefix + "_MCP_ROOT": root, + self.prefix + "_MCP_ALLOW_WRITE": "1" if allowed else "0", + self.prefix + "_MCP_WRITE_TOOLS": ",".join(allowed), + self.prefix + "_MCP_ALLOW_EXEC": "0", + self.prefix + "_MCP_ALLOW_NETWORK": "0", + self.prefix + "_MCP_READ_LEDGERS": canonical(config["external_ledgers"])} + if self.product == "yeoul": + env.update(YEOUL_PROJECTS=str(Path(root) / "projects"), + YEOUL_INDEX=str(Path(root) / "KNOWLEDGE_INDEX.md"), + YEOUL_CLOSED_REGISTRY=str(Path(root) / "registry/closed_questions.jsonl")) + approval = config["verification"] + if config["mode"] == "develop" and approval: + path = checked_path(approval["path"], existing=True) + if not path.is_relative_to(Path(root) / ".yeoul-approved"): + raise ValueError("Approved baseline must be inside .yeoul-approved.") + if hashlib.sha256(path.read_bytes()).hexdigest() != approval["sha256"]: + raise ValueError("Approved baseline changed; re-approval required.") + env.update(YEOUL_MCP_ALLOW_EXEC="1", YEOUL_MCP_VERIFY_BASELINE=str(path), + YEOUL_MCP_VERIFY_BASELINE_SHA256=approval["sha256"]) + return env + + @contextmanager + def activated(self, root): + config = self.load(root) + # Only CLI/startup uses this. MCP task tools never elevate by loading a profile. + previous = dict(os.environ) + clean = [key for key in os.environ if key.startswith(self.prefix + "_")] + try: + for key in clean: + os.environ.pop(key, None) + os.environ.update(self.environment(config)) + yield config + finally: + os.environ.clear() + os.environ.update(previous) + + def connection(self, root): + self.load(root) + return {"mcpServers": {self.product: { + "command": sys.executable, + "args": ["-m", self.package + ".product", "serve", "--workspace", str(self.root(root))] + }}} + + def require_managed(self, root): + value = os.environ.get(self.prefix + "_MCP_ROOT") + if not value or self.root(value) != self.root(root): + raise ValueError("Start this workspace's managed server before preparing tasks.") + + def job_path(self, root, job_id): + if not isinstance(job_id, str) or not JOB_ID.fullmatch(job_id): + raise ValueError("Invalid task ID.") + return Path(root) / self.control / "tasks" / (job_id + ".json") + + def load_job(self, root, job_id): + job = read_json(self.job_path(root, job_id)) + if (not isinstance(job, dict) or set(job) != {"schema", "id", "tool", "arguments", "created", "digest"} + or type(job["schema"]) is not int or job["schema"] != 1 or job["id"] != job_id + or not isinstance(job["tool"], str) or not isinstance(job["arguments"], dict) + or "operation_id" in job["arguments"] or type(job["created"]) not in (int, float)): + raise ValueError("Invalid task record; preserve it for inspection.") + unsigned = {k: v for k, v in job.items() if k != "digest"} + if hashlib.sha256(canonical(unsigned).encode()).hexdigest() != job["digest"]: + raise ValueError("Task record changed; refusing execution.") + return job + + def prepare(self, root, tool, arguments): + self.require_managed(root) + if not isinstance(arguments, dict) or "operation_id" in arguments: + raise ValueError("Arguments must be an object; task IDs are managed internally.") + tools = self.tools() + if tool not in tools: + raise ValueError("Unknown business tool.") + fn = tools[tool] + inspect.signature(fn).bind(**arguments) + if "operation_id" in inspect.signature(fn).parameters: + if os.environ.get(self.prefix + "_MCP_ALLOW_WRITE") != "1": + raise ValueError("This workspace is read-only.") + allowed = os.environ.get(self.prefix + "_MCP_WRITE_TOOLS") + if allowed is not None and tool not in allowed.split(","): + raise ValueError("This tool is not permitted in the selected mode.") + job = dict(schema=1, id=uuid.uuid4().hex, tool=tool, arguments=arguments, created=time.time()) + job["digest"] = hashlib.sha256(canonical(job).encode()).hexdigest() + with self.runtime.workspace_lock(Path(root)): + directory = self.job_path(root, job["id"]).parent + checked_path(directory) + directory.mkdir(mode=0o700, exist_ok=True) + write_json(self.job_path(root, job["id"]), job) + return {"task_id": job["id"], "state": "prepared", "tool": tool, + "message": "Task prepared. Retain this ID before execution and reuse it for delivery retries."} + + def execute(self, root, job_id): + self.require_managed(root) + with self.runtime.workspace_lock(Path(root)): + job = self.load_job(root, job_id) + tools = self.tools() + if job["tool"] not in tools: + raise ValueError("Tool no longer exists; task cannot be migrated silently.") + fn = tools[job["tool"]] + arguments = dict(job["arguments"]) + if "operation_id" in inspect.signature(fn).parameters: + arguments["operation_id"] = job_id + try: + result = fn(**arguments) + except (ValueError, OSError) as exc: + text = str(exc) + state = "needs_attention" if "RECONCILE" in text or "CONFLICT" in text else "blocked" + return {"task_id": job_id, "state": state, "error": text, + "message": "Stopped. Inspect the workspace with doctor or recover."} + if isinstance(result, dict) and result.get("runtime_status"): + return {"task_id": job_id, "state": "needs_attention", "result": result, + "message": "Inspection required. Do not bypass an uncertain operation with a new task ID."} + return {"task_id": job_id, "state": "returned", "result": result, + "message": "Tool response delivered. Execution completion is not verification success."} + + def receipt(self, root, job_id): + path = Path(root) / self.control / (hashlib.sha256(job_id.encode()).hexdigest() + ".json") + if (path.parent / "recovery" / path.name).exists(): + return {"state": "retired", "note": "Operator reconciled; this ID cannot execute again."} + if not path.exists(): + return {"state": "not_recorded", "note": "No receipt is not proof of no effects."} + row = self.runtime._read_receipt(path) if self.product == "mirror-stack" else self.runtime.read_json(path) + state = row.get("status", row.get("state")) + return {"state": state, "tool": row.get("tool", (row.get("request") or {}).get("tool"))} + + def tasks(self, root): + directory = Path(root) / self.control / "tasks" + checked_path(directory) + out = [] + if directory.exists(): + for path in sorted(directory.glob("*.json"), key=lambda p: p.name): + if len(out) >= 1000: + break + job = self.load_job(root, path.stem) + out.append({"task_id": job["id"], "tool": job["tool"], "created": job["created"], + **self.receipt(root, job["id"])}) + return {"tasks": out, "limit": 1000, "message": "Status inspection does not execute tasks or clear pending state."} + + def active(self, root): + path = Path(root) / self.control / "active.json" + if not path.exists(): + return None + reader = self.runtime._read_receipt if self.product == "mirror-stack" else self.runtime.read_json + marker = reader(path) + name = marker.get("receipt") + if not isinstance(name, str) or not re.fullmatch(r"[0-9a-f]{64}\.json", name): + raise ValueError("Invalid active marker; restore metadata from evidence.") + receipt = path.parent / name + if not receipt.exists(): + return {"receipt": name, "state": "missing", "needs_attention": True} + row = reader(receipt) + state = row.get("status", row.get("state")) + return {"receipt": name, "state": state, "needs_attention": state not in ("done", "complete")} + + def doctor(self, root): + checks = [] + try: + config = self.load(root) + self.environment(config) + checks.append({"name": "workspace/config", "ok": True}) + except (ValueError, OSError) as exc: + return {"ok": False, "checks": [{"name": "workspace/config", "ok": False, "error": str(exc)}]} + try: + active = self.active(root) + checks.append({"name": "pending operation", "ok": not active or not active["needs_attention"], + "detail": active}) + except (ValueError, OSError) as exc: + checks.append({"name": "pending operation", "ok": False, "error": str(exc)}) + if self.product == "yeoul": + try: + from .server import bash_command + bash = bash_command() + found = Path(bash).is_file() if Path(bash).is_absolute() else shutil.which(bash) + checks.append({"name": "Bash", "ok": bool(found)}) + except OSError as exc: + checks.append({"name": "Bash", "ok": False, "error": str(exc)}) + for value in config["external_ledgers"]: + try: + checked_path(value, existing=True) + checks.append({"name": "linked ledger", "path": value, "ok": True}) + except (ValueError, OSError) as exc: + checks.append({"name": "linked ledger", "path": value, "ok": False, "error": str(exc)}) + return {"ok": all(row["ok"] for row in checks), "checks": checks, + "mode": config["mode"], "scope": "configuration and local prerequisites; not business verification", + "message": "Readiness check only; not certification of ledger truth or business success."} + + def link(self, root, ledger, remove=False): + config = self.load(root) + revision = config["revision"] + path = checked_path(ledger, existing=not remove) + if not remove and not path.is_file(): + raise ValueError("Select one ledger file, not a directory.") + if remove: + config["external_ledgers"] = [p for p in config["external_ledgers"] if p != str(path)] + elif str(path) not in config["external_ledgers"]: + if self.product == "mirror-stack": + from .integrity import read_verified + _, error = read_verified(path) + if error: + raise ValueError(error) + else: + from .server import BIN + spec = importlib.util.spec_from_file_location("_yeoul_prereg_check", BIN / "prereg_check.py") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + module.read_verified(path) + config["external_ledgers"].append(str(path)) + config["revision"] = uuid.uuid4().hex + self.save_config(Path(root), config, expected_revision=revision) + return {"ledger": str(path), "access": "revoked" if remove else "read-only", + "message": "Reconnect to apply the change. The source ledger was not modified."} + + def recover(self, root, acknowledge=False, note="", children_stopped=False): + self.load(root) + if not acknowledge: + return {"active": self.active(root), "tasks": self.tasks(root), + "message": "Preserve business data and receipts. Inspect changed files and surviving children. No automatic retry."} + if not note.strip() or not children_stopped: + raise ValueError("A reconciliation note and --children-stopped are required.") + with self.runtime.workspace_lock(Path(root)): + active = self.active(root) + if not active or not active["needs_attention"]: + return {"changed": False, "message": "No interrupted active operation to reconcile."} + directory = Path(root) / self.control / "recovery" + checked_path(directory) + directory.mkdir(mode=0o700, exist_ok=True) + record = {"schema": 1, "active": active, "note": note.strip(), + "children_stopped_attested": True, "time": time.time(), + "scope": "operator attestation, not automatic verification"} + # A deterministic tombstone also blocks replay when the original receipt + # was never created. Both low-level and product entrypoints check it. + write_json(directory / active["receipt"], record) + # Remove ONLY the pointer, under its own workspace lock, after durable audit. + # The old pending receipt remains non-retriable. + (Path(root) / self.control / "active.json").unlink() + sync_dir(Path(root) / self.control) + return {"changed": True, "message": "Reconciliation recorded. The interrupted task is retired; only new work may proceed."} + + def approve(self, root, todo): + if self.product != "yeoul": + raise ValueError("Verification approval is a Yeoul operator function.") + config = self.load(root) + revision = config["revision"] + if config["mode"] != "develop": + raise ValueError("Select develop mode first.") + path = checked_path(todo, existing=True) + if not path.is_relative_to(Path(root)): + raise ValueError("TODO must be inside this workspace.") + from .server import BIN + # Import the same verifier without executing any verification command. + spec = importlib.util.spec_from_file_location("_yeoul_verify_core", BIN / "verify_core.py") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + content = path.read_text(encoding="utf-8") + if not module.eligible(content): + raise ValueError("TODO has missing/invalid verification criteria.") + directory = Path(root) / ".yeoul-approved" + checked_path(directory) + directory.mkdir(mode=0o700, exist_ok=True) + baseline = directory / (uuid.uuid4().hex + ".json") + write_json(baseline, {"version": 1, "todo": str(path), "text": module.canonical(content)}) + config.update(verification={"path": str(baseline), + "sha256": hashlib.sha256(baseline.read_bytes()).hexdigest()}, + revision=uuid.uuid4().hex) + self.save_config(Path(root), config, expected_revision=revision) + return {"approved": str(baseline), "message": "Commands approved. OS isolation is still required where needed. Reconnect to apply."} + + def cli(self, argv=None): + parser = argparse.ArgumentParser(prog=self.product, description="Standalone workspace setup, execution and diagnosis") + parser.add_argument("--workspace", default=os.getcwd()) + sub = parser.add_subparsers(dest="command") + def command(name): + p = sub.add_parser(name) + p.add_argument("--workspace", default=argparse.SUPPRESS) + return p + for name in ("setup", "configure"): + p = command(name) + p.add_argument("folder", nargs="?") + p.add_argument("--mode", choices=list(self.modes), default=None) + p.add_argument("--yes", action="store_true") + for name in ("doctor", "connect", "tasks", "serve"): + command(name) + p = command("run") + p.add_argument("tool") + p.add_argument("--arguments", default="{}") + p.add_argument("--yes", action="store_true") + p = command("retry") + p.add_argument("task_id") + p.add_argument("--yes", action="store_true") + for name in ("link", "unlink"): + p = command(name) + p.add_argument("ledger") + p.add_argument("--yes", action="store_true") + p = command("recover") + p.add_argument("--acknowledge", action="store_true") + p.add_argument("--children-stopped", action="store_true") + p.add_argument("--note", default="") + p.add_argument("--yes", action="store_true") + p = command("approve") + p.add_argument("todo") + p.add_argument("--yes", action="store_true") + for name, (tool, parameter, default) in self.defaults.items(): + p = command(name) + p.add_argument(parameter, nargs="?", default=default) + p.add_argument("--yes", action="store_true") + args = parser.parse_args(argv) + root = args.workspace + cmd = args.command + def confirm(message): + if getattr(args, "yes", False): + return + if not sys.stdin.isatty(): + raise ValueError("Interactive approval required; review first, then use --yes.") + if input(message + " [y/N] ").strip().lower() not in ("y", "yes"): + raise ValueError("Cancelled. No changes made.") + try: + if cmd is None: + parser.print_help() + print("\nStart here: " + self.product + " setup") + return 0 + if cmd in ("setup", "configure"): + folder = args.folder or root + if not args.folder and sys.stdin.isatty(): + folder = input("Workspace folder [%s]: " % folder).strip() or folder + mode = args.mode or "observe" + if not args.mode and sys.stdin.isatty(): + mode = input("Mode %s [%s]: " % ("/".join(self.modes), mode)).strip() or mode + confirm("Configure folder %s / mode %s for %s." % (folder, mode, self.product)) + result = self.setup(folder, mode) if cmd == "setup" else self.configure(self.root(folder), mode) + elif cmd == "doctor": + result = self.doctor(root) + elif cmd == "connect": + result = self.connection(root) + elif cmd == "serve": + with self.activated(root): + importlib.import_module(self.package + ".server").main() + return 0 + elif cmd == "tasks": + result = self.tasks(self.root(root)) + elif cmd == "recover": + if args.acknowledge: + confirm("Clear only the pending marker after operator inspection of files and surviving processes.") + result = self.recover(self.root(root), args.acknowledge, args.note, args.children_stopped) + elif cmd in ("link", "unlink"): + confirm("Change external ledger read permission: " + cmd + " " + args.ledger) + result = self.link(self.root(root), args.ledger, remove=cmd == "unlink") + elif cmd == "approve": + path = checked_path(args.todo, existing=True) + print(path.read_text(encoding="utf-8"), file=sys.stderr) + confirm("These commands run with the server account's authority. Approve these criteria and commands?") + result = self.approve(self.root(root), str(path)) + elif cmd in ("run", "retry") or cmd in self.defaults: + with self.activated(root): + if cmd == "retry": + confirm("Resume this task. Completed responses replay; interrupted tasks do not execute again.") + result = self.execute(self.root(root), args.task_id) + else: + if cmd in self.defaults: + tool, parameter, _ = self.defaults[cmd] + values = {parameter: getattr(args, parameter)} + if self.product == "mirror-stack" and cmd == "record": + values = dict(ledger_path="actions.jsonl", agent="user", action="note", + payload={"text": values["text"]}) + else: + tool, values = args.tool, json.loads(args.arguments) + if tool in self.tools() and "operation_id" in inspect.signature(self.tools()[tool]).parameters: + confirm("Execute task: %s %s" % (tool, canonical(values))) + task = self.prepare(self.root(root), tool, values) + print("task_id=" + task["task_id"], file=sys.stderr, flush=True) + result = self.execute(self.root(root), task["task_id"]) + else: + raise ValueError("Unknown command.") + print(json.dumps(result, ensure_ascii=False, indent=2)) + if isinstance(result, dict): + if result.get("ok") is False or result.get("state") in ("blocked", "needs_attention"): + return 2 + business = result.get("result") + if isinstance(business, dict) and business.get("exit_code", 0) != 0: + return 1 + if isinstance(business, dict) and (business.get("ok") is False or business.get("decision") == "BLOCK"): + return 1 + if isinstance(business, list) and any("FAIL" in str(item) for item in business): + return 1 + return 0 + except (ValueError, OSError, KeyError, TypeError) as exc: + print("Stopped: " + str(exc), file=sys.stderr) + return 2 diff --git a/setup/install.sh b/setup/install.sh index 22a5f87..ab0f239 100755 --- a/setup/install.sh +++ b/setup/install.sh @@ -1,11 +1,12 @@ #!/usr/bin/env bash set -euo pipefail -# Install the tested MCP combination. Requested installation failures are fatal. +# Install independent Yeoul. Mirror is opt-in; requested failures are fatal. SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO="$(cd "$SCRIPT_DIR/.." && pwd)" -WITH_MIRROR=1; PRINT_ONLY=0 +WITH_MIRROR=0; PRINT_ONLY=0 for a in "$@"; do case "$a" in --no-mirror-stack) WITH_MIRROR=0 ;; + --with-mirror-stack) WITH_MIRROR=1 ;; --print-config) PRINT_ONLY=1 ;; *) echo "unknown option: $a" >&2; exit 2 ;; esac; done @@ -17,12 +18,13 @@ fi PY="$(yeoul_pybin)" || yeoul_pybin_die "$PY" -m pip --version >/dev/null if [ "$WITH_MIRROR" -eq 1 ]; then - "$PY" -m pip install "git+https://github.com/mirror-stack/mirror-stack-mcp@v0.2.14" + "$PY" -m pip install "git+https://github.com/mirror-stack/mirror-stack-mcp@v0.3.0" else echo "Mirror installation skipped: discussion closes remain explicitly file-only without a recorder." fi "$PY" -m pip install "$REPO/mcp" echo "Installed yeoul-mcp (bundled harness). Bash must be available on PATH." -echo "For checkout CLI commands, add $REPO/bin to PATH." -echo "Merge the following MCP configuration (omit mirror-stack if skipped):" -cat "$SCRIPT_DIR/mcp-servers.json" +echo "Start with: yeoul setup" +echo "Then: yeoul doctor --workspace FOLDER" +echo "Generate your managed client configuration: yeoul connect --workspace FOLDER" +echo "No existing client configuration or business data was changed." diff --git a/setup/mcp-servers.json b/setup/mcp-servers.json index b565a02..69d6faa 100644 --- a/setup/mcp-servers.json +++ b/setup/mcp-servers.json @@ -1,9 +1,6 @@ { - "_comment": "Register BOTH servers together. Yeoul (practice layer) composes with mirror-stack (discipline primitive: pre-registration + tamper-evident ledger). Merge this into your MCP client config (Claude Desktop/Code: mcpServers). Without mirror-stack, Yeoul still runs — discussion closures are explicitly file-only when no recorder is available. Yeoul includes its own harness; no YEOUL_BIN override is needed.", + "_comment": "Legacy trusted-local example only, not managed mode. Recommended: yeoul setup, then yeoul connect --workspace FOLDER to generate configuration. Mirror is independent and optional; configure it separately if needed.", "mcpServers": { - "mirror-stack": { - "command": "mirror-stack-mcp" - }, "yeoul": { "command": "yeoul-mcp" } diff --git a/tests/test_hardening.py b/tests/test_hardening.py index 75eecf1..232535d 100644 --- a/tests/test_hardening.py +++ b/tests/test_hardening.py @@ -152,8 +152,8 @@ def test_baseline_creation_is_explicit_and_non_overwriting(self): def test_verify_timeout(self): baseline = '- [x] bounded. verify: `sleep 5`\n' self.todo.write_text(baseline) - self.assertEqual(verify(self.todo, baseline=baseline, timeout=0.05, revert=True), 1) - self.assertIn('[ ] bounded', self.todo.read_text()) + self.assertEqual(verify(self.todo, baseline=baseline, timeout=0.05, revert=True), 124) + self.assertEqual(self.todo.read_text(), baseline, 'interruption requires reconciliation') def test_malformed_and_empty_todos_are_ineligible(self): for text in ['', '# no items', '- [x] absent', '- [ ] missing. verify:', From 5fc05c49230ceac4d4115f01420ef09e3d1ed4e8 Mon Sep 17 00:00:00 2001 From: Mother Seara Date: Thu, 10 Sep 2026 19:02:14 +0900 Subject: [PATCH 2/2] fix: UTF-8 product CLI and separate-root prereg integration coverage --- mcp/tests/test_product.py | 36 ++++++++++++++++++++++++++++++++++-- mcp/yeoul_mcp/workspace.py | 6 ++++++ 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/mcp/tests/test_product.py b/mcp/tests/test_product.py index 18d97e4..2a82b7b 100644 --- a/mcp/tests/test_product.py +++ b/mcp/tests/test_product.py @@ -165,11 +165,12 @@ def test_external_grant_exact_file_readonly_and_revocable(self): self.assertEqual(ledger.read_bytes(), original) def test_cli_outside_launch_directory(self): - env = dict(os.environ, PYTHONPATH=SOURCE, PYTHONDONTWRITEBYTECODE="1") + env = dict(os.environ, PYTHONPATH=SOURCE, PYTHONDONTWRITEBYTECODE="1", + PYTHONIOENCODING="ascii") def cli(*args): return subprocess.run([sys.executable, "-B", "-m", PACKAGE + ".product", *args, "--workspace", str(self.root)], cwd=self.base, env=env, - text=True, capture_output=True, timeout=30) + text=True, encoding="utf-8", capture_output=True, timeout=30) self.assertEqual(cli("doctor").returncode, 0) result = cli("run", TOOL, "--arguments", json.dumps(ARGUMENTS), "--yes") self.assertEqual(result.returncode, 0, result.stderr + result.stdout) @@ -180,6 +181,37 @@ def cli(*args): self.assertEqual(json.loads(result.stdout), json.loads(repeated.stdout)) + def test_separate_root_ledger_binds_arc_without_mirror_installation(self): + ledger = self.base / "producer" / "claims.jsonl" + ledger.parent.mkdir() + row = {"prev_seal": "genesis", "claim_id": "separate", "metric": "accuracy", + "kill_condition": "Accuracy remains below 0.6 across three independent seeds"} + row["seal"] = hashlib.sha256(json.dumps(row, sort_keys=True, ensure_ascii=False).encode()).hexdigest() + ledger.write_text(json.dumps(row) + "\n") + original = ledger.read_bytes() + arc = self.root / "arc" + arc.mkdir() + (arc / "0001_spec.md").write_text( + "- **Goal**: Measure classification accuracy across three independent seeds\n" + "- **Success condition**: Accuracy exceeds the preregistered threshold in every seed\n" + "- **Kill-condition**: Accuracy remains below the threshold across independent seeds\n" + "- **Constraints**: Use held-out data with fixed sample size and no training overlap\n") + workspace.link(self.root, ledger) + with workspace.activated(self.root): + task = server.workspace_prepare("arc_prereg", {"arc_dir": "arc", "claim_id": "separate", + "ledger": str(ledger)}) + got = server.workspace_execute(task["task_id"]) + self.assertEqual(got["result"]["exit_code"], 0, got) + self.assertTrue((arc / ".prereg").is_file()) + self.assertIn(str(ledger), (arc / ".prereg").read_text()) + self.assertEqual(ledger.read_bytes(), original) + self.assertEqual(got, server.workspace_execute(task["task_id"])) + workspace.link(self.root, ledger, remove=True) + with workspace.activated(self.root): + got = server.arc_close("arc", "GO", operation_id="revoked") + self.assertNotEqual(got["exit_code"], 0) + self.assertEqual(ledger.read_bytes(), original) + def test_approval_pins_baseline_and_mode_change_revokes(self): workspace.configure(self.root, "develop") todo = self.root / "TODO.md" diff --git a/mcp/yeoul_mcp/workspace.py b/mcp/yeoul_mcp/workspace.py index d438583..96d0355 100644 --- a/mcp/yeoul_mcp/workspace.py +++ b/mcp/yeoul_mcp/workspace.py @@ -441,6 +441,12 @@ def approve(self, root, todo): return {"approved": str(baseline), "message": "Commands approved. OS isolation is still required where needed. Reconnect to apply."} def cli(self, argv=None): + # The CLI emits JSON containing Unicode tool output. Windows redirected + # streams otherwise default to a legacy code page and can fail AFTER a + # committed operation. Fix the transport, never replace verdict characters. + for stream in (sys.stdin, sys.stdout, sys.stderr): + if hasattr(stream, "reconfigure"): + stream.reconfigure(encoding="utf-8", errors="strict") parser = argparse.ArgumentParser(prog=self.product, description="Standalone workspace setup, execution and diagnosis") parser.add_argument("--workspace", default=os.getcwd()) sub = parser.add_subparsers(dest="command")