{"version":3,"file":"permission-gate.d.ts","sourceRoot":"","sources":["../../../src/extensions/core/permission-gate.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAGH,OAAO,KAAK,EACX,YAAY,EAIZ,MAAM,gCAAgC,CAAC;AAuGxC,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,YAAY,GAAG,IAAI,CA6H3D","sourcesContent":["/**\n * Permission gate — prompts before bash/write/edit/webfetch/websearch; checks\n * modes.{mode}.auto_allow from the merged (global + project) config; persists\n * \"always\" choices back to the global config. Hard enforcement (denied tools,\n * enabled_tools allowlists, bash command patterns, .webtoolsignore hosts)\n * applies even without a UI.\n */\n\nimport { isAbsolute, relative } from \"node:path\";\nimport type {\n\tExtensionAPI,\n\tExtensionContext,\n\tToolCallEvent,\n\tToolCallEventResult,\n} from \"../../core/extensions/types.js\";\nimport { isToolCallEventType } from \"../../core/extensions/types.js\";\nimport { blockedHostForUrl } from \"../../core/tools/webtools-shared.js\";\nimport { readConfig, readMergedConfig, writeConfig } from \"./config.js\";\n\nconst GATED_TOOLS = new Set([\"bash\", \"write\", \"edit\", \"webfetch\", \"websearch\"]);\n\n/**\n * Checks if a file path matches any of the mode's `allowed_write_paths`.\n * Supports glob patterns with `*` and exact paths.\n *\n * Both sides are normalized to forward slashes first. Patterns are written with\n * `/`, but the paths a model is handed come from `relative()`, which yields\n * backslashes on Windows — and because this check runs *before* `auto_allow`, a\n * non-match is a hard block rather than a prompt. Without normalizing, a mode\n * that sets `allowed_write_paths` could not write anything at all on Windows.\n *\n * An absolute path is also tried in its cwd-relative form, so a model that\n * resolves the relative path it was given still matches. A target outside cwd\n * relativizes to `../…` and correctly fails to match.\n */\nfunction matchesAllowedPath(filePath: string, allowedPatterns: string[], cwd: string): boolean {\n\tif (allowedPatterns.length === 0) return true;\n\tif (!filePath) return false;\n\n\tconst toPosix = (value: string): string => value.replace(/\\\\/g, \"/\");\n\n\tconst candidates = new Set<string>([toPosix(filePath)]);\n\tif (isAbsolute(filePath)) {\n\t\tconst rel = toPosix(relative(cwd, filePath));\n\t\tif (rel) candidates.add(rel);\n\t}\n\n\tfor (const pattern of allowedPatterns) {\n\t\tconst normalizedPattern = toPosix(pattern);\n\t\tif (candidates.has(normalizedPattern)) return true;\n\t\tif (!normalizedPattern.includes(\"*\")) continue;\n\t\t// Escape regex metacharacters before letting `*` mean \"any run of chars\",\n\t\t// so a pattern like `.hoocode/plans/*` cannot also match `Xhoocode/...`.\n\t\tconst source = `^${normalizedPattern.replace(/[.+?^${}()|[\\]\\\\]/g, \"\\\\$&\").replace(/\\*/g, \".*\")}$`;\n\t\tlet regex: RegExp;\n\t\ttry {\n\t\t\tregex = new RegExp(source);\n\t\t} catch {\n\t\t\tcontinue;\n\t\t}\n\t\tfor (const candidate of candidates) {\n\t\t\tif (regex.test(candidate)) return true;\n\t\t}\n\t}\n\treturn false;\n}\n\n/**\n * Tests a bash command string against a regex pattern string.\n * Returns false (no match) if the pattern is an invalid regex.\n */\nfunction matchesBashPattern(pattern: string, command: string): boolean {\n\ttry {\n\t\treturn new RegExp(pattern).test(command);\n\t} catch {\n\t\treturn false;\n\t}\n}\n\n/**\n * The path an edit/write call targets.\n *\n * Both tools declare the argument as `path`; `file_path` is accepted only\n * because some models emit that name and the renderers have long taken either.\n * Reading `file_path` alone matched neither tool's schema, so the confirmation\n * prompt read \"edit (unknown)\" and `allowed_write_paths` compared against an\n * empty string - which matches no pattern, blocking every write in any mode\n * that configured one.\n */\nfunction mutationPath(input: unknown): string | undefined {\n\tconst args = input as { path?: unknown; file_path?: unknown } | undefined;\n\tif (typeof args?.path === \"string\" && args.path.length > 0) return args.path;\n\tif (typeof args?.file_path === \"string\" && args.file_path.length > 0) return args.file_path;\n\treturn undefined;\n}\n\nfunction describeTool(event: ToolCallEvent): string {\n\tif (isToolCallEventType(\"bash\", event)) {\n\t\treturn `$ ${event.input.command.replace(/\\s+/g, \" \").slice(0, 100)}`;\n\t}\n\tif (isToolCallEventType(\"edit\", event)) {\n\t\treturn `edit ${mutationPath(event.input) ?? \"(unknown)\"}`;\n\t}\n\tif (isToolCallEventType(\"write\", event)) {\n\t\treturn `write ${mutationPath(event.input) ?? \"(unknown)\"}`;\n\t}\n\tif (event.toolName === \"webfetch\") {\n\t\tconst url = (event.input as { url?: string }).url ?? \"(unknown)\";\n\t\treturn `webfetch ${url}`;\n\t}\n\tif (event.toolName === \"websearch\") {\n\t\tconst query = (event.input as { query?: string }).query ?? \"(unknown)\";\n\t\treturn `websearch \"${query}\"`;\n\t}\n\treturn event.toolName;\n}\n\nexport function setupPermissionGate(hoo: ExtensionAPI): void {\n\thoo.on(\n\t\t\"tool_call\",\n\t\tasync (event: ToolCallEvent, ctx: ExtensionContext): Promise<ToolCallEventResult | undefined> => {\n\t\t\t// Use the merged config so project-local entries are respected\n\t\t\tconst config = readMergedConfig(ctx.cwd);\n\t\t\tconst mode = config.active_mode ?? \"build\";\n\t\t\tconst modeCfg = config.modes?.[mode];\n\n\t\t\t// ── Hard enforcement (always applies, regardless of UI) ───────────────────\n\n\t\t\t// Explicitly denied tools are blocked unconditionally\n\t\t\tif (modeCfg?.denied_tools?.includes(event.toolName)) {\n\t\t\t\treturn {\n\t\t\t\t\tblock: true,\n\t\t\t\t\treason: `Tool \"${event.toolName}\" is denied in mode \"${mode}\".`,\n\t\t\t\t};\n\t\t\t}\n\n\t\t\t// enabled_tools acts as a strict allowlist: only listed tools may execute\n\t\t\tif (\n\t\t\t\tmodeCfg?.enabled_tools &&\n\t\t\t\tmodeCfg.enabled_tools.length > 0 &&\n\t\t\t\t!modeCfg.enabled_tools.includes(event.toolName)\n\t\t\t) {\n\t\t\t\treturn {\n\t\t\t\t\tblock: true,\n\t\t\t\t\treason:\n\t\t\t\t\t\t`Tool \"${event.toolName}\" is not enabled in mode \"${mode}\" ` +\n\t\t\t\t\t\t`(enabled: ${modeCfg.enabled_tools.join(\", \")}).`,\n\t\t\t\t};\n\t\t\t}\n\n\t\t\t// Bash command-level filtering\n\t\t\tif (isToolCallEventType(\"bash\", event)) {\n\t\t\t\tconst command = (event.input as { command?: string }).command ?? \"\";\n\n\t\t\t\t// denied_bash_commands: block if any pattern matches\n\t\t\t\tif (modeCfg?.denied_bash_commands?.length) {\n\t\t\t\t\tfor (const pattern of modeCfg.denied_bash_commands) {\n\t\t\t\t\t\tif (matchesBashPattern(pattern, command)) {\n\t\t\t\t\t\t\treturn {\n\t\t\t\t\t\t\t\tblock: true,\n\t\t\t\t\t\t\t\treason: `Bash command matches a denied pattern in mode \"${mode}\": ${pattern}`,\n\t\t\t\t\t\t\t};\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\t// allowed_bash_commands: block unless at least one pattern matches\n\t\t\t\tif (modeCfg?.allowed_bash_commands?.length) {\n\t\t\t\t\tconst permitted = modeCfg.allowed_bash_commands.some((p) => matchesBashPattern(p, command));\n\t\t\t\t\tif (!permitted) {\n\t\t\t\t\t\treturn {\n\t\t\t\t\t\t\tblock: true,\n\t\t\t\t\t\t\treason:\n\t\t\t\t\t\t\t\t`Bash command is not permitted in mode \"${mode}\". ` +\n\t\t\t\t\t\t\t\t`Allowed patterns: ${modeCfg.allowed_bash_commands.join(\", \")}`,\n\t\t\t\t\t\t};\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// webfetch host policy (.webtoolsignore). Hard enforcement, always applies:\n\t\t\t// a blocked host is denied even in headless runs. SSRF/private-address\n\t\t\t// blocking lives in the webtools binary; this is host allow/deny policy only.\n\t\t\tif (event.toolName === \"webfetch\") {\n\t\t\t\tconst url = (event.input as { url?: string }).url ?? \"\";\n\t\t\t\tconst blockedHost = url ? blockedHostForUrl(ctx.cwd, url) : undefined;\n\t\t\t\tif (blockedHost) {\n\t\t\t\t\treturn {\n\t\t\t\t\t\tblock: true,\n\t\t\t\t\t\treason: `Host \"${blockedHost}\" is blocked by .webtoolsignore policy.`,\n\t\t\t\t\t};\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// ── UI-based permission prompting (interactive sessions only) ─────────────\n\n\t\t\tif (!GATED_TOOLS.has(event.toolName) || !ctx.hasUI) return;\n\n\t\t\tconst autoAllow = modeCfg?.auto_allow ?? [];\n\n\t\t\t// Check allowed_write_paths for write/edit operations\n\t\t\tif ((event.toolName === \"write\" || event.toolName === \"edit\") && modeCfg?.allowed_write_paths) {\n\t\t\t\t// Absent path stays blocked: an unidentifiable write cannot be checked\n\t\t\t\t// against an allowlist, and refusing is the safe direction.\n\t\t\t\tconst filePath = mutationPath(event.input) ?? \"\";\n\t\t\t\tif (!matchesAllowedPath(filePath, modeCfg.allowed_write_paths, ctx.cwd)) {\n\t\t\t\t\treturn {\n\t\t\t\t\t\tblock: true,\n\t\t\t\t\t\treason:\n\t\t\t\t\t\t\t`Mode \"${mode}\" only allows writes to: ${modeCfg.allowed_write_paths.join(\", \")}. ` +\n\t\t\t\t\t\t\t`Attempted to ${event.toolName}: ${filePath}. ` +\n\t\t\t\t\t\t\t`Switch to \"/mode build\" to modify source files.`,\n\t\t\t\t\t};\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif (autoAllow.includes(event.toolName)) return;\n\n\t\t\tconst choice = await ctx.ui.select(`Allow: ${describeTool(event)}`, [\n\t\t\t\t\"Yes (once)\",\n\t\t\t\t\"No (block)\",\n\t\t\t\t\"Always (add to auto-allow for this mode)\",\n\t\t\t]);\n\n\t\t\tif (!choice || choice.startsWith(\"No\")) {\n\t\t\t\treturn { block: true, reason: \"Denied by permission gate\" };\n\t\t\t}\n\n\t\t\tif (choice.startsWith(\"Always\")) {\n\t\t\t\t// Write \"always\" choices to the global config only\n\t\t\t\tconst latest = readConfig();\n\t\t\t\tconst currentMode = latest.active_mode ?? \"build\";\n\t\t\t\tlatest.modes ??= {};\n\t\t\t\tlatest.modes[currentMode] ??= {};\n\t\t\t\tlatest.modes[currentMode].auto_allow = Array.from(\n\t\t\t\t\tnew Set([...(latest.modes[currentMode].auto_allow ?? []), event.toolName]),\n\t\t\t\t);\n\t\t\t\twriteConfig(latest);\n\t\t\t\tctx.ui.notify(`\"${event.toolName}\" added to auto-allow for mode \"${currentMode}\"`, \"info\");\n\t\t\t}\n\t\t},\n\t);\n}\n"]}