fix(store): refuse unmarked targets by default; reclaim only under --reclaim (W-F4 review)
ci/woodpecker/pr/ci Pipeline failed
ci/woodpecker/pr/ci Pipeline failed
Resolves the review finding onc23a71d7: '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.c23a71d7remains the reviewed object, untouched.
This commit is contained in:
@@ -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}`,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user