(
absolutePath: string,
input: { [key: string]: unknown },
)
| 1477 | * Returns a PermissionResult - either 'allow' if matched, or 'passthrough' to continue checking. |
| 1478 | */ |
| 1479 | export function checkEditableInternalPath( |
| 1480 | absolutePath: string, |
| 1481 | input: { [key: string]: unknown }, |
| 1482 | ): PermissionResult { |
| 1483 | // SECURITY: Normalize path to prevent traversal bypasses via .. segments |
| 1484 | // This is defense-in-depth; individual helper functions also normalize |
| 1485 | const normalizedPath = normalize(absolutePath) |
| 1486 | |
| 1487 | // Plan files for current session |
| 1488 | if (isSessionPlanFile(normalizedPath)) { |
| 1489 | return { |
| 1490 | behavior: 'allow', |
| 1491 | updatedInput: input, |
| 1492 | decisionReason: { |
| 1493 | type: 'other', |
| 1494 | reason: 'Plan files for current session are allowed for writing', |
| 1495 | }, |
| 1496 | } |
| 1497 | } |
| 1498 | |
| 1499 | // Scratchpad directory for current session |
| 1500 | if (isScratchpadPath(normalizedPath)) { |
| 1501 | return { |
| 1502 | behavior: 'allow', |
| 1503 | updatedInput: input, |
| 1504 | decisionReason: { |
| 1505 | type: 'other', |
| 1506 | reason: 'Scratchpad files for current session are allowed for writing', |
| 1507 | }, |
| 1508 | } |
| 1509 | } |
| 1510 | |
| 1511 | // Template job's own directory. Env key hardcoded (vs importing JOB_ENV_KEY |
| 1512 | // from jobs/state) so tree-shaking eliminates the string from external |
| 1513 | // builds — spawn.test.ts asserts the string matches. Hijack guard: the env |
| 1514 | // var value must itself resolve under ~/.claude/jobs/. Symlink guard: every |
| 1515 | // resolved form of the target (lexical + symlink chain) must fall under some |
| 1516 | // resolved form of the job dir, so a symlink inside the job dir pointing at |
| 1517 | // e.g. ~/.ssh/authorized_keys does not get a free write. Resolving both |
| 1518 | // sides handles the macOS /tmp → /private/tmp case where the config dir |
| 1519 | // lives under a symlinked root. |
| 1520 | if (feature('TEMPLATES')) { |
| 1521 | const jobDir = process.env.CLAUDE_JOB_DIR |
| 1522 | if (jobDir) { |
| 1523 | const jobsRoot = join(getClaudeConfigHomeDir(), 'jobs') |
| 1524 | const jobDirForms = getPathsForPermissionCheck(jobDir).map(normalize) |
| 1525 | const jobsRootForms = getPathsForPermissionCheck(jobsRoot).map(normalize) |
| 1526 | // Hijack guard: every resolved form of the job dir must sit under |
| 1527 | // some resolved form of the jobs root. Resolving both sides handles |
| 1528 | // the case where ~/.claude is a symlink (e.g. to /data/claude-config). |
| 1529 | const isUnderJobsRoot = jobDirForms.every(jd => |
| 1530 | jobsRootForms.some(jr => jd.startsWith(jr + sep)), |
| 1531 | ) |
| 1532 | if (isUnderJobsRoot) { |
| 1533 | const targetForms = getPathsForPermissionCheck(absolutePath) |
| 1534 | const allInsideJobDir = targetForms.every(p => { |
| 1535 | const np = normalize(p) |
| 1536 | return jobDirForms.some(jd => np === jd || np.startsWith(jd + sep)) |
no test coverage detected