* fix(#3050): fail closed when a worktree guard cannot verify safety Three places answered "safe" when they had not actually checked. The base-divergence gate held the clearest evidence against itself: within one function, an unresolvable fork ref correctly degrades, while an unresolvable HEAD twenty-five lines earlier returned "proceed". Because a timeout collapsed into the same branch as "not a git repository", a locked index or a stalled mount produced a green gate that had never resolved the fork base -- and that value decides parallel versus sequential dispatch. Timeouts are now distinguished from a genuine absence of a repository. A timeout degrades with its own reason and message; not-a-git-repo keeps today's non-degrading behavior, because there is no worktree concern there. The same conflation in worktree-context resolution is surfaced rather than silently falling back to the current directory. Worktree creation's root confinement was opt-in: omitting the root skipped the check entirely, leaving only the leading-dash and parent-segment guards. The sole caller always passed it, so nothing was exploitable -- it is now mandatory so a future caller cannot inherit an unconfined path by forgetting. The timeout predicate was checked against what Node actually emits on a spawnSync timeout, not only against the fixtures, so it cannot be a guard that fires solely in tests. Coverage is deliberately behavioral. The existing worktree suites -- 134 tests across two files -- require no production module and call no production function; they assert against prose and would pass with the implementation deleted. That is how three fail-open guards survived in a heavily-tested module, so the new tests drive the real resolvers through an injected git seam, with five of them pinning the paths that must NOT change. One existing test asserted the opt-in confinement behavior and was rewritten rather than left green against the corrected code. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(#3050): stop a CLI exit code leaking into the test process The runner reported the new file as failed while the file's own summary said nine tests passed and none failed. That signature is a non-zero process exit after a green run, not a failing assertion. Cause: the confinement test calls the worktree-create command function directly, and that function sets process.exitCode on its failure path as a CLI would. In process, that exit code became the test file's own exit status. The sibling suite already guards this with a save/restore wrapper and a comment naming the hazard; the new file simply did not follow the convention. It does now. Root cause is in the test, not the production code -- setting an exit code is correct behavior for a command entry point, and the existing convention exists precisely because tests call these functions in process. Verified by exit-code and active-handle probes rather than by re-running: exit was 1, is now 0, with zero lingering handles and all nine tests still passing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore(#3050): backfill changeset pr number Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -98,6 +98,20 @@ function buildMsgDiverged(headSha: string | null, forkRef: string | null, forkSh
|
||||
|
||||
const MSG_UNKNOWN = `⚠ Cannot determine the worktree fork base (origin/HEAD unresolved). Running this phase sequentially on the main working tree to avoid a base mismatch. To keep parallel worktrees, set worktree.baseRef:"head" in .claude/settings.local.json (or run: gsd-tools worktree set-baseref). See #683.`;
|
||||
|
||||
const MSG_HEAD_UNRESOLVABLE = `⚠ Cannot determine the worktree base (git rev-parse HEAD timed out or could not complete). Running this phase sequentially on the main working tree to avoid an unverified base mismatch. To keep parallel worktrees, set worktree.baseRef:"head" in .claude/settings.local.json (or run: gsd-tools worktree set-baseref). See #683, #3050.`;
|
||||
|
||||
/**
|
||||
* Returns true when an execGit result indicates the subprocess was killed by
|
||||
* a timeout (SIGTERM + ETIMEDOUT), mirroring the idiom already established in
|
||||
* worktree-safety.cts's execGitDefault. A timeout means the command genuinely
|
||||
* could not complete — it must never be treated the same as a clean non-zero
|
||||
* exit (e.g. "not a git repository"), which DID complete and reported a real
|
||||
* answer.
|
||||
*/
|
||||
function isExecGitTimeout(result: { signal: string | null; error: unknown }): boolean {
|
||||
return result.signal === 'SIGTERM' && (result.error as NodeJS.ErrnoException | null | undefined)?.code === 'ETIMEDOUT';
|
||||
}
|
||||
|
||||
// ─── Exports ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -351,6 +365,12 @@ export function evaluateWorktreeBaseDegrade(deps?: {
|
||||
|
||||
// b. Resolve HEAD sha.
|
||||
const headResult = execGit(['rev-parse', 'HEAD'], cwdOpts);
|
||||
// A TIMEOUT means the command never completed — it is not evidence of "not a
|
||||
// git repository" and must fail closed (distinct from the clean-exit-128
|
||||
// "no-head" case below, which genuinely completed and reported no HEAD).
|
||||
if (isExecGitTimeout(headResult)) {
|
||||
return { shouldDegrade: true, reason: 'head-unresolvable', message: MSG_HEAD_UNRESOLVABLE, headSha: null, forkRef: null, forkSha: null };
|
||||
}
|
||||
const headStdout = headResult.stdout ? headResult.stdout.trim() : '';
|
||||
if (headResult.exitCode !== 0 || !headStdout) {
|
||||
return { shouldDegrade: false, reason: 'no-head', message: null, headSha: null, forkRef: null, forkSha: null };
|
||||
|
||||
@@ -163,6 +163,19 @@ function resolveWorktreeContext(cwd: string, deps: WorktreeDeps = {}): WorktreeC
|
||||
|
||||
const gitDir = execGit(['rev-parse', '--git-dir'], { cwd });
|
||||
const commonDir = execGit(['rev-parse', '--git-common-dir'], { cwd });
|
||||
// A TIMEOUT means the command never completed — it is not evidence of "not a
|
||||
// git repository" (which completes fast, with a clean non-zero exit). Surface
|
||||
// it under a distinct reason so callers can tell "genuinely not a repo" apart
|
||||
// from "could not determine" (#3050). effectiveRoot still degrades to cwd
|
||||
// (there is no safer default without a resolved git-dir), but the reason is
|
||||
// no longer indistinguishable from the benign case.
|
||||
if (gitDir.timedOut || commonDir.timedOut) {
|
||||
return {
|
||||
effectiveRoot: cwd,
|
||||
mode: 'current_directory',
|
||||
reason: 'git_timed_out',
|
||||
};
|
||||
}
|
||||
if (gitDir.exitCode !== 0 || commonDir.exitCode !== 0) {
|
||||
return {
|
||||
effectiveRoot: cwd,
|
||||
@@ -1341,7 +1354,7 @@ interface WorktreeCreateCmdResult {
|
||||
* validated manifest entry so the worktree is immediately manageable by
|
||||
* `worktree cleanup-wave` / `worktree reap-orphans`.
|
||||
*
|
||||
* Usage: worktree create --manifest <path> --agent-id <id> --path <worktree> --branch <branch> --base <sha>
|
||||
* Usage: worktree create --manifest <path> --agent-id <id> --path <worktree> --branch <branch> --base <sha> --root <dir>
|
||||
*
|
||||
* #2584 FIX 1 — ORDERING CONTRACT: every manifest read/parse/shape-validate/
|
||||
* plan step runs BEFORE the git side effect (step 5). The ONLY manifest
|
||||
@@ -1362,7 +1375,7 @@ function cmdWorktreeCreate(cwd: string, args: string[] = [], deps: RecordAgentCm
|
||||
|
||||
const manifestPath = flag('--manifest');
|
||||
if (!manifestPath) {
|
||||
writeErr('Usage: worktree create --manifest <path> --agent-id <id> --path <worktree> --branch <branch> --base <sha> [--root <dir>]\n');
|
||||
writeErr('Usage: worktree create --manifest <path> --agent-id <id> --path <worktree> --branch <branch> --base <sha> --root <dir>\n');
|
||||
process.exitCode = 2;
|
||||
return { ok: false, reason: 'usage' };
|
||||
}
|
||||
@@ -1435,25 +1448,38 @@ function cmdWorktreeCreate(cwd: string, args: string[] = [], deps: RecordAgentCm
|
||||
return { ok: false, reason: plan.reason, hint: plan.hint };
|
||||
}
|
||||
|
||||
// 3b. Optional root confinement (#2627, Phase 3 — the confinement Phase 2
|
||||
// deferred here from planWorktreeCreate's path-traversal guard).
|
||||
// planWorktreeCreate rejects a literal ".." SEGMENT, but a plain absolute
|
||||
// path outside the project contains no ".." and passes. Phase 3 makes the
|
||||
// orchestrator SPAWN executor processes into these paths, so an
|
||||
// unconfined --path is a write primitive aimed anywhere on the filesystem.
|
||||
// 3b. Mandatory root confinement (#2627 Phase 3 introduced it; #3050 made it
|
||||
// mandatory — the confinement Phase 2 deferred here from
|
||||
// planWorktreeCreate's path-traversal guard). planWorktreeCreate rejects a
|
||||
// literal ".." SEGMENT, but a plain absolute path outside the project
|
||||
// contains no ".." and passes. The orchestrator SPAWNS executor processes
|
||||
// into these paths, so an unconfined --path is a write primitive aimed
|
||||
// anywhere on the filesystem.
|
||||
//
|
||||
// The root is DECLARED by the caller (`--root`) rather than inferred: agent
|
||||
// worktrees legitimately live outside the orchestrator's own root (a lane
|
||||
// orchestrator creates siblings under the repo's .claude/worktrees/), so
|
||||
// there is no layout this module could derive without guessing. Absent
|
||||
// `--root` the behavior is exactly as shipped in Phase 2 — the
|
||||
// orchestrator-worktree scheduler path always passes it.
|
||||
// there is no layout this module could derive without guessing.
|
||||
//
|
||||
// Lexical by design: the worktree does not exist yet, so there is nothing
|
||||
// to realpath, and resolving only the root would not close a symlinked-leaf
|
||||
// hole. Pairs with the leading-dash and ".."-segment guards above.
|
||||
//
|
||||
// #3050: confinement does not depend on the caller remembering to pass
|
||||
// `--root` — it used to be silently skippable, so a caller that forgot the
|
||||
// flag got an unconfined `--path` with no warning. Fail closed instead:
|
||||
// absent `--root`, this verb refuses to create anything. The one current
|
||||
// caller (execute-phase's orchestrator-worktree dispatch) always passes
|
||||
// `--root`, so this closes the gap without breaking it.
|
||||
const rootFlag = flag('--root');
|
||||
if (rootFlag) {
|
||||
if (!rootFlag) {
|
||||
const hint = '--root is required (fail-closed root confinement, #3050). Pass --root <orchestrator-root-dir> so worktree.create can verify --path resolves inside it before creating anything.';
|
||||
writeErr(`[gsd] worktree.create: root_required — ${hint}\n`);
|
||||
write(`${JSON.stringify({ ok: false, reason: 'root_required', hint }, null, 2)}\n`);
|
||||
process.exitCode = 1;
|
||||
return { ok: false, reason: 'root_required', hint };
|
||||
}
|
||||
{
|
||||
const absRoot = path.resolve(cwd, rootFlag);
|
||||
const absWorktree = path.resolve(cwd, plan.entry.worktree_path);
|
||||
const rel = path.relative(absRoot, absWorktree);
|
||||
|
||||
Reference in New Issue
Block a user