* feat(#52): add agent_skills_security.trusted_global_roots allowlist Opt-in allowlist so a global: agent skill whose SKILL.md realpath resolves outside the default global skills base (e.g. ~/.claude/skills) is accepted when its real target lies under a user-declared trusted root. Default [] is byte-identical to prior behavior; the symlink-escape guard is preserved and simply re-applied against each declared root. - src/security.cts: loadTrustedGlobalRoots — tilde-expand (~ and ~/), reject project-relative and dangerously broad roots (filesystem/UNC root, homedir), realpath-canonicalize each root every run and drop non-existent ones. - src/init.cts: on base-check failure the guard consults the trusted roots (hoisted out of the loop); emits a stderr NOTE when a skill is accepted via a trusted root so the widened boundary is visible. - src/core.cts: thread agent_skills_security through loadConfig. - config-schema.manifest.json: allow the new key path. - docs/CONFIGURATION.md: document the option and its security model. - tests/agent-skills.test.cjs: unit + end-to-end CLI coverage (regression, feature, negative, broad-root hardening, stderr NOTE). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs(#52): add changeset fragment for trusted_global_roots (#754) 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:
@@ -19,6 +19,7 @@
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
// ─── Path Traversal Prevention ──────────────────────────────────────────────
|
||||
@@ -75,6 +76,74 @@ export function validatePath(filePath: unknown, baseDir: unknown, opts: { allowA
|
||||
return { safe: true, resolved: resolvedPath };
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the opt-in trusted global roots allowlist from config.
|
||||
*
|
||||
* Reads `config.agent_skills_security.trusted_global_roots` (an array of
|
||||
* path strings). Each entry is canonicalized via realpathSync: non-strings
|
||||
* are dropped, leading `~/` is expanded to `os.homedir()`, entries that are
|
||||
* not absolute after expansion are dropped (project-relative paths are
|
||||
* rejected as a security boundary), and entries that do not exist on disk are
|
||||
* dropped (a non-existent root is not trustworthy). The canonical realpath is
|
||||
* used for all subsequent checks and as the stored value — this closes the
|
||||
* case-insensitive bypass on macOS APFS (`/users/alice` vs `/Users/alice`)
|
||||
* and ensures trust doesn't drift across re-invocations if a root is
|
||||
* re-created at a different target. Results are de-duplicated by canonical path.
|
||||
*/
|
||||
export function loadTrustedGlobalRoots(config: unknown): string[] {
|
||||
const roots = (config as Record<string, unknown> | null | undefined)
|
||||
?.['agent_skills_security'] as Record<string, unknown> | undefined;
|
||||
const raw = roots?.['trusted_global_roots'];
|
||||
if (!Array.isArray(raw)) return [];
|
||||
|
||||
// Compute canonical homedir once for case-insensitive-safe comparison.
|
||||
let realHome: string;
|
||||
try {
|
||||
realHome = fs.realpathSync(os.homedir());
|
||||
} catch {
|
||||
realHome = os.homedir();
|
||||
}
|
||||
|
||||
const seen = new Set<string>();
|
||||
const result: string[] = [];
|
||||
for (const entry of raw) {
|
||||
if (typeof entry !== 'string') continue;
|
||||
let expanded: string;
|
||||
if (entry === '~') {
|
||||
expanded = os.homedir();
|
||||
} else if (entry.startsWith('~/')) {
|
||||
expanded = path.join(os.homedir(), entry.slice(2));
|
||||
} else {
|
||||
expanded = entry;
|
||||
}
|
||||
if (!path.isAbsolute(expanded)) continue; // reject project-relative
|
||||
|
||||
// Canonicalize: resolve symlinks and normalise case. If the path doesn't
|
||||
// exist or can't be read, skip it — a non-existent root is not trustworthy.
|
||||
let real: string;
|
||||
try {
|
||||
real = fs.realpathSync(expanded);
|
||||
} catch {
|
||||
continue; // non-existent or unreadable — skip
|
||||
}
|
||||
|
||||
// Reject dangerously broad roots: filesystem root (e.g. '/' or 'C:\' or UNC '\\server\share').
|
||||
// Normalize both sides by stripping trailing path separators before comparing so that
|
||||
// Windows UNC shares (where path.parse().root includes a trailing separator) are caught.
|
||||
const stripTrailingSep = (p: string): string => p.replace(/[\\/]+$/, '');
|
||||
if (stripTrailingSep(path.parse(real).root) === stripTrailingSep(real)) continue;
|
||||
// Reject homedir itself (canonical compare closes case-insensitive bypass).
|
||||
// Apply stripTrailingSep for robustness on platforms where realpathSync may
|
||||
// or may not include a trailing separator on the homedir path.
|
||||
if (stripTrailingSep(real) === stripTrailingSep(realHome)) continue;
|
||||
|
||||
if (seen.has(real)) continue;
|
||||
seen.add(real);
|
||||
result.push(real);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a file path and throw on traversal attempt.
|
||||
* Convenience wrapper around validatePath for use in CLI commands.
|
||||
|
||||
Reference in New Issue
Block a user