Skip to content
Merged
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,4 @@ __pycache__/
config/

.tools/
.stow-notes.md
3 changes: 3 additions & 0 deletions .no-mistakes.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@
# HEAD-continuity guard; see docs/architecture.md "No-mistakes gate authority boundary."
disable_project_settings: true

jev:
review_assist: true

# Trusted documentation placement policy for the Document step.
# The audience inventory and coding guideline own the detail; keep this as a
# pointer so gate instructions cannot become a second prose policy.
Expand Down
35 changes: 35 additions & 0 deletions .omo/evidence/security-privacy-gate-review.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Security and privacy gate review

- recommendation: REJECT
- originalIntent: Review the branch range `1bb72cc5f88014c86e3d03244efa0bb26c22d001..e9e9ec8e772fcc588d59ca751e9441a49d147d5e` for reachable auth, secret, protected-data, command/path, or disclosure defects in the Jev, Discord relay, wake/state, and external-command boundaries.
- desiredOutcome: Only the operator/captain can supply authority-bearing Discord instructions; Jev and persisted state do not disclose protected data; external command paths cannot be redirected or injected.
- userOutcomeReview: The self-hosted Discord path violates its downstream owner-only trust contract. With no channel allowlist configured, the poller enumerates accessible guild channels and DMs, accepts any non-bot author who mentions or DMs the bot, and emits the same `x-mention` payload consumed by `fmx-respond`. That consumer explicitly treats every direct author as the captain and autonomously performs normal lifecycle work. No author-id or role check exists in the introduced poller.

## Blockers

1. violatedCriterion: SEC-AUTH-1 — identify an exact reachable authorization bypass and consequence
- severity: HIGH
- evidencePointer: `bin/fm-discord-poll.js:66-98,116-149,182-192`; `.agents/skills/fmx-respond/SKILL.md:26-44,70-80`; `docs/configuration.md:689-699`
- observation: Any Discord user able to DM the bot or mention it in an accessible channel is converted into a captain-authorized request. The default configuration scans guild channels and enables DMs, while the poller checks only `author.bot`, mention/DM status, and channel exclusion. The shared response skill then treats the direct author as the captain and may file work, dispatch agents, investigate, or ship gated changes.
- bypass: Send a DM to the bot, or mention it in any channel visible to the bot, without being the operator/captain.
- consequence: An untrusted Discord user gains authority to trigger autonomous replies and normal reversible lifecycle actions on the operator's machine; the system may also expose public-safe operational outcomes to that user.

## Notes

- Jev: tracked `.no-mistakes.yaml` sets `jev.review_assist`, but the branch documentation states this key is global-only and ignored from repository config. No reachable new Jev disclosure path was established from this repository change.
- Wake/state and external command paths: no additional source-backed security or privacy finding was established in the reviewed changes.
- remove-ai-slops/programming direct pass: The Discord boundary uses no author authentication and relies on a downstream hosted-Relay invariant that the self-hosted adapter does not establish. Tests cover channel selection, DM defaults, and delivery, but do not prove an owner identity boundary. No slop-only concern is promoted as a blocker.

## Checked artifacts

- Diff and history: `1bb72cc5f88014c86e3d03244efa0bb26c22d001..e9e9ec8e772fcc588d59ca751e9441a49d147d5e`, especially commits `5879ec3` and `6f2a79b`
- Source: `bin/fm-discord-{lib,poll,reply}.{sh,js}`, `bin/fm-{bootstrap,watch,x-reply,x-dismiss,spawn,control,crew-state,wake-lib,session-lock-lib}.sh`, `.pi/extensions/*.ts`
- Policy/call contract: `.agents/skills/fmx-respond/SKILL.md`, `AGENTS.md`, `docs/configuration.md`
- Tests read: `tests/fm-discord-selfhosted.test.sh`, relevant `tests/fm-x-mode.test.sh` request-id and reply cases
- Existing evidence read: `.omo/evidence/*gate-review*.md`

## Exact evidence gaps

- Tests were not run, as explicitly prohibited by the assignment.
- No live Discord API call was made. Reachability is established from the poller's source and the downstream instruction contract.
- No ulw-loop plan exists; `omo ulw-loop status --json` returned `ULW_LOOP_PLAN_MISSING`, so this fallback report path is used.
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ Tracked files hold shared instructions and tooling; `data/` holds durable privat
Load `firstmate-layout` before reasoning about any more specific path.

