Skip to content

Fix replay safety and git security issues from architecture review - #6

Merged
sergeyzenchenko merged 9 commits into
mainfrom
claude/project-architecture-review-ajglvu
Aug 26, 2026
Merged

Fix replay safety and git security issues from architecture review#6
sergeyzenchenko merged 9 commits into
mainfrom
claude/project-architecture-review-ajglvu

Conversation

@sergeyzenchenko

Copy link
Copy Markdown
Member

This PR addresses multiple critical issues identified in the architecture review at commit 627b28e, focusing on replay correctness, git security, and ownership claim management.

Summary

Fixes several interrelated bugs in workflow replay, human gate handling, git command execution, and run lifecycle management that could cause incorrect behavior during resume operations or when running untrusted repositories.

Key Changes

Replay and Human Gate Safety:

  • Fixed human gate matching to use position-based identity when bundle hash is stable, preventing answers from being served to the wrong gate when gates are deleted or reordered
  • Added key parameter to human step options (HumanAskOptions) to allow explicit stable identity, mirroring agent step behavior
  • Updated ReplayIndex.matchHuman() to report ambiguity rather than guess when script changes make position unreliable

Git Security Hardening:

  • Added safeGit() wrapper function that disables core.fsmonitor and sets safety environment variables on all git calls in tree helpers
  • Prevents arbitrary code execution when scanning untrusted repositories (fsmonitor can be set to execute a program on worktree scans)
  • Applied to treeHash() and integrationBaseCommit() which run git add -A on every write-step dispatch

Integration and Patch Idempotency:

  • Fixed issue where reverted integrations were re-applied from journal instead of being re-executed when tree state diverged
  • Added replay.diverged detection when patch merge baseTree equals resultTree (signature of buggy re-journaling)
  • Ensures patches are actually applied to the tree, not merely marked as merged

Run Lifecycle and Ownership:

  • Added drainSteps() function to wait for in-flight steps to complete before releasing ownership claim
  • Prevents terminal records from landing while steps are still executing
  • Added tree-aware fencing logic with rootOf() and treeOf() helpers to properly handle parent-child run relationships
  • Ensures ownership claims are held until all work completes

Runtime Improvements:

  • Fixed checkIdle() to iterate over a copy of idle listeners, preventing skipped listeners when callbacks unregister during iteration
  • Added .weft/.gitignore creation to prevent run state (journals, blobs) from being committed to git
  • Improved CORS origin validation in daemon to check both host and port match, not just loopback status

Test Coverage:

  • Added comprehensive regression tests for reverted integrations, human gate answer routing, terminal record timing, and git security
  • Tests verify correct behavior on resume after tree modifications and script changes

https://claude.ai/code/session_01P8RM3ozqRYTHt6NAqct175

claude added 8 commits August 26, 2026 09:12
A deep review of architecture quality, soundness, type safety and edge
cases, covering the subsystems that landed after the previous review:
the daemon + HTTP API + React UI (7d500a7) and workflow task tracking
(627b28e).

64 findings survived an adversarial refutation pass: 2 critical, 15
high, 31 medium, 16 low. The two criticals both sit in the write path
and both end with a green run whose tree does not contain the patch --
`ctx.integrate`'s verify-refused re-execution serving its own nested
apply step, and its unsynchronised rollback on conflict. The critical
in `matchHuman` replays a recorded denial as an approval after a script
edit. All three were reproduced by running them.

The review groups the findings under three missing abstractions rather
than listing them flat: ownership modelled per-run instead of per-tree,
the keyless-ambiguity guard living in one match function instead of in
step identity, and the integration working tree being the one shared
resource with no coordinator.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P8RM3ozqRYTHt6NAqct175
Both end with a green run whose journal claims something the world does
not agree with, and both were reproduced before being fixed.

`matchHuman` resolved a replayed human step by content hash alone. Human
steps carry no `key` -- ask/approve/review and gateStep expose none -- so
two gates with the same question are identical by content, and the first
unconsumed entry for a hash was handed over. Deleting one of two
identically worded gates slid the survivor onto the deleted gate's
answer, so a recorded denial replayed as an approval with nothing in the
journal to say it happened. It now takes seq and positionsTrusted like
`matchStep`, serves on position where positions are trusted, and
otherwise journals `replay.diverged` and re-opens the request. Re-asking
costs a wait; guessing costs the truth.

