Skip to content

POC: prepare and apply desktop standup plans through MCP - #331

Draft
itsHabib wants to merge 4 commits into
mainfrom
codex/standup-mcp-poc
Draft

POC: prepare and apply desktop standup plans through MCP#331
itsHabib wants to merge 4 commits into
mainfrom
codex/standup-mcp-poc

Conversation

@itsHabib

Copy link
Copy Markdown
Owner

Summary

A desktop voice or text conversation can now build a persisted standup plan through a local MCP connection, check what it would do before confirmation, then apply that exact version through the existing Fleet compiler. Preparation no longer requires manufacturing a confirmation just to reach a dry run.

What this adds

  • standup mcp: agenda, new, draft, show, prepare, confirm, apply and status, using the same CLI handlers and store. No custom audio transport or new launcher.
  • Draft updates require the prior plan digest, accept only editable fields, clear confirmation when changed, and refuse execution history. MCP confirm/apply require the displayed digest; store writes use the existing portable advisory lock mechanism.
  • Prepare returns compiler validity and exact steps separately from current delivery warnings. Status preserves Fleet observations and required receipt evidence; process activity is not completion.
  • Desktop installation instructions and a one-card manual voice trial in cmd/standup/README.md.

Validation

  • go vet ./... and golangci-lint run ./... passed.
  • go test ./... passed; focused standup race tests passed.
  • CLI/MCP fixture conversation covers draft edits, stale digests, vague confirmation refusal, pre-confirmation no-write behavior, apply replay, execution-history protection, malformed arguments, scoped runtime observations and pending/failed/complete/unknown receipts.
  • Human desktop voice and live Fleet dispatch remain untested. The server trusts the desktop agent to relay actual user words after readback; phrase matching does not authenticate audio.

POC only. No production delivery configuration, worker launch, or merge is part of this change.

@itsHabib

Copy link
Copy Markdown
Owner Author

@codex review
@claude review

Please review head 8508d77. Focus on MCP argument confinement, draft/confirmation version binding, preparation having no Fleet effects, reuse of the apply compiler, and truthful status under missing or stale evidence. This POC trusts the desktop agent to relay user words; it does not claim audio authentication. Report actionable findings against this exact head; do not edit code.

@claude

claude Bot commented Sep 12, 2026

Copy link
Copy Markdown

Claude finished @itsHabib's task in 6m 46s —— View job


Review — head 8508d77

Reviewed files: mcp.go, mcp_schema.go, planning.go, planning_test.go, internal/standup/draft.go, internal/standup/prepare.go, internal/standup/apply.go (full), internal/standup/record.go, internal/standup/env.go, main.go.


1. MCP Argument Confinement ✓ with one gap

What works: validateTool (mcp.go:127) rejects any key not declared in spec.properties. The record/agenda/from regex (^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$ plus no .json suffix) blocks path traversal and flag-like values; TestMCPRejectsFileReferences and TestMCPWireAndInvalidArguments (including the ../../config and cwd injection attempts) cover the cases. The hardcoded key list in callTool (mcp.go:103) means no unlisted flag can be added to the CLI invocation regardless of what the caller sends.

F1 — plan bytes bypass the validated values map (P2, documentation gap).
At mcp.go:109–112:

if spec.verb == "draft" {
    args = append(args, "--file", "-")
    input = call.Arguments["plan"]   // raw JSON.RawMessage, never put into values
}