```
AGENTS.md this file (CLAUDE.md is a real @AGENTS.md pointer to it)
AGENTS.md this file
CONTRIBUTING.md contributor workflow and repo conventions
README.md public overview and development notes
.github/workflows/ shared CI and PR enforcement, committed
Expand Down Expand Up @@ -458,7 +458,7 @@ Each skill owns its own daemon procedure, which is otherwise identical; these sa

- Every current daemon injection uses the `away-supervisor` kind from `bin/fm-operational-input.sh` after `FM_OPERATIONAL_PREFIX` (U+2063 INVISIBLE SEPARATOR followed by `FIRSTMATE_OP: `), while the `/afk` skill owns legacy bare-marker compatibility.
- `state/.afk-contract` is the away posture, written only after the captain confirms the read-back of their away words; entry announces hold-for-return only, and the record's clauses are recorded, not executed, in this release.
- While `state/.afk` exists, the daemon owns supervision; do not arm a separate watcher.
- On harnesses that launch the away daemon, while `state/.afk` exists, the daemon owns supervision; do not arm a separate watcher.
The daemon is never launched on Pi, where the ordinary supervision session continues under the record with main parked: the branch takes every safe actionable wake it can, and only a declined wake (including a broken branch or unsafe scan) or a watcher failure wakes main.
- A marked message while away or quiet mode is active is internal escalation and does not exit that mode.
- A message beginning `/afk` refreshes away mode; a message beginning `/quiet` refreshes quiet mode.
Expand Down
15 changes: 15 additions & 0 deletions bin/backends/cmux.sh
Original file line number Diff line number Diff line change
Expand Up @@ -632,6 +632,21 @@ fm_backend_cmux_kill() { # <target> [unused] [expected-label]
fm_backend_cmux_cli close-workspace --workspace "$wsid" >/dev/null 2>&1 || true
}

fm_backend_cmux_endpoint_confirmed_gone() {
local target=$1 expected_label=${3:-} workspaces workspace_count expected_title
[ -n "$expected_label" ] || return 1
fm_backend_cmux_parse_target "$target" || return 1
workspaces=$(fm_backend_cmux_cli workspace list --json --id-format uuids 2>/dev/null) || return 1
expected_title=$(fm_backend_cmux_scoped_title "$expected_label")
workspace_count=$(printf '%s' "$workspaces" | jq -er --arg title "$expected_title" \
'[.workspaces[]? | select(.title == $title)] | length' 2>/dev/null) || return 1
[ "$workspace_count" -eq 0 ] || return 1
workspace_count=$(printf '%s' "$workspaces" | jq -er --arg w "$FM_BACKEND_CMUX_WORKSPACE" \
'[.workspaces[]? | select(.id == $w)] | length' 2>/dev/null) || return 1
[ "$workspace_count" -eq 0 ] && return 0
return 1
}

# fm_backend_cmux_list_live: recovery/orphan discovery. Lists every workspace
# whose title is scoped to this firstmate home, by TITLE - never by trusting a
# stored uuid, since workspace ids do NOT survive an app relaunch (finding #5).
Expand Down
3 changes: 2 additions & 1 deletion bin/backends/herdr.sh
Original file line number Diff line number Diff line change
Expand Up @@ -3392,11 +3392,12 @@ fm_backend_herdr_kill() { # <target>
done
fi
if [ "$lock_held" = 1 ]; then
fm_backend_herdr_kill_serialized "$session" "$pane"
fm_backend_herdr_kill_serialized "$session" "$pane" || true
fm_lock_release "$lock_path" || true
else
echo "warning: herdr task kill could not acquire its session presentation lock; refusing an unlocked pane close" >&2
fi
return 0
}

# fm_backend_herdr_endpoint_confirmed_gone: gate durable-record removal on
Expand Down
20 changes: 12 additions & 8 deletions bin/backends/orca.sh
Original file line number Diff line number Diff line change
Expand Up @@ -284,15 +284,19 @@ fm_backend_orca_send_text_submit() { # <terminal-id> <text> <retries> <enter-sl
"$terminal" "$retries" "$sleep_s"
}

# fm_backend_orca_kill: close one recorded task terminal. A missing CLI is a
# close that was never even attempted, not an endpoint proven gone - with no
# CLI there is no read that could show the terminal absent - so it reports the
# failure its tool check already named instead of a success. The close call
# itself stays best-effort: whether an accepted-then-failed close left the
# terminal alive is not yet decidable without a presence re-read proven
# against the real Orca binary (docs/verification/runtime-backends.md
# "Endpoint close").
fm_backend_orca_kill() { # <terminal-id>
fm_backend_orca_tool_check || return 1
orca terminal close --terminal "$1" --json >/dev/null 2>&1 || true
}

fm_backend_orca_endpoint_confirmed_gone() {
local output
fm_backend_orca_tool_check || return 1
output=$(orca terminal read --terminal "$1" --limit 1 --json 2>&1) || true
printf '%s' "$output" | node -e '
const fs = require("fs");
let value;
try { value = JSON.parse(fs.readFileSync(0, "utf8")); } catch (_) { process.exit(1); }
process.exit(value && value.ok === false && value.error && value.error.code === "terminal_handle_stale" ? 0 : 1);
'
}
14 changes: 14 additions & 0 deletions bin/backends/tmux.sh
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,20 @@ fm_backend_tmux_kill() { # <target>
return 1
}

fm_backend_tmux_endpoint_confirmed_gone() {
local target=$1 session window windows inventory_status
case "$target" in
*:*) session=${target%%:*}; window=${target#*:} ;;
*) return 1 ;;
esac
case "$session:$window" in :*|*:|*:*:*) return 1 ;; esac
windows=$(fm_backend_tmux_window_inventory "=$session")
inventory_status=$?
[ "$inventory_status" -eq 2 ] && return 0
[ "$inventory_status" -eq 0 ] || return 1
! printf '%s\n' "$windows" | grep -qxF -- "$window"
}

# fm_backend_tmux_current_command: <target>'s live foreground process name -
# tmux's own `#{pane_current_command}`, already resolved from the pty's
# foreground process group (verified empirically with real tmux 3.6a: a
Expand Down
17 changes: 17 additions & 0 deletions bin/backends/zellij.sh
Original file line number Diff line number Diff line change
Expand Up @@ -621,6 +621,23 @@ fm_backend_zellij_kill() { # <target> [tab_id] [expected_label]
fi
}