`ctx.integrate` guards itself with verifyServe, but the snapshot and
apply that do the work were nested journaled steps with no guard. When
verifyServe correctly refused -- the tree no longer carried the patch --
the parent re-executed and the nested pair was served straight back,
returning ok without touching the tree, then re-journaling `patch.merged`
with resultTree equal to baseTree. The change was lost permanently, since
the new completion matched the untouched tree from then on. A
re-establishing pass now does the work directly. `reExecuting` is set
only when a journaled completion was refused, so a resume mid-ask, which
leaves the parent incomplete, keeps reusing the pre-conflict snapshot as
before.

Four regression tests, each verified to fail with the fix reverted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P8RM3ozqRYTHt6NAqct175
`treeHash` and `integrationBaseCommit` shelled out to git directly,
bypassing GitCli, which prefixes `-c core.fsmonitor=false` on every call
it makes. A pathname-valued core.fsmonitor is a program git executes on
any worktree scan, and these two helpers run `add -A .` on every
write-step dispatch and inside every ctx.integrate -- so cloning an
untrusted repository was enough to execute repository-configured code
with no gate. Verified against git 2.43: the hook fires on `add -A`
without the flag and does not fire with it.

All ten bare invocations now go through one local helper that applies the
same floor. Its safety vars are spread last, because these callers build
their env from process.env and spreading them first would let an
inherited hostile value win.

Separately, `checkIdle()` iterated the live listener array while
listeners unregister themselves through `offIdle`, which splices it --
shifting every later element under the loop index and skipping every
second waiter. With two waiters one never learns the run went idle. It
iterates a copy now.

Four regression tests, each verified to fail with the fix reverted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P8RM3ozqRYTHt6NAqct175
A human step is identified by its content alone -- question, detail,
schema, risk, timeouts -- so two gates that ask the same thing in
different contexts are indistinguishable. The previous commit made that
case re-open both gates rather than guess between them, which is the safe
answer but not a usable one: there was no way to tell weft the gates were
different, because ask/approve/review exposed no `key`.

They do now, folded into the identity hash the same way `AgentOptions.key`
is. Two gates worded "ship?" for staging and for prod keep their own
answers across an edit that deletes one of them, instead of both being
re-asked.

Regression test covers the keyed pair surviving exactly the edit that
forces a re-ask when the keys are absent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P8RM3ozqRYTHt6NAqct175
`ctx.agent.detailed(..., { onError: "null" })` returned a bare null when
the suppression happened live, but its replay path revived the carrier
`{ value: null, ... }` -- so a workflow branching on `result === null`
took one path on the first run and the other on a resume. The value alone
cannot distinguish the two, because a schema may legitimately permit
`value: null`, so the revived carrier is marked in a process-local
WeakSet and the caller restores the bare null from that.

`createWeft` now writes `.weft/.gitignore` covering runs/, blobs/,
tasks/ and index.sqlite. `.weft/` lives inside the user's working tree
and weft's own tree helpers run `git add -A .` on every write-step
dispatch and inside every ctx.integrate, so a user's repo folded its
journal and blob store into a git object on the first write step and
copied them into every agent worktree. workflows/ is deliberately not
listed -- it is source. The file is written once and never overwritten.
(This repository already ignores those paths in its own .gitignore; the
gap was every repo weft is pointed at.)

Two regression tests; the replay-shape one verified to fail with the fix
reverted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P8RM3ozqRYTHt6NAqct175
Both defects come from the same gap: ownership, abort and terminality are
tree-wide properties, and only the single run was modelled.

`drive()` appended run.completed/run.failed and released the ownership
claim as soon as the workflow BODY settled, without waiting for steps it
had stopped awaiting -- a Promise.race loser, a lane of a raw Promise.all
whose sibling rejected, a floating step. Those kept spending, kept
writing to the repository, and appended into a journal that was already
terminal, while the freed claim let a second process resume and
interleave into the same file. It now waits, bounded, for its own steps
before the terminal record, and holds the claim if they outlive the
window, which is the position cancel() and shutdown() already take.

