File size: 62,254 Bytes
064bfd6 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 987 988 989 990 991 992 993 994 995 996 997 998 999 1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 1017 1018 1019 1020 1021 1022 1023 1024 1025 1026 1027 1028 1029 1030 1031 1032 1033 1034 1035 1036 1037 1038 1039 1040 1041 1042 1043 1044 1045 1046 1047 1048 1049 1050 1051 1052 1053 1054 1055 1056 1057 1058 1059 1060 1061 1062 1063 1064 1065 1066 1067 1068 1069 1070 1071 1072 1073 1074 1075 1076 1077 1078 1079 1080 1081 1082 1083 1084 1085 1086 1087 1088 1089 1090 1091 1092 1093 1094 1095 1096 1097 1098 1099 1100 1101 1102 1103 1104 1105 1106 1107 1108 1109 1110 1111 1112 1113 1114 1115 1116 1117 1118 1119 1120 1121 1122 1123 1124 1125 1126 1127 1128 1129 1130 1131 1132 1133 1134 1135 1136 1137 1138 1139 1140 1141 1142 1143 1144 1145 1146 1147 1148 1149 1150 1151 1152 1153 1154 1155 1156 1157 1158 1159 1160 1161 1162 1163 1164 1165 1166 1167 1168 1169 1170 1171 1172 1173 1174 1175 1176 1177 1178 1179 1180 1181 1182 1183 1184 1185 1186 1187 1188 1189 1190 1191 1192 1193 1194 1195 1196 1197 1198 1199 1200 1201 1202 1203 1204 1205 1206 1207 1208 1209 1210 1211 1212 1213 1214 1215 1216 1217 1218 1219 1220 1221 1222 1223 1224 1225 1226 1227 1228 1229 1230 1231 1232 1233 1234 1235 1236 1237 1238 1239 1240 1241 1242 1243 1244 1245 1246 1247 1248 1249 1250 1251 1252 1253 1254 1255 1256 1257 1258 1259 1260 1261 1262 1263 1264 1265 1266 1267 1268 1269 1270 1271 1272 1273 1274 1275 1276 1277 1278 1279 1280 1281 1282 1283 1284 1285 1286 1287 1288 1289 1290 1291 1292 1293 1294 1295 1296 1297 1298 1299 1300 1301 1302 1303 1304 1305 1306 1307 1308 1309 1310 1311 1312 1313 1314 1315 1316 1317 1318 1319 1320 1321 1322 1323 1324 1325 1326 1327 1328 1329 1330 1331 1332 1333 1334 1335 1336 1337 1338 1339 1340 1341 1342 1343 1344 1345 1346 1347 1348 1349 1350 1351 1352 1353 1354 1355 1356 1357 1358 1359 1360 1361 1362 1363 1364 1365 1366 1367 1368 1369 1370 1371 1372 1373 1374 1375 1376 1377 1378 1379 1380 1381 1382 1383 1384 1385 1386 1387 1388 1389 1390 1391 1392 1393 1394 1395 1396 1397 1398 1399 1400 1401 1402 1403 1404 1405 1406 1407 1408 1409 1410 1411 1412 1413 1414 1415 1416 1417 1418 1419 1420 1421 1422 1423 1424 1425 1426 1427 1428 1429 1430 1431 1432 1433 1434 1435 1436 1437 1438 1439 1440 1441 1442 1443 1444 1445 1446 1447 1448 1449 1450 1451 1452 1453 1454 1455 1456 1457 1458 1459 1460 1461 1462 1463 1464 1465 1466 1467 1468 1469 1470 1471 1472 1473 1474 1475 1476 1477 1478 1479 1480 1481 1482 1483 1484 1485 1486 1487 1488 1489 1490 1491 1492 1493 1494 1495 1496 1497 1498 1499 1500 1501 1502 1503 1504 1505 1506 1507 1508 1509 1510 1511 1512 1513 1514 1515 1516 1517 1518 1519 1520 1521 1522 1523 1524 1525 1526 1527 1528 1529 1530 1531 1532 1533 1534 1535 1536 1537 1538 1539 1540 1541 1542 1543 1544 1545 1546 1547 1548 1549 1550 1551 1552 1553 1554 1555 1556 1557 1558 1559 1560 1561 1562 1563 1564 1565 1566 1567 1568 1569 1570 1571 1572 1573 1574 1575 1576 1577 1578 1579 1580 1581 1582 1583 1584 1585 1586 1587 1588 1589 1590 1591 1592 1593 1594 1595 1596 1597 1598 1599 1600 1601 1602 1603 1604 1605 1606 1607 1608 1609 1610 1611 1612 1613 1614 1615 1616 1617 1618 1619 1620 1621 1622 1623 1624 1625 1626 1627 1628 1629 1630 1631 1632 1633 1634 1635 1636 1637 1638 1639 1640 1641 1642 1643 1644 1645 1646 1647 1648 1649 1650 1651 1652 1653 1654 1655 1656 1657 1658 1659 1660 1661 1662 1663 1664 1665 1666 1667 1668 1669 1670 1671 1672 1673 1674 1675 1676 1677 1678 1679 1680 1681 1682 1683 1684 1685 1686 1687 1688 1689 1690 1691 1692 1693 1694 1695 1696 1697 1698 1699 1700 1701 1702 1703 1704 1705 1706 1707 1708 1709 1710 1711 1712 1713 1714 1715 1716 1717 1718 1719 1720 1721 1722 1723 1724 1725 1726 1727 1728 1729 1730 1731 1732 1733 1734 1735 1736 1737 1738 1739 1740 1741 1742 1743 1744 1745 1746 1747 1748 1749 1750 1751 1752 1753 1754 1755 1756 1757 1758 1759 1760 1761 1762 1763 1764 1765 1766 1767 1768 1769 1770 1771 1772 1773 1774 1775 1776 1777 1778 | import { feature } from 'bun:bundle'
import { randomBytes } from 'crypto'
import ignore from 'ignore'
import memoize from 'lodash-es/memoize.js'
import { homedir, tmpdir } from 'os'
import { join, normalize, posix, sep } from 'path'
import { hasAutoMemPathOverride, isAutoMemPath } from 'src/memdir/paths.js'
import { isAgentMemoryPath } from 'src/tools/AgentTool/agentMemory.js'
import {
CLAUDE_FOLDER_PERMISSION_PATTERN,
FILE_EDIT_TOOL_NAME,
GLOBAL_CLAUDE_FOLDER_PERMISSION_PATTERN,
} from 'src/tools/FileEditTool/constants.js'
import type { z } from 'zod/v4'
import { getOriginalCwd, getSessionId } from '../../bootstrap/state.js'
import { checkStatsigFeatureGate_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js'
import type { AnyObject, Tool, ToolPermissionContext } from '../../Tool.js'
import { FILE_READ_TOOL_NAME } from '../../tools/FileReadTool/prompt.js'
import { getCwd } from '../cwd.js'
import { getClaudeConfigHomeDir } from '../envUtils.js'
import {
getFsImplementation,
getPathsForPermissionCheck,
} from '../fsOperations.js'
import {
containsPathTraversal,
expandPath,
getDirectoryForPath,
sanitizePath,
} from '../path.js'
import { getPlanSlug, getPlansDirectory } from '../plans.js'
import { getPlatform } from '../platform.js'
import { getProjectDir } from '../sessionStorage.js'
import { SETTING_SOURCES } from '../settings/constants.js'
import {
getSettingsFilePathForSource,
getSettingsRootPathForSource,
} from '../settings/settings.js'
import { containsVulnerableUncPath } from '../shell/readOnlyCommandValidation.js'
import { getToolResultsDir } from '../toolResultStorage.js'
import { windowsPathToPosixPath } from '../windowsPaths.js'
import type {
PermissionDecision,
PermissionResult,
} from './PermissionResult.js'
import type { PermissionRule, PermissionRuleSource } from './PermissionRule.js'
import { createReadRuleSuggestion } from './PermissionUpdate.js'
import type { PermissionUpdate } from './PermissionUpdateSchema.js'
import { getRuleByContentsForToolName } from './permissions.js'
declare const MACRO: { VERSION: string }
/**
* Dangerous files that should be protected from auto-editing.
* These files can be used for code execution or data exfiltration.
*/
export const DANGEROUS_FILES = [
'.gitconfig',
'.gitmodules',
'.bashrc',
'.bash_profile',
'.zshrc',
'.zprofile',
'.profile',
'.ripgreprc',
'.mcp.json',
'.claude.json',
] as const
/**
* Dangerous directories that should be protected from auto-editing.
* These directories contain sensitive configuration or executable files.
*/
export const DANGEROUS_DIRECTORIES = [
'.git',
'.vscode',
'.idea',
'.claude',
] as const
/**
* Normalizes a path for case-insensitive comparison.
* This prevents bypassing security checks using mixed-case paths on case-insensitive
* filesystems (macOS/Windows) like `.cLauDe/Settings.locaL.json`.
*
* We always normalize to lowercase regardless of platform for consistent security.
* @param path The path to normalize
* @returns The lowercase path for safe comparison
*/
export function normalizeCaseForComparison(path: string): string {
return path.toLowerCase()
}
/**
* If filePath is inside a .claude/skills/{name}/ directory (project or global),
* return the skill name and a session-allow pattern scoped to just that skill.
* Used to offer a narrower "allow edits to this skill only" option in the
* permission dialog and SDK suggestions, so iterating on one skill doesn't
* require granting session access to all of .claude/ (settings.json, hooks/, etc.).
*/
export function getClaudeSkillScope(
filePath: string,
): { skillName: string; pattern: string } | null {
const absolutePath = expandPath(filePath)
const absolutePathLower = normalizeCaseForComparison(absolutePath)
const bases = [
{
dir: expandPath(join(getOriginalCwd(), '.claude', 'skills')),
prefix: '/.claude/skills/',
},
{
dir: expandPath(join(homedir(), '.claude', 'skills')),
prefix: '~/.claude/skills/',
},
]
for (const { dir, prefix } of bases) {
const dirLower = normalizeCaseForComparison(dir)
// Try both path separators (Windows paths may not be normalized to /)
for (const s of [sep, '/']) {
if (absolutePathLower.startsWith(dirLower + s.toLowerCase())) {
// Match on lowercase, but slice the ORIGINAL path so the skill name
// preserves case (pattern matching downstream is case-sensitive)
const rest = absolutePath.slice(dir.length + s.length)
const slash = rest.indexOf('/')
const bslash = sep === '\\' ? rest.indexOf('\\') : -1
const cut =
slash === -1
? bslash
: bslash === -1
? slash
: Math.min(slash, bslash)
// Require a separator: file must be INSIDE the skill dir, not a
// file directly under skills/ (no skill scope for that)
if (cut <= 0) return null
const skillName = rest.slice(0, cut)
// Reject traversal and empty. Use includes('..') not === '..' to
// match step 1.6's ruleContent.includes('..') guard: a skillName like
// 'v2..beta' would otherwise produce a suggestion step 1.7 emits but
// step 1.6 always rejects (dead suggestion, infinite re-prompt).
if (!skillName || skillName === '.' || skillName.includes('..')) {
return null
}
// Reject glob metacharacters. skillName is interpolated into a
// gitignore pattern consumed by ignore().add() in matchingRuleForInput
// at step 1.6. A directory literally named '*' (valid on POSIX) would
// produce '/.claude/skills/*/**' which matches ALL skills. Return null
// to fall through to generateSuggestions() instead.
if (/[*?[\]]/.test(skillName)) return null
return { skillName, pattern: prefix + skillName + '/**' }
}
}
}
return null
}
// Always use / as the path separator per gitignore spec
// https://git-scm.com/docs/gitignore
const DIR_SEP = posix.sep
/**
* Cross-platform relative path calculation that returns POSIX-style paths.
* Handles Windows path conversion internally.
* @param from The base path
* @param to The target path
* @returns A POSIX-style relative path
*/
export function relativePath(from: string, to: string): string {
if (getPlatform() === 'windows') {
// Convert Windows paths to POSIX for consistent comparison
const posixFrom = windowsPathToPosixPath(from)
const posixTo = windowsPathToPosixPath(to)
return posix.relative(posixFrom, posixTo)
}
// Use POSIX paths directly
return posix.relative(from, to)
}
/**
* Converts a path to POSIX format for pattern matching.
* Handles Windows path conversion internally.
* @param path The path to convert
* @returns A POSIX-style path
*/
export function toPosixPath(path: string): string {
if (getPlatform() === 'windows') {
return windowsPathToPosixPath(path)
}
return path
}
function getSettingsPaths(): string[] {
return SETTING_SOURCES.map(source =>
getSettingsFilePathForSource(source),
).filter(path => path !== undefined)
}
export function isClaudeSettingsPath(filePath: string): boolean {
// SECURITY: Normalize path structure first to prevent bypass via redundant ./
// sequences like `./.claude/./settings.json` which would evade the endsWith() check
const expandedPath = expandPath(filePath)
// Normalize for case-insensitive comparison to prevent bypassing security
// with paths like .cLauDe/Settings.locaL.json
const normalizedPath = normalizeCaseForComparison(expandedPath)
// Use platform separator so endsWith checks work on both Unix (/) and Windows (\)
if (
normalizedPath.endsWith(`${sep}.claude${sep}settings.json`) ||
normalizedPath.endsWith(`${sep}.claude${sep}settings.local.json`)
) {
// Include .claude/settings.json even for other projects
return true
}
// Check for current project's settings files (including managed settings and CLI args)
// Both paths are now absolute and normalized for consistent comparison
return getSettingsPaths().some(
settingsPath => normalizeCaseForComparison(settingsPath) === normalizedPath,
)
}
// Always ask when Claude Code tries to edit its own config files
function isClaudeConfigFilePath(filePath: string): boolean {
if (isClaudeSettingsPath(filePath)) {
return true
}
// Check if file is within .claude/commands or .claude/agents directories
// using proper path segment validation (not string matching with includes())
// pathInWorkingPath now handles case-insensitive comparison to prevent bypasses
const commandsDir = join(getOriginalCwd(), '.claude', 'commands')
const agentsDir = join(getOriginalCwd(), '.claude', 'agents')
const skillsDir = join(getOriginalCwd(), '.claude', 'skills')
return (
pathInWorkingPath(filePath, commandsDir) ||
pathInWorkingPath(filePath, agentsDir) ||
pathInWorkingPath(filePath, skillsDir)
)
}
// Check if file is the plan file for the current session
function isSessionPlanFile(absolutePath: string): boolean {
// Check if path is a plan file for this session (main or agent-specific)
// Main plan file: {plansDir}/{planSlug}.md
// Agent plan file: {plansDir}/{planSlug}-agent-{agentId}.md
const expectedPrefix = join(getPlansDirectory(), getPlanSlug())
// SECURITY: Normalize to prevent path traversal bypasses via .. segments
const normalizedPath = normalize(absolutePath)
return (
normalizedPath.startsWith(expectedPrefix) && normalizedPath.endsWith('.md')
)
}
/**
* Returns the session memory directory path for the current session with trailing separator.
* Path format: {projectDir}/{sessionId}/session-memory/
*/
export function getSessionMemoryDir(): string {
return join(getProjectDir(getCwd()), getSessionId(), 'session-memory') + sep
}
/**
* Returns the session memory file path for the current session.
* Path format: {projectDir}/{sessionId}/session-memory/summary.md
*/
export function getSessionMemoryPath(): string {
return join(getSessionMemoryDir(), 'summary.md')
}
// Check if file is within the session memory directory
function isSessionMemoryPath(absolutePath: string): boolean {
// SECURITY: Normalize to prevent path traversal bypasses via .. segments
const normalizedPath = normalize(absolutePath)
return normalizedPath.startsWith(getSessionMemoryDir())
}
/**
* Check if file is within the current project's directory.
* Path format: ~/.claude/projects/{sanitized-cwd}/...
*/
function isProjectDirPath(absolutePath: string): boolean {
const projectDir = getProjectDir(getCwd())
// SECURITY: Normalize to prevent path traversal bypasses via .. segments
const normalizedPath = normalize(absolutePath)
return (
normalizedPath === projectDir || normalizedPath.startsWith(projectDir + sep)
)
}
/**
* Checks if the scratchpad directory feature is enabled.
* The scratchpad is a per-session directory for Claude to write temporary files.
* Controlled by the tengu_scratch Statsig gate.
*/
export function isScratchpadEnabled(): boolean {
return checkStatsigFeatureGate_CACHED_MAY_BE_STALE('tengu_scratch')
}
/**
* Returns the user-specific Claude temp directory name.
* On Unix: 'claude-{uid}' to prevent multi-user permission conflicts
* On Windows: 'claude' (tmpdir() is already per-user)
*/
export function getClaudeTempDirName(): string {
if (getPlatform() === 'windows') {
return 'claude'
}
// Use UID to create per-user directories, preventing permission conflicts
// when multiple users share the same /tmp directory
const uid = process.getuid?.() ?? 0
return `claude-${uid}`
}
/**
* Returns the Claude temp directory path with symlinks resolved.
* Uses TMPDIR env var if set, otherwise:
* - On Unix: /tmp/claude-{uid}/ (resolved to /private/tmp/claude-{uid}/ on macOS)
* - On Windows: {tmpdir}/claude/ (e.g., C:\Users\{user}\AppData\Local\Temp\claude\)
* This is a per-user temporary directory used by Claude Code for all temp files.
*
* NOTE: We resolve symlinks to ensure this path matches the resolved paths used
* in permission checks. On macOS, /tmp is a symlink to /private/tmp, so without
* resolution, paths like /tmp/claude-{uid}/... wouldn't match /private/tmp/claude-{uid}/...
*/
// Memoized: called per-tool from permission checks (yoloClassifier, sandbox-adapter)
// and per-turn from BashTool prompt. Inputs (CLAUDE_CODE_TMPDIR env + platform) are
// fixed at startup, and the realpath of the system tmp dir does not change mid-session.
export const getClaudeTempDir = memoize(function getClaudeTempDir(): string {
const baseTmpDir =
process.env.CLAUDE_CODE_TMPDIR ||
(getPlatform() === 'windows' ? tmpdir() : '/tmp')
// Resolve symlinks in the base temp directory (e.g., /tmp -> /private/tmp on macOS)
// This ensures the path matches resolved paths in permission checks
const fs = getFsImplementation()
let resolvedBaseTmpDir = baseTmpDir
try {
resolvedBaseTmpDir = fs.realpathSync(baseTmpDir)
} catch {
// If resolution fails, use the original path
}
return join(resolvedBaseTmpDir, getClaudeTempDirName()) + sep
})
/**
* Root for bundled-skill file extraction (see bundledSkills.ts).
*
* SECURITY: The per-process random nonce is the load-bearing defense here.
* Every other path component (uid, VERSION, skill name, file keys) is public
* knowledge, so without it a local attacker can pre-create the tree on a
* shared /tmp β sticky bit prevents deletion, not creation β and either
* symlink an intermediate directory (O_NOFOLLOW only checks the final
* component) or own a parent dir and swap file contents post-write for prompt
* injection via the read allowlist. diskOutput.ts gets the same property from
* the session-ID UUID in its path.
*
* Memoized so the extraction writes and the permission check agree on the
* path for the life of the process. Version-scoped so stale extractions from
* other binaries don't fall under the allowlist.
*/
export const getBundledSkillsRoot = memoize(
function getBundledSkillsRoot(): string {
const nonce = randomBytes(16).toString('hex')
return join(getClaudeTempDir(), 'bundled-skills', MACRO.VERSION, nonce)
},
)
/**
* Returns the project temp directory path with trailing separator.
* Path format: /tmp/claude-{uid}/{sanitized-cwd}/
*/
export function getProjectTempDir(): string {
return join(getClaudeTempDir(), sanitizePath(getOriginalCwd())) + sep
}
/**
* Returns the scratchpad directory path for the current session.
* Path format: /tmp/claude-{uid}/{sanitized-cwd}/{sessionId}/scratchpad/
*/
export function getScratchpadDir(): string {
return join(getProjectTempDir(), getSessionId(), 'scratchpad')
}
/**
* Ensures the scratchpad directory exists for the current session.
* Creates the directory with secure permissions (0o700) if it doesn't exist.
* Returns the path to the scratchpad directory.
* @throws If scratchpad feature is not enabled
*/
export async function ensureScratchpadDir(): Promise<string> {
if (!isScratchpadEnabled()) {
throw new Error('Scratchpad directory feature is not enabled')
}
const fs = getFsImplementation()
const scratchpadDir = getScratchpadDir()
// Create directory recursively with secure permissions (owner-only access)
// FsOperations.mkdir handles recursive: true internally and is a no-op if dir exists
await fs.mkdir(scratchpadDir, { mode: 0o700 })
return scratchpadDir
}
// Check if file is within the scratchpad directory
function isScratchpadPath(absolutePath: string): boolean {
if (!isScratchpadEnabled()) {
return false
}
const scratchpadDir = getScratchpadDir()
// SECURITY: Normalize the path to resolve .. segments before checking
// This prevents path traversal bypasses like:
// echo "malicious" > /tmp/claude-0/proj/session/scratchpad/../../../etc/passwd
// Without normalization, the path would pass the startsWith check but write to /etc/passwd
const normalizedPath = normalize(absolutePath)
return (
normalizedPath === scratchpadDir ||
normalizedPath.startsWith(scratchpadDir + sep)
)
}
/**
* Check if a file path is dangerous to auto-edit without explicit permission.
* This includes:
* - Files in .git directories or .gitconfig files (to prevent git-based data exfiltration and code execution)
* - Files in .vscode directories (to prevent VS Code settings manipulation and potential code execution)
* - Files in .idea directories (to prevent JetBrains IDE settings manipulation)
* - Shell configuration files (to prevent shell startup script manipulation)
* - UNC paths (to prevent network file access and WebDAV attacks)
*/
function isDangerousFilePathToAutoEdit(path: string): boolean {
const absolutePath = expandPath(path)
const pathSegments = absolutePath.split(sep)
const fileName = pathSegments.at(-1)
// Check for UNC paths (defense-in-depth to catch any patterns that might not be caught by containsVulnerableUncPath)
// Block anything starting with \\ or // as these are potentially UNC paths that could access network resources
if (path.startsWith('\\\\') || path.startsWith('//')) {
return true
}
// Check if path is within dangerous directories (case-insensitive to prevent bypasses)
for (let i = 0; i < pathSegments.length; i++) {
const segment = pathSegments[i]!
const normalizedSegment = normalizeCaseForComparison(segment)
for (const dir of DANGEROUS_DIRECTORIES) {
if (normalizedSegment !== normalizeCaseForComparison(dir)) {
continue
}
// Special case: .claude/worktrees/ is a structural path (where Claude stores
// git worktrees), not a user-created dangerous directory. Skip the .claude
// segment when it's followed by 'worktrees'. Any nested .claude directories
// within the worktree (not followed by 'worktrees') are still blocked.
if (dir === '.claude') {
const nextSegment = pathSegments[i + 1]
if (
nextSegment &&
normalizeCaseForComparison(nextSegment) === 'worktrees'
) {
break // Skip this .claude, continue checking other segments
}
}
return true
}
}
// Check for dangerous configuration files (case-insensitive)
if (fileName) {
const normalizedFileName = normalizeCaseForComparison(fileName)
if (
(DANGEROUS_FILES as readonly string[]).some(
dangerousFile =>
normalizeCaseForComparison(dangerousFile) === normalizedFileName,
)
) {
return true
}
}
return false
}
/**
* Detects suspicious Windows path patterns that could bypass security checks.
* These patterns include:
* - NTFS Alternate Data Streams (e.g., file.txt::$DATA or file.txt:stream)
* - 8.3 short names (e.g., GIT~1, CLAUDE~1, SETTIN~1.JSON)
* - Long path prefixes (e.g., \\?\C:\..., \\.\C:\..., //?/C:/..., //./C:/...)
* - Trailing dots and spaces (e.g., .git., .claude , .bashrc...)
* - DOS device names (e.g., .git.CON, settings.json.PRN, .bashrc.AUX)
* - Three or more consecutive dots (e.g., .../file.txt, path/.../file, file...txt)
*
* When detected, these paths should always require manual approval to prevent
* bypassing security checks through path canonicalization vulnerabilities.
*
* ## Why Check on All Platforms?
*
* While these patterns are primarily Windows-specific, NTFS filesystems can be
* mounted on Linux and macOS (e.g., using ntfs-3g). On these systems, the same
* bypass techniques would work - an attacker could use short names or long path
* prefixes to bypass security checks. Therefore, we check for these patterns on
* all platforms to ensure comprehensive protection. (Note: the ADS colon check
* is Windows/WSL-only, since colon syntax is only interpreted by the Windows
* kernel; on Linux/macOS, NTFS ADS is accessed via xattrs, not colon syntax.)
*
* ## Why Detection Instead of Normalization?
*
* An alternative approach would be to normalize these paths using Windows APIs
* (e.g., GetLongPathNameW). However, this approach has significant challenges:
*
* 1. **Filesystem dependency**: Short path normalization is relative to files that
* currently exist on the filesystem. This creates issues when writing to new
* files since they don't exist yet and cannot be normalized.
*
* 2. **Race conditions**: The filesystem state can change between normalization
* and actual file access, creating TOCTOU (Time-Of-Check-Time-Of-Use) vulnerabilities.
*
* 3. **Complexity**: Proper normalization requires Windows-specific APIs, handling
* multiple edge cases, and dealing with various path formats (UNC, device paths, etc.).
*
* 4. **Reliability**: Pattern detection is more predictable and doesn't depend on
* external system state.
*
* If you are considering adding normalization for these paths, please reach out to
* AppSec first to discuss the security implications and implementation approach.
*
* @param path The path to check for suspicious patterns
* @returns true if suspicious Windows path patterns are detected
*/
function hasSuspiciousWindowsPathPattern(path: string): boolean {
// Check for NTFS Alternate Data Streams
// Look for ':' after position 2 to skip drive letters (e.g., C:\)
// Examples: file.txt::$DATA, .bashrc:hidden, settings.json:stream
// Note: ADS colon syntax is only interpreted by the Windows kernel. On WSL,
// DrvFs mounts route file operations through the Windows kernel, so colon
// syntax is still interpreted as ADS separators. On Linux/macOS (non-WSL),
// even when NTFS is mounted, ADS is accessed via xattrs (ntfs-3g) not colon
// syntax, and colons are valid filename characters.
if (getPlatform() === 'windows' || getPlatform() === 'wsl') {
const colonIndex = path.indexOf(':', 2)
if (colonIndex !== -1) {
return true
}
}
// Check for 8.3 short names
// Look for '~' followed by a digit
// Examples: GIT~1, CLAUDE~1, SETTIN~1.JSON, BASHRC~1
if (/~\d/.test(path)) {
return true
}
// Check for long path prefixes (both backslash and forward slash variants)
// Examples: \\?\C:\Users\..., \\.\C:\..., //?/C:/..., //./C:/...
if (
path.startsWith('\\\\?\\') ||
path.startsWith('\\\\.\\') ||
path.startsWith('//?/') ||
path.startsWith('//./')
) {
return true
}
// Check for trailing dots and spaces that Windows strips during path resolution
// Examples: .git., .claude , .bashrc..., settings.json.
// This can bypass string matching if ".git" is blocked but ".git." is used
if (/[.\s]+$/.test(path)) {
return true
}
// Check for DOS device names that Windows treats as special devices
// Examples: .git.CON, settings.json.PRN, .bashrc.AUX
// Device names: CON, PRN, AUX, NUL, COM1-9, LPT1-9
if (/\.(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$/i.test(path)) {
return true
}
// Check for three or more consecutive dots (...) when used as a path component
// This pattern can be used to bypass security checks or create confusion
// Examples: .../file.txt, path/.../file
// Only block when dots are preceded AND followed by path separators (/ or \)
// This allows legitimate uses like Next.js catch-all routes [...]name]
if (/(^|\/|\\)\.{3,}(\/|\\|$)/.test(path)) {
return true
}
// Check for UNC paths (on all platforms for defense-in-depth)
// Examples: \\server\share, \\foo.com\file, //server/share, \\192.168.1.1\share
// UNC paths can access remote resources, leak credentials, and bypass working directory restrictions
if (containsVulnerableUncPath(path)) {
return true
}
return false
}
/**
* Checks if a path is safe for auto-editing (acceptEdits mode).
* Returns information about why the path is unsafe, or null if all checks pass.
*
* This function performs comprehensive safety checks including:
* - Suspicious Windows path patterns (NTFS streams, 8.3 names, long path prefixes, etc.)
* - Claude config files (.claude/settings.json, .claude/commands/, .claude/agents/)
* - MCP CLI state files (managed internally by Claude Code)
* - Dangerous files (.bashrc, .gitconfig, .git/, .vscode/, .idea/, etc.)
*
* IMPORTANT: This function checks BOTH the original path AND resolved symlink paths
* to prevent bypasses via symlinks pointing to protected files.
*
* @param path The path to check for safety
* @returns Object with safe=false and message if unsafe, or { safe: true } if all checks pass
*/
export function checkPathSafetyForAutoEdit(
path: string,
precomputedPathsToCheck?: readonly string[],
):
| { safe: true }
| { safe: false; message: string; classifierApprovable: boolean } {
// Get all paths to check (original + symlink resolved paths)
const pathsToCheck =
precomputedPathsToCheck ?? getPathsForPermissionCheck(path)
// Check for suspicious Windows path patterns on all paths
for (const pathToCheck of pathsToCheck) {
if (hasSuspiciousWindowsPathPattern(pathToCheck)) {
return {
safe: false,
message: `Claude requested permissions to write to ${path}, which contains a suspicious Windows path pattern that requires manual approval.`,
classifierApprovable: false,
}
}
}
// Check for Claude config files on all paths
for (const pathToCheck of pathsToCheck) {
if (isClaudeConfigFilePath(pathToCheck)) {
return {
safe: false,
message: `Claude requested permissions to write to ${path}, but you haven't granted it yet.`,
classifierApprovable: true,
}
}
}
// Check for dangerous files on all paths
for (const pathToCheck of pathsToCheck) {
if (isDangerousFilePathToAutoEdit(pathToCheck)) {
return {
safe: false,
message: `Claude requested permissions to edit ${path} which is a sensitive file.`,
classifierApprovable: true,
}
}
}
// All safety checks passed
return { safe: true }
}
export function allWorkingDirectories(
context: ToolPermissionContext,
): Set<string> {
return new Set([
getOriginalCwd(),
...context.additionalWorkingDirectories.keys(),
])
}
// Working directories are session-stable; memoize their resolved forms to
// avoid repeated existsSync/lstatSync/realpathSync syscalls on every
// permission check. Keyed by path string β getPathsForPermissionCheck is
// deterministic for existing directories within a session.
// Exported for test/preload.ts cache clearing (shard-isolation).
export const getResolvedWorkingDirPaths = memoize(getPathsForPermissionCheck)
export function pathInAllowedWorkingPath(
path: string,
toolPermissionContext: ToolPermissionContext,
precomputedPathsToCheck?: readonly string[],
): boolean {
// Check both the original path and the resolved symlink path
const pathsToCheck =
precomputedPathsToCheck ?? getPathsForPermissionCheck(path)
// Resolve working directories the same way we resolve input paths so
// comparisons are symmetric. Without this, a resolved input path
// (e.g. /System/Volumes/Data/home/... on macOS) would not match an
// unresolved working directory (/home/...), causing false denials.
const workingPaths = Array.from(
allWorkingDirectories(toolPermissionContext),
).flatMap(wp => getResolvedWorkingDirPaths(wp))
// All paths must be within allowed working paths
// If any resolved path is outside, deny access
return pathsToCheck.every(pathToCheck =>
workingPaths.some(workingPath =>
pathInWorkingPath(pathToCheck, workingPath),
),
)
}
export function pathInWorkingPath(path: string, workingPath: string): boolean {
const absolutePath = expandPath(path)
const absoluteWorkingPath = expandPath(workingPath)
// On macOS, handle common symlink issues:
// - /var -> /private/var
// - /tmp -> /private/tmp
const normalizedPath = absolutePath
.replace(/^\/private\/var\//, '/var/')
.replace(/^\/private\/tmp(\/|$)/, '/tmp$1')
const normalizedWorkingPath = absoluteWorkingPath
.replace(/^\/private\/var\//, '/var/')
.replace(/^\/private\/tmp(\/|$)/, '/tmp$1')
// Normalize case for case-insensitive comparison to prevent bypassing security
// checks on case-insensitive filesystems (macOS/Windows) like .cLauDe/CoMmAnDs
const caseNormalizedPath = normalizeCaseForComparison(normalizedPath)
const caseNormalizedWorkingPath = normalizeCaseForComparison(
normalizedWorkingPath,
)
// Use cross-platform relative path helper
const relative = relativePath(caseNormalizedWorkingPath, caseNormalizedPath)
// Same path
if (relative === '') {
return true
}
if (containsPathTraversal(relative)) {
return false
}
// Path is inside (relative path that doesn't go up)
return !posix.isAbsolute(relative)
}
function rootPathForSource(source: PermissionRuleSource): string {
switch (source) {
case 'cliArg':
case 'command':
case 'session':
return expandPath(getOriginalCwd())
case 'userSettings':
case 'policySettings':
case 'projectSettings':
case 'localSettings':
case 'flagSettings':
return getSettingsRootPathForSource(source)
}
}
function prependDirSep(path: string): string {
return posix.join(DIR_SEP, path)
}
function normalizePatternToPath({
patternRoot,
pattern,
rootPath,
}: {
patternRoot: string
pattern: string
rootPath: string
}): string | null {
// If the pattern root + pattern combination starts with our reference root
const fullPattern = posix.join(patternRoot, pattern)
if (patternRoot === rootPath) {
// If the pattern root exactly matches our reference root no need to change
return prependDirSep(pattern)
} else if (fullPattern.startsWith(`${rootPath}${DIR_SEP}`)) {
// Extract the relative part
const relativePart = fullPattern.slice(rootPath.length)
return prependDirSep(relativePart)
} else {
// Handle patterns that are inside the reference root but not starting with it
const relativePath = posix.relative(rootPath, patternRoot)
if (
!relativePath ||
relativePath.startsWith(`..${DIR_SEP}`) ||
relativePath === '..'
) {
// Pattern is outside the reference root, so it can be skipped
return null
} else {
const relativePattern = posix.join(relativePath, pattern)
return prependDirSep(relativePattern)
}
}
}
export function normalizePatternsToPath(
patternsByRoot: Map<string | null, string[]>,
root: string,
): string[] {
// null root means the pattern can match anywhere
const result = new Set(patternsByRoot.get(null) ?? [])
for (const [patternRoot, patterns] of patternsByRoot.entries()) {
if (patternRoot === null) {
// already added
continue
}
// Check each pattern to see if the full path starts with our reference root
for (const pattern of patterns) {
const normalizedPattern = normalizePatternToPath({
patternRoot,
pattern,
rootPath: root,
})
if (normalizedPattern) {
result.add(normalizedPattern)
}
}
}
return Array.from(result)
}
/**
* Collects all deny rules for file read permissions and returns their ignore patterns
* Each pattern must be resolved relative to its root (map key)
* Null keys are used for patterns that don't have a root
*
* This is used to hide files that are blocked by Read deny rules.
*
* @param toolPermissionContext
*/
export function getFileReadIgnorePatterns(
toolPermissionContext: ToolPermissionContext,
): Map<string | null, string[]> {
const patternsByRoot = getPatternsByRoot(
toolPermissionContext,
'read',
'deny',
)
const result = new Map<string | null, string[]>()
for (const [patternRoot, patternMap] of patternsByRoot.entries()) {
result.set(patternRoot, Array.from(patternMap.keys()))
}
return result
}
function patternWithRoot(
pattern: string,
source: PermissionRuleSource,
): {
relativePattern: string
root: string | null
} {
if (pattern.startsWith(`${DIR_SEP}${DIR_SEP}`)) {
// Patterns starting with // resolve relative to /
const patternWithoutDoubleSlash = pattern.slice(1)
// On Windows, check if this is a POSIX-style drive path like //c/Users/...
// Note: UNC paths (//server/share) will not match this regex and will be treated
// as root-relative patterns, which may need separate handling in the future
if (
getPlatform() === 'windows' &&
patternWithoutDoubleSlash.match(/^\/[a-z]\//i)
) {
// Convert POSIX path to Windows format
// The pattern is like /c/Users/... so we convert it to C:\Users\...
const driveLetter = patternWithoutDoubleSlash[1]?.toUpperCase() ?? 'C'
// Keep the pattern in POSIX format since relativePath returns POSIX paths
const pathAfterDrive = patternWithoutDoubleSlash.slice(2)
// Extract the drive root (C:\) and the rest of the pattern
const driveRoot = `${driveLetter}:\\`
const relativeFromDrive = pathAfterDrive.startsWith('/')
? pathAfterDrive.slice(1)
: pathAfterDrive
return {
relativePattern: relativeFromDrive,
root: driveRoot,
}
}
return {
relativePattern: patternWithoutDoubleSlash,
root: DIR_SEP,
}
} else if (pattern.startsWith(`~${DIR_SEP}`)) {
// Patterns starting with ~/ resolve relative to homedir
return {
relativePattern: pattern.slice(1),
root: homedir().normalize('NFC'),
}
} else if (pattern.startsWith(DIR_SEP)) {
// Patterns starting with / resolve relative to the directory where settings are stored (without .claude/)
return {
relativePattern: pattern,
root: rootPathForSource(source),
}
}
// No root specified, put it with all the other patterns
// Normalize patterns that start with "./" to remove the prefix
// This ensures that patterns like "./.env" match files like ".env"
let normalizedPattern = pattern
if (pattern.startsWith(`.${DIR_SEP}`)) {
normalizedPattern = pattern.slice(2)
}
return {
relativePattern: normalizedPattern,
root: null,
}
}
function getPatternsByRoot(
toolPermissionContext: ToolPermissionContext,
toolType: 'edit' | 'read',
behavior: 'allow' | 'deny' | 'ask',
): Map<string | null, Map<string, PermissionRule>> {
const toolName = (() => {
switch (toolType) {
case 'edit':
// Apply Edit tool rules to any tool editing files
return FILE_EDIT_TOOL_NAME
case 'read':
// Apply Read tool rules to any tool reading files
return FILE_READ_TOOL_NAME
}
})()
const rules = getRuleByContentsForToolName(
toolPermissionContext,
toolName,
behavior,
)
// Resolve rules relative to path based on source
const patternsByRoot = new Map<string | null, Map<string, PermissionRule>>()
for (const [pattern, rule] of rules.entries()) {
const { relativePattern, root } = patternWithRoot(pattern, rule.source)
let patternsForRoot = patternsByRoot.get(root)
if (patternsForRoot === undefined) {
patternsForRoot = new Map<string, PermissionRule>()
patternsByRoot.set(root, patternsForRoot)
}
// Store the rule keyed by the root
patternsForRoot.set(relativePattern, rule)
}
return patternsByRoot
}
export function matchingRuleForInput(
path: string,
toolPermissionContext: ToolPermissionContext,
toolType: 'edit' | 'read',
behavior: 'allow' | 'deny' | 'ask',
): PermissionRule | null {
let fileAbsolutePath = expandPath(path)
// On Windows, convert to POSIX format to match against permission patterns
if (getPlatform() === 'windows' && fileAbsolutePath.includes('\\')) {
fileAbsolutePath = windowsPathToPosixPath(fileAbsolutePath)
}
const patternsByRoot = getPatternsByRoot(
toolPermissionContext,
toolType,
behavior,
)
// Check each root for a matching pattern
for (const [root, patternMap] of patternsByRoot.entries()) {
// Transform patterns for the ignore library
const patterns = Array.from(patternMap.keys()).map(pattern => {
let adjustedPattern = pattern
// Remove /** suffix - ignore library treats 'path' as matching both
// the path itself and everything inside it
if (adjustedPattern.endsWith('/**')) {
adjustedPattern = adjustedPattern.slice(0, -3)
}
return adjustedPattern
})
const ig = ignore().add(patterns)
// Use cross-platform relative path helper for POSIX-style patterns
const relativePathStr = relativePath(
root ?? getCwd(),
fileAbsolutePath ?? getCwd(),
)
if (relativePathStr.startsWith(`..${DIR_SEP}`)) {
// The path is outside the root, so ignore it
continue
}
// Important: ig.test throws if you give it an empty string
if (!relativePathStr) {
continue
}
const igResult = ig.test(relativePathStr)
if (igResult.ignored && igResult.rule) {
// Map the matched pattern back to the original rule
const originalPattern = igResult.rule.pattern
// Check if this was a /** pattern we simplified
const withWildcard = originalPattern + '/**'
if (patternMap.has(withWildcard)) {
return patternMap.get(withWildcard) ?? null
}
return patternMap.get(originalPattern) ?? null
}
}
// No matching rule found
return null
}
/**
* Permission result for read permission for the specified tool & tool input
*/
export function checkReadPermissionForTool(
tool: Tool,
input: { [key: string]: unknown },
toolPermissionContext: ToolPermissionContext,
): PermissionDecision {
if (typeof tool.getPath !== 'function') {
return {
behavior: 'ask',
message: `Claude requested permissions to use ${tool.name}, but you haven't granted it yet.`,
}
}
const path = tool.getPath(input)
// Get paths to check (includes both original and resolved symlinks).
// Computed once here and threaded through checkWritePermissionForTool β
// checkPathSafetyForAutoEdit β pathInAllowedWorkingPath to avoid redundant
// existsSync/lstatSync/realpathSync syscalls on the same path (previously
// 6Γ = 30 syscalls per Read permission check).
const pathsToCheck = getPathsForPermissionCheck(path)
// 1. Defense-in-depth: Block UNC paths early (before other checks)
// This catches paths starting with \\ or // that could access network resources
// This may catch some UNC patterns not detected by containsVulnerableUncPath
for (const pathToCheck of pathsToCheck) {
if (pathToCheck.startsWith('\\\\') || pathToCheck.startsWith('//')) {
return {
behavior: 'ask',
message: `Claude requested permissions to read from ${path}, which appears to be a UNC path that could access network resources.`,
decisionReason: {
type: 'other',
reason: 'UNC path detected (defense-in-depth check)',
},
}
}
}
// 2. Check for suspicious Windows path patterns (defense in depth)
for (const pathToCheck of pathsToCheck) {
if (hasSuspiciousWindowsPathPattern(pathToCheck)) {
return {
behavior: 'ask',
message: `Claude requested permissions to read from ${path}, which contains a suspicious Windows path pattern that requires manual approval.`,
decisionReason: {
type: 'other',
reason:
'Path contains suspicious Windows-specific patterns (alternate data streams, short names, long path prefixes, or three or more consecutive dots) that require manual verification',
},
}
}
}
// 3. Check for READ-SPECIFIC deny rules first - check both the original path and resolved symlink path
// SECURITY: This must come before any allow checks (including "edit access implies read access")
// to prevent bypassing explicit read deny rules
for (const pathToCheck of pathsToCheck) {
const denyRule = matchingRuleForInput(
pathToCheck,
toolPermissionContext,
'read',
'deny',
)
if (denyRule) {
return {
behavior: 'deny',
message: `Permission to read ${path} has been denied.`,
decisionReason: {
type: 'rule',
rule: denyRule,
},
}
}
}
// 4. Check for READ-SPECIFIC ask rules - check both the original path and resolved symlink path
// SECURITY: This must come before implicit allow checks to ensure explicit ask rules are honored
for (const pathToCheck of pathsToCheck) {
const askRule = matchingRuleForInput(
pathToCheck,
toolPermissionContext,
'read',
'ask',
)
if (askRule) {
return {
behavior: 'ask',
message: `Claude requested permissions to read from ${path}, but you haven't granted it yet.`,
decisionReason: {
type: 'rule',
rule: askRule,
},
}
}
}
// 5. Edit access implies read access (but only if no read-specific deny/ask rules exist)
// We check this after read-specific rules so that explicit read restrictions take precedence
const editResult = checkWritePermissionForTool(
tool,
input,
toolPermissionContext,
pathsToCheck,
)
if (editResult.behavior === 'allow') {
return editResult
}
// 6. Allow reads in working directories
const isInWorkingDir = pathInAllowedWorkingPath(
path,
toolPermissionContext,
pathsToCheck,
)
if (isInWorkingDir) {
return {
behavior: 'allow',
updatedInput: input,
decisionReason: {
type: 'mode',
mode: 'default',
},
}
}
// 7. Allow reads from internal harness paths (session-memory, plans, tool-results)
const absolutePath = expandPath(path)
const internalReadResult = checkReadableInternalPath(absolutePath, input)
if (internalReadResult.behavior !== 'passthrough') {
return internalReadResult
}
// 8. Check for allow rules
const allowRule = matchingRuleForInput(
path,
toolPermissionContext,
'read',
'allow',
)
if (allowRule) {
return {
behavior: 'allow',
updatedInput: input,
decisionReason: {
type: 'rule',
rule: allowRule,
},
}
}
// 12. Default to asking for permission
// At this point, isInWorkingDir is false (from step #6), so path is outside working directories
return {
behavior: 'ask',
message: `Claude requested permissions to read from ${path}, but you haven't granted it yet.`,
suggestions: generateSuggestions(
path,
'read',
toolPermissionContext,
pathsToCheck,
),
decisionReason: {
type: 'workingDir',
reason: 'Path is outside allowed working directories',
},
}
}
/**
* Permission result for write permission for the specified tool & tool input.
*
* @param precomputedPathsToCheck - Optional cached result of
* `getPathsForPermissionCheck(tool.getPath(input))`. Callers MUST derive this
* from the same `tool` and `input` in the same synchronous frame β `path` is
* re-derived internally for error messages and internal-path checks, so a
* stale value would silently check deny rules for the wrong path.
*/
export function checkWritePermissionForTool<Input extends AnyObject>(
tool: Tool<Input>,
input: z.infer<Input>,
toolPermissionContext: ToolPermissionContext,
precomputedPathsToCheck?: readonly string[],
): PermissionDecision {
if (typeof tool.getPath !== 'function') {
return {
behavior: 'ask',
message: `Claude requested permissions to use ${tool.name}, but you haven't granted it yet.`,
}
}
const path = tool.getPath(input)
// 1. Check for deny rules - check both the original path and resolved symlink path
const pathsToCheck =
precomputedPathsToCheck ?? getPathsForPermissionCheck(path)
for (const pathToCheck of pathsToCheck) {
const denyRule = matchingRuleForInput(
pathToCheck,
toolPermissionContext,
'edit',
'deny',
)
if (denyRule) {
return {
behavior: 'deny',
message: `Permission to edit ${path} has been denied.`,
decisionReason: {
type: 'rule',
rule: denyRule,
},
}
}
}
// 1.5. Allow writes to internal editable paths (plan files, scratchpad)
// This MUST come before isDangerousFilePathToAutoEdit check since .claude is a dangerous directory
const absolutePathForEdit = expandPath(path)
const internalEditResult = checkEditableInternalPath(
absolutePathForEdit,
input,
)
if (internalEditResult.behavior !== 'passthrough') {
return internalEditResult
}
// 1.6. Check for .claude/** allow rules BEFORE safety checks
// This allows session-level permissions to bypass the safety blocks for .claude/
// We only allow this for session-level rules to prevent users from accidentally
// permanently granting broad access to their .claude/ folder.
//
// matchingRuleForInput returns the first match across all sources. If the user
// also has a broader Edit(.claude) rule in userSettings (e.g. from sandbox
// write-allow conversion), that rule would be found first and its source check
// below would fail. Scope the search to session-only rules so the dialog's
// "allow Claude to edit its own settings for this session" option actually works.
const claudeFolderAllowRule = matchingRuleForInput(
path,
{
...toolPermissionContext,
alwaysAllowRules: {
session: toolPermissionContext.alwaysAllowRules.session ?? [],
},
},
'edit',
'allow',
)
if (claudeFolderAllowRule) {
// Check if this rule is scoped under .claude/ (project or global).
// Accepts both the broad patterns ('/.claude/**', '~/.claude/**') and
// narrowed ones like '/.claude/skills/my-skill/**' so users can grant
// session access to a single skill without also exposing settings.json
// or hooks/. The rule already matched the path via matchingRuleForInput;
// this is an additional scope check. Reject '..' to prevent a rule like
// '/.claude/../**' from leaking this bypass outside .claude/.
const ruleContent = claudeFolderAllowRule.ruleValue.ruleContent
if (
ruleContent &&
(ruleContent.startsWith(CLAUDE_FOLDER_PERMISSION_PATTERN.slice(0, -2)) ||
ruleContent.startsWith(
GLOBAL_CLAUDE_FOLDER_PERMISSION_PATTERN.slice(0, -2),
)) &&
!ruleContent.includes('..') &&
ruleContent.endsWith('/**')
) {
return {
behavior: 'allow',
updatedInput: input,
decisionReason: {
type: 'rule',
rule: claudeFolderAllowRule,
},
}
}
}
// 1.7. Check comprehensive safety validations (Windows patterns, Claude config, dangerous files)
// This MUST come before checking allow rules to prevent users from accidentally granting
// permission to edit protected files
const safetyCheck = checkPathSafetyForAutoEdit(path, pathsToCheck)
if (!safetyCheck.safe) {
// SDK suggestion: if under .claude/skills/{name}/, emit the narrowed
// session-scoped addRules that step 1.6 will honor on the next call.
// Everything else (.claude/settings.json, .git/, .vscode/, .idea/) falls
// back to generateSuggestions β its setMode suggestion doesn't bypass
// this check, but preserving it avoids a surprising empty array.
const skillScope = getClaudeSkillScope(path)
const safetySuggestions: PermissionUpdate[] = skillScope
? [
{
type: 'addRules',
rules: [
{
toolName: FILE_EDIT_TOOL_NAME,
ruleContent: skillScope.pattern,
},
],
behavior: 'allow',
destination: 'session',
},
]
: generateSuggestions(path, 'write', toolPermissionContext, pathsToCheck)
return {
behavior: 'ask',
message: safetyCheck.message,
suggestions: safetySuggestions,
decisionReason: {
type: 'safetyCheck',
reason: safetyCheck.message,
classifierApprovable: safetyCheck.classifierApprovable,
},
}
}
// 2. Check for ask rules - check both the original path and resolved symlink path
for (const pathToCheck of pathsToCheck) {
const askRule = matchingRuleForInput(
pathToCheck,
toolPermissionContext,
'edit',
'ask',
)
if (askRule) {
return {
behavior: 'ask',
message: `Claude requested permissions to write to ${path}, but you haven't granted it yet.`,
decisionReason: {
type: 'rule',
rule: askRule,
},
}
}
}
// 3. If in acceptEdits or sandboxBashMode mode, allow all writes in original cwd
const isInWorkingDir = pathInAllowedWorkingPath(
path,
toolPermissionContext,
pathsToCheck,
)
if (toolPermissionContext.mode === 'acceptEdits' && isInWorkingDir) {
return {
behavior: 'allow',
updatedInput: input,
decisionReason: {
type: 'mode',
mode: toolPermissionContext.mode,
},
}
}
// 4. Check for allow rules
const allowRule = matchingRuleForInput(
path,
toolPermissionContext,
'edit',
'allow',
)
if (allowRule) {
return {
behavior: 'allow',
updatedInput: input,
decisionReason: {
type: 'rule',
rule: allowRule,
},
}
}
// 5. Default to asking for permission
return {
behavior: 'ask',
message: `Claude requested permissions to write to ${path}, but you haven't granted it yet.`,
suggestions: generateSuggestions(
path,
'write',
toolPermissionContext,
pathsToCheck,
),
decisionReason: !isInWorkingDir
? {
type: 'workingDir',
reason: 'Path is outside allowed working directories',
}
: undefined,
}
}
export function generateSuggestions(
filePath: string,
operationType: 'read' | 'write' | 'create',
toolPermissionContext: ToolPermissionContext,
precomputedPathsToCheck?: readonly string[],
): PermissionUpdate[] {
const isOutsideWorkingDir = !pathInAllowedWorkingPath(
filePath,
toolPermissionContext,
precomputedPathsToCheck,
)
if (operationType === 'read' && isOutsideWorkingDir) {
// For read operations outside working directories, add Read rules
// IMPORTANT: Include both the symlink path and resolved path so subsequent checks pass
const dirPath = getDirectoryForPath(filePath)
const dirsToAdd = getPathsForPermissionCheck(dirPath)
const suggestions = dirsToAdd
.map(dir => createReadRuleSuggestion(dir, 'session'))
.filter((s): s is PermissionUpdate => s !== undefined)
return suggestions
}
// Only suggest setMode:acceptEdits when it would be an upgrade. In auto
// mode the classifier already auto-approves edits; in bypassPermissions
// everything is allowed; in acceptEdits it's a no-op. Suggesting it
// anyway and having the SDK host apply it on "Always allow" silently
// downgrades auto β acceptEdits, which then prompts for MCP/Bash.
const shouldSuggestAcceptEdits =
toolPermissionContext.mode === 'default' ||
toolPermissionContext.mode === 'plan'
if (operationType === 'write' || operationType === 'create') {
const updates: PermissionUpdate[] = shouldSuggestAcceptEdits
? [{ type: 'setMode', mode: 'acceptEdits', destination: 'session' }]
: []
if (isOutsideWorkingDir) {
// For write operations outside working directories, also add the directory
// IMPORTANT: Include both the symlink path and resolved path so subsequent checks pass
const dirPath = getDirectoryForPath(filePath)
const dirsToAdd = getPathsForPermissionCheck(dirPath)
updates.push({
type: 'addDirectories',
directories: dirsToAdd,
destination: 'session',
})
}
return updates
}
// For read operations inside working directories, just change mode
return shouldSuggestAcceptEdits
? [{ type: 'setMode', mode: 'acceptEdits', destination: 'session' }]
: []
}
/**
* Check if a path is an internal path that can be edited without permission.
* Returns a PermissionResult - either 'allow' if matched, or 'passthrough' to continue checking.
*/
export function checkEditableInternalPath(
absolutePath: string,
input: { [key: string]: unknown },
): PermissionResult {
// SECURITY: Normalize path to prevent traversal bypasses via .. segments
// This is defense-in-depth; individual helper functions also normalize
const normalizedPath = normalize(absolutePath)
// Plan files for current session
if (isSessionPlanFile(normalizedPath)) {
return {
behavior: 'allow',
updatedInput: input,
decisionReason: {
type: 'other',
reason: 'Plan files for current session are allowed for writing',
},
}
}
// Scratchpad directory for current session
if (isScratchpadPath(normalizedPath)) {
return {
behavior: 'allow',
updatedInput: input,
decisionReason: {
type: 'other',
reason: 'Scratchpad files for current session are allowed for writing',
},
}
}
// Template job's own directory. Env key hardcoded (vs importing JOB_ENV_KEY
// from jobs/state) so tree-shaking eliminates the string from external
// builds β spawn.test.ts asserts the string matches. Hijack guard: the env
// var value must itself resolve under ~/.claude/jobs/. Symlink guard: every
// resolved form of the target (lexical + symlink chain) must fall under some
// resolved form of the job dir, so a symlink inside the job dir pointing at
// e.g. ~/.ssh/authorized_keys does not get a free write. Resolving both
// sides handles the macOS /tmp β /private/tmp case where the config dir
// lives under a symlinked root.
if (feature('TEMPLATES')) {
const jobDir = process.env.CLAUDE_JOB_DIR
if (jobDir) {
const jobsRoot = join(getClaudeConfigHomeDir(), 'jobs')
const jobDirForms = getPathsForPermissionCheck(jobDir).map(normalize)
const jobsRootForms = getPathsForPermissionCheck(jobsRoot).map(normalize)
// Hijack guard: every resolved form of the job dir must sit under
// some resolved form of the jobs root. Resolving both sides handles
// the case where ~/.claude is a symlink (e.g. to /data/claude-config).
const isUnderJobsRoot = jobDirForms.every(jd =>
jobsRootForms.some(jr => jd.startsWith(jr + sep)),
)
if (isUnderJobsRoot) {
const targetForms = getPathsForPermissionCheck(absolutePath)
const allInsideJobDir = targetForms.every(p => {
const np = normalize(p)
return jobDirForms.some(jd => np === jd || np.startsWith(jd + sep))
})
if (allInsideJobDir) {
return {
behavior: 'allow',
updatedInput: input,
decisionReason: {
type: 'other',
reason:
'Job directory files for current job are allowed for writing',
},
}
}
}
}
}
// Agent memory directory (for self-improving agents)
if (isAgentMemoryPath(normalizedPath)) {
return {
behavior: 'allow',
updatedInput: input,
decisionReason: {
type: 'other',
reason: 'Agent memory files are allowed for writing',
},
}
}
// Memdir directory (persistent memory for cross-session learning)
// This pre-safety-check carve-out exists because the default path is under
// ~/.claude/, which is in DANGEROUS_DIRECTORIES. The CLAUDE_COWORK_MEMORY_PATH_OVERRIDE
// override is an arbitrary caller-designated directory with no such conflict,
// so it gets NO special permission treatment here β writes go through normal
// permission flow (step 5 β ask). SDK callers who want silent memory should
// pass an allow rule for the override path.
if (!hasAutoMemPathOverride() && isAutoMemPath(normalizedPath)) {
return {
behavior: 'allow',
updatedInput: input,
decisionReason: {
type: 'other',
reason: 'auto memory files are allowed for writing',
},
}
}
// .claude/launch.json β desktop preview config (dev server command + port).
// The desktop's preview_start MCP tool instructs Claude to create/update
// this file as part of the preview workflow. Without this carve-out the
// .claude/ DANGEROUS_DIRECTORIES check prompts for it, which in SDK mode
// cascades: user clicks "Always allow" β setMode:acceptEdits suggestion
// applied β silent downgrade from auto mode. Matches the project-level
// .claude/ only (not ~/.claude/) since launch.json is per-project.
if (
normalizeCaseForComparison(normalizedPath) ===
normalizeCaseForComparison(join(getOriginalCwd(), '.claude', 'launch.json'))
) {
return {
behavior: 'allow',
updatedInput: input,
decisionReason: {
type: 'other',
reason: 'Preview launch config is allowed for writing',
},
}
}
return { behavior: 'passthrough', message: '' }
}
/**
* Check if a path is an internal path that can be read without permission.
* Returns a PermissionResult - either 'allow' if matched, or 'passthrough' to continue checking.
*/
export function checkReadableInternalPath(
absolutePath: string,
input: { [key: string]: unknown },
): PermissionResult {
// SECURITY: Normalize path to prevent traversal bypasses via .. segments
// This is defense-in-depth; individual helper functions also normalize
const normalizedPath = normalize(absolutePath)
// Session memory directory
if (isSessionMemoryPath(normalizedPath)) {
return {
behavior: 'allow',
updatedInput: input,
decisionReason: {
type: 'other',
reason: 'Session memory files are allowed for reading',
},
}
}
// Project directory (for reading past session memories)
// Path format: ~/.claude/projects/{sanitized-cwd}/...
if (isProjectDirPath(normalizedPath)) {
return {
behavior: 'allow',
updatedInput: input,
decisionReason: {
type: 'other',
reason: 'Project directory files are allowed for reading',
},
}
}
// Plan files for current session
if (isSessionPlanFile(normalizedPath)) {
return {
behavior: 'allow',
updatedInput: input,
decisionReason: {
type: 'other',
reason: 'Plan files for current session are allowed for reading',
},
}
}
// Tool results directory (persisted large outputs)
// Use path separator suffix to prevent path traversal (e.g., tool-results-evil/)
const toolResultsDir = getToolResultsDir()
const toolResultsDirWithSep = toolResultsDir.endsWith(sep)
? toolResultsDir
: toolResultsDir + sep
if (
normalizedPath === toolResultsDir ||
normalizedPath.startsWith(toolResultsDirWithSep)
) {
return {
behavior: 'allow',
updatedInput: input,
decisionReason: {
type: 'other',
reason: 'Tool result files are allowed for reading',
},
}
}
// Scratchpad directory for current session
if (isScratchpadPath(normalizedPath)) {
return {
behavior: 'allow',
updatedInput: input,
decisionReason: {
type: 'other',
reason: 'Scratchpad files for current session are allowed for reading',
},
}
}
// Project temp directory (/tmp/claude/{sanitized-cwd}/)
// Intentionally allows reading files from all sessions in this project, not just the current session.
// This enables cross-session file access within the same project's temp space.
const projectTempDir = getProjectTempDir()
if (normalizedPath.startsWith(projectTempDir)) {
return {
behavior: 'allow',
updatedInput: input,
decisionReason: {
type: 'other',
reason: 'Project temp directory files are allowed for reading',
},
}
}
// Agent memory directory (for self-improving agents)
if (isAgentMemoryPath(normalizedPath)) {
return {
behavior: 'allow',
updatedInput: input,
decisionReason: {
type: 'other',
reason: 'Agent memory files are allowed for reading',
},
}
}
// Memdir directory (persistent memory for cross-session learning)
if (isAutoMemPath(normalizedPath)) {
return {
behavior: 'allow',
updatedInput: input,
decisionReason: {
type: 'other',
reason: 'auto memory files are allowed for reading',
},
}
}
// Tasks directory (~/.claude/tasks/) for swarm task coordination
const tasksDir = join(getClaudeConfigHomeDir(), 'tasks') + sep
if (
normalizedPath === tasksDir.slice(0, -1) ||
normalizedPath.startsWith(tasksDir)
) {
return {
behavior: 'allow',
updatedInput: input,
decisionReason: {
type: 'other',
reason: 'Task files are allowed for reading',
},
}
}
// Teams directory (~/.claude/teams/) for swarm coordination
const teamsReadDir = join(getClaudeConfigHomeDir(), 'teams') + sep
if (
normalizedPath === teamsReadDir.slice(0, -1) ||
normalizedPath.startsWith(teamsReadDir)
) {
return {
behavior: 'allow',
updatedInput: input,
decisionReason: {
type: 'other',
reason: 'Team files are allowed for reading',
},
}
}
// Bundled skill reference files extracted on first invocation.
// SECURITY: See getBundledSkillsRoot() β the per-process nonce in the path
// is the load-bearing defense; uid/VERSION alone are public knowledge and
// squattable. We always write-before-read on invocation, so content under
// this subtree is harness-controlled.
const bundledSkillsRoot = getBundledSkillsRoot() + sep
if (normalizedPath.startsWith(bundledSkillsRoot)) {
return {
behavior: 'allow',
updatedInput: input,
decisionReason: {
type: 'other',
reason: 'Bundled skill reference files are allowed for reading',
},
}
}
return { behavior: 'passthrough', message: '' }
}
|