Skip to content

Commit 4b6742e

Browse files
authored
Merge pull request #387 from ruby-dlee/codex/nm-scoped-live-proof
fix(azure): harden no-mistakes worker execution
2 parents 81bea8e + 9a0dac1 commit 4b6742e

7 files changed

Lines changed: 187 additions & 41 deletions

.github/workflows/ci.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,8 @@ jobs:
213213

214214
agent-fleet:
215215
name: Agent Fleet package
216+
needs: behavior-test-plan
217+
if: needs.behavior-test-plan.outputs.mode == 'full'
216218
runs-on: ubuntu-latest
217219
steps:
218220
- uses: actions/checkout@v6

bin/fm-azure-worker-provider.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2121,6 +2121,12 @@ def bootstrap_script(action):
21212121
bindings = action["bindings"]
21222122
script = """set -eu
21232123
umask 077
2124+
if ! /usr/bin/getent passwd fmworker >/dev/null; then
2125+
/usr/sbin/useradd --system --user-group --create-home --home-dir /var/lib/firstmate-worker-user --shell /usr/sbin/nologin fmworker
2126+
fi
2127+
[ "$(/usr/bin/id -u fmworker)" -ne 0 ]
2128+
[ "$(/usr/bin/getent passwd fmworker | /usr/bin/cut -d: -f6)" = /var/lib/firstmate-worker-user ]
2129+
[ "$(/usr/bin/getent passwd fmworker | /usr/bin/cut -d: -f7)" = /usr/sbin/nologin ]
21242130
install -d -m 0755 /usr/local/libexec
21252131
python3 - <<'PY'
21262132
from pathlib import Path

bin/fm-worker-supervisor.py

Lines changed: 83 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
import json
1414
import os
1515
from pathlib import Path
16+
import pwd
1617
import re
1718
import shutil
1819
import subprocess
@@ -312,28 +313,16 @@ def stage_payload(request, worktree, account_home):
312313
repo = worktree / "repo"
313314
if repo.is_symlink() or not repo.is_dir():
314315
raise SupervisorError("existing task-disk repository is unavailable or redirected")
315-
top = subprocess.run(
316-
["git", "-C", str(repo), "rev-parse", "--show-toplevel"],
317-
stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
318-
timeout=GIT_HEAD_TIMEOUT, check=False,
319-
)
316+
top = git_in(repo, "rev-parse", "--show-toplevel", timeout=GIT_HEAD_TIMEOUT)
320317
if top.returncode != 0 or Path(top.stdout.decode().strip()).resolve() != repo.resolve():
321318
raise SupervisorError("existing task-disk repository is not the exact repository root")
322-
lineage = subprocess.run(
323-
[
324-
"git", "-C", str(repo), "merge-base", "--is-ancestor",
325-
request["repository_generation"], "HEAD",
326-
],
327-
stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
328-
timeout=GIT_HEAD_TIMEOUT, check=False,
319+
lineage = git_in(
320+
repo, "merge-base", "--is-ancestor",
321+
request["repository_generation"], "HEAD", timeout=GIT_HEAD_TIMEOUT,
329322
)
330323
if lineage.returncode != 0:
331324
raise SupervisorError("existing task-disk repository lost its dispatched lineage")
332-
readable = subprocess.run(
333-
["git", "-C", str(repo), "status", "--porcelain"],
334-
stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
335-
timeout=GIT_STATUS_TIMEOUT, check=False,
336-
)
325+
readable = git_in(repo, "status", "--porcelain", timeout=GIT_STATUS_TIMEOUT)
337326
if readable.returncode != 0:
338327
raise SupervisorError("existing task-disk working tree is unreadable")
339328
return repo
@@ -367,11 +356,7 @@ def stage_payload(request, worktree, account_home):
367356
clone.stderr.decode("utf-8", errors="replace")[-500:]
368357
)
369358
)
370-
head = subprocess.run(
371-
["git", "-C", str(repo), "rev-parse", "HEAD"],
372-
stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
373-
timeout=GIT_HEAD_TIMEOUT, check=False,
374-
)
359+
head = git_in(repo, "rev-parse", "HEAD", timeout=GIT_HEAD_TIMEOUT)
375360
if head.returncode != 0 or head.stdout.decode().strip() != request["repository_generation"]:
376361
raise SupervisorError("staged repository head differs from the bound repository generation")
377362
if request.get("worker_role") == "no-mistakes":
@@ -387,7 +372,8 @@ def stage_no_mistakes_runtime(source, target, enforce_linux=True):
387372
if target.is_symlink() or not target.is_dir():
388373
raise SupervisorError("no-mistakes runtime target is unsafe")
389374
shutil.rmtree(target)
390-
target.mkdir(mode=0o700)
375+
target.mkdir(mode=0o755)
376+
target.chmod(0o755)
391377
extracted = {}
392378
total = 0
393379
try:
@@ -411,7 +397,7 @@ def stage_no_mistakes_runtime(source, target, enforce_linux=True):
411397
if total > 2 * 1024 * 1024 * 1024:
412398
raise SupervisorError("no-mistakes runtime expands beyond its bound")
413399
destination = target.joinpath(*parts)
414-
destination.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
400+
destination.parent.mkdir(parents=True, exist_ok=True, mode=0o755)
415401
destination.write_bytes(body)
416402
destination.chmod(member.mode)
417403
extracted[member.name] = body
@@ -482,13 +468,69 @@ def stage_no_mistakes_runtime(source, target, enforce_linux=True):
482468
):
483469
raise SupervisorError(
484470
"no-mistakes runtime {} is not Linux amd64".format(path))
471+
for directory, directories, _ in os.walk(target):
472+
Path(directory).chmod(0o755)
473+
for name in directories:
474+
child = Path(directory) / name
475+
if child.is_symlink():
476+
raise SupervisorError("no-mistakes runtime contains a redirected directory")
477+
child.chmod(0o755)
478+
479+
480+
def no_mistakes_execution_identity():
481+
try:
482+
identity = pwd.getpwnam("fmworker") if os.geteuid() == 0 else pwd.getpwuid(os.geteuid())
483+
except KeyError:
484+
raise SupervisorError("no-mistakes service user is unavailable") from None
485+
if identity.pw_uid == 0 or identity.pw_gid == 0:
486+
raise SupervisorError("no-mistakes service user is privileged")
487+
return identity
485488

486489

487-
def git_in(repo, *arguments, timeout=BUNDLE_CREATE_TIMEOUT):
490+
def chown_tree(root, uid, gid):
491+
root = Path(root)
492+
if root.is_symlink() or not root.is_dir():
493+
raise SupervisorError("no-mistakes writable root is unavailable or redirected")
494+
os.chown(root, uid, gid, follow_symlinks=False)
495+
for directory, directories, files in os.walk(root, followlinks=False):
496+
base = Path(directory)
497+
os.chown(base, uid, gid, follow_symlinks=False)
498+
for name in directories + files:
499+
os.chown(base / name, uid, gid, follow_symlinks=False)
500+
501+
502+
def prepare_no_mistakes_execution(worktree, worktree_root, account_home, brief):
503+
identity = no_mistakes_execution_identity()
504+
if os.geteuid() == 0:
505+
chown_tree(worktree, identity.pw_uid, identity.pw_gid)
506+
chown_tree(account_home, identity.pw_uid, identity.pw_gid)
507+
account_home.chmod(0o700)
508+
runtime = worktree_root / ".fm-runtime"
509+
if runtime.is_symlink() or not runtime.is_dir():
510+
raise SupervisorError("no-mistakes runtime root is unavailable or redirected")
511+
for directory, directories, _ in os.walk(runtime, followlinks=False):
512+
Path(directory).chmod(0o755)
513+
for name in directories:
514+
child = Path(directory) / name
515+
if child.is_symlink():
516+
raise SupervisorError("no-mistakes runtime contains a redirected directory")
517+
child.chmod(0o755)
518+
worktree_root.chmod(0o711)
519+
brief.parent.chmod(0o711)
520+
if os.geteuid() == 0:
521+
os.chown(brief, identity.pw_uid, identity.pw_gid, follow_symlinks=False)
522+
brief.chmod(0o400)
523+
if os.geteuid() == 0:
524+
return {"user": identity.pw_uid, "group": identity.pw_gid, "extra_groups": []}
525+
return {}
526+
527+
528+
def git_in(repo, *arguments, timeout=BUNDLE_CREATE_TIMEOUT, input_bytes=None, env=None):
488529
return subprocess.run(
489-
["git", "-C", str(repo), *arguments],
490-
stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
491-
timeout=timeout, check=False,
530+
["git", "-c", "safe.directory={}".format(repo), "-C", str(repo), *arguments],
531+
input=input_bytes, stdin=subprocess.DEVNULL if input_bytes is None else None,
532+
stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=timeout, check=False,
533+
env=env,
492534
)
493535

494536

@@ -589,9 +631,9 @@ def _scratch_artifacts(repo):
589631

590632

591633
def _hash_blob(repo, body):
592-
result = subprocess.run(
593-
["git", "-C", str(repo), "hash-object", "-w", "--stdin"], input=body,
594-
stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=GIT_HEAD_TIMEOUT, check=False,
634+
result = git_in(
635+
repo, "hash-object", "-w", "--stdin", input_bytes=body,
636+
timeout=GIT_HEAD_TIMEOUT,
595637
)
596638
if result.returncode != 0:
597639
raise SupervisorError("returned artifact could not be stored in the repository")
@@ -602,9 +644,8 @@ def _return_commit(repo, base, artifacts, request):
602644
entries = []
603645
for name, body in sorted(artifacts.items()):
604646
entries.append("100644 blob {}\t{}\n".format(_hash_blob(repo, body), name))
605-
tree = subprocess.run(
606-
["git", "-C", str(repo), "mktree"], input="".join(entries).encode(),
607-
stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=GIT_HEAD_TIMEOUT, check=False,
647+
tree = git_in(
648+
repo, "mktree", input_bytes="".join(entries).encode(), timeout=GIT_HEAD_TIMEOUT,
608649
)
609650
if tree.returncode != 0:
610651
raise SupervisorError("returned artifact tree could not be created")
@@ -617,11 +658,10 @@ def _return_commit(repo, base, artifacts, request):
617658
"GIT_AUTHOR_DATE": "@0 +0000",
618659
"GIT_COMMITTER_DATE": "@0 +0000",
619660
})
620-
committed = subprocess.run(
621-
["git", "-C", str(repo), "commit-tree", tree.stdout.decode().strip(), "-p", base],
622-
input=("Firstmate worker return {}\n".format(request["request_digest"])).encode(),
623-
stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=GIT_HEAD_TIMEOUT,
624-
check=False, env=environment,
661+
committed = git_in(
662+
repo, "commit-tree", tree.stdout.decode().strip(), "-p", base,
663+
input_bytes=("Firstmate worker return {}\n".format(request["request_digest"])).encode(),
664+
timeout=GIT_HEAD_TIMEOUT, env=environment,
625665
)
626666
if committed.returncode != 0:
627667
raise SupervisorError("returned artifact commit could not be created")
@@ -945,6 +985,7 @@ def execute(request, worktree, worktree_root):
945985
"GIT_ASKPASS": "/bin/false",
946986
}
947987
argv = request["argv"]
988+
execution_identity = {}
948989
if request.get("worker_role") == "no-mistakes":
949990
# The no-mistakes guest is already the isolated Azure test boundary.
950991
# Its project command uses this fixed marker to avoid recursively
@@ -959,11 +1000,13 @@ def execute(request, worktree, worktree_root):
9591000
raise SupervisorError("no-mistakes staged brief is unavailable or redirected")
9601001
argv = list(argv)
9611002
argv[6] = str(brief.resolve())
1003+
execution_identity = prepare_no_mistakes_execution(
1004+
worktree, worktree_root, account_home, brief)
9621005
try:
9631006
completed = subprocess.run(
9641007
argv, cwd=str(worktree), env=safe_env,
9651008
stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
966-
timeout=request["wall_seconds"], check=False,
1009+
timeout=request["wall_seconds"], check=False, **execution_identity,
9671010
)
9681011
timed_out = False
9691012
exit_code = completed.returncode

docs/azure-workers.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -406,8 +406,10 @@ Repair results return one digest-bound single-ref bundle whose head must descend
406406
The wrapper records a retryable local candidate before cleanup, releases through `service-complete` only after the lifecycle owns the exact execution result, and replays the candidate after a lost response instead of executing the step again.
407407
Admission, execute recovery, and cleanup use `service-reconcile`, which advances only the caller's exact task generation or replays that task's own pending slot claim rather than converging unrelated fleet work.
408408
The guest supervisor marks a no-mistakes Azure execution as the already-isolated test boundary, so the repository test command runs the focused service suite directly instead of recursively provisioning the general validation fleet or a Herdr lab.
409+
The root-owned supervisor stages the job, then runs the no-mistakes process as the dedicated non-root `fmworker` user with no supplementary groups; the sealed runtime remains root-owned and read-only while the exact repository and projected account are writable only by that service identity.
409410
`bin/fm-azure-service-test-scope.py` owns that focused inventory and the narrow source set eligible for focused pull-request CI; an empty, mixed, or unknown diff and every push to `main` retain the complete behavior suite.
410411
The wrapper preserves first-seen admission, execute, and cleanup start/completion timestamps in the task's `phase-evidence.json`, so retries keep one stable latency record.
412+
That evidence is stored at `$FM_HOME/state/no-mistakes-workers/<task>/<generation>/phase-evidence.json` as epoch milliseconds. Subtract each phase's `*_started` value from its `*_completed` value to measure admission, guest execution, and cleanup independently; a missing completion timestamp means that phase has not durably finished and must not be reported as complete.
411413
No caller chooses an Azure account, sees a credential, invokes `fm-azure-runner.sh`, or bypasses lifecycle cleanup.
412414

413415
The failed retained proof `azr-763d70ab8206` used the generic Azure runner, reached runtime dependency installation, then exited 125 at `guest bootstrap: isolated executor failed` without any structured result.

tests/fm-azure-service-test-scope.test.sh

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,14 @@ workflow_contract() {
9090
# shellcheck disable=SC2016 # The assertion requires the literal workflow expansion.
9191
assert_grep 'bin/fm-lint.sh "${files[@]}"' "$CI" \
9292
"focused CI does not delegate its changed shell files to the canonical lint owner"
93+
python3 - "$CI" <<'PY' || fail "focused CI still runs the unrelated Agent Fleet package job"
94+
from pathlib import Path
95+
import sys
96+
body = Path(sys.argv[1]).read_text()
97+
block = body.split("\n agent-fleet:\n", 1)[1].split("\n invariants:\n", 1)[0]
98+
assert "needs: behavior-test-plan" in block
99+
assert "if: needs.behavior-test-plan.outputs.mode == 'full'" in block
100+
PY
93101
pass "CI runs focused test and lint paths behind stable required checks and preserves full fallback"
94102
}
95103

tests/fm-no-mistakes-worker.test.sh

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -320,6 +320,8 @@ target = temporary / "verified-runtime"
320320
module.stage_no_mistakes_runtime(archive_path, target, enforce_linux=False)
321321
assert (target / "bin/no-mistakes").read_bytes() == binary
322322
assert (target / "bin/no-mistakes").stat().st_mode & 0o111
323+
assert target.stat().st_mode & 0o777 == 0o755
324+
assert all(path.stat().st_mode & 0o777 == 0o755 for path in target.rglob("*") if path.is_dir())
323325
PY
324326
pass "guest supervisor re-verifies the sealed runtime inventory and executable"
325327

tests/fm-worker-supervisor.test.sh

Lines changed: 84 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -725,6 +725,21 @@ run_supervisor_existing_task_disk_recovery() {
725725
fm_git_init_commit "$repo"
726726
base=$(git -C "$repo" rev-parse HEAD)
727727
printf 'uncommitted scout evidence\n' > "$repo/scratch.txt"
728+
python3 - "$repo" <<'PY' \
729+
|| fail "could not prepare the cross-owner retained repository fixture"
730+
import os
731+
from pathlib import Path
732+
import pwd
733+
import sys
734+
735+
root = Path(sys.argv[1])
736+
if os.geteuid() == 0:
737+
identity = pwd.getpwnam("nobody")
738+
for directory, directories, files in os.walk(root):
739+
os.chown(directory, identity.pw_uid, identity.pw_gid)
740+
for name in directories + files:
741+
os.chown(Path(directory) / name, identity.pw_uid, identity.pw_gid)
742+
PY
728743
cat > "$work/.fm-return/data/recover-existing/report.md" <<'REPORT'
729744
## Summary
730745
@@ -841,7 +856,74 @@ PY
841856
expect_code 2 "$status" "existing task-disk recovery with foreign lineage should refuse: $out"
842857
assert_contains "$out" "lost its dispatched lineage" "existing task-disk lineage refusal was not explicit"
843858
assert_present "$repo/scratch.txt" "a refused existing task-disk recovery removed scout work"
844-
pass "existing task-disk recovery returns reports and scratch without restaging or lineage drift"
859+
pass "existing task-disk recovery and return Git work across ownership without lineage drift"
860+
}
861+
862+
run_no_mistakes_privilege_contract() {
863+
local tmp
864+
fm_test_tmproot_into tmp fm-worker-supervisor-no-mistakes-user
865+
python3 - "$SUPERVISOR" "$ROOT/bin/fm-azure-worker-provider.py" "$tmp" <<'PY' \
866+
|| fail "no-mistakes guest did not execute through its unprivileged service identity"
867+
import importlib.util
868+
import os
869+
from pathlib import Path
870+
from types import SimpleNamespace
871+
import sys
872+
873+
supervisor_path, provider_path, temporary = map(Path, sys.argv[1:])
874+
spec = importlib.util.spec_from_file_location("fm_worker_supervisor", supervisor_path)
875+
module = importlib.util.module_from_spec(spec)
876+
spec.loader.exec_module(module)
877+
878+
root = temporary / "work"
879+
repo = root / "repo"
880+
account = temporary / "account"
881+
brief = root / ".fm-task" / "brief.md"
882+
runtime_dir = root / ".fm-runtime" / "lib" / "pi"
883+
for directory in (repo, account / "pi-agent", brief.parent, runtime_dir):
884+
directory.mkdir(parents=True, exist_ok=True, mode=0o700)
885+
(repo / "tracked.txt").write_text("tracked\n")
886+
(account / "pi-agent" / "auth.json").write_text("{}\n")
887+
brief.write_text("{}\n")
888+
(runtime_dir / "cli.js").write_text("export {};\n")
889+
890+
module.no_mistakes_execution_identity = lambda: SimpleNamespace(pw_uid=os.getuid(), pw_gid=os.getgid())
891+
module.os.geteuid = lambda: 0
892+
options = module.prepare_no_mistakes_execution(repo, root, account, brief)
893+
assert options == {"user": os.getuid(), "group": os.getgid(), "extra_groups": []}, options
894+
assert brief.stat().st_mode & 0o777 == 0o400
895+
assert root.stat().st_mode & 0o111
896+
assert all(path.stat().st_mode & 0o111 for path in (root / ".fm-runtime").rglob("*") if path.is_dir())
897+
898+
calls = []
899+
def fake_run(argv, **kwargs):
900+
calls.append((argv, kwargs))
901+
return SimpleNamespace(returncode=0, stdout=b"", stderr=b"")
902+
module.subprocess.run = fake_run
903+
module.os.environ["FM_WORKER_ACCOUNT_HOME"] = str(account)
904+
request = {
905+
"worker_role": "no-mistakes",
906+
"argv": ["no-mistakes", "worker", "run", "--role", "test", "--brief", "brief.md", "--result", "outcome.json"],
907+
"wall_seconds": 60,
908+
"assignment_generation": "asg-1",
909+
"request_digest": "a" * 64,
910+
"task": "task-1",
911+
"task_generation": "gen-1",
912+
"cloud_instance_id": "vm-1",
913+
"repository_binding": "b" * 64,
914+
"repository_generation": "c" * 40,
915+
}
916+
module.execute(request, repo, root)
917+
task_call = next(kwargs for argv, kwargs in calls if argv and argv[0] == "no-mistakes")
918+
assert task_call["user"] == os.getuid()
919+
assert task_call["group"] == os.getgid()
920+
assert task_call["extra_groups"] == []
921+
922+
provider = provider_path.read_text()
923+
assert "useradd --system --user-group" in provider
924+
assert "fmworker" in provider
925+
PY
926+
pass "no-mistakes guest runs as a dedicated non-root service user"
845927
}
846928

847929
run_supervisor_controls
@@ -850,4 +932,5 @@ run_supervisor_steer_controls
850932
run_supervisor_payload_staging
851933
run_supervisor_outcome_collection
852934
run_supervisor_existing_task_disk_recovery
935+
run_no_mistakes_privilege_contract
853936
echo "# fm-worker-supervisor.test.sh: all assertions passed"

0 commit comments

Comments
 (0)