Note it deliberately does NOT abort first the way those two do: every run
in a tree shares one AbortController, so a child completing normally
would tear down its parent. Waiting is the part that matters here.

`fenceLostRun` fenced only the runtime that noticed. Because of that same
shared controller the rest of the tree saw an ordinary cancellation and
durably journaled run.cancelled -- so a child losing its claim terminally
cancelled its parent, the exact inversion of the fence's contract, firing
precisely when the system is under the stress the fence exists for.
Fencing now walks to the root and covers every live descendant, so the
whole tree stays resumable for its next owner.

Two regression tests, each verified to fail with its fix reverted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P8RM3ozqRYTHt6NAqct175
The daemon can start runs, answer approval gates, cancel and resume, and
it has no authentication behind the ten lines of Host/Origin middleware
that guard it. Two holes in that middleware were reachable from a browser.

Loopback was treated as one origin, so any page served from any other
localhost port -- a dev server on :3000, another local tool -- passed the
Origin test. A CORS-simple POST from such a page carries an honest
loopback Origin and could start runs and satisfy human approval gates.
Origin now has to name the same host AND port the request was addressed
to, normalised through URL so localhost:4781 and http://localhost:4781
still compare equal.

A no-cors cross-site GET -- an img, a script, a form -- carries no Origin
at all, so neither check ever saw it, and this surface has a GET that
writes: /api/workflows/:name/tasks registers a namespace. Browsers that
omit Origin send Sec-Fetch-Site instead, so that is now required to be
same-origin or none.

This narrows the browser-reachable surface; it is not authentication. Any
local process can still reach the API, which is the gap the review's
per-daemon token would close and this does not.

Two regression tests, both verified to fail against the old guard.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P8RM3ozqRYTHt6NAqct175
Adds a Status table to the review naming each Now-list finding and the
fix that closed it, so the document does not read as a list of open
problems.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P8RM3ozqRYTHt6NAqct175

@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: 4eabc17307

ℹ️ 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 packages/core/src/engine.ts
Comment thread packages/core/src/engine.ts Outdated
Two P1 findings from the Codex review on PR #6, both real, both gaps in
the drain added by the previous commit. Each was reproduced against the
current code before being fixed.

The cancellation exit never drained. `drained` kept its `true`
initializer, so the finally released the ownership claim. cancel() keeps
the claim when it times out, but an abort makes a COOPERATIVE lane reject
at once -- so the body can reach this branch while a step that ignores
its signal runs on, cancel() sees a body that settled promptly and drains
happily, and the claim goes free with a step still executing.

`drainSteps` consulted only `liveStepCount()`, which excludes steps that
called markWaiting(). A workflow that stops awaiting a ctx.sleep hit the
zero fast path immediately; confirmed by observation that the abandoned
timer then appends `timer.fired` and `step.completed` after
`run.completed`.

Draining on waits unconditionally was wrong, and the suite caught it: one
counter serves two different waits, because a sub-workflow step bridges
its child's suspension into the parent's `waitingSteps`. A terminal root
with a descendant still suspended on a person is a supported state, not
an abandoned wait -- that child's records land in the child's journal and
its question must stay answerable. Live children are the only reason a
wait legitimately outlives the body, so the wait drain is gated on the
run having none.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P8RM3ozqRYTHt6NAqct175

@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: 5e0c915bac

ℹ️ 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".

// precedes a `step.completed`, and releasing the claim frees the run for another
// process while this one is mid-step. `cancel()` and `shutdown()` already refuse to
// do that; this is the path every run takes.
drained = await drainSteps(rt, TERMINAL_DRAIN_MS, active.children.size === 0);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Keep draining parent-journal waits when children exist

Do not disable all wait draining merely because active.children is nonempty. If a workflow abandons a suspended ctx.workflow(...) via Promise.race or an early return, the parent workflow step is marked waiting while the child remains in this set, so this call immediately reports drained, appends run.completed, and releases/deletes the parent. When the child is later answered, launchChild returns and the parent runStep appends step.completed to the already-terminal, unowned parent journal. The new active.children.size === 0 condition is fresh evidence that the previously reported durable-wait problem remains for child-bridged waits.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Reproduced and real — but not something this PR introduced, and I'm leaving it rather than pushing a third reshaping of this code.

