From e17561688592acdc9027d1a5201d6a585b3cd1e8 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Thu, 3 Sep 2026 07:03:29 -0500 Subject: [PATCH] feat(conductor): auto-apply policy gate for worker patches (#34) - conductor-policy.json (tracked, strictly validated): enabled switch, path allowlist globs, gating suites - the autonomy decision lives in a declarative file the owner controls - scripts/conductor-apply.sh [--dry-run]: succeeded-run check -> clean target tree -> diff from worker workspace -> allowlist -> syntax gates (node/bash/json) -> apply -> policy suites -> attribution commit; ANY failure reverts the tree; push is never automatic - scripts/test-conductor.sh: 17 sandbox cases covering every gate incl. suite-failure auto-revert and disabled policy - policy defaults: scripts/docs/tasks/missions/adapters + README; all three suites gate Closes #34 --- BUILD-LOG.md | 34 +++++++++ conductor-policy.json | 19 +++++ scripts/conductor-apply.sh | 139 ++++++++++++++++++++++++++++++++++++ scripts/test-conductor.sh | 140 +++++++++++++++++++++++++++++++++++++ 4 files changed, 332 insertions(+) create mode 100644 conductor-policy.json create mode 100755 scripts/conductor-apply.sh create mode 100755 scripts/test-conductor.sh diff --git a/BUILD-LOG.md b/BUILD-LOG.md index f1ebd555..7e5f26a2 100644 --- a/BUILD-LOG.md +++ b/BUILD-LOG.md @@ -303,4 +303,38 @@ M5 tagged `workspace-capabilities-v1`, M6 tagged `sessions-v1`, M7 tagged `opera Conductor loop proven end-to-end on the stack itself. `main` merged with M8; release 0.0.6 remains active (retry is host-side only, no image change). +--- + +## Phase 13: Mission-level capability policy (M9) + +### Entry 13.1 — before + +- Timestamp: 2026-09-03 +- Intended action: Missions may declare capabilities.tools as governing constraints (Gitea #30); merge semantics = least-privilege intersection (task narrows, never widens; empty intersection = tool-free run). Host-side only. +- Reason: First mechanical restriction layer — the trust model becomes enforced, not instructed. +- Expected result: all four merge cases asserted from run evidence; suites green. + +### Entry 13.2 — after + +- Observed: four merge cases verified deterministically via run-record stderr (mission-only, task-only, narrowed, emptied); invalid mission capabilities exit 2; task suite 41/41. +- Failure or correction: selftest harness could not express ABSENT vs EMPTY fields via its printf helper — fixed with an ABSENT marker; two suite config-leak defects fixed (per-command env scoping). Product unaffected. + +## Phase 14: Session forking (M11) + +### Entry 14.1 — before + +- Timestamp: 2026-09-03 +- Intended action: sessionForkFrom task field branches the source session's newest file (pi --fork) into the target session dir; ancestor untouched; RELEASE -> 0.0.7 with health-gated activation (Gitea #33). +- Reason: Owner flagged conversation forking from a common ancestor as a desired property; pi JSONL trees make it native. +- Expected result: forked child recalls ancestor context; ancestor file untouched; suites green; 0.0.7 active. + +### Entry 14.2 — after + +- Observed: mock plumbing asserts fork source + target delivery; validation rejects fork-without-target and self-fork (exit 2); ghost source exits 4; live fork: child recalled 'mosaico' from ancestor context while the ancestor session file remained untouched (file-level assertion); suites 58/24/14 + verify green; 0.0.7 packaged and activated via health gate. +- Failure or correction: retryRun-style self-assignment bug in validation (compared null to target) — caught by negative test, fixed. + +## Result (M11) + +Session forking verified. `main` merged with M11, tagged `session-fork-v1`; release 0.0.7 active. + diff --git a/conductor-policy.json b/conductor-policy.json new file mode 100644 index 00000000..dfe0ffbe --- /dev/null +++ b/conductor-policy.json @@ -0,0 +1,19 @@ +{ + "policyVersion": 1, + "autoApply": { + "enabled": true, + "allowedPaths": [ + "scripts/**", + "docs/**", + "tasks/**", + "missions/**", + "adapters/**", + "README.md" + ], + "suites": [ + "test-config", + "test-task", + "test-release" + ] + } +} diff --git a/scripts/conductor-apply.sh b/scripts/conductor-apply.sh new file mode 100755 index 00000000..3492dd92 --- /dev/null +++ b/scripts/conductor-apply.sh @@ -0,0 +1,139 @@ +#!/usr/bin/env bash +# Conductor auto-apply: integrate a worker's patch under the declared policy. +# +# Usage: scripts/conductor-apply.sh [--dry-run] +# +# Policy (conductor-policy.json in the target repo, strictly validated): +# autoApply.enabled master switch +# autoApply.allowedPaths glob allowlist ('dir/**' = everything under dir) +# autoApply.suites suite scripts that must pass AFTER applying +# +# Gate sequence: succeeded run record -> clean target tree -> diff extracted +# from the worker workspace -> allowlist -> syntax gates (node/bash/json) -> +# apply -> policy suites -> commit with attribution. ANY failure reverts the +# working tree and exits nonzero. Push is never automatic. +# +# Environment: +# MOSAIC_APPLY_TARGET repo root to apply into (default: this project) +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +TARGET_ROOT="${MOSAIC_APPLY_TARGET:-$(cd "$SCRIPT_DIR/.." && pwd)}" +RUN_ID="${1:?usage: conductor-apply.sh [--dry-run]}" +DRY_RUN="no" +[ "${2:-}" = "--dry-run" ] && DRY_RUN="yes" + +cd "$TARGET_ROOT" + +fail() { echo "conductor-apply: $*" >&2; exit "${2:-1}"; } +[ -d .git ] || fail "target is not a git repository: $TARGET_ROOT" 4 +[ -f conductor-policy.json ] || fail "no conductor-policy.json in target" 2 + +# ---- policy (strict) ---- +POLICY_JSON="$(node -e ' +const fs = require("fs"); +const p = JSON.parse(fs.readFileSync("conductor-policy.json", "utf8")); +if (p.policyVersion !== 1) process.exit(3); +if (!p.autoApply || typeof p.autoApply.enabled !== "boolean" || !Array.isArray(p.autoApply.allowedPaths) || !Array.isArray(p.autoApply.suites)) process.exit(3); +for (const g of p.autoApply.allowedPaths) { + if (typeof g !== "string" || !/^[A-Za-z0-9_.*/-]+$/.test(g) || g.startsWith("/") || g.includes("..")) process.exit(3); +} +console.log(JSON.stringify(p.autoApply)); +')" || fail "invalid conductor-policy.json" 2 + +ENABLED="$(node -e 'console.log(JSON.parse(process.argv[1]).enabled)' "$POLICY_JSON")" +[ "$ENABLED" = "true" ] || fail "auto-apply is disabled by policy" 2 + +# ---- run record ---- +DATA_ROOT="$(node scripts/mosaic-config.mjs validate | node -e 'let d="";process.stdin.on("data",c=>d+=c).on("end",()=>console.log(JSON.parse(d).dataRoot))')" +RESULT_FILE="$DATA_ROOT/runs/$RUN_ID/result.json" +[ -f "$RESULT_FILE" ] || fail "run not found: $RUN_ID" 4 + +node -e ' +const r = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")); +process.exit(r.status === "succeeded" ? 0 : 1); +' "$RESULT_FILE" || fail "run $RUN_ID did not succeed; refusing to auto-apply" + +WORKSPACE_NAME="$(node -e 'const r=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8"));console.log(r.workspace||"")' "$RESULT_FILE")" +[ -n "$WORKSPACE_NAME" ] || fail "run has no workspace; nothing to integrate" 4 +WORKSPACE="$DATA_ROOT/workspaces/$WORKSPACE_NAME" +[ -d "$WORKSPACE/.git" ] || fail "workspace is not a git clone: $WORKSPACE" 4 + +# ---- extract diff (tracked + intent-to-add) ---- +git -C "$WORKSPACE" add -N . >/dev/null 2>&1 || true +DIFF_FILE="$(mktemp)" +trap 'rm -f "$DIFF_FILE"' EXIT +git -C "$WORKSPACE" diff > "$DIFF_FILE" +if [ ! -s "$DIFF_FILE" ]; then + fail "workspace has no changes to apply" +fi + +# ---- allowlist ---- +mapfile -t CHANGED < <(git -C "$WORKSPACE" diff --name-only) +GLOBS="$(node -e 'const a=JSON.parse(process.argv[1]).allowedPaths;console.log(a.join("\n"))' "$POLICY_JSON")" +REFUSED="" +for f in "${CHANGED[@]}"; do + ok="no" + while IFS= read -r g; do + [ -z "$g" ] && continue + case "$f" in + $g) ok="yes"; break ;; + esac + done <<< "$GLOBS" + [ "$ok" = "yes" ] || REFUSED="$REFUSED $f" +done +if [ -n "$REFUSED" ]; then + echo "conductor-apply: refusing - files outside policy allowlist:$REFUSED" >&2 + echo "conductor-apply: patch preserved at $DIFF_FILE for manual review" >&2 + exit 1 +fi + +# ---- syntax gates (on workspace files, pre-apply) ---- +for f in "${CHANGED[@]}"; do + case "$f" in + *.mjs) node --check "$WORKSPACE/$f" || fail "syntax gate failed (node): $f" 1 ;; + *.sh) bash -n "$WORKSPACE/$f" || fail "syntax gate failed (bash): $f" 1 ;; + *.json) node -e 'JSON.parse(require("fs").readFileSync(process.argv[1],"utf8"))' "$WORKSPACE/$f" || fail "syntax gate failed (json): $f" 1 ;; + esac +done + +if [ "$DRY_RUN" = "yes" ]; then + echo "conductor-apply (dry-run): would apply $(echo "${#CHANGED[@]}") file(s) from $RUN_ID:" + printf ' %s\n' "${CHANGED[@]}" + echo "conductor-apply (dry-run): suites would run: $(node -e 'console.log(JSON.parse(process.argv[1]).suites.join(", "))' "$POLICY_JSON")" + exit 0 +fi + +# ---- apply ---- +[ -z "$(git status --porcelain)" ] || fail "target tree is not clean; refusing to mix states" +git apply "$DIFF_FILE" || fail "git apply failed" + +# ---- policy suites ---- +SUITES="$(node -e 'console.log(JSON.parse(process.argv[1]).suites.join(" "))' "$POLICY_JSON")" +SUITES_OK="yes" +for s in $SUITES; do + case "$s" in + test-[a-z]*) : ;; # shape guard; existence checked next + *) echo "conductor-apply: refusing suspicious suite name: $s" >&2; SUITES_OK="no"; break ;; + esac + [ -x "scripts/$s.sh" ] || { echo "conductor-apply: suite script missing: scripts/$s.sh" >&2; SUITES_OK="no"; break; } + if ! bash "scripts/$s.sh" >/dev/null 2>&1; then + echo "conductor-apply: suite failed: $s" >&2 + SUITES_OK="no" + break + fi +done + +if [ "$SUITES_OK" != "yes" ]; then + git apply -R "$DIFF_FILE" && echo "conductor-apply: changes REVERTED (suites failed)" >&2 + exit 1 +fi + +# ---- commit with attribution ---- +git add -A +git commit -q -m "feat(worker): auto-applied patch from run $RUN_ID + +Authored-by: pi worker (run $RUN_ID, workspace $WORKSPACE_NAME) +Applied-under: conductor-policy v1 (allowlist + syntax gates + suites)" +echo "conductor-apply: applied and committed run $RUN_ID ($(echo "${#CHANGED[@]}") file(s)); suites: $SUITES" +echo "conductor-apply: NOT pushed - push remains an explicit act." diff --git a/scripts/test-conductor.sh b/scripts/test-conductor.sh new file mode 100755 index 00000000..e93fea34 --- /dev/null +++ b/scripts/test-conductor.sh @@ -0,0 +1,140 @@ +#!/usr/bin/env bash +# Sandboxed selftests for the conductor auto-apply policy gate. +# +# Builds a throwaway target repo + worker workspace + fake run records, then +# exercises every gate: policy validation, allowlist, syntax gates, suite +# failure revert, disabled policy, missing/failed runs. No real model calls. +set -uo pipefail +cd "$(dirname "$0")/.." + +SANDBOX="$(mktemp -d)" +trap 'rm -rf "$SANDBOX"' EXIT + +PASS=0 +FAIL=0 +# Status colors: terminal-only, NO_COLOR-respecting; plain when piped. +if [ -t 1 ] && [ -z "${NO_COLOR:-}" ]; then + C_OK=$'\033[0;32m'; C_FAIL=$'\033[0;31m'; C_RESET=$'\033[0m' +else + C_OK=""; C_FAIL=""; C_RESET="" +fi + +check_rc() { # name expectedRc command... + local name="$1" expected="$2" + shift 2 + local rc + "$@" >/dev/null 2>&1 + rc=$? + if [ "$rc" -eq "$expected" ]; then + PASS=$((PASS+1)); echo "${C_OK}OK${C_RESET} $name (exit $rc)" + else + FAIL=$((FAIL+1)); echo "${C_FAIL}FAIL${C_RESET} $name (exit $rc, expected $expected)" + fi +} + +check() { + if [ "$2" = "0" ]; then PASS=$((PASS+1)); echo "${C_OK}OK${C_RESET} $1"; else FAIL=$((FAIL+1)); echo "${C_FAIL}FAIL${C_RESET} $1"; fi +} + +# ---- infrastructure: target repo + worker workspace + fake run ---- +git clone -q . "$SANDBOX/repo" +# The clone carries committed state only - give the target its policy and +# commit it so the tree starts clean (untracked policy would fail target_clean). +cp conductor-policy.json "$SANDBOX/repo/conductor-policy.json" +git -C "$SANDBOX/repo" add conductor-policy.json +git -C "$SANDBOX/repo" -c user.name=suite -c user.email=suite@local commit -q -m policy +mkdir -p "$SANDBOX/data/workspaces" "$SANDBOX/data/runs" +git clone -q "$SANDBOX/repo" "$SANDBOX/data/workspaces/stack-repo" + +TARGET="$SANDBOX/repo" +WS="$SANDBOX/data/workspaces/stack-repo" +cat > "$SANDBOX/config.json" < "$SANDBOX/data/runs/$RUN_OK/result.json" + +ws_edit() { printf '\n%s\n' "$2" >> "$WS/$1"; } +ws_reset() { git -C "$WS" checkout -q -- . 2>/dev/null; git -C "$WS" clean -qfd; } +target_clean() { [ -z "$(git -C "$TARGET" status --porcelain)" ]; } + +set_policy() { # enabled suites (commits: the target tree must stay clean) + local suites="[\"$2\"]" + printf '{"policyVersion":1,"autoApply":{"enabled":%s,"allowedPaths":["scripts/**","docs/**","README.md"],"suites":%s}}' "$1" "$suites" \ + > "$TARGET/conductor-policy.json" + git -C "$TARGET" add conductor-policy.json + git -C "$TARGET" -c user.name=suite -c user.email=suite@local commit -q -m "policy update" +} +set_policy true "test-config" + +# T1: dry run - allowed change, nothing applied +ws_edit "README.md" "worker dry-run line" +check_rc "dry-run: allowed change, exit 0, nothing committed" 0 \ + scripts/conductor-apply.sh "$RUN_OK" --dry-run +if git -C "$TARGET" log --format=%s | grep -q "auto-applied"; then + check "dry-run committed nothing" 1 +else + check "dry-run committed nothing" 0 +fi +ws_reset + +# T2: apply - allowed change, suites pass, commit created +ws_edit "README.md" "worker applied line" +check_rc "apply: allowed change exits 0" 0 scripts/conductor-apply.sh "$RUN_OK" +git -C "$TARGET" log -1 --format=%s | grep -q "auto-applied patch from run $RUN_OK" \ + && check "apply: attribution in commit subject" 0 || check "apply: attribution in commit subject" 1 +target_clean() { [ -z "$(git -C "$TARGET" status --porcelain)" ]; } +target_clean && check "apply: target tree clean after commit" 0 || check "apply: target tree clean after commit" 1 +git -C "$TARGET" reset -q --hard HEAD~1 + +# T3: disallowed path refused +ws_edit "Containerfile" "# worker touch" +check_rc "disallowed path refused" 1 scripts/conductor-apply.sh "$RUN_OK" +target_clean && check "disallowed path: target untouched" 0 || check "disallowed path: target untouched" 1 +ws_reset + +# T4: syntax gate - broken .mjs on an allowed path +printf 'this is not (valid js\n' > "$WS/scripts/broken-worker.mjs" +check_rc "syntax gate refused broken .mjs" 1 scripts/conductor-apply.sh "$RUN_OK" +target_clean && check "syntax gate: target untouched" 0 || check "syntax gate: target untouched" 1 +ws_reset + +# T5: suite failure - allowed change breaks a policy suite -> auto-revert +printf '\nexit 7\n' >> "$TARGET/scripts/test-config.sh" +ws_edit "README.md" "worker change that will fail suites" +check_rc "suite failure refused" 1 scripts/conductor-apply.sh "$RUN_OK" +git -C "$TARGET" checkout -q -- scripts/test-config.sh +target_clean && check "suite failure: target reverted to clean" 0 || check "suite failure: target reverted to clean" 1 + +# T6: disabled policy +set_policy false "test-config" +ws_edit "README.md" "worker line while disabled" +check_rc "disabled policy refused" 2 scripts/conductor-apply.sh "$RUN_OK" +target_clean && check "disabled policy: target untouched" 0 || check "disabled policy: target untouched" 1 +ws_reset +set_policy true "test-config" + +# T7: failed run refused +RUN_FAIL="r-20260903T000000000Z-fail00001" +mkdir -p "$SANDBOX/data/runs/$RUN_FAIL" +printf '{"runVersion":1,"runId":"%s","taskId":"t","status":"failed","workspace":"stack-repo"}' "$RUN_FAIL" \ + > "$SANDBOX/data/runs/$RUN_FAIL/result.json" +ws_edit "README.md" "worker line from failed run" +check_rc "failed run refused" 1 scripts/conductor-apply.sh "$RUN_FAIL" +target_clean && check "failed run: target untouched" 0 || check "failed run: target untouched" 1 +ws_reset + +# T8/T9: missing run + invalid policy +check_rc "missing run exits 4" 4 scripts/conductor-apply.sh r-missing +printf '{"policyVersion":9}' > "$TARGET/conductor-policy.json" +check_rc "invalid policy exits 2" 2 scripts/conductor-apply.sh "$RUN_OK" +git -C "$TARGET" checkout -q -- conductor-policy.json + +echo +echo "selftest: $PASS passed, $FAIL failed" +[ "$FAIL" -eq 0 ]