Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -525,7 +525,8 @@ Use its scaffold as the contract, then fill `## Captain's intent` (`{TASK}`) wit
`bin/fm-dod-lib.sh` owns what a no-mistakes worker may pass as `--intent` and its rule that the string must be self-sufficient.
Keep additions task-specific rather than repeating lifecycle instructions, and alter generated sections only when the task genuinely differs from the standard shape.

Every ship brief must retain the worktree-isolation assertion and stop if launched in the primary checkout.
Every ship brief must retain both the worktree-path assertion and the launch-installed Git guard assertion, and every scout brief must retain the Git guard assertion.
The assertions stop the worker when its assigned isolation cannot be verified.
If a ship task touches firstmate's shared tracked material, explicitly require `firstmate-coding-guidelines` before editing.
If a task will drive Herdr lifecycle behavior, scaffold with `--herdr-lab`; if that need appears after an unguarded scaffold, stop and regenerate rather than adding commands by hand.
The generated Herdr contract must use a named non-`default` isolated lab and its guarded helper for every lifecycle action.
Expand Down
21 changes: 20 additions & 1 deletion bin/fm-brief.sh
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,8 @@
# "Delivery contract: mode=<mode>" line. bin/fm-spawn.sh reads that line and refuses
# to launch a ship task whose explicit --mode disagrees, so an adjusted brief and the
# recorded task metadata cannot drift apart.
# Ship briefs begin with a worktree-isolation assertion before the branch step.
# Ship and scout briefs assert the launch-installed Git isolation guard before work begins.
# Ship briefs keep the filesystem path assertion before their branch step too.
# --mode is refused on scout and secondmate scaffolds: a scout's deliverable is a
# report rather than a merge, and a charter is not a delivery contract.
# There is no --yolo flag here. The worker never owns merge decisions, so yolo is
Expand Down Expand Up @@ -350,6 +351,19 @@ IFS= read -r -d '' TASK_SECTION <<'EOF' || true
EOF
TASK_SECTION=${TASK_SECTION%$'\n'}

IFS= read -r -d '' GIT_ISOLATION_SECTION <<'EOF' || true
## Best-effort Git isolation
The worker launch places a task-frozen Git guard first on `PATH` as a speed bump against accidental Git invocations whose working directory drifts into the primary checkout.
The guard catches that incident class plus basic explicit targets, but it is deliberately not an evasion-resistant Git parser because the planned D2 delivery cutover retires the shared-primary-landing premise.
Known bypasses are an absolute Git binary; a login shell that resets `PATH`; `core.worktree` through `-c`, `--config-env`, or `GIT_CONFIG_*`; unknown global-option arity; non-shell alias injection; an unrecognized linked-primary Git directory; and `GIT_INDEX_FILE` or related file-target redirection.
The guard also shares the legacy `/tmp/fm-<task-id>` parent, so the same task id in another home can collide and its cleanup can remove this guard while this worker remains alive.
Run `git fm-isolation-check` now; it must report the assigned worktree and its distinct primary checkout before any task work begins.
If that assertion is unavailable or refuses the binding, append `blocked: worker Git isolation guard is not active` to the status file and stop.
Do not replace `PATH` or invoke Git by an absolute binary path, because either would bypass the task guard.
Run any test that creates, deletes, or switches its own Git refs only against a disposable fixture repository, never against the task worktree or primary checkout.
EOF
GIT_ISOLATION_SECTION=${GIT_ISOLATION_SECTION%$'\n'}

if [ "$KIND" = scout ]; then
cat > "$BRIEF" <<EOF
You are a crewmate: an autonomous worker agent managed by firstmate. Work on your own; do not wait for a human.
Expand All @@ -360,6 +374,9 @@ $HERDR_SECTION

# Setup
You are in a disposable git worktree of $REPO, at a detached HEAD on a clean default branch.

$GIT_ISOLATION_SECTION

