Skip to content

MOO-72 Commit 6: harden secure temporary workspaces and artifact lifecycle - #16

Merged
OwenTanzer merged 3 commits into
mainfrom
moo72-commit6-workspace-hardening
Jul 29, 2026
Merged

MOO-72 Commit 6: harden secure temporary workspaces and artifact lifecycle#16
OwenTanzer merged 3 commits into
mainfrom
moo72-commit6-workspace-hardening

Conversation

@OwenTanzer

Copy link
Copy Markdown
Owner

Summary

  • WorkspaceManager gains a per-process instances/<bootId>/ namespace (a fresh randomUUID() per process) + PID lock file, and an ownership marker (.codeflow-owned-v1) written once by ensureRoot().
  • New sweepStaleWorkspaces(): process-restart cleanup. Only removes other instance directories confirmed not-alive via process.kill(pid, 0) — a live overlapping instance (e.g. mid rolling-deploy, or any two processes sharing a root) is never touched. Refuses to run at all against a root lacking the ownership marker, and only ever considers UUID-shaped entries under instances/ — a WORKSPACE_ROOT ever misconfigured to point at a shared/non-dedicated directory has none of its unrelated contents touched.
  • The workspace object returned by createRequestWorkspace gains writeFile()/copyTree() as the only sanctioned way to put content into it:
    • writeFile(): walks every ancestor path component for a symlink (not just the immediate parent), then an atomic wx (O_CREAT|O_EXCL) exclusive-create write — closes both the TOCTOU race between checking and writing, and the case where the final target itself already exists as a symlink (a realpath-only parent check would miss this entirely). Private file/dir permissions (0600/0700).
    • copyTree() (for analyze.js's local-tree path, a second workspace-materialization path distinct from pyan3's per-file writes): rejects — aborts the whole copy — if the source tree contains any symlink at all, rather than silently dereferencing or skipping. Recursively tightens permissions on everything copied.
  • stagePythonFiles (pyan3Adapter.js) and analyze.js's cp() call both now route through these instead of their own raw mkdir/writeFile/cp.
  • No diagnostic-artifact retention mechanism added — documented in docs/baseline.md as a deliberate policy: every diagnostic stays in-memory/response-only, consistent with "avoid retaining private source longer than necessary" right next to it in the same checklist entry.
  • CodeVisualizer's function-layer analysis is confirmed (and documented) to never touch disk at all — no second, parallel workspace implementation exists to reconcile.

