* 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:
@@ -364,6 +364,7 @@ const {
|
||||
resolveRuntimeArtifactLayout,
|
||||
} = require(path.join(_gsdLibDir, 'runtime-artifact-layout.cjs'));
|
||||
const {
|
||||
assertDestWithinConfigHome,
|
||||
createRuntimeArtifactInstallPlan,
|
||||
createRuntimeArtifactUninstallPlan,
|
||||
} = require(path.join(_gsdLibDir, 'runtime-artifact-install-plan.cjs'));
|
||||
@@ -6633,13 +6634,20 @@ function migrateLegacyDevPreferencesToSkill(targetDir, saved, runtime, scope = '
|
||||
const skillsKindEntry = layout.kinds.find((k) => k.kind === 'skills');
|
||||
if (!skillsKindEntry) return false; // runtime has no skills layout at this scope (e.g. cline local)
|
||||
const stemName = skillsKindEntry.prefix === '' ? 'dev-preferences' : 'gsd-dev-preferences';
|
||||
skillDir = path.join(targetDir, skillsKindEntry.destSubpath, stemName);
|
||||
skillDir = path.join(assertDestWithinConfigHome(targetDir, skillsKindEntry.destSubpath), stemName);
|
||||
} else {
|
||||
// Legacy fallback for callers that have not yet been updated to pass runtime
|
||||
skillDir = path.join(targetDir, 'skills', 'gsd-dev-preferences');
|
||||
skillDir = path.join(assertDestWithinConfigHome(targetDir, 'skills'), 'gsd-dev-preferences');
|
||||
}
|
||||
const skillFile = path.join(skillDir, 'SKILL.md');
|
||||
if (fs.existsSync(skillFile)) return false;
|
||||
// Symlink-escape guard: reject if any path component between targetDir and
|
||||
// skillDir is a symlink that would redirect writes outside the config root.
|
||||
if (hasExistingSymlinkBetween(path.resolve(targetDir), skillDir)) {
|
||||
throw new Error(
|
||||
`migrateLegacyDevPreferencesToSkill: skillDir "${skillDir}" contains a symlink escaping the install root "${targetDir}" — refusing to write`,
|
||||
);
|
||||
}
|
||||
try {
|
||||
fs.mkdirSync(skillDir, { recursive: true });
|
||||
fs.writeFileSync(skillFile, saved.get('dev-preferences.md'), 'utf8');
|
||||
@@ -6726,7 +6734,26 @@ const _stampNonClaudeRuntimeDefaults = runtimeArtifactConversion._stampNonClaude
|
||||
* - agents: write as-is (files already carry their own `gsd-` prefix).
|
||||
* For kimi-agents kind: recursively copy generated YAML/prompt files.
|
||||
*/
|
||||
function _copyStaged(stagedDir, destDir, kind) {
|
||||
function _copyStaged(stagedDir, destDir, kind, configDir) {
|
||||
// Defense-in-depth: verify destDir is within the install root even if the
|
||||
// upstream assertDestWithinConfigHome check was somehow bypassed. This guards
|
||||
// the actual write site against any future call-site drift.
|
||||
if (configDir !== undefined) {
|
||||
const resolvedDest = path.resolve(destDir);
|
||||
const resolvedRoot = path.resolve(configDir);
|
||||
if (resolvedDest === resolvedRoot || !resolvedDest.startsWith(resolvedRoot + path.sep)) {
|
||||
throw new Error(
|
||||
`_copyStaged: destDir "${destDir}" must be strictly inside the install root "${configDir}", not the root itself — refusing to write`,
|
||||
);
|
||||
}
|
||||
// Symlink-escape guard: reject if any path component between configDir and
|
||||
// destDir is a symlink that would redirect writes outside configDir.
|
||||
if (hasExistingSymlinkBetween(resolvedRoot, resolvedDest)) {
|
||||
throw new Error(
|
||||
`_copyStaged: destDir "${destDir}" contains a symlink escaping the install root "${configDir}" — refusing to write`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!fs.existsSync(stagedDir)) return;
|
||||
fs.mkdirSync(destDir, { recursive: true });
|
||||
|
||||
@@ -7040,6 +7067,14 @@ function installRuntimeArtifacts(runtime, configDir, scope, resolvedProfile) {
|
||||
const kind = kindsByName.get(item.kind);
|
||||
if (!kind) throw new Error(`Install plan returned unknown artifact kind: ${item.kind}`);
|
||||
const dest = item.destDir;
|
||||
// Symlink-escape guard: reject before mkdir if dest (or any component
|
||||
// between configDir and dest) is a symlink pointing outside configDir.
|
||||
// mkdirSync follows symlinks, so this must run BEFORE the mkdir call.
|
||||
if (hasExistingSymlinkBetween(path.resolve(configDir), dest)) {
|
||||
throw new Error(
|
||||
`installRuntimeArtifacts: destDir "${dest}" contains a symlink escaping the install root "${configDir}" — refusing to create`,
|
||||
);
|
||||
}
|
||||
fs.mkdirSync(dest, { recursive: true });
|
||||
if (kind.kind === 'skills' && fs.existsSync(dest)) {
|
||||
// Pre-prune: snapshot user-owned content before _removeGsdEntries wipes it,
|
||||
@@ -7066,7 +7101,7 @@ function installRuntimeArtifacts(runtime, configDir, scope, resolvedProfile) {
|
||||
}
|
||||
|
||||
_removeGsdEntries(dest, kind);
|
||||
_copyStaged(item.sourceDir, dest, kind);
|
||||
_copyStaged(item.sourceDir, dest, kind, configDir);
|
||||
|
||||
// Restore user-owned dirs after the prune+copy
|
||||
for (const [dirName, snap] of toPreserve) {
|
||||
@@ -7076,7 +7111,7 @@ function installRuntimeArtifacts(runtime, configDir, scope, resolvedProfile) {
|
||||
// For non-skills kinds (commands, agents): no user content to preserve;
|
||||
// just prune stale gsd-* entries and copy new ones.
|
||||
_removeGsdEntries(dest, kind);
|
||||
_copyStaged(item.sourceDir, dest, kind);
|
||||
_copyStaged(item.sourceDir, dest, kind, configDir);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
@@ -7140,7 +7175,14 @@ function installOpencodeFamilySkills(runtime, targetDir, rawCommandsDir, pathPre
|
||||
? convertClaudeCommandToKiloSkill
|
||||
: convertClaudeCommandToOpencodeSkill;
|
||||
|
||||
const dest = path.join(targetDir, skillsKindEntry.destSubpath);
|
||||
const dest = assertDestWithinConfigHome(targetDir, skillsKindEntry.destSubpath);
|
||||
// Symlink-escape guard: reject if any path component between targetDir and
|
||||
// dest is a symlink that would redirect writes outside the config root.
|
||||
if (hasExistingSymlinkBetween(path.resolve(targetDir), dest)) {
|
||||
throw new Error(
|
||||
`installOpencodeFamilySkills: destDir "${dest}" contains a symlink escaping the install root "${targetDir}" — refusing to write`,
|
||||
);
|
||||
}
|
||||
fs.mkdirSync(dest, { recursive: true });
|
||||
|
||||
// Preserve user-owned GSD-prefixed skill dirs across the gsd-* prune.
|
||||
@@ -12322,6 +12364,7 @@ module.exports = {
|
||||
// runtimeArtifactConversion spread (#1559).
|
||||
processAttribution,
|
||||
applyRuntimeContentRewritesForCommandsInPlace,
|
||||
_copyStaged,
|
||||
};
|
||||
|
||||
// Main logic — only run when not loaded as a module for testing
|
||||
|
||||
Reference in New Issue
Block a user