This is a SCOUT task: the deliverable is a written report, not a PR.
The worktree is your laboratory - install, run, edit, and make scratch commits freely; all of it is discarded at teardown.
The report is the only thing that survives, so anything worth keeping must be in it.
Expand Down Expand Up @@ -440,6 +457,8 @@ You are in a disposable git worktree of $REPO, at a detached HEAD on a clean def
The path check is authoritative: \`git rev-parse --git-dir\` and \`git rev-parse --git-common-dir\` can help inspect the repo, but they do not prove you are outside the primary checkout.
If the top-level path is the primary checkout or not the worktree you were launched in, STOP - do not branch or commit here - append \`blocked: launched in primary checkout, not an isolated worktree\` to the status file and stop.

$GIT_ISOLATION_SECTION

1. First action: create your branch: \`git checkout -b fm/$ID\`$SETUP2

# Rules
Expand Down
115 changes: 115 additions & 0 deletions bin/fm-spawn.sh
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,10 @@
# configured host for a remote home. Skipped syncs warn and launch unchanged.
# Ship/scout spawns refuse to launch unless the resolved task path is a real
# git worktree root distinct from the primary project checkout.
# Every ship/scout spawn also freezes bin/fm-worker-git-guard.sh under the task temp root and places that copy first on the worker's PATH before launch.
# The guard is a documented best-effort speed bump for accidental working-directory drift into the primary checkout, not an evasion-resistant Git parser.
# Its header and the generated brief name unsupported Git-routing forms and the legacy same-id cross-home temp-root collision.
# Existing running workers are untouched, and secondmates remain unwrapped.
# Before a fresh ship or scout worker starts, its clean task worktree fetches
# origin, resolves the current remote default branch, and resets to its tip.
# An unreachable origin, unresolved default branch, or non-clean worktree
Expand Down Expand Up @@ -2629,6 +2633,86 @@ fi
TASK_TMP="/tmp/fm-$ID"
mkdir -p "$TASK_TMP/gotmp"

# Freeze a per-task Git guard before every ship/scout worker launches.
# The copy is first on that worker's initial PATH, so a later accidental Git invocation from the project's primary checkout is refused before it can switch the shared checkout's branch.
# This is intentionally a best-effort guard rather than a complete Git-routing parser, and bin/fm-worker-git-guard.sh documents its known bypasses.
# The adjacent config binds the copy to this exact task root, primary checkout, and real Git executable.
# Nothing is installed into the project or the harness's global configuration, and a secondmate remains unwrapped because its own home is intentionally primary.
# The legacy /tmp/fm-<task-id> root can collide across homes, so the guard header and generated brief disclose that cleanup lifetime gap.
# bin/fm-worker-git-guard.sh owns the invocation-time decision and fails closed when these spawn-validated bindings cannot be re-established.
WORKER_GIT_GUARD_DIR=
WORKER_GIT_GUARD_READY=
if [ "$KIND" != secondmate ]; then
WORKER_GIT_GUARD_SOURCE="$FM_ROOT/bin/fm-worker-git-guard.sh"
[ -f "$WORKER_GIT_GUARD_SOURCE" ] || {
echo "error: worker Git isolation guard is missing: $WORKER_GIT_GUARD_SOURCE" >&2
exit 1
}
WORKER_GIT_REAL=$(type -P git 2>/dev/null) || {
echo "error: worker Git isolation guard could not resolve the real Git executable" >&2
exit 1
}
case "$WORKER_GIT_REAL" in
/*) ;;
*)
WORKER_GIT_REAL_DIR=$(CDPATH='' cd -- "$(dirname -- "$WORKER_GIT_REAL")" 2>/dev/null && pwd -P) || {
echo "error: worker Git isolation guard could not resolve Git at $WORKER_GIT_REAL" >&2
exit 1
}
WORKER_GIT_REAL="$WORKER_GIT_REAL_DIR/$(basename -- "$WORKER_GIT_REAL")"
;;
esac
WORKER_GIT_WORKTREE=$(CDPATH='' cd -- "$WT" 2>/dev/null && pwd -P) || {
echo "error: worker Git isolation guard could not resolve task worktree $WT" >&2
exit 1
}
WORKER_GIT_PRIMARY=$(CDPATH='' cd -- "$PROJ_ABS" 2>/dev/null && pwd -P) || {
echo "error: worker Git isolation guard could not resolve primary checkout $PROJ_ABS" >&2
exit 1
}
WORKER_GIT_TASK_DIR=$("$WORKER_GIT_REAL" -C "$WORKER_GIT_WORKTREE" rev-parse --absolute-git-dir 2>/dev/null) || {
echo "error: worker Git isolation guard could not resolve the task git dir" >&2
exit 1
}
WORKER_GIT_PRIMARY_DIR=$("$WORKER_GIT_REAL" -C "$WORKER_GIT_PRIMARY" rev-parse --absolute-git-dir 2>/dev/null) || {
echo "error: worker Git isolation guard could not resolve the primary git dir" >&2
exit 1
}
WORKER_GIT_TASK_DIR=$(CDPATH='' cd -- "$WORKER_GIT_TASK_DIR" 2>/dev/null && pwd -P) || {
echo "error: worker Git isolation guard task git dir is unavailable" >&2
exit 1
}
WORKER_GIT_PRIMARY_DIR=$(CDPATH='' cd -- "$WORKER_GIT_PRIMARY_DIR" 2>/dev/null && pwd -P) || {
echo "error: worker Git isolation guard primary git dir is unavailable" >&2
exit 1
}
[ "$WORKER_GIT_WORKTREE" != "$WORKER_GIT_PRIMARY" ] \
&& [ "$WORKER_GIT_TASK_DIR" != "$WORKER_GIT_PRIMARY_DIR" ] || {
echo "error: worker Git isolation guard refused a task root that is not isolated from the primary checkout" >&2
exit 1
}
WORKER_GIT_GUARD_DIR=$(mktemp -d "$TASK_TMP/git-guard.XXXXXXXXXXXX") || {
echo "error: worker Git isolation guard could not allocate its private PATH directory" >&2
exit 1
}
chmod 0700 "$WORKER_GIT_GUARD_DIR"
cp "$WORKER_GIT_GUARD_SOURCE" "$WORKER_GIT_GUARD_DIR/git"
chmod 0700 "$WORKER_GIT_GUARD_DIR/git"
WORKER_GIT_CONFIG_TMP="$WORKER_GIT_GUARD_DIR/.git.conf.${BASHPID:-$$}"
old_umask=$(umask)
umask 077
{
printf 'worktree=%s\n' "$WORKER_GIT_WORKTREE"
printf 'primary=%s\n' "$WORKER_GIT_PRIMARY"
printf 'real_git=%s\n' "$WORKER_GIT_REAL"
printf 'task_git_dir=%s\n' "$WORKER_GIT_TASK_DIR"
printf 'primary_git_dir=%s\n' "$WORKER_GIT_PRIMARY_DIR"
} > "$WORKER_GIT_CONFIG_TMP"
umask "$old_umask"
mv "$WORKER_GIT_CONFIG_TMP" "$WORKER_GIT_GUARD_DIR/git.conf"
WORKER_GIT_GUARD_READY="$WORKER_GIT_GUARD_DIR/.path-verified"
fi

# Per-harness turn-end hook where enabled: a file that touches
# state/<id>.turn-ended when the agent finishes a turn. Worktree-resident hooks
# and token pointers stay out of git's view so they never block teardown's dirty
Expand Down Expand Up @@ -3227,10 +3311,41 @@ spawn_record_traceparent() {
return "$status"
}

spawn_preserve_worker_git_guard_recovery() {
local reason=$1
SPAWN_FRESH_COMMIT_PENDING=0
echo "error: worker Git isolation guard $reason; refusing to append the launch command and preserving task record $STATE/$ID.meta for endpoint $T and local copy $WT recovery" >&2
}

# Export GOTMPDIR into the crewmate's pane shell so the agent and every child
# process (go build, go test, ...) inherit it. Sent before the launch command so
# the env is set when the agent starts; the brief sleep lets the export land.
spawn_send_text_line "$T" "export GOTMPDIR=$TASK_TMP/gotmp"
# A ship/scout gets the frozen Git guard first on PATH before the harness starts.
# Existing workers are untouched because this export reaches only this launch.
# The pane-level export is the same cross-backend inheritance boundary GOTMPDIR already uses, so descendants that preserve PATH inherit one binding.
# The worker shell publishes proof only after resolving and executing that exact frozen guard, so a dropped export cannot silently launch an unguarded worker.
if [ -n "$WORKER_GIT_GUARD_DIR" ]; then
if ! spawn_send_text_line "$T" "export PATH=$(shell_quote "$WORKER_GIT_GUARD_DIR"):\"\$PATH\"; if [ \"\$(command -v git 2>/dev/null)\" = $(shell_quote "$WORKER_GIT_GUARD_DIR/git") ] && git fm-isolation-check >/dev/null; then : > $(shell_quote "$WORKER_GIT_GUARD_READY"); else printf '%s\\n' 'error: worker Git isolation guard is not active on PATH; refusing launch' >&2; false; fi"; then
if [ "$RELAUNCH" -eq 0 ]; then
spawn_preserve_worker_git_guard_recovery "activation command could not be delivered"
fi
exit 1
fi
WORKER_GIT_VERIFY_ATTEMPT=0
while [ ! -f "$WORKER_GIT_GUARD_READY" ] && [ "$WORKER_GIT_VERIFY_ATTEMPT" -lt 20 ]; do
sleep 0.1
WORKER_GIT_VERIFY_ATTEMPT=$((WORKER_GIT_VERIFY_ATTEMPT + 1))
done
[ -f "$WORKER_GIT_GUARD_READY" ] || {
if [ "$RELAUNCH" -eq 0 ]; then
spawn_preserve_worker_git_guard_recovery "could not be verified first on PATH"
else
echo "error: worker Git isolation guard could not be verified first on PATH; refusing to append the launch command" >&2
fi
exit 1
}
fi
Comment thread
greptile-apps[bot] marked this conversation as resolved.
# Send through the exact channel that already ships GOTMPDIR, so every backend
# and harness - ship, scout, and secondmate - gets it before launch. Skipped
# entirely when trace context is off.
Expand Down
2 changes: 1 addition & 1 deletion bin/fm-test-run.sh
Original file line number Diff line number Diff line change
Expand Up @@ -295,7 +295,7 @@ family_for_basename() {
fm-control.test.sh|fm-control-relaunch.test.sh|\
fm-herdr-session-cleanup.test.sh|fm-send-resolve-key.test.sh|fm-send-strict.test.sh|\
fm-send-inbox.test.sh|fm-spawn-batch.test.sh|\
fm-spawn-dispatch-profile.test.sh|fm-claude-trust.test.sh|\
fm-spawn-dispatch-profile.test.sh|fm-worker-git-guard.test.sh|fm-claude-trust.test.sh|\
fm-trace-context-spawn.test.sh|fm-spawn-worktree-settle.test.sh|\
fm-teardown-endpoint-safety.test.sh)
printf '%s\n' backend-dispatch
Expand Down
Loading
Loading