Probe: parent does Promise.race([ctx.workflow(child), fastAgent]), child suspends on a gate, agent wins.

PARENT JOURNAL:  … step.completed step.settled run.completed
CLAIM STEALABLE: true
AFTER TERMINAL:  step.completed      ← after answering the abandoned child

Two things make this a design change rather than a fix:

It predates the PR. Before this branch there was no terminal drain at all, so every abandoned wait behaved this way. children.size === 0 narrowed that to the child-bridged case; it didn't create it.

The obvious remedy breaks a supported state. I tried unconditional wait draining first and the suite rejected it: a sub-workflow step bridges its child's suspension into the parent's waitingSteps, and packages/daemon/test/api.test.ts ("keeps a sibling's question in the queue after the root has failed") asserts a terminal root with a live suspended child. Draining there blocks for the whole window, leaves the run reading executing, and — because that child stays suspended indefinitely — the root would never release its claim at all.

Both cases have children.size > 0 and a standing bridge wait. Separating them means knowing whether the body still awaits that child, which waitingSteps cannot express: one counter serves ctx.sleep, signal waits, in-step gates and the child bridge.

Proposed patch, for @sergeyzenchenko to take or leave: give RunRuntime a bridgedWaits counter incremented only on the waitBridge path in executeChildRun, and have the terminal drain wait on hasPendingWaits() minus bridged, unconditionally — no children gate. That drains the abandoned-race case while leaving a genuinely-awaited child alone. It touches the runtime's wait accounting, so it wants its own change and its own tests rather than riding along here.


Generated by Claude Code

const poll = setInterval(() => {
if (!outstanding()) done(true);
}, 10);
const timer = setTimeout(() => done(false), ms);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Withhold terminal events when the drain deadline expires

Return a nonterminal/fenced outcome when outstanding work survives this timeout instead of letting drive() append its terminal event unconditionally. For example, an abandoned 60-second ctx.sleep reaches this five-second timeout, after which run.completed is recorded and lease renewal is stopped; the filesystem lease expires after 15 seconds, so another process can acquire the run before the original timer appends timer.fired and step.completed. The fresh evidence is that the new drain now detects pending waits but converts that detection to false after a fixed deadline without preventing the subsequent terminal append.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

The facts check out — fs lease TTL is 15s (packages/store-fs/src/journal.ts:615) and the finally clears the renewal timer unconditionally — but I'm not making this change here, because it isn't a defect in the diff; it's the posture the whole file already takes.

shutdown() says it outright: "a step that ignores its abort signal past the drain window forfeits the release and its claim TTL-expires instead." cancel() does the same, and documents it. A bounded drain that gives up and proceeds is the existing contract in all three exits, not something this PR introduced — before it, drive() didn't wait at all.

The suggested remedy is a real semantic change, not a tightening. Withholding run.completed when the deadline expires means a workflow whose body completed successfully, whose output passed its schema, and whose patches all integrated, is never recorded as finished — it stays open for another owner to resume, purely because an unrelated abandoned timer is still armed. That trades a narrow ordering artifact for losing the durable record of a genuinely completed run, and it would apply to run.failed and run.cancelled too.

If the ordering artifact is worth closing, the cheaper lever is the other half of the same finally: keep renewing the claim while drained === false instead of stopping renewal, so nobody else can acquire the run while a zombie is still appending. That keeps the terminal record honest and closes the acquisition window, at the cost of holding a claim until the process exits. Worth a deliberate decision by @sergeyzenchenko either way — both options change how a wedged step ends a run, which is exactly the kind of thing the comments in this file were written to pin down.

Flagging one process point: this is the third review round on this hunk, and each fix has drawn a reshaped finding on the trade-offs of the drain rather than on a defect. The two concrete bugs from round two are fixed and tested; I've stopped pushing here so the remaining design calls stay with the author.


Generated by Claude Code

@sergeyzenchenko
sergeyzenchenko merged commit 79f2538 into main Aug 26, 2026
6 checks passed
@sergeyzenchenko
sergeyzenchenko deleted the claude/project-architecture-review-ajglvu branch August 26, 2026 11:54
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