fix(store): refuse unmarked targets by default; reclaim only under --reclaim (W-F4 review)
ci/woodpecker/pr/ci Pipeline failed

Resolves the review finding on c23a71d7: 'store add' silently deleted a
markerless target directory and reported it as recovered-partial, but the
code cannot distinguish its own interrupted-write debris from content the
operator placed by hand — and the USER root's entire contract is that
tooling never destroys operator content.

- addStoreEntry now throws typed STORE_TARGET_UNMARKED on an unmarked
  target; deletion happens only when the caller passes { reclaim: true }.
- CLI: 'store add' gains --reclaim ('replace an existing UNMARKED target
  directory; refuses without this flag').
- Status renamed recovered-partial -> reclaimed-unmarked so even the
  opted-in path names what it did (fix 2 folded into fix 1).
- Spec: default-refusal test asserts operator content SURVIVES; opt-in
  test asserts replacement; two CLI tests cover exit codes.
- Ordering test (second review round): 'reclaim can never destroy a
  marked, vetted entry' — adds a vetted entry, re-adds with reclaim:true,
  asserts STORE_ALREADY_PRESENT AND the original content + marker survive
  on disk. Pins marker-check-before-reclaim-check against the
  guard-clause-migrates-upward refactor; discrimination proven by
  sabotaging the order (1 failed, exactly this test) and restoring (49/49).
- TOCTOU note added at assertSourceTreeHasNoSymlinks per review (known
  check-then-use window, accepted for a local operator-run CLI).

Gates (settled set, rc-honest): store spec 49/49; package vitest 87 files
/ 1596 tests; package build+lint rc0; root build 25/25 + typecheck 45/45;
prettier --check rc0 on all four touched files.

c23a71d7 remains the reviewed object, untouched.
This commit is contained in:
fargo
2026-08-17 13:16:23 -05:00
parent c23a71d7e3
commit 9e1b0dcb62
2 changed files with 97 additions and 12 deletions
+32 -6
View File
@@ -25,8 +25,11 @@ import { DEFAULT_MOSAIC_USER_HOME } from '../constants.js';
* boundary: content lands here only through an explicit `store add` carrying a
* named vetting attribution, and every entry is versioned
* (`store/<kind>s/<name>/<version>/`) with a `store-entry.json` marker written
* LAST — a version directory without its marker is a partial write, never a
* usable entry.
* LAST — a version directory without its marker is never a usable entry, and
* an unmarked target is REFUSED by default: it may be this tool's own debris
* from an interrupted add, or content the operator placed by hand, and the
* code cannot tell those apart — so deletion happens only under an explicit
* `--reclaim` opt-in, and the result status names what was done.
*
* Deferred by design (W-F6 and later): activation/symlink-install into agent
* homes, `current`-pointer pinning, network acquisition. `add` accepts a local
@@ -59,7 +62,7 @@ export interface StoreEntryMeta {
notes?: string;
}
export type StoreAddStatus = 'added' | 'recovered-partial';
export type StoreAddStatus = 'added' | 'reclaimed-unmarked';
export interface StoreAddResult {
kind: StoreKind;
@@ -209,6 +212,11 @@ function isInsideRoot(candidate: string, root: string): boolean {
/**
* Refuse any symlink in the source tree — the vetting boundary copies real
* content only, so a vetted entry can never carry a link that escapes it.
*
* NOTE: known check-then-use window between this walk and the `cpSync` below:
* a symlink created concurrently with the add could slip through. Accepted
* for a local, operator-run CLI; revisit before any unattended or networked
* acquisition path exists.
*/
function assertSourceTreeHasNoSymlinks(sourcePath: string): void {
const stack: string[] = [sourcePath];
@@ -243,6 +251,7 @@ export function addStoreEntry(
vettedBy: string,
notes: string | undefined,
paths: StorePaths = getDefaultStorePaths(),
options: { reclaim?: boolean } = {},
): StoreAddResult {
validateStoreKind(kind);
validateStoreName(name);
@@ -285,9 +294,19 @@ export function addStoreEntry(
`${kind} "${name}" version "${version}" is already present at ${target}; stores are append-only — add a new version instead.`,
);
}
// Partial write (no marker): safe to reclaim.
// Unmarked target: either this tool's own debris from an interrupted add,
// or content the operator placed by hand — indistinguishable on disk. The
// USER root's contract is that tooling never destroys operator content,
// so deletion requires the explicit --reclaim opt-in (review finding on
// c23a71d7: silent rmSync under a benign-sounding status).
if (!options.reclaim) {
throw new StoreError(
'STORE_TARGET_UNMARKED',
`Target exists without ${STORE_ENTRY_MARKER}: ${target}. Refusing to delete unmarked content — if this is debris from an interrupted add, re-run with --reclaim to replace it.`,
);
}
rmSync(target, { recursive: true, force: true });
status = 'recovered-partial';
status = 'reclaimed-unmarked';
}
mkdirSync(target, { recursive: true });
@@ -451,6 +470,10 @@ export function registerStoreCommand(
.requiredOption('--from <path>', 'Local source directory to vet (no network acquisition)')
.requiredOption('--by <operator>', 'Name of the operator vouching for this content')
.option('--notes <notes>', 'Vetting notes recorded in the entry metadata')
.option(
'--reclaim',
'Replace an existing UNMARKED target directory (e.g. debris from an interrupted add); refuses without this flag',
)
.action(
async (
kind: string,
@@ -460,6 +483,7 @@ export function registerStoreCommand(
from: string;
by: string;
notes?: string;
reclaim: boolean;
},
) => {
try {
@@ -471,8 +495,10 @@ export function registerStoreCommand(
opts.by,
opts.notes,
paths,
{ reclaim: opts.reclaim },
);
const suffix = result.status === 'recovered-partial' ? ' (recovered partial write)' : '';
const suffix =
result.status === 'reclaimed-unmarked' ? ' (replaced unmarked directory)' : '';
console.log(
`${result.kind} ${displayStoreName(result.name)} ${result.version}: added${suffix}`,
);