Test plan

  • npm test — 672/673 passing (1 expected skip: file-symlink creation isn't permitted in this Windows dev environment without Developer Mode — the equivalent junction-based ancestor-symlink test and the copyTree symlink-rejection test both pass for real)
  • Real (not synthetic) crash-cleanup test: spawns an actual child process that creates a real workspace via the real WorkspaceManager and hard-exits without running its own cleanup(), then confirms the next process's real startup sweep removes it.
  • Real signal-driven abort test: drives runSharedPyan3Analysis with a real AbortController that fires almost immediately, confirming the actual execFile-level kill path still leaves the shared workspace cleaned up.
  • Overlap safety: an instance directory whose lock file names this test process's own (guaranteed-alive) PID is never removed by sweepStaleWorkspaces(); one naming a confirmed-dead PID (a real spawned-then-exited child) is removed.
  • Ownership-marker gating: a root without the marker makes the sweep a no-op; unrelated files/directories outside instances/ on an owned root are never touched.
  • Ran the real end-to-end smoke test (tests/server-smoke.mjs) against the actual server process — all 22 steps pass, including the updated "every per-request workspace was cleaned up" assertion (now checking this instance's own namespace is empty, since the root legitimately carries the ownership marker + instances/ for the process's lifetime) and confirming analyze.js's new copyTree() path still matches the golden-world baseline exactly (files:6 functions:7).

🤖 Generated with Claude Code

…cycle

WorkspaceManager gains a per-process instances/<bootId>/ namespace + PID
lock file, an ownership marker (.codeflow-owned-v1), and
sweepStaleWorkspaces() -- process-restart cleanup that only removes
other instance directories confirmed not-alive (process.kill(pid, 0)),
never a live overlapping instance, and refuses to run at all against a
root lacking the ownership marker (protects a misconfigured shared
WORKSPACE_ROOT from unrelated-file deletion).

The workspace object gains writeFile()/copyTree() as the only sanctioned
way to put content into a workspace: an ancestor-walking symlink check
(not just the immediate parent), an atomic `wx` exclusive-create write
(closing the TOCTOU race and the final-target-is-a-symlink case a
realpath-only check would miss), and private file/directory permissions.
stagePythonFiles (pyan3Adapter.js) and analyze.js's local-tree copy both
route through these instead of their own raw mkdir/writeFile/cp calls --
copyTree rejects (aborts) rather than dereferences or skips a symlinked
source tree.

No diagnostic-artifact retention mechanism is added -- documented as a
deliberate, explicit policy (docs/baseline.md): every diagnostic stays
in-memory/response-only, consistent with "avoid retaining private source
longer than necessary."

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@linear-code

linear-code Bot commented Jul 29, 2026

Copy link
Copy Markdown

MOO-72

@OwenTanzer OwenTanzer left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Not ready to merge yet: two blocking issues.

Comment thread server/lib/workspace.js Outdated
Comment thread tests/server-workspace.test.mjs Outdated
OwenTanzer and others added 2 commits July 29, 2026 10:41
…I fixture

The ownership marker was written unconditionally by ensureRoot() before
sweepStaleWorkspaces() ever checked for it -- since server/index.js always
calls ensureRoot() first, the marker was always present by sweep time,
so the "refuse to sweep an unmarked root" protection never actually
triggered in the real startup path. Fixed by having ensureRoot() record
whether the marker already existed *before* this call wrote it
(_rootWasPreviouslyOwned), and gating the sweep on that instead -- a
root's first-ever startup now genuinely never sweeps, only a second and
later startup (once a prior process established continuity of ownership)
does. Added the exact regression test the review asked for: the real,
unmodified ensureRoot()->sweepStaleWorkspaces() order against a
previously-unowned root with a stray UUID-shaped directory.

Also fixed a Linux CI failure: one malicious-path fixture
('..\..\windows\system32\config\sam') isn't actually a traversal on
POSIX, where backslash is just an ordinary filename character -- made it
Windows-only in the test.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…never be swept

The previous fix only delayed the destructive behavior by one restart
cycle: a genuinely foreign instances/<uuid>/ directory (no lock file
inside it at all, so nothing proves CodeFlow ever created it) survived
the first startup (root not yet marked owned) but was deleted on the
second, once the first startup's own ensureRoot() call had written the
root-level ownership marker. The root-level marker only proves "CodeFlow
has run against this root before" -- it says nothing about whether any
specific instances/<uuid>/ directory was actually created by CodeFlow.

Fixed at the source: isInstanceAlive's boolean return (missing/malformed
lock file => "not alive" => removed) is replaced with a three-way
instanceLifecycleState ('alive' | 'dead' | 'unknown'). Only 'dead' (a
lock file naming a PID confirmed via process.kill(pid, 0) to not be
running) is ever removed. 'unknown' (no lock file, or one that doesn't
parse as a PID) is now treated the same as 'alive' -- never touched,
regardless of the root-level marker or how many restarts have happened.

Added the exact regression test requested: two full startup cycles
against a root containing a pre-existing, lock-file-less UUID-shaped
directory, confirming it survives both, not just the first.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

@OwenTanzer OwenTanzer left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Review complete: both blocking fixes are verified, the final thread is resolved, the full local suite and production build pass, and all GitHub Actions checks are green. Ready to merge.

@OwenTanzer
OwenTanzer merged commit dd07abb into main Jul 29, 2026
3 checks passed
@OwenTanzer
OwenTanzer deleted the moo72-commit6-workspace-hardening branch July 29, 2026 15:16
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.

1 participant