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