Files
Daniel Avila 0281438fc6 feat: Function Hooks section (experimental) — 10 hooks, CLI flag, blog (#867)
* feat: add Function Hooks component type (experimental) with 10 hooks and blog

Introduce "function-hooks" as a new first-class component type based on
the Anthropic proposal in anthropics/claude-code#91870 (TypeScript hooks
as Koa-style middleware on a $ engine interface). The feature is not
shipped; every API name is provisional and the section is labelled
experimental everywhere.

- 10 function hooks under cli-tool/components/function-hooks/
  (security, productivity, observability, ui, integrations, enterprise),
  each as a .md doc page plus the installable .ts/.tsx hooks-module
- CLI: --function-hook installs the module as a local plugin under
  .claude/plugins/<name>/ (plugin.json, hooks/hooks.json with modules)
  and prints the experimental warning
- Catalog generator: scan function-hooks and emit dashboard artifacts
- Dashboard: type wiring, /function-hooks page with experimental banner
  linking to the issue, Events/Module cards on the detail page,
  sitemap, cart, webmcp; track-download now accepts loop/function-hook
- Blog: "Claude Code Function Hooks (Experimental)" article with cover
- Docs: CLAUDE.md and cli-tool rule

Claude-Session: https://claude.ai/code/session_01YBoJbmNMcRCexuKpH9mQtW

* fix: store function hooks as hooks.json + module, install as skills-dir plugin

Function hooks are not markdown docs. Per the proposal's architecture doc,
a function hook is a plugin's hooks/hooks.json with a "modules" key naming
a .ts/.tsx hooks-module beside it. Align the catalog and the CLI with that:

- Components are now {name}.json (hooks.json + catalog description) plus
  the {name}.ts/.tsx module it names, mirroring how shell hooks store
  .json + .py/.sh. The .md docs are removed.
- Generator reads the named module into the per-component content file
  (module, moduleSource); the detail page renders the JSON and the module
  with the experimental notice.
- CLI --function-hook downloads hooks.json and every module it names,
  strips description, and writes the plugin to .claude/skills/{name}/,
  which Claude Code auto-loads as {name}@skills-dir per the plugins
  reference (no --plugin-dir needed).
- Docs and blog updated to the same install path.

Claude-Session: https://claude.ai/code/session_01YBoJbmNMcRCexuKpH9mQtW

* fix(function-hooks): address review findings

- CartSidebar: add --loop and --function-hook to TYPE_FLAGS so stack commands install them
- SendToRepoModal: export loops and function hooks (plugin layout with hooks.json + module)
- CLI: validate function hook identifier and hooks-module file names before building URLs/paths
- block-destructive-commands: rm rule now catches split flags, --recursive, quoted/trailing-slash targets, --no-preserve-root
- admin-capability-lockdown: shellPolicy deny|guardrail|allow; default withholds Bash, guardrail is labelled bypassable
- regenerate catalog

Claude-Session: https://claude.ai/code/session_01WsWgpvgeJgRMoLiYDcRXMU
2026-09-05 11:29:13 -04:00

67 lines
2.3 KiB
TypeScript

/**
* protected-paths-guard — Function Hook (EXPERIMENTAL)
*
* Denies Edit / Write / MultiEdit / NotebookEdit calls that target sensitive
* files (.env, lockfiles, CI workflows, git internals, private keys) unless
* the path is allowlisted. Placement: "instead" on match, pass-through otherwise.
*
* Function hooks are an Anthropic proposal under community review:
* https://github.com/anthropics/claude-code/issues/91870
* Every API name below is provisional.
*/
type Engine = any;
type Next = ((e: any) => Promise<any>) & { event: string; origin: string; signal: AbortSignal };
// Minimal glob support: "**" = any depth, "*" = any chars except "/".
function globToRegExp(glob: string): RegExp {
const escaped = glob
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
.replace(/\*\*\//g, "(?:.*/)?")
.replace(/\*\*/g, ".*")
.replace(/\*/g, "[^/]*");
return new RegExp(`(^|/)${escaped}$`);
}
const DEFAULT_PROTECTED = [
".env",
".env.*",
"**/.git/**",
"package-lock.json",
"pnpm-lock.yaml",
"yarn.lock",
"Cargo.lock",
"poetry.lock",
".github/workflows/*.yml",
".github/workflows/*.yaml",
"**/*.pem",
"**/*.key",
"**/id_rsa*",
];
export function register(on: any, options: Record<string, any> = {}) {
const protectedGlobs: string[] = [...DEFAULT_PROTECTED, ...(options.protect ?? [])];
const allowGlobs: string[] = options.allow ?? [];
const protectedRes = protectedGlobs.map(globToRegExp);
const allowRes = allowGlobs.map(globToRegExp);
// An array in a matcher matches when any element matches (design doc §6.1).
on("tool.call", { tool: ["Edit", "Write", "MultiEdit", "NotebookEdit"] }, ($: Engine, e: any, next: Next) => {
const filePath: string = (e.file_path ?? e.notebook_path ?? "").replace(/\\/g, "/");
if (!filePath) return next(e);
if (allowRes.some((re) => re.test(filePath))) return next(e);
const hit = protectedRes.findIndex((re) => re.test(filePath));
if (hit !== -1) {
$.ui.log(`[protected-paths-guard] denied ${e.tool} on ${filePath} (rule: ${protectedGlobs[hit]})`);
return {
deny: `${filePath} is protected by protected-paths-guard (rule "${protectedGlobs[hit]}"). ` +
`Ask the user to edit it manually or add the path to the plugin's "allow" option.`,
};
}
return next(e);
});
}