* feat(#1679): confine install writes within configHome ADR-1239 Phase B write-confinement: a pure assertDestWithinConfigHome(configDir, destSubpath) rejects a destSubpath that escapes configHome (path traversal / NUL byte) at plan-build time on BOTH the install and uninstall plan paths; surface.applySurface and installOpencodeFamilySkills route through it, and _copyStaged carries a defense-in-depth containment check. Security-load-bearing for the Phase C third-party-descriptor loader. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs(#1704): add changeset for destSubpath write-confinement Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(#1704): fix windows path-portability in confinement test The N1 'accepts a true child subpath' assertion compared against path.join (no drive resolution) while the helper uses path.resolve — on Windows that mismatches the C: drive prefix. Compute the expected via path.resolve to mirror the helper. Windows-CI-only failure (local gsd-test is Mac+Linux). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -87,6 +87,35 @@ interface CreateRuntimeArtifactInstallPlanArgs {
|
||||
deps?: Dependencies;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that `destSubpath` resolves to a path inside `configDir`.
|
||||
*
|
||||
* Rejects any path that escapes the configDir root (e.g. "../../etc") and any
|
||||
* path containing a NUL byte. This is a security gate for Phase B of
|
||||
* ADR-1239: third-party descriptors must never be able to write outside the
|
||||
* designated config home directory.
|
||||
*
|
||||
* @param configDir - The root config directory (e.g. ~/.claude).
|
||||
* @param destSubpath - The relative path declared by the runtime descriptor.
|
||||
* @returns The resolved absolute path under configDir.
|
||||
* @throws {Error} if destSubpath escapes configDir or contains a NUL byte.
|
||||
*/
|
||||
function assertDestWithinConfigHome(configDir: string, destSubpath: string): string {
|
||||
if (destSubpath.includes('\0')) {
|
||||
throw new Error(
|
||||
`destSubpath "${destSubpath}" contains a NUL byte and is not valid`,
|
||||
);
|
||||
}
|
||||
const root = path.resolve(configDir);
|
||||
const resolved = path.resolve(configDir, destSubpath);
|
||||
if (resolved === root || !resolved.startsWith(root + path.sep)) {
|
||||
throw new Error(
|
||||
`destSubpath "${destSubpath}" must be a strict subpath of configHome "${configDir}" — not configHome itself or outside it (escapes configHome)`,
|
||||
);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function errorMessage(err: unknown): string {
|
||||
if (err instanceof Error) return err.message;
|
||||
return String(err);
|
||||
@@ -146,7 +175,7 @@ function createRuntimeArtifactInstallPlan(args: CreateRuntimeArtifactInstallPlan
|
||||
items.push({
|
||||
kind: kind.kind,
|
||||
sourceDir,
|
||||
destDir: path.join(layout.configDir, kind.destSubpath),
|
||||
destDir: assertDestWithinConfigHome(layout.configDir, kind.destSubpath),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -157,9 +186,9 @@ function createRuntimeArtifactUninstallPlan(layout: Layout): UninstallPlan {
|
||||
return {
|
||||
items: layout.kinds.map((kind) => ({
|
||||
kind: kind.kind,
|
||||
destDir: path.join(layout.configDir, kind.destSubpath),
|
||||
destDir: assertDestWithinConfigHome(layout.configDir, kind.destSubpath),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export = { createRuntimeArtifactInstallPlan, createRuntimeArtifactUninstallPlan };
|
||||
export = { assertDestWithinConfigHome, createRuntimeArtifactInstallPlan, createRuntimeArtifactUninstallPlan };
|
||||
|
||||
@@ -45,6 +45,9 @@ import runtimeArtifactLayout = require('./runtime-artifact-layout.cjs');
|
||||
const { findInstallSourceRoot } = runtimeArtifactLayout;
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import runtimeArtifactConversion = require('./runtime-artifact-conversion.cjs');
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import runtimeArtifactInstallPlan = require('./runtime-artifact-install-plan.cjs');
|
||||
const { assertDestWithinConfigHome } = runtimeArtifactInstallPlan;
|
||||
|
||||
const SURFACE_FILE_NAME = '.gsd-surface.json';
|
||||
|
||||
@@ -341,7 +344,7 @@ function applySurface(runtimeConfigDir: string, layout: Layout, manifest: Map<st
|
||||
tempDirsToClean.push(rewritten);
|
||||
}
|
||||
}
|
||||
const dest = path.join(layout.configDir, kind.destSubpath);
|
||||
const dest = assertDestWithinConfigHome(layout.configDir, kind.destSubpath);
|
||||
_syncGsdDir(staged, dest, kind, skillManifest);
|
||||
}
|
||||
} finally {
|
||||
|
||||
Reference in New Issue
Block a user