fm_backend_zellij_endpoint_confirmed_gone() {
local target=$1 expected_label=${3:-} sessions panes count tabs scoped
fm_backend_zellij_parse_target "$target" || return 1
sessions=$(zellij list-sessions --short --no-formatting 2>/dev/null) || return 1
printf '%s\n' "$sessions" | grep -qxF -- "$FM_BACKEND_ZELLIJ_SESSION" || return 0
[ -n "$expected_label" ] || return 1
scoped=$(fm_backend_zellij_scoped_title "$expected_label")
tabs=$(fm_backend_zellij_cli "$FM_BACKEND_ZELLIJ_SESSION" action list-tabs --json 2>/dev/null) || return 1
count=$(printf '%s' "$tabs" | jq -er --arg scoped "$scoped" --arg bare "$expected_label" \
'[.[]? | select(.name == $scoped or .name == $bare)] | length' 2>/dev/null) || return 1
[ "$count" -eq 0 ] || return 1
panes=$(fm_backend_zellij_cli "$FM_BACKEND_ZELLIJ_SESSION" action list-panes --json 2>/dev/null) || return 1
count=$(printf '%s' "$panes" | jq -er --argjson p "$FM_BACKEND_ZELLIJ_PANE" \
'[.[]? | select(.id == $p and .is_plugin == false)] | length' 2>/dev/null) || return 1
[ "$count" -eq 0 ]
}

# fm_backend_zellij_list_live: recovery/orphan discovery. Lists every tab in
# <session> whose title carries THIS firstmate home's own tag
# (fm-<hometag>-, fm_backend_zellij_home_label) - never any other home's
Expand Down
25 changes: 20 additions & 5 deletions bin/fm-backend.sh
Original file line number Diff line number Diff line change
Expand Up @@ -795,14 +795,10 @@ fm_backend_send_text_submit() { # <backend> <target> <text> <retries> <enter-sl
# not do its job and the endpoint may still be live: the caller owns that
# refusal and must not delete the durable records that are the only thing
# naming the endpoint (bin/fm-teardown.sh's retain-and-stop path).
# How much each adapter can prove differs, and no arm ever guesses: tmux
# resolves a failed close against the window's exact recorded identity, Orca
# reports a close its missing CLI never attempted, and the remaining arms
# still report 0 for a close command that failed after being accepted.
# docs/verification/runtime-backends.md "Endpoint close" is the per-backend
# record.
fm_backend_kill() { # <backend> <target>
local backend=$1
local backend=$1 kill_status
shift
[ -n "${1:-}" ] || { echo "error: refusing empty backend kill target" >&2; return 1; }
fm_backend_source "$backend" || return 1
Expand All @@ -814,6 +810,25 @@ fm_backend_kill() { # <backend> <target>
cmux) fm_backend_cmux_kill "$@" ;;
*) echo "error: no kill implementation for backend '$backend'" >&2; return 1 ;;
esac
kill_status=$?
[ "$kill_status" -eq 0 ] || return 1
fm_backend_endpoint_confirmed_gone "$backend" "$@" && return 0
return 1
}

fm_backend_endpoint_confirmed_gone() {
local backend=$1
shift
local helper="fm_backend_${backend}_endpoint_confirmed_gone"
declare -F "$helper" >/dev/null 2>&1 || return 0
case "$backend" in
tmux) fm_backend_tmux_endpoint_confirmed_gone "$@" ;;
herdr) fm_backend_herdr_endpoint_confirmed_gone "$@" ;;
zellij) fm_backend_zellij_endpoint_confirmed_gone "$@" ;;
orca) fm_backend_orca_endpoint_confirmed_gone "$@" ;;
cmux) fm_backend_cmux_endpoint_confirmed_gone "$@" ;;
*) return 1 ;;
esac
}

fm_backend_remove_worktree() { # <backend> <worktree-id>
Expand Down
15 changes: 14 additions & 1 deletion bin/fm-bootstrap.sh
Original file line number Diff line number Diff line change
Expand Up @@ -826,7 +826,20 @@ secondmate_liveness_one() { # <meta> <id>
dead|missing)
if [ "$agent_state" = dead ]; then
cause="confirmed agent absence on existing endpoint"
fm_backend_kill "$backend" "$target" 2>/dev/null || true
case "$backend" in
zellij)
fm_backend_kill "$backend" "$target" "$(fm_meta_get "$meta" zellij_tab_id)" "fm-$id" 2>/dev/null
;;
cmux)
fm_backend_kill "$backend" "$target" '' "fm-$id" 2>/dev/null
;;
*)
fm_backend_kill "$backend" "$target" 2>/dev/null
;;
esac || {
echo "SECONDMATE_LIVENESS: secondmate $id: skipped: endpoint cleanup could not be confirmed (backend=$backend)"
return 0
}
else
cause="recorded endpoint confidently missing"
fi
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 @@ -1644,7 +1644,7 @@ families_for_changed_path() {
tests/*)
printf '%s\n' "__unmapped__:$path"
;;
README.md|LICENSE|assets/*|docs/*|.gitignore)
README.md|LICENSE|assets/*|docs/*|.omo/evidence/*|.gitignore)
;;
*)
if [ -e "$path" ]; then
Expand Down
2 changes: 1 addition & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ A busy pane is otherwise exempt from staleness, but only until its last complete
A crew that declared an external wait (`paused:`) or a verified captain-held transfer is the first exception to that bound: its busy verdict supplies liveness while identifying the long-running foreground call as the declared wait, so it takes the bounded `FM_PAUSE_RESURFACE_SECS` recheck instead of a wedge escalation, except that a captain-held transfer is not rechecked while the away-posture record exists.
In a home that armed `config/wedge-defer-parked-gate`, a crew whose own validation gate awaits the supervisor's still-open decision for that run is the second, reached through the shared wedge timer rather than the declaration branch, because who owes that answer does not depend on what the pane is rendering; it takes the same bounded recheck, including while the away-posture record exists.
Lifting the declaration restores the unchanged busy-pane wedge path, while a pane that is no longer busy returns to the existing idle declared-wait classification.
While the legacy daemon flag is active, a busy pane that crosses the bound under a declared external wait is handed to the daemon as the plain wake identity instead of taking that recheck in the watcher, because the daemon owns triage there and a wake already decorated as a possible wedge would override the daemon's own declared-wait verdict; an undeclared busy pane past the bound still takes the wedge escalation.
On a harness that runs the away daemon, while the legacy daemon flag is active, a busy pane that crosses the bound under a declared external wait is handed to the daemon as the plain wake identity instead of taking that recheck in the watcher, because the daemon owns triage there and a wake already decorated as a possible wedge would override the daemon's own declared-wait verdict; an undeclared busy pane past the bound still takes the wedge escalation.
That handoff is keyed on the declaration itself (the status log's signature) rather than on the pane capture, so a harness footer that ticks on every poll wakes the daemon once per declaration instead of once per poll, and it clears the wedge timer, escalation count, and worktree-write deferral exactly as the normal-mode absorber does, so an undeclared busy phase's timer does not resume when the declaration lifts.
Those actionable wakes are written to a durable local queue (`state/.wake-queue`) only after generation-bound recovery evidence is published, so an interrupted watcher or handling turn can be recovered without losing the queue record.
Agent endpoint liveness and queue-consumption liveness are separate: on each poll, the primary watcher reads the oldest valid actionable row from every endpoint-recorded local secondmate home's durable wake queue without locking, consuming, or rewriting that foreign queue.
Expand Down
1 change: 1 addition & 0 deletions docs/cmux-backend.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ The sibling never carries an `fm-` title and is ignored by recovery.
The exact window membership is re-read before this operation.
A selected workspace that is not last closes normally; selection itself is not the trigger.
Firstmate does not attempt to close the macOS window because cmux's socket cannot close a window holding a live terminal.
The shared cleanup path also requires a post-close proof that the recorded workspace and scoped task workspace are absent; an unreadable or ambiguous proof fails closed and retains the task identity.

Real tests share the captain's running app rather than creating an isolated cmux session.
`tests/cmux-test-safety.sh` permits cleanup only for an exact currently listed `fm-test-` workspace and never enumerates and closes unrelated workspaces or relaunches the app.
Expand Down
Loading
Loading