DSH plugin pack.ts deletes the repo on empty --out

TroubleshootingPublished 2026-09-12Author: DeepSeek Plugin Market
DeepSeek HarnessDSH pluginpack.tsempty --outrecursive delete
An empty --out passed to the pack script recursively deletes the whole checkout: the empty string passes the default and resolves to the repository root.

If the release script's --out receives an empty string, it does not mean "produce no output" — it recursively deletes the entire checkout, including every uncommitted change you have. The cause is that scripts/release/pack.ts parses --out and then has no empty-value or root-path guard, while path.resolve(root, '') resolves to the same thing as path.resolve(root, '.')root itself; the rmSync(destination, { recursive: true, force: true }) that follows then treats the repository root as the output directory to clean (#457).

DSH plugin incident: one command recursively deletes the whole repository

What makes this severe is not probability but consequence — it is irreversible, and the second trigger requires no human mistake at all. Concretely:

  1. The dangerous code is three lines: scripts/release/pack.ts:47-51 (based on master 47f943859):
ts
const destination = resolve(root, values.out ?? DEFAULT_OUTPUT)
// ...
rmSync(destination, { recursive: true, force: true })
mkdirSync(destination, { recursive: true })

(#457) 2. Trigger one is a typo: passing an empty string reproduces it — tsx scripts/release/pack.ts --family dsh --out '' performs rmSync(repo root), recursively deleting the whole checkout including uncommitted changes (#457). 3. Trigger two is more dangerous: an empty CI environment variable. --out \"$RELEASE_DIR\" triggers it whenever RELEASE_DIR is empty. This deserves special emphasis — a reviewer instinctively looks for "somebody wrote --out ''", whereas in practice the far more common case is the variable inside those quotes being empty, with the command text looking entirely normal (#457). 4. The impact is irreversible data loss: local uncommitted changes and the CI workspace both disappear. The precondition is operator error or an empty environment variable, but release scripts are exactly where such inputs are most likely — which is why this was rated high severity (#457). 5. It was surfaced by systematic review: the issue was found by multiple rounds of AI-assisted code review and cross-read by two independent external models at high reasoning effort, with three-way consensus. That is instructive: a "one guard was missed" defect is very hard to find by reading every script by hand, yet stands out immediately when compared against its siblings (#457).

DSH plugin mechanism: path.resolve(root, '') normalises to the root, and rmSync has no guard

Two individually reasonable facts stack into a repository deletion: a default that only recognises undefined, and path normalisation that turns an empty string into the root. Layer by layer:

  1. The empty value passes through the default: the ?? in values.out ?? DEFAULT_OUTPUT only applies to undefined (and null); parseArgs accepts --out '', so the empty string passes through verbatim and destination receives an empty string rather than the default output directory (#457).
  2. path.resolve normalisation turns the empty string into the root: path.resolve(root, '') and path.resolve(root, '.') return the same value, root itself. So "empty input" is path-semantically equivalent to "the repository root", not to "the default output under the current directory" (#457).
  3. The delete has no preceding assertion: rmSync(destination, { recursive: true, force: true }) runs directly, and force: true even suppresses errors for non-existent paths. Not one line asks whether this target is the repository root or one of its ancestors, so the dangerous input sails through (#457).
  4. The sibling scripts do have guards; only this one does not: scripts/clean.ts asserts the target is a descendant path before deleting and resolves ancestor symlinks to prevent escape, while scripts/release/verify-built-package-invariants.mjs only deletes the directory it created with mkdtemp. The repository already contains two correct guard patterns; pack.ts simply did not adopt either (#457).
  5. The minimal fix stops the most common cases: changing the default to values.out?.trim() || DEFAULT_OUTPUT, or validating before rmSync that the resolved path sits inside a known output root. The former stops empty and whitespace-only values, but not --out ., --out .., or the symlink detour below (#457).

DSH plugin fix: ancestor guard, canonical deletion target, and regression-test shape

The community has already produced a cherry-pickable patch, and it is more complete than the minimal fix — it handles the "path looks inside the repository but physically points back at its root" detour. Specifically:

  1. First version: reject any output path resolving to the repository root or its ancestors. Branch yha9806/deepseek-harness @ codex/fix-release-pack-out-guard, commit e18f94a4. The guard uses a containment check such as containsPath(destination, root), so it covers the empty string, ., .., and the filesystem root, while normal subdirectories and sibling outputs remain usable (#457).
  2. The regression-test shape is worth copying: do not mock rmSync. It runs the real TypeScript release script in a one-off fixture created with mkdtemp--out '' and --out .. genuinely delete the fixture's sentinel before the fix and fail fast with the sentinel intact afterwards. Focused tests pass 2/2 and the pre-push typecheck is clean. Asserting on real deletion behaviour is what prevents a false green from "the guard was written on the wrong branch" (#457).
  3. A gap the review found: symlinks. The first version does not resolve symlinks, so an --out symlink pointing outside the repository still passes. The discussion then upgraded this from "optional hardening" to a real hole (#457).
  4. Two filesystem semantics must be kept apart: ① on the tested POSIX runtime, when the final --out entry itself is a symlink, recursive rmSync unlinks that entry and preserves its target (Windows junction behaviour differs); ② but when an intermediate ancestor is a symlink or junction, rmSync traverses it. So the danger is not "the final entry is a link" but "a link sits on an intermediate ancestor" (#457).
  5. A worst-case bypass was reproduced: inside a disposable repository, creating parent-link -> container and running with --out parent-link/repository produces a path that is lexically below the checkout but physically resolves back to the checkout root. Before the follow-up fix, the real release script did delete the repository sentinel (#457).
  6. The follow-up guard's shape: commit b8f2957f. The guard now checks both lexical containment and the canonical deletion target derived from the nearest existing destination parent, while deliberately not following the final path entry. That preserves the existing semantics for "the output directory does not exist yet" and for "a final link is only unlinked", while rejecting ancestor symlink/junction paths that route deletion through the repository (#457).
  7. The verification list: TDD red then green — the new real-script regression really did delete the sentinel before the follow-up fix; all 3 focused release-pack safety tests pass; a real safe pack to a previously missing dist/npm succeeds; documentation sync passes 28/28; full lint and the pre-push build/typecheck pass. The pattern was also folded into docs/defensive-patterns.md as a reusable idiom (#457).
  8. An honest boundary statement (worth remembering alongside the fix): this provides fail-fast protection against static misconfiguration. A hostile same-user process could still replace an ancestor after validation, so it is not a race-free filesystem security boundary. Stating the boundary plainly is more valuable than claiming a complete fix (#457).
  9. What this means for plugin authors: this is the lesson every script that deletes or overwrites should copy — any path reaching rmSync / rm -rf must first be proven to be an allowed output descendant, rather than trusting the input. When you publish a plugin with build or packaging steps through DSH Plugin Hub, and your script cleans an output directory, follow the pattern here: lexical containment plus canonical-target validation, do not follow the final entry, and lock it down with tests that assert real deletion behaviour (#457).

DSH plugin troubleshooting notes

Remember first that this is not a low-probability manual slip — it is irreversible, and the second trigger path (an empty CI variable) needs no human error at all, so the guard belongs in the script rather than in operator discipline. Eight points to keep in mind when a DeepSeek Harness plugin ships a destructive release script:

  1. An empty string passes through ??: the default only recognises undefined, not ''.
  2. An empty environment variable is just as fatal: --out \"$RELEASE_DIR\" equals passing an empty string when the variable is empty.
  3. path.resolve(root, '') equals the root: empty input is path-semantically the repository root.
  4. The minimal fix is not enough: ?.trim() || DEFAULT does not stop ., .., or the symlink detour.
  5. The danger is on intermediate ancestors: a final link is usually just unlinked; an intermediate ancestor link is what gets traversed.
  6. Do not mock deletion: assert sentinel survival with a real script in an mkdtemp fixture to avoid a false green.
  7. The sibling scripts already show the pattern: clean.ts's "descendant assertion plus ancestor link resolution" can be mirrored directly.
  8. It is not a security boundary: it guards against static misconfiguration, not a same-user process replacing an ancestor after validation.
DSH Plugin Hub install confirmation: shows the plugin name, source repo and the exact command before anything runs

Sources: Discussion #457, fix-release-pack-out-guard.

FAQ

In a DSH plugin build script, does an empty --out really delete the repository, and what triggers it?

In a DSH plugin build script an empty --out really does delete the repository, and it is irreversible. There are two triggers: ① an operator typo passing --out ''; ② **CI passing --out "$RELEASE_DIR" where RELEASE_DIR is an empty environment variable**. The second is the more dangerous, because nobody reviews a --out argument that looks perfectly normal. Once triggered, rmSync(destination, { recursive: true, force: true }) **recursively deletes the entire checkout, including uncommitted changes** (Source: Discussion #457).

In a DeepSeek Harness plugin release script, why does an empty string reach the repository root?

In a DeepSeek Harness plugin release script an empty string reaches the repository root because path.resolve(root, '') returns the **same** value as path.resolve(root, '.'), namely root itself. The --out default only applies to undefinedparseArgs accepts --out '', and the empty string **passes straight through** values.out ?? DEFAULT_OUTPUT. So destination is the repository root, and the rmSync right after it deletes it recursively (Source: Discussion #457).

How do the repository's other destructive scripts guard against an empty --out in a DSH plugin?

In a DSH plugin the sibling guards are the model: scripts/clean.ts **asserts the target is a descendant path** before deleting and resolves ancestor symlinks to prevent escape, while scripts/release/verify-built-package-invariants.mjs **only deletes the directory it created with mkdtemp**. So this is not a case of the team not knowing how, but of one missing guard in pack.ts (Source: Discussion #457).

For a DSH plugin, is a one-line `values.out?.trim() || DEFAULT_OUTPUT` enough?

For a DSH plugin a one-line values.out?.trim() || DEFAULT_OUTPUT is not enough: it only stops empty or whitespace-only values, while --out ., --out .., and **ancestor symlinks** still get through. The community patch takes a stronger "ancestor guard" route: a lexical containment check first, then realpath of the nearest existing parent to derive the true deletion target — covering the empty string, ., .., the filesystem root, and the symlink/junction detour where a path looks like it is inside the repository but physically points back at its root (Source: Discussion #457).

Related Terms

empty-value pass-through
When argument validation relies only on ?? for defaults, only undefined hits the default; an empty string '' passes through. Combined with path.resolve(root, '') === root, that produces a valid-looking input that reaches a dangerous target.https://github.com/deepseek-ai/deepseek-harness/discussions/457
ancestor symlink detour
When the final path entry itself is a symlink, recursive deletion usually just unlinks that entry and preserves its target; but when an intermediate ancestor is a symlink or junction, deletion traverses it. A path lexically inside the repo can therefore physically point at the repo root.https://github.com/deepseek-ai/deepseek-harness/discussions/457
canonical deletion target
Realpath the nearest existing parent (following symlinks in existing components), then append the missing suffix plus the final entry while deliberately not following that final entry — yielding the true deletion target. This preserves the semantics for missing output dirs and for unlinking a final link, while rejecting ancestor-link routing.https://github.com/deepseek-ai/deepseek-harness/discussions/457

Sources