The only MCP-layer check on plan is the { prefix (mcp.go:134). The full structural defense lives in DecodeDraft (draft.go:21–32) via DisallowUnknownFields and io.LimitReader(in, 1<<20). That defense is sound, but the comment at line 109 doesn't say why plan is excluded from values—a reader could mistake the absence for an oversight rather than a deliberate delegation. A one-line comment at the assignment site would close that gap.

F2 — by attribution is silently fixed in MCP confirm (correct, but untested). The MCP standup_confirm spec has no by property, so all MCP confirmations land as "human:" + cfg.Tenant. This is the right call—the MCP layer shouldn't let callers claim arbitrary attributions. However TestMCPConversation (planning_test.go:176) checks Confirm.Surface but not Confirm.By. Adding Confirm.By == "human:acme" to that assertion would lock in the intent.


2. Draft/Confirmation Version Binding ✓

What works: UpdateDraft (draft.go:37–55) enforces the correct order: digest check → execution-history guard → field replacement → confirmation clearing → validation. checkConfirm re-derives the digest independently at apply time, so confirm can't outlive an edit that happened between confirm and apply. PlanDigest covers AgendaDigest (record.go:270), so re-pointing at a fresh agenda invalidates confirmation. DecodeDraft uses DisallowUnknownFields recursively, so typos and protected fields (including nested card fields) are rejected before UpdateDraft is even called; TestDraftRejectsProtectedOrUnknownFields exercises this.

F3 — stale-digest ordering relative to execution-history guard is safe but the two checks are conceptually independent (observation only). UpdateDraft checks the expected digest first (draft.go:38), then the execution history guard (draft.go:41). A caller with a correct digest but a record that has already been applied gets a refusal. A caller with a stale digest gets a refusal too, but without ever seeing whether the record has history. The ordering is correct (and the test at planning_test.go:89 verifies the history block). No change needed; calling this out because the two checks look symmetrical but serve different invariants.


3. Preparation Has No Fleet Effects ✓ with one output-ordering note

What works: prepareSteps (apply.go:546) never calls a write verb. All Fleet calls in the preparation path (fleet work, fleet receipts, fleet decisions, fleet status, fleet watch status) are reads. TestPrepareBeforeConfirmationDoesNotWrite checks r.callLog() == "" after both a valid and a stale prepare.

F4 — cmdInspect emits the JSON result before checking the error (planning.go:106–112). For prepare:

result, err := standup.Prepare(env, cfg, r)
if code := printJSON(out, errOut, result); code != 0 {   // stdout written here
    return code
}
if err != nil {
    return fail(errOut, err)   // error reported after
}

When the world has moved, Prepare returns (result_with_plan_valid_false, *Refusal). stdout gets the partial JSON; stderr gets the refusal; exit is 1. callTool (mcp.go:124) maps non-zero exit to isError: true and the MCP client sees both data (partial result) and isError. This is intentional—the caller gets compiler validity even when planning refuses—but it is asymmetric with the status path (planning.go:115–119) which emits nothing on error. The test at planning_test.go:39–54 confirms the two-channel behavior. No change needed as long as callers are aware that a prepare response with isError: true can still carry a useful data payload; the mcpInstructions constant doesn't mention this explicitly.


4. Reuse of the Apply Compiler ✓ with a step-display note

What works: Prepare calls prepareSteps(e, cfg, r, false) (prepare.go:24), the exact same function Apply uses (apply.go:430). The false means force-stale is never available from prepare, so the same world-movement check, ownership checks, and ledger-consistency check (alreadyDone) all fire. There is no separate planning path in preparation.

F5 — markStatus(steps, done, true) labels all steps "plan", including already-ledgered ones (P2, CLI-only). At prepare.go:29:

markStatus(steps, done, true)   // dryRun=true overrides every status to "plan"
p.Steps, p.PlanValid = steps, true

done is computed by alreadyDone and IS used for consistency checking (if a ledger step's args diverged, prepareSteps errors and plan_valid is false). But done is not reflected in the step statuses: a step already on the ledger with code 0 appears as "plan" in prepare output rather than "done".

Through the MCP path this is unreachable: UpdateDraft (draft.go:41) refuses a record with len(r.Applied) > 0, so the desktop operator can never draft → prepare a partially-applied record. Through the CLI (standup prepare <id> directly) it is reachable. A CLI user who runs prepare after a partial apply sees all steps as "plan" with no signal of which have already run. They'd need to read status or show to reconstruct the ledger. The done variable being passed but ignored for display is easy to misread as a bug. Fix this →


5. Truthful Status Under Missing or Stale Evidence ✓ with two findings

What works: cardStatus (prepare.go:181) initializes State: "unknown" and only transitions on unambiguous evidence. Exits 2 and 4 from fleet done stay "unknown". A nil Data (unreadable JSON) stays "unknown". The exit-0-with-ok=false case also stays "unknown" (conservative). TestStatusUsesReceiptsNotRunningWorker exercises all four outcomes.

F6 — observe treats an explicit JSON null response as "unreadable" (prepare.go:61–63). If Fleet returns null (valid JSON, nil map), json.Unmarshal succeeds (err == nil) but o.Data is nil, so the error path fires:

if err := json.Unmarshal([]byte(res.Stdout), &o.Data); err != nil || o.Data == nil {
    o.Error = fmt.Sprintf("unreadable Fleet output (exit %d): ...")
    return o
}

This is conservative (correct outcome), but the error message "unreadable Fleet output" is misleading when Fleet deliberately responded with null. Low impact; most Fleet verbs return a JSON object, not null. Mentioning it because a future Fleet command that returns null on empty-set (instead of [] or {}) would silently appear as an error in delivery warnings.

F7 — Delivery observation in Prepare uses tenantRequired=false and may include cross-tenant workers (prepare.go:34). scopeWorkers(e, r, &p.Delivery, false) includes workers whose address matches one of this plan's seats AND whose cwd matches the seat's directory, but without the tenant filter. A worker from another tenant that happens to share the same seat name and directory would appear in the delivery warnings. The runtime path (prepare.go:33) correctly uses tenantRequired=true; the delivery path's false is not tested in TestPrepareBeforeConfirmationDoesNotWrite. Since delivery warnings are advisory only and the scenario requires an exact address+cwd collision across tenants, severity is low—but the asymmetry with the runtime call is worth an explicit note or a matching test. Fix this →


Summary

# Area Severity Finding
F1 MCP confinement P3 / doc plan bytes skip values; defense is in DecodeDraft—a comment at mcp.go:111 would explain the delegation
F2 MCP confinement P3 / test TestMCPConversation checks Surface but not By; add assertion for Confirm.By == "human:acme"
F3 Version binding observation Stale-digest and execution-history checks are ordered correctly and serve independent invariants
F4 No Fleet effects P3 / doc Prepare result is emitted before the error check; MCP callers see both data and isError: true simultaneously on planning refusal
F5 Compiler reuse P2 / CLI markStatus(steps, done, true) ignores ledger-done status in prepare output; unreachable via MCP but misleading on CLI
F6 Truthful status P3 observe treats JSON null as "unreadable Fleet output"; conservative but misleading message
F7 Truthful status P2 / test Delivery observation uses tenantRequired=false; cross-tenant workers sharing address+cwd would appear in warnings

No correctness, security, or authorization invariant failures found at this head. The five focus areas are sound: arguments are confined, version binding is correct at both the draft and apply boundaries, prepare calls no write verb, both paths share prepareSteps, and status derives state from evidence rather than process activity.

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 12, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-12T19:29:15.501546Z bddbcc6 Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 8508d774af

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread cmd/standup/internal/standup/prepare.go Outdated
Comment on lines +170 to +172
seats, err := e.Seats(cfg.Tenant)
if err != nil {
return s, err

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve unknown status when roles.map is unavailable

When roles.map is missing or unreadable, this return causes standup_status to emit only an error, discarding the collected runtime observation and all per-card unknown results. This also breaks status for empty or checkout-only plans that do not need a seat lookup. Keep the structured status response and record the lookup failure as unknown evidence instead, as required for missing source data.

AGENTS.md reference: cmd/standup/AGENTS.md:L83-L84

Useful? React with 👍 / 👎.

@itsHabib

Copy link
Copy Markdown
Owner Author

@codex review
@claude review

Fix round 1, head e117219. Consolidated initial findings:

  • Codex missing-role-map status finding: fixed. Seat cards retain explicit unknown evidence; checkout-only/empty plans remain readable; collected runtime evidence is preserved. Regression test added.
  • Claude F5: fixed for both CLI and direct MCP prepare; already-recorded steps show done, existing effects skip, remaining effects plan.
  • Claude F7: Fleet watch status currently omits tenant. Delivery therefore matches address and cwd against the plan tenant's roles.map and now rejects an explicit mismatched tenant; runtime still requires tenant. Regression coverage exercises both response shapes.
  • Claude F1/F2/F4/F6: clarified decoder delegation and partial error payloads, asserted MCP human attribution, and clarified non-object Fleet output diagnostics.
  • Live preparation exposed unavailable lead mail in the agenda; prepare now repeats unavailable source warnings. The installed sample stays unconfirmed, with no live dispatch.

Focused race tests, vet, lint, and the updated standup suite passed. Please re-review this exact head without editing code. No merge requested.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Unresolved critical and moderate issues affect read-only guarantees, tenant isolation, and confirmation security.

Get a fresh assessment by requesting another Copilot review.

Pull request overview

Adds a local MCP interface for creating, editing, preparing, confirming, applying, and monitoring persisted standup plans through Fleet.

Changes:

  • Adds digest-guarded draft editing and MCP tools.
  • Adds preparation, status, receipt reporting, and store locking.
  • Adds tests and desktop voice-trial documentation.
File summaries
File Description
cmd/standup/README.md MCP usage and desktop trial documentation
cmd/standup/planning.go Planning handlers and locking
cmd/standup/planning_test.go Planning and MCP tests
cmd/standup/mcp.go JSON-RPC MCP adapter
cmd/standup/mcp_schema.go MCP tool schemas
cmd/standup/main.go CLI integration and digest checks
cmd/standup/main_test.go Runtime and receipt fixtures
cmd/standup/internal/standup/prepare.go Preparation and receipt observations
cmd/standup/internal/standup/draft.go Draft decoding and updates
cmd/standup/internal/standup/apply.go Planning and step serialization
cmd/standup/CLAUDE.md MCP implementation guidance
cmd/standup/AGENTS.md MCP implementation guidance
Review details

Suppressed comments (4)

cmd/standup/main.go:67

  • The lock acquired here is always $STANDUP_DIR/.lock, but these mutating handlers still accept path references (and new --out can target an arbitrary path). Two callers can therefore update the same out-of-store record under different locks; atomic rename does not prevent a lost update. Either restrict locked operations to store IDs or derive the lock from the resolved target path.
		release, err := lockStore(env)
		if err != nil {
			return fail(stderr, err)
		}
		defer release()

cmd/standup/main.go:240

  • The digest check does not establish record identity. Because cfg is already loaded, a valid record for another tenant/lead in the same store can be confirmed and persisted through this path; apply will reject it only later. Reject r.Tenant/r.Lead mismatches before ConfirmRecord.
	if err := expectDigest(r, *expected); err != nil {
		return fail(stderr, err)
	}

cmd/standup/mcp.go:64

  • All errors from rpcResult are mapped to -32602, so an unknown top-level JSON-RPC method is reported as Invalid Params. MCP/JSON-RPC clients rely on -32601 to distinguish an unsupported method from malformed parameters; map the unknown-method case separately, as the other MCP servers in this repository do.
	result, err := rpcResult(req)
	if err != nil {
		return rpcError(req.ID, -32602, err.Error())
	}

cmd/standup/planning.go:82

  • This new MCP-reachable mutation loads any record ID in the configured store and passes it to UpdateDraft without checking r.Tenant/r.Lead against the configured identity. A foreign record retained in the store can therefore be rewritten (and its confirmation cleared) from this tenant's server, even though apply later refuses it. Load the config and reject an identity mismatch before updating the draft.
	path := env.ResolveRecord(fs.Arg(0))
	r, err := standup.LoadRecord(path)
	if err != nil {
		return fail(errOut, err)
	}
	if err := standup.UpdateDraft(r, d, *expected); err != nil {
		return fail(errOut, err)
  • Files reviewed: 12/12 changed files
  • Comments generated: 5
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

if moved != "" && !forceStale {
return nil, nil, "", refuse("the world moved since agenda %s:\n%s\nre-run standup agenda and re-plan, or apply --force-stale to proceed anyway", agenda.ID, moved)
}
world, err := ReadWorld(e, cfg)
Comment thread cmd/standup/internal/standup/prepare.go
s.Receipt.Error = err.Error()
return s
}
s.Receipt = observe(e, dir, "done", c.Change, "--kind", c.As, "--json")
Comment thread cmd/standup/planning.go
Comment on lines +53 to +54
func printView(out, errOut io.Writer, path string, r *standup.Record) int {
return printJSON(out, errOut, map[string]any{"path": path, "plan_digest": r.PlanDigest(), "record": r, "readback": r.Readback()})
Comment thread cmd/standup/internal/standup/draft.go Outdated
@claude

claude Bot commented Sep 12, 2026

Copy link
Copy Markdown

Claude finished @itsHabib's task in 3m 44s —— View job


Review — Fix Round 1, head e117219

Tasks

  • Load tools
  • Read updated files
  • Verify Codex missing-role-map fix
  • Verify Claude F5 (markStatus done/plan)
  • Verify Claude F7 (tenantRequired delivery)
  • Verify Claude F1/F2/F4/F6 (doc/test clarifications)
  • Check new lead-mail warning propagation
  • Post full review

All five focus areas are verified at this head. Changes are confined to prepare.go, planning_test.go, mcp.go, and README.md — no changes to draft.go, apply.go, planning.go, or main.go.

Codex: missing-role-map status ✓

ReadStatus now uses planSeats(e, r) (lazy: only calls e.Seats when at least one seat card exists), and the error is no longer propagated to the caller — seat-backed cards get State: "unknown" with "seat lookup unavailable: ...", checkout-only cards still reach cardStatus, and the runtime observation is always populated. TestStatusSurvivesMissingRoleMap covers: exit 0, the error message, "receipt_state": "pending" for the seat card, "runtime" present, and empty-card fallback. Fix is correct and complete.

F5: markStatus accurate in prepare ✓

prepare.go:29–34 now calls markStatus(steps, done, false) then replaces "not reached" with "plan". Ledger-done steps show "done", world-skipped steps show "skip", unrun steps show "plan". TestPreparePreservesLedgerAndScopesDelivery verifies "status": "done" is present after a full apply. The MCP gate (UpdateDraft refusing applied records) makes the partially-applied case unreachable via the MCP path; the test exercises it on the CLI path with a full apply. Both surfaces are now honest.

F7: delivery scoping with explicit tenant mismatch ✓

scopeWorkers at prepare.go:108 changed from tenantRequired && tenant != r.Tenant to (tenantRequired || tenant != "") && tenant != r.Tenant. Result: delivery path accepts rows with no tenant (normal watch status output, scoped by address+cwd against the plan tenant's roles.map), and rejects rows with an explicit mismatched tenant on both surfaces. TestPreparePreservesLedgerAndScopesDelivery places two same-address workers — one with "tenant":"other" and one with no tenant — and asserts "private-other-tenant" is absent while the tenantless worker's warning ("starts are paused") is present. README.md documents the asymmetry clearly. Fix is correct.

One detail on planSeats in scopeWorkers: scopeWorkers also switched from e.Seats(r.Tenant) to planSeats(e, r), so checkout-only plans no longer trigger a roles.map load during delivery/runtime scoping. This is correct, but note that when planSeats does error (roles.map missing with seat cards), scopeWorkers unconditionally overwrites o.Error, which can replace a pre-existing Fleet error with the roles.map error. The outcome is still a degraded observation; only the diagnostic message changes. No behavioral consequence.

F1: plan bytes comment ✓

Comment added at mcp.go:111: // DecodeDraft performs strict, recursive typed validation at the CLI seam. The delegation is now explicit and a reader won't mistake the absence from values for an oversight.

F2: Confirm.By assertion ✓

TestMCPConversation at planning_test.go:176–179 now extracts the confirmation record and asserts confirmation.Surface != "voice" || confirmation.By != "human:acme". The MCP human attribution is locked in.

F4: mcpInstructions isError note ✓

mcpInstructions (mcp.go:86) now includes "A refused prepare can include useful data alongside isError." MCP callers are informed that a nonzero exit doesn't mean the data payload is absent.

F6: observe error message ✓

prepare.go:67 changed from "unreadable Fleet output (exit %d): ..." to "expected a Fleet JSON object (exit %d): ...". The message is no longer misleading for a deliberate null response; it names what was expected rather than characterizing the actual output.

New: agendaWarnings in Prepare ✓

agendaWarnings added at prepare.go:225–237; called from Prepare at line 40. Each unavailable agenda source produces a "<name> unavailable in pinned agenda: <error>" warning in the returned Warnings slice. TestPrepareRepeatsUnavailableAgendaSources verifies the case where org is offline at agenda-build time. Fix is correct and the test is specific.

Minor observation: agendaWarnings re-opens and re-parses the agenda file; prepareSteps already loaded and validated the same file. For the POC this is fine (agendas are small files). Worth flagging for a production path where agenda loading may be remote.


Residual findings not in original Claude scope

These are Copilot-origin findings from the first review. They were not part of the declared fix scope for this round; listing them for completeness.

Unaddressed — DecodeDraft accepts JSON null (Copilot P2): encoding/json decodes null into a Go struct without returning an error, producing a zero Draft{}. UpdateDraft then normalizes it and clears all editable plan fields. DisallowUnknownFields does not catch this; TestDraftRejectsProtectedOrUnknownFields does not include "null" as a test case. The fix is a pre-check: reject input whose first non-whitespace byte is not {.

Unaddressed — printView exposes Confirm.Phrase (Copilot observation): standup_show returns the full record including Confirm.Phrase. A desktop agent that calls standup_show on a confirmed record learns the phrase and could re-use it without the human speaking it. By design this server trusts the desktop agent (explicit in mcpInstructions), so this is a documented POC decision, not an oversight — but the trust boundary is unmarked in the code. A comment at printView noting the deliberate exposure would close the gap for future readers.

Unaddressed — Fleet CLI legacy-migration side effects in read paths (Copilot structural): ReadWorld and cardStatus's fleet done call the normal Fleet CLI. If Fleet's dispatch runs MigrateLegacyKeys unconditionally, prepare and status are not strictly read-only with respect to Fleet's own state files. This is structural to the POC architecture (Fleet is invoked as a CLI, not through a read-only entry point) and out of scope for this round.


Summary

Finding Status
Codex: missing-role-map status ✓ Fixed and tested
F5: accurate step status in prepare ✓ Fixed and tested
F7: explicit cross-tenant delivery rejection ✓ Fixed and tested
F1: plan bytes delegation comment ✓ Documented
F2: Confirm.By assertion ✓ Tested
F4: mcpInstructions isError note ✓ Documented
F6: Fleet null output message ✓ Clarified
New: agenda source warnings in Prepare ✓ Added and tested
Copilot: DecodeDraft null acceptance Not addressed (Copilot P2)
Copilot: printView phrase exposure Not addressed (POC design decision)
Copilot: Fleet CLI migration in read paths Not addressed (structural/POC scope)

No correctness, security, or authorization invariant failures found at this head. The five focus areas are sound at e117219.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e117219aed

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

}
keep := []any{}
for _, v := range workers {
row, ok := v.(map[string]any)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Canonicalize checkout paths before filtering workers

When roles.map and Fleet identify the same checkout through different path spellings—such as a symlink versus its resolved path, /var versus /private/var, or case differences on Windows—this raw string comparison drops the matching worker and reports that no runtime target was observed. Fleet's own identity matching canonicalizes both paths, so valid, deliverable seats can otherwise be presented by prepare as unavailable; canonicalize both values before comparing them.

Useful? React with 👍 / 👎.

@itsHabib

Copy link
Copy Markdown
Owner Author

Final fix round is now at bddbcc6bbb8e9f16a9093c9f8db63a2d85c66b74 (remote verified). This task is taking responsibility for the Workbench queue while preserving this POC's draft and operator-trial boundaries.

Verified and fixed together: Fleet work/receipts/decisions/done observations could run legacy migration; draft/confirm could mutate a record for another configured tenant or lead; CLI JSON null could clear editable plan fields. Existing observation verbs now avoid migration and refuse actual legacy ambiguity, and the shared identity check precedes draft/confirmation. Fresh observation requires no migration marker. Added CLI/MCP identity and checkpoint-preservation tests and checked a compiled Fleet binary against fresh/legacy private stores.

Passed: scoped Standup/Fleet tests, vet, lint, focused race tests; compiled-binary observations made no Fleet state changes. Independent exact-head verification is underway. No live confirmation, dispatch, installation or voice trial was performed by this fix.

Consolidated residuals: external CLI record paths used from distinct STANDUP_DIR roots lack cross-store serialization; unknown JSON-RPC method error code remains a small protocol issue. MCP restricts references to store IDs. Confirmation phrase exposure does not create audio authentication: the documented POC trusts the desktop agent to relay actual words. Explicit cross-tenant delivery filtering was already addressed at e117219 and was not changed again.

This is fix round two. The final panel is being requested once; no fourth cycle. Verified final P1/authorization findings will be addressed with evidence, and lower-priority residuals will retain written disposition. Trial/merge holds remain in force.

@itsHabib

Copy link
Copy Markdown
Owner Author

@codex review

@itsHabib

Copy link
Copy Markdown
Owner Author

@claude review

@itsHabib
itsHabib requested a lite review from Copilot September 12, 2026 19:25
@claude

claude Bot commented Sep 12, 2026

Copy link
Copy Markdown

Claude finished @itsHabib's task in 2m 40s —— View job


Review — Fix Round 2, head bddbcc6

Tasks

  • Load tools
  • Read changed files at bddbcc6
  • Verify Fleet read-only / migration avoidance fix
  • Verify identity check before draft/confirm
  • Verify JSON null rejection in draft
  • Check new tests
  • Assess residuals and overall state
  • Post final review

All three P1/authorization findings from the final round are correctly implemented and tested. Changes touch cmd/fleet/internal/verbs/verbs.go, cmd/fleet/internal/fleet/lease.go, cmd/fleet/internal/verbs/observation_readonly_test.go, cmd/standup/internal/standup/draft.go, cmd/standup/planning.go, and cmd/standup/planning_test.go.


1. Fleet observational read-only guard ✓

observational() (verbs.go:712–718) identifies work, receipts, decisions, done as the verbs standup uses for observations. Before dispatching any of them, Dispatch (verbs.go:159–166):

  1. Saves and sets fleet.ReadOnly = true (restored via defer)
  2. Calls fleet.LegacyKeysPresent() — the read-only preflight (lease.go:437–444) which scans for old-format records without touching them
  3. Returns a refusal if any legacy ambiguity exists — no migration runs

The MigrateLegacyKeys() call at line 167 comes after the observational block, so it only fires for mutating verbs. The ReadOnly flag is already honored in migrateLegacyKeys itself (lease.go:349) and in WriteLease (lease.go:88), so this is defense in depth.

fleet watch status dispatches via the early CmdStatus return (verbs.go:147–149) before either the observational guard or MigrateLegacyKeys(), so it was already clean.

TestObservationalDispatchNeverMigrates verifies three properties: a fresh store stays empty, a store with legacy records produces a refusal, and the legacy record is byte-identical after the call. It also asserts ReadOnly was restored. The test is specific and correct.

One observation: LegacyKeysPresent() scans all three subdirs on every observational call. For a store with many lease files this is a linear scan. This is proportionate for the current use case; worth flagging for a future production path with large fleets.

2. Identity check before draft/confirm ✓

cmdDraft (planning.go:85) now calls standup.CheckIdentity(cfg, r) between loading the record and calling UpdateDraft. CheckIdentity (apply.go:590–595) compares r.Tenant == cfg.Tenant && r.Lead == cfg.Lead — so a foreign record in the store cannot be rewritten from this tenant's server.

The CLI confirm path was already protected: ConfirmRecord (apply.go:30) calls CheckIdentity internally. cmdInspect for prepare also calls CheckIdentity at planning.go:109, adding an early check before prepareSteps (which calls it again at apply.go:553 — redundant but harmless).

TestForeignRecordMutationRefused covers both fields (tenant, lead) across CLI (draftCall, cmdConfirm) and MCP (assertForeignMCPRefused for standup_draft, standup_confirm). The record is asserted byte-identical after each refusal. Fix is correct and complete.

One detail: cmdConfirm (main.go:238) checks expectDigest before ConfirmRecord fires CheckIdentity. A foreign record with the same digest would fail the identity check (correct outcome); a foreign record with a stale digest would fail the digest check first (also correct, slightly different error). Both paths refuse before writing.

3. JSON null rejection in draft ✓

DecodeDraft (draft.go:20–35) now decodes into *Draft (pointer). After decoding, an explicit d == nil check returns "draft must be a JSON object". encoding/json decodes the literal null into a nil pointer without error, and the old value-type decode accepted it as a zero Draft{} — a silent clear of all editable fields. The fix catches this before UpdateDraft is reached.

TestNullDraftPreservesRecord verifies the fix: null input returns exit code 1 and the record file is byte-identical before and after.

Corner case confirmed clean: DecodeDraft also checks that the decoder is at EOF after the first value (draft.go:30–33), catching trailing content. And io.LimitReader(in, 1<<20) bounds the payload. The pointer approach plus the nil check is the minimal correct fix.

4. New tests ✓

Test Covers
TestObservationalDispatchNeverMigrates Fresh-state no-op, legacy refusal, state immutability, ReadOnly restore
TestForeignRecordMutationRefused tenant and lead mismatch, CLI and MCP, record immutability
TestNullDraftPreservesRecord JSON null rejection, record immutability

All three new tests are assertion-specific and not trivially satisfiable.

5. Residuals (author-documented)

Item Status
External --out paths lack cross-store locking Documented; lockStore uses $STANDUP_DIR/.lock always; new --out can target outside the store
Unknown JSON-RPC method maps to -32602 not -32601 Documented; low impact; protocol issue only
printView exposes Confirm.Phrase Documented POC trust boundary; no change
Checkout path canonicalization in scopeWorkers (Codex P2) Documented; unaddressed; delivery warnings only

These are correctly carried forward per the review-cycle discipline: verified-addressed P1s at this head, lower-priority residuals with written disposition, no fourth panel round.


Summary

Finding Status at bddbcc6
Fleet legacy migration in observational paths ✓ Fixed and tested
draft/confirm identity mismatch across tenants/leads ✓ Fixed and tested
JSON null clears editable plan fields ✓ Fixed and tested
Residuals (cross-store lock, RPC error code, phrase exposure, path canon) Documented; within POC scope

No correctness, security, or authorization invariant failures found at bddbcc6. The five original focus areas remain sound; the three final-round P1 fixes are verified correct.

@github-actions

Copy link
Copy Markdown

Reviewer: claude
Reviewed commit: bddbcc6bbb8e9f16a9093c9f8db63a2d85c66b74

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Swish!

Reviewed commit: bddbcc6bbb

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@itsHabib

Copy link
Copy Markdown
Owner Author

Final disposition at bddbcc6bbb8e9f16a9093c9f8db63a2d85c66b74: CI green, independent local exact-head verification passed, final Claude and Codex reviews report no blocking findings. Copilot was re-requested but has not produced a new exact-head review; its original migration/identity/null findings are verified against the fixes rather than counted as retracted.

Residuals retained: external CLI record paths across distinct stores lack shared serialization; unknown JSON-RPC method error code; path canonicalization can cause false unavailable-seat warnings; phrase disclosure remains within the explicitly trusted-agent POC model and is not audio authentication. The done unit fixture proves dispatch read-only protection rather than full receipt resolution; no voice trial is claimed.

The two-fix-round limit is reached. No further panel loop or Gate/merge attempt. Draft/operator-trial boundary remains intact. Workbench #332 has independently merged; reconcile its small Dispatch insertion when this POC is next prepared to land.

@itsHabib

itsHabib commented Sep 12, 2026

Copy link
Copy Markdown
Owner Author

Integrated merged main (#332) at c0c622c71891e785d443635a303cc30fdaff626a; remote SHA verified. The sole conflict was the Dispatch insertion point: handoff keeps its early validated route, followed by the observation read-only guard. Independent integration review verified both parents are preserved; scoped Standup/Fleet verbs tests and lint passed. All CI passed at the combined head, including macOS and Windows seats; GitHub reports CLEAN. Final focused race and vet checks also passed. The completed final panel remains anchored to bddbcc6; no new panel cycle requested, and no trial/Gate/merge boundary changed.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Unresolved correctness and safety issues remain in locking, confirmation handling, input validation, and Fleet observation paths.

Get a fresh assessment by requesting another Copilot review.

Review details

Suppressed comments (8)

Previously missed (1) — in code that hasn't changed since the last review.

cmd/standup/internal/standup/prepare.go:102

  • These comparisons use raw strings, but Fleet's status projections canonicalize paths, including symlinks and platform aliases, before matching. If roles.map contains an alias, a valid worker is emitted with the canonical cwd and is dropped here, so prepare/status incorrectly report no runtime target. Use the same canonical directory comparison for wanted and the row cwd while preserving the raw path for execution.

cmd/fleet/internal/fleet/lease.go:440

  • This guard is documented as catching unreadable ownership records, but unresolvedLegacy relies on listDir, which returns nil for both a missing and an unreadable/non-directory path. If leases, stop, or handoff cannot be read, LegacyKeysPresent returns false and observational commands proceed with an incomplete state view instead of refusing. Preserve directory read errors and treat non-ENOENT failures as unresolved.
	for _, sub := range []string{"leases", "stop", "handoff"} {
		if unresolvedLegacy(sub) {
			return true

cmd/standup/internal/standup/draft.go:24

  • The MCP schema marks roles, cards, decisions, and deferred as required, but decoding into zero-value slices accepts a plan such as {}. UpdateDraft then replaces every editable field with empty slices, so a caller/model that omits one field can erase the existing plan while supplying a valid digest. Enforce presence of each non-optional field at the decode seam, or explicitly change the schema and implement merge semantics.
	dec := json.NewDecoder(io.LimitReader(in, 1<<20))
	dec.DisallowUnknownFields()
	if err := dec.Decode(&d); err != nil {

cmd/standup/internal/standup/draft.go:23

  • Because the decoder is fed an io.LimitReader capped at exactly 1 MiB, an object whose closing byte lands at the limit is followed by EOF from the limited reader; any extra JSON or garbage in the original input is silently ignored. That bypasses the claimed exact-one-object/trailing-input protection at the boundary. Track overflow (for example, read one extra byte or use a counting reader) and reject inputs larger than the limit.
	dec := json.NewDecoder(io.LimitReader(in, 1<<20))
	dec.DisallowUnknownFields()

cmd/standup/internal/standup/prepare.go:80

  • This runtime snapshot uses fleet status --all, but that top-level command goes directly to watch.AllStatus and bypasses the legacy-state guard added for work/receipts/decisions/done. With a legacy or unreadable lease, AllStatus silently drops it from leaseRows and can return a normal snapshot showing a vacant/unoccupied binding, so standup status reports incorrect evidence instead of unknown/refused. Add a legacy preflight to the status --all/AllStatus path before relying on this observation.
	o := observe(e, e.LeadDir, "status", "--all", "--json")
	scopeWorkers(e, r, &o, true)

cmd/standup/main.go:224

  • --surface is documented and MCP-schema-restricted to text|voice, but the shared CLI path forwards any other nonempty value to ConfirmRecord, which stores it and reports it in readback. A direct standup confirm --surface=... can therefore create records outside the advertised contract and misstate attribution; validate the enum in the shared confirmation/record validation, not only in MCP.
	surface := fs.String("surface", "text", "text or voice")

cmd/standup/main.go:254

  • --expect is now a supported apply flag, but the top-level usage and the parse-error usage still omit it. Users following standup --help will not discover the digest guard that the README says CLI apply accepts; update both usage strings.
	expected := fs.String("expect", "", "plan digest from readback")

cmd/standup/planning.go:54

  • This MCP-facing view serializes *Record verbatim, so a confirmed record exposes record.confirm.phrase. After one legitimate confirmation, the desktop agent can call standup_show, reuse that revealed phrase on a new record, and pass standup_confirm without the user saying it, defeating the documented actual-words gate. Return a redacted MCP record (or omit Confirm.Phrase) while retaining the phrase only for verification.
	return printJSON(out, errOut, map[string]any{"path": path, "plan_digest": r.PlanDigest(), "record": r, "readback": r.Readback()})
  • Files reviewed: 15/15 changed files
  • Comments generated: 1
  • Review effort level: Lite

Comment thread cmd/standup/planning.go
Comment on lines +22 to +26
func lockStore(env standup.Env) (func(), error) {
if err := os.MkdirAll(env.Dir, 0o755); err != nil {
return nil, err
}
f, err := os.OpenFile(filepath.Join(env.Dir, ".lock"), os.O_CREATE|os.O_RDWR, 0o600)
@itsHabib

Copy link
Copy Markdown
Owner Author

Late final-review disposition — c0c622c

Reconciled Copilot's late review of bddbcc6 against the combined current head. Read-only source inspection and isolated overlay/subprocess probes confirmed the defects below. No new demonstrated P1 or authorization bypass was found. The two-fix-round cap is exhausted, so no additional fixes or panel cycle were opened. Existing draft, operator voice-trial, and no-Gate/no-merge boundaries remain intact.

Verified P2 residuals:

  • Ownership directory read errors: with leases a regular file, actual Fleet returns successful empty observations from work/decisions because directory read errors are collapsed into an empty listing. The earlier fix prevents migration but does not establish complete readable evidence. Future repair: preserve non-ENOENT directory errors in the legacy preflight; test missing, regular-file, and unreadable paths without writes.
  • Omitted required draft fields: {} with a current digest succeeds and clears existing cards. The MCP schema requires roles/cards/decisions/deferred but the shared decoder does not enforce presence. Changed drafts still clear confirmation and execution-history edits still refuse; this is data loss, not execution authorization bypass. Future repair: require all four non-null arrays while permitting explicit empty arrays; assert byte preservation for omitted/null fields.
  • Oversized trailing input: a JSON object ending exactly at the 1 MiB limit followed by a second object is accepted. Future repair: detect overflow before decoding rather than manufacturing EOF at the size limit.
  • Legacy status evidence: status --all bypasses the new legacy guard; unreadable or legacy lease evidence can disappear from the occupancy projection. Future repair: preserve partial observations with explicit unknown/incomplete occupancy, tested against an actual bound seat.
  • CLI confirmation surface: --surface made-up persists. Future repair: enforce text/voice in shared confirmation validation. Existing phrase, identity, and digest checks still apply.
  • Previously recorded: external CLI paths across distinct stores lack shared locking; raw checkout path comparisons can yield false unavailable-seat warnings; unknown RPC method uses the wrong protocol error code.

P3: usage text omits apply's --expect flag.

Phrase disclosure does not constitute an authentication bypass in this POC's explicit trusted-agent model. Redacting the phrase would not authenticate audio or establish that the user spoke it; no new authentication scheme is justified by this finding.

Before a voice trial, surface these residuals in its preparation: send complete explicit draft arrays, inspect saved changes before readback, keep records inside one configured store, and do not interpret a normal occupancy response as complete evidence when ownership storage is malformed or legacy. These are trial constraints, not claims the defects are fixed. The installed POC has not been refreshed or validated by this reconciliation, and no live confirmation or dispatch occurred.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants