feat(#52): add agent_skills_security.trusted_global_roots allowlist for global skills (#754)

* 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:
Tom Boucher
2026-06-07 01:06:41 -04:00
committed by GitHub
parent ad1203f9d4
commit f7e902f1cf
7 changed files with 574 additions and 7 deletions

View File

@@ -536,6 +536,7 @@ function loadConfig(cwd: string, options: Record<string, unknown> = {}): Record<
effort: (parsed['effort']) || null,
fast_mode: (parsed['fast_mode']) || null,
agent_skills: (parsed['agent_skills']) || {},
agent_skills_security: (parsed['agent_skills_security']) || null,
manager: (parsed['manager']) || {},
response_language: get('response_language') || null,
claude_md_path: get('claude_md_path') || null,

View File

@@ -21,7 +21,7 @@ import { stateExtractField } from './state-document.cjs';
import { formatGsdSlash, resolveRuntime } from './runtime-slash.cjs';
// eslint-disable-next-line @typescript-eslint/no-require-imports -- commands.cjs is an export= CommonJS module
import commandsMod = require('./commands.cjs');
import { validatePath } from './security.cjs';
import { validatePath, loadTrustedGlobalRoots } from './security.cjs';
import { getGlobalSkillDir, getGlobalSkillDisplayPath, getGlobalSkillsBase } from './runtime-homes.cjs';
// eslint-disable-next-line @typescript-eslint/no-require-imports -- frontmatter.cjs is an export= CommonJS module
import frontmatterMod = require('./frontmatter.cjs');
@@ -1870,6 +1870,12 @@ function buildAgentSkillsBlock(
if (typeof skillPaths === 'string') skillPaths = [skillPaths];
if (!Array.isArray(skillPaths) || skillPaths.length === 0) return '';
// Hoist trusted roots computation before the loop: loadTrustedGlobalRoots does
// realpathSync I/O and should run at most once per call, not once per failing skill.
// It returns [] cheaply when no roots are configured, so the realpath cost only
// occurs when the caller has actually set trusted_global_roots.
const trustedGlobalRoots = loadTrustedGlobalRoots(config);
const validPaths: { ref: string; display: string }[] = [];
for (const skillPath of skillPaths) {
if (typeof skillPath !== 'string') continue;
@@ -1905,10 +1911,17 @@ function buildAgentSkillsBlock(
}
const pathCheck = validatePath(globalSkillMd, globalSkillsBase, { allowAbsolute: true }) as unknown as Record<string, unknown>;
if (!pathCheck['safe']) {
process.stderr.write(
`[agent-skills] WARNING: Global skill "${skillName}" failed path check (symlink escape?) — skipping\n`,
);
continue;
const acceptedViaTrustedRoot = trustedGlobalRoots.some((root) => {
const rootCheck = validatePath(globalSkillMd, root, { allowAbsolute: true }) as unknown as Record<string, unknown>;
return Boolean(rootCheck['safe']);
});
if (!acceptedViaTrustedRoot) {
process.stderr.write(
`[agent-skills] WARNING: Global skill "${skillName}" failed path check (symlink escape?) — skipping\n`,
);
continue;
}
process.stderr.write(`[agent-skills] NOTE: Global skill "${skillName}" accepted via trusted_global_roots (resolves outside the default skills dir)\n`);
}
validPaths.push({ ref: `${globalSkillDir}/SKILL.md`, display: displayPath });
continue;

View File

@@ -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.