From 67caf0625a38835e96f1e1be31682447d6baef49 Mon Sep 17 00:00:00 2001 From: lacasoft Date: Fri, 30 Oct 2026 00:30:26 +0100 Subject: [PATCH 1/4] fix: pre-bash bloquea leer un .env real desde la shell MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Con la herramienta de lectura bloqueada, un modelo real usó grep … .env y devolvió el secreto: pre-read no veía la shell. pre-bash deniega ahora los comandos que vuelcan o cargan un .env real (cat, grep, head, sed, awk, source, una redirección <…). Los .env.example y comandos como cp .env.example .env o --env-file .env siguen permitidos. pre-read usa la misma regla. Aplica igual en Claude Code y en Pi. --- shared/.claude/helpers/hook-handler.cjs | 30 ++++++++++++++++++++++++- test/hook-handler.test.cjs | 12 ++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/shared/.claude/helpers/hook-handler.cjs b/shared/.claude/helpers/hook-handler.cjs index 684abeb..3d05ce0 100644 --- a/shared/.claude/helpers/hook-handler.cjs +++ b/shared/.claude/helpers/hook-handler.cjs @@ -132,6 +132,27 @@ const BASH_DENY = [ [/^(?!\s*node\s+\.claude\/helpers\/loop\.cjs\b).*\.claude\/loop\/state\.json/, "toca el estado del ciclo (.claude/loop/state.json), que solo escribe loop.cjs: el verificador no se negocia; usa loop.cjs status o stop"], ]; +// Archivos de entorno reales (.env, .env.local, .envrc…): los ejemplos (.example, .sample, +// .template) documentan las variables sin valores y sí se pueden leer. Misma regla que pre-read. +const isRealEnvFile = (p) => /^\.env(rc|\..+)?$/.test(path.basename(p)) && !/\.(example|sample|template)$/.test(p); +// Comandos que vuelcan o cargan el contenido de un archivo. Leer un .env real desde la shell +// esquivaría el hook pre-read: `grep DB_PASSWORD .env` devolvía el secreto al modelo. +const ENV_READERS = new Set(["cat", "tac", "nl", "less", "more", "head", "tail", "grep", "egrep", "fgrep", "rg", "ag", "awk", "gawk", "sed", "cut", "sort", "uniq", "strings", "xxd", "od", "hexdump", "base64", "bat", "batcat", "source", ".", "diff", "comm", "paste", "jq", "yq"]); +function readsRealEnv(cmd) { + for (const segment of cmd.split(/&&|\|\||;|\||\n/)) { + const words = segment.trim().split(/\s+/).filter(Boolean); + while (words.length && /^[A-Za-z_]\w*=/.test(words[0])) words.shift(); // VAR=valor delante del comando + if (words[0] === "sudo") words.shift(); + const reader = ENV_READERS.has(words[0]); + for (let i = 1; i < words.length; i++) { + const redir = words[i].startsWith("<") || words[i - 1] === "<"; + const file = words[i].replace(/^<+/, "").replace(/^['"]|['"]$/g, ""); + if (file && isRealEnvFile(file) && (reader || redir)) return file; + } + } + return null; +} + // Paquetes que un comando de instalación añadiría. null = no es una instalación; // [] = instala desde el lockfile/manifiesto del propio repo (seguro, no se pregunta). const INSTALL_RE = /(?:^|[;&|]\s*)(?:npm|pnpm|yarn|bun|pip3?|uv|composer|gem|cargo|go)\s+(?:install|add|require|get|i)\b([^\n;&|]*)/i; @@ -227,6 +248,13 @@ switch (action) { } } + const env = readsRealEnv(cmd); + if (env) { + denyPreTool( + `Lectura bloqueada: el comando lee ${env}, que puede contener secretos reales. Usa .env.example para conocer las variables (solo nombres); si necesitas un valor concreto, pídeselo a la persona.` + ); + } + // ...y meter código de terceros nuevo se confirma con la persona. const pkgs = newPackages(cmd); if (pkgs && pkgs.length) { @@ -282,7 +310,7 @@ switch (action) { // en permissions.deny porque las reglas de permisos no admiten excepciones: Read(./.env.*) // bloqueaba también .env.example. const base = path.basename(toolInput.file_path || ""); - if (/^\.env(rc|\..+)?$/.test(base) && !/\.(example|sample|template)$/.test(base)) { + if (isRealEnvFile(toolInput.file_path || "")) { denyPreTool( `Lectura bloqueada: ${base} puede contener secretos reales. Usa .env.example para conocer las variables (solo nombres); si necesitas un valor concreto, pídeselo a la persona.` ); diff --git a/test/hook-handler.test.cjs b/test/hook-handler.test.cjs index cce0262..863f5d0 100644 --- a/test/hook-handler.test.cjs +++ b/test/hook-handler.test.cjs @@ -22,6 +22,18 @@ function decision(payload) { } } +// ---- pre-bash: leer un .env real desde la shell esquivaría pre-read ---- +const bashDecision = (command) => { + const { out } = run("pre-bash", { tool_input: { command } }); + return out.trim() ? JSON.parse(out).hookSpecificOutput.permissionDecision : null; +}; +for (const c of ["cat .env", "grep -E '^DB_PASSWORD=' .env", "head -n1 config/.env.local", "source .env && npm start", ". ./.env", "while read l; do echo $l; done < .env", "sed -n 1p .env.production", "DEBUG=1 cat .envrc"]) { + test(`pre-bash DENIEGA leer un .env real: ${c}`, () => assert.equal(bashDecision(c), "deny")); +} +for (const c of ["cat .env.example", "cp .env.example .env", "grep X src/.env.sample", "docker compose --env-file .env up", "echo .env >> .gitignore"]) { + test(`pre-bash PERMITE: ${c}`, () => assert.equal(bashDecision(c), null)); +} + // ---- pre-bash: guards destructivos ---- test("pre-bash BLOQUEA rm -rf /", () => assert.equal(run("pre-bash", { tool_input: { command: "rm -rf /" } }).code, 2)); test("pre-bash BLOQUEA rm -rf $HOME", () => assert.equal(run("pre-bash", { tool_input: { command: "rm -rf $HOME" } }).code, 2)); From 5c53819d6b311a69f2b6be3216aaaec74a78bec5 Mon Sep 17 00:00:00 2001 From: lacasoft Date: Fri, 30 Oct 2026 00:30:26 +0100 Subject: [PATCH 2/4] =?UTF-8?q?feat:=20helper=20que=20eval=C3=BAa=20las=20?= =?UTF-8?q?reglas=20deny/ask=20de=20settings.json=20como=20Claude=20Code?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit claude-permissions.cjs lee .claude/settings.json y settings.local.json y decide una llamada: Bash(…) con comodines y comandos compuestos, rutas estilo gitignore (~/, //, /), Read para grep/find/ls, Edit/Write para cualquier escritura y reglas de MCP. Zero-dep; lo usa la capa de Pi Agent. --- shared/.claude/helpers/claude-permissions.cjs | 125 ++++++++++++++++++ test/claude-permissions.test.cjs | 77 +++++++++++ 2 files changed, 202 insertions(+) create mode 100644 shared/.claude/helpers/claude-permissions.cjs create mode 100644 test/claude-permissions.test.cjs diff --git a/shared/.claude/helpers/claude-permissions.cjs b/shared/.claude/helpers/claude-permissions.cjs new file mode 100644 index 0000000..1c8e459 --- /dev/null +++ b/shared/.claude/helpers/claude-permissions.cjs @@ -0,0 +1,125 @@ +#!/usr/bin/env node +/** + * claude-permissions.cjs — evalúa las reglas `deny` y `ask` de .claude/settings.json (y de + * settings.local.json) como Claude Code, para agentes que no las aplican (Pi Agent). Zero-dep; + * lo usa .pi/extensions/. Solo deny y ask: lo demás Pi ya lo hace sin preguntar. + * + * node .claude/helpers/claude-permissions.cjs lista las reglas deny y ask que se aplicarían + * + * Reglas soportadas: + * Herramienta toda la herramienta (Bash, Read, Edit, Write, o una de MCP: mcp__servidor) + * Bash() el comando, con * como comodín ("prefijo:*" es la forma antigua); + * un comando compuesto (&&, ||, ;, |) se revisa por partes. + * Read|Edit|Write() estilo gitignore: ** cualquier cosa, * dentro de un segmento. + * //ruta es absoluta, ~/ruta está en tu home, /ruta y ./ruta parten de la raíz. + * Como en Claude Code, Read cubre también las herramientas de búsqueda (grep, find, ls) y Edit o + * Write cubren cualquier herramienta que escriba archivos. + */ +const fs = require("fs"); +const os = require("os"); +const path = require("path"); + +const ROOT = process.env.CLAUDE_PROJECT_DIR || path.resolve(__dirname, "..", ".."); + +// Herramienta de Pi → reglas de Claude que le aplican. +const CLAUDE_TOOLS = { + bash: ["Bash"], + powershell: ["Bash"], + read: ["Read"], + grep: ["Read"], + find: ["Read"], + ls: ["Read"], + write: ["Edit", "Write"], + edit: ["Edit", "Write"], +}; + +/** Reglas deny y ask de settings.json y settings.local.json de .claude/, sin duplicados. */ +function load(root = ROOT) { + const rules = { deny: [], ask: [] }; + for (const f of ["settings.json", "settings.local.json"]) { + let s; + try { + s = JSON.parse(fs.readFileSync(path.join(root, ".claude", f), "utf8")); + } catch (_) { + continue; + } + for (const k of ["deny", "ask"]) { + for (const r of (s.permissions && s.permissions[k]) || []) if (typeof r === "string" && !rules[k].includes(r)) rules[k].push(r); + } + } + return rules; +} + +function globToRegex(glob) { + let re = ""; + for (let i = 0; i < glob.length; i++) { + const c = glob[i]; + if (c === "*" && glob[i + 1] === "*") { + i++; + if (glob[i + 1] === "/") { + i++; + re += "(?:.*/)?"; + } else re += ".*"; + } else if (c === "*") re += "[^/]*"; + else if (c === "?") re += "[^/]"; + else re += c.replace(/[.+^${}()|[\]\\]/g, "\\$&"); + } + return new RegExp(`^${re}$`); +} + +function pathMatches(spec, target, { root, home }) { + let abs; + if (spec.startsWith("//")) abs = spec.slice(1); + else if (spec.startsWith("~/")) abs = path.join(home, spec.slice(2)); + else if (spec.startsWith("/")) abs = path.join(root, spec.slice(1)); + else abs = path.join(root, spec.replace(/^\.\//, "")); + // path.join normaliza, pero conserva los comodines; con una barra final, la carpeta y su contenido. + if (spec.endsWith("/")) abs += "/**"; + // "carpeta/**" protege también la carpeta misma: si no, grep o find sobre ella leerían todo su contenido. + if (abs.endsWith("/**") && target === abs.slice(0, -3)) return true; + return globToRegex(abs).test(target); +} + +function commandMatches(spec, command) { + const re = spec.endsWith(":*") + ? new RegExp(`^${spec.slice(0, -2).replace(/[.+^${}()|[\]\\*?]/g, "\\$&")}(\\s.*)?$`) + : new RegExp(`^${spec.split("*").map((p) => p.replace(/[.+^${}()|[\]\\?]/g, "\\$&")).join(".*")}$`); + return command + .split(/&&|\|\||;|\||\n/) + .map((p) => p.trim()) + .filter(Boolean) + .some((p) => re.test(p)); +} + +function ruleMatches(rule, toolName, input, ctx) { + const m = rule.match(/^([\w-]+)(?:\((.*)\))?$/s); + if (!m) return false; + const [, tool, spec] = m; + const claude = CLAUDE_TOOLS[toolName] || []; + if (spec === undefined) return claude.includes(tool) || toolName === tool || toolName.startsWith(`${tool}__`); + if (!claude.includes(tool)) return false; + if (tool === "Bash") return typeof input.command === "string" && commandMatches(spec, input.command); + const p = input.path === undefined ? "." : String(input.path); + const target = p.startsWith("~/") ? path.join(ctx.home, p.slice(2)) : path.resolve(ctx.cwd, p); + return pathMatches(spec, target, ctx); +} + +/** + * Decide una llamada de Pi: { decision: "deny" | "ask" | null, rule }. deny gana sobre ask. + * `ctx`: { root, cwd, home } (por defecto la raíz del proyecto y tu home). + */ +function decide(rules, toolName, input = {}, ctx = {}) { + const c = { root: ctx.root || ROOT, cwd: ctx.cwd || ctx.root || ROOT, home: ctx.home || os.homedir() }; + for (const k of ["deny", "ask"]) { + const rule = rules[k].find((r) => ruleMatches(r, toolName, input, c)); + if (rule) return { decision: k, rule }; + } + return { decision: null, rule: null }; +} + +module.exports = { load, decide, globToRegex }; + +if (require.main === module) { + const r = load(); + console.log(`deny: ${r.deny.join(", ") || "—"}\nask: ${r.ask.join(", ") || "—"}`); +} diff --git a/test/claude-permissions.test.cjs b/test/claude-permissions.test.cjs new file mode 100644 index 0000000..67d0a9f --- /dev/null +++ b/test/claude-permissions.test.cjs @@ -0,0 +1,77 @@ +// Tests del helper que evalúa las reglas deny/ask de .claude/settings.json para la capa de Pi. +const { test } = require("node:test"); +const assert = require("node:assert"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); + +const { load, decide, globToRegex } = require(path.join(__dirname, "..", "shared", ".claude", "helpers", "claude-permissions.cjs")); + +const ROOT = "/proyecto"; +const HOME = "/home/ana"; +const ctx = { root: ROOT, cwd: ROOT, home: HOME }; +const KIT = JSON.parse(fs.readFileSync(path.join(__dirname, "..", "shared", ".claude", "settings.json"), "utf8")).permissions; +const reglas = { deny: KIT.deny, ask: [] }; +const d = (tool, input, r = reglas) => decide(r, tool, input, ctx).decision; + +test("globs estilo gitignore", () => { + assert.ok(globToRegex("/a/**").test("/a/b/c")); + assert.ok(globToRegex("/a/**/c.md").test("/a/c.md")); + assert.ok(globToRegex("/a/*.env").test("/a/x.env")); + assert.ok(!globToRegex("/a/*.env").test("/a/b/x.env")); +}); + +test("las denegaciones de lectura del kit cubren ~/.ssh, ~/.aws y los .env reales", () => { + assert.equal(d("read", { path: "~/.ssh/id_rsa" }), "deny"); + assert.equal(d("read", { path: "/home/ana/.aws/credentials" }), "deny"); + assert.equal(d("read", { path: ".env" }), "deny"); + assert.equal(d("read", { path: "./.env.production" }), "deny"); + assert.equal(d("read", { path: ".env.example" }), null); + assert.equal(d("read", { path: "src/app.ts" }), null); +}); + +test("Read cubre también grep, find y ls", () => { + assert.equal(d("grep", { pattern: "x", path: "~/.ssh" }), "deny", "buscar en la carpeta leería su contenido"); + assert.equal(d("grep", { pattern: "x", path: "." }), null, "buscar en el proyecto sigue permitido"); + assert.equal(d("grep", { pattern: "x", path: "~/.ssh/config" }), "deny"); + assert.equal(d("ls", { path: "~/.aws/sso" }), "deny"); +}); + +test("las denegaciones de Bash del kit, también dentro de un comando compuesto", () => { + assert.equal(d("bash", { command: "sudo rm -rf build" }), "deny"); + assert.equal(d("bash", { command: "npm test && npm publish --access public" }), "deny"); + assert.equal(d("bash", { command: "chmod 777 script.sh" }), "deny"); + assert.equal(d("bash", { command: "npm test" }), null); + assert.equal(d("bash", { command: "echo sudo" }), null, "solo cuenta el comando, no un argumento"); +}); + +test("la forma antigua prefijo:* y las reglas sin paréntesis", () => { + const r = { deny: ["Bash(git push:*)", "Edit"], ask: [] }; + assert.equal(d("bash", { command: "git push origin main" }, r), "deny"); + assert.equal(d("bash", { command: "git pushx" }, r), null); + assert.equal(d("write", { path: "a.ts", content: "x" }, r), "deny", "Edit cubre write"); + assert.equal(d("read", { path: "a.ts" }, r), null); +}); + +test("ask y reglas de MCP; deny gana sobre ask", () => { + const r = { deny: ["mcp__github__delete_repo"], ask: ["Bash(git push*)", "mcp__github"] }; + assert.equal(d("bash", { command: "git push" }, r), "ask"); + assert.equal(d("mcp__github__create_issue", {}, r), "ask"); + assert.equal(d("mcp__github__delete_repo", {}, r), "deny"); +}); + +test("rutas absolutas (//) y relativas a la raíz (/)", () => { + const r = { deny: ["Edit(//etc/**)", "Write(/infra/prod/**)"], ask: [] }; + assert.equal(d("edit", { path: "/etc/hosts" }, r), "deny"); + assert.equal(d("write", { path: "infra/prod/main.tf" }, r), "deny"); + assert.equal(d("write", { path: "infra/dev/main.tf" }, r), null); +}); + +test("load junta settings.json y settings.local.json sin duplicar", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "kit-perms-")); + fs.mkdirSync(path.join(dir, ".claude")); + fs.writeFileSync(path.join(dir, ".claude", "settings.json"), JSON.stringify({ permissions: { deny: ["Bash(sudo *)"], allow: ["Bash(npm test)"] } })); + fs.writeFileSync(path.join(dir, ".claude", "settings.local.json"), JSON.stringify({ permissions: { deny: ["Bash(sudo *)", "Bash(terraform destroy*)"], ask: ["Bash(git push*)"] } })); + assert.deepEqual(load(dir), { deny: ["Bash(sudo *)", "Bash(terraform destroy*)"], ask: ["Bash(git push*)"] }); + fs.rmSync(dir, { recursive: true, force: true }); +}); From 9c7d5deaee6ae861e99e187e9019470e9baeb6e2 Mon Sep 17 00:00:00 2001 From: lacasoft Date: Fri, 30 Oct 2026 00:30:26 +0100 Subject: [PATCH 3/4] feat: guardrails de seguridad en Pi Agent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Antes de cada herramienta (también las llamadas anidadas de codemode), la extensión aplica las reglas de hook-handler.cjs traduciendo todas sus salidas (exit 2 y deny bloquean, ask se confirma y sin interfaz se bloquea, additionalContext se anexa, post-edit formatea), las deny/ask de settings.json y una confirmación por sesión para las herramientas que el kit no conoce. Prueba de integración dentro de Pi 1.4.0 con el modelo simulado. --- install.js | 2 + shared/.pi/extensions/dev-starter-kit.ts | 108 ++++++++++++++++++++--- test/pi.test.cjs | 51 ++++++++++- 3 files changed, 145 insertions(+), 16 deletions(-) diff --git a/install.js b/install.js index cec959f..dc830cd 100644 --- a/install.js +++ b/install.js @@ -1343,6 +1343,8 @@ async function main() { console.log(" • Abre pi en el proyecto y acepta su confianza: sin ella Pi no carga .pi/ (ni las skills, ni los"); console.log(' imports de CLAUDE.md, ni las reglas). Con la capa activa verás "🧰 dev-kit" abajo.'); console.log(" • Las skills se invocan como /skill: (/skill:project-context, /skill:tdd…) y el estado del kit con /status."); + console.log(" • Guardrails: antes de cada herramienta se aplican las reglas del kit y las deny/ask de .claude/settings.json;"); + console.log(" lo que pide confirmación se bloquea si no hay interfaz (pi -p). No es aislamiento: para eso, un contenedor o una VM."); console.log(" • En Pi no hay subagentes: /swarm y los agentes de .claude/agents/ son solo de Claude Code."); console.log(" • Pi envía telemetría anónima de instalación por defecto: PI_TELEMETRY=0 la apaga."); console.log(" • Instala ripgrep y fd con el gestor de paquetes de tu sistema: si faltan, Pi descarga la última versión sin fijarla."); diff --git a/shared/.pi/extensions/dev-starter-kit.ts b/shared/.pi/extensions/dev-starter-kit.ts index 3287ff7..6843b75 100644 --- a/shared/.pi/extensions/dev-starter-kit.ts +++ b/shared/.pi/extensions/dev-starter-kit.ts @@ -1,49 +1,95 @@ // Dev Starter Kit para Pi Agent. Pi lee CLAUDE.md, pero no resuelve sus @imports, no carga -// .claude/rules/ ni corre los hooks de Claude Code. Esta extensión cubre la base con los mismos -// helpers del kit: -// - expande los imports de CLAUDE.md (claude-imports.cjs): CLAUDE.project.md y la baseline; -// - añade las reglas de .claude/rules/ que Claude carga siempre, las que no tienen `paths:` -// (claude-rules.cjs); las de un monorepo, por carpeta, todavía no; -// - pone en el contexto los avisos del inicio de sesión (hook-handler.cjs session-start). -// Todo se calcula al iniciar, recargar o compactar la sesión y se reutiliza entre turnos, igual -// que Claude no relee CLAUDE.md a cada mensaje. +// .claude/rules/, no corre los hooks de Claude Code ni tiene sistema de permisos. Esta extensión +// cubre eso con los mismos helpers del kit: +// - contexto: expande los imports de CLAUDE.md (claude-imports.cjs), añade las reglas de +// .claude/rules/ que Claude carga siempre, las que no tienen `paths:` (claude-rules.cjs), y los +// avisos del inicio de sesión (hook-handler.cjs session-start); +// - guardrails: antes de cada herramienta, las mismas reglas que los hooks PreToolUse de Claude +// (hook-handler.cjs) y las reglas deny/ask de .claude/settings.json (claude-permissions.cjs). +// Lo que pide aprobación se confirma con la persona y, sin interfaz (pi -p), se bloquea. +// Una herramienta que el kit no conoce (MCP, otra extensión) se confirma una vez por sesión. +// Son guardrails, no aislamiento: Pi corre con los permisos de tu usuario. Para garantías +// reales, un contenedor o una VM. import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { spawnSync } from "node:child_process"; import { createRequire } from "node:module"; +import * as os from "node:os"; import * as path from "node:path"; import { fileURLToPath } from "node:url"; const require = createRequire(import.meta.url); const { expand } = require("../../.claude/helpers/claude-imports.cjs"); const rules = require("../../.claude/helpers/claude-rules.cjs"); +const permissions = require("../../.claude/helpers/claude-permissions.cjs"); const ROOT = fileURLToPath(new URL("../..", import.meta.url)); const HOOKS = path.join(ROOT, ".claude", "helpers", "hook-handler.cjs"); +const KNOWN = new Set(["read", "bash", "edit", "write", "grep", "find", "ls", "powershell"]); -function avisosDeInicio(): string { - const r = spawnSync("node", [HOOKS, "session-start"], { - input: JSON.stringify({ source: "startup" }), +type Input = Record; +type Veredicto = { block?: string; ask?: string; context?: string }; + +// Corre una acción de hook-handler.cjs con el payload que le mandaría Claude Code. +function hook(action: string, toolInput: Input, source = "startup"): { code: number | null; stdout: string; stderr: string } { + const r = spawnSync("node", [HOOKS, action], { + input: JSON.stringify({ tool_input: toolInput, source }), encoding: "utf8", env: { ...process.env, CLAUDE_PROJECT_DIR: ROOT, PI_CODING_AGENT: "true" }, }); - return (r.stdout || "").trim(); + return { code: r.status, stdout: r.stdout || "", stderr: r.stderr || "" }; +} + +// Las salidas de PreToolUse en Claude: exit 2 (bloquea con stderr), permissionDecision deny o +// ask, y additionalContext. +function preToolUse(action: string, toolInput: Input): Veredicto { + const r = hook(action, toolInput); + if (r.code === 2) return { block: r.stderr.trim() || "Bloqueado por el kit." }; + let out: Input | undefined; + try { + out = JSON.parse(r.stdout.trim() || "null")?.hookSpecificOutput; + } catch (_) { + return {}; + } + if (!out) return {}; + if (out.permissionDecision === "deny") return { block: out.permissionDecisionReason }; + if (out.permissionDecision === "ask") return { ask: out.permissionDecisionReason }; + return out.additionalContext ? { context: out.additionalContext } : {}; +} + +// Herramienta de Pi → acción del hook-handler y payload con la forma de Claude Code. +function comoClaude(tool: string, input: Input, cwd: string): [string, Input] | null { + const abs = (p: unknown) => { + const s = p === undefined ? "." : String(p); + return s.startsWith("~/") ? path.join(os.homedir(), s.slice(2)) : path.resolve(cwd, s); + }; + if (tool === "bash" || tool === "powershell") return ["pre-bash", { command: input.command }]; + if (tool === "read" || tool === "grep" || tool === "find" || tool === "ls") return ["pre-read", { file_path: abs(input.path) }]; + if (tool === "write") return ["pre-edit", { file_path: abs(input.path), content: input.content }]; + // Pi manda edits[].newText; el hook-handler revisa edits[].new_string, como en MultiEdit de Claude. + if (tool === "edit") return ["pre-edit", { file_path: abs(input.path), edits: (input.edits ?? []).map((e: Input) => ({ new_string: e.newText })) }]; + return null; } export default function (pi: ExtensionAPI) { let imports = new Map(); let fijas: { path: string; content: string }[] = []; let avisos = ""; + let reglas = { deny: [] as string[], ask: [] as string[] }; + let aprobadas = new Set(); + const contexto = new Map(); const refrescar = (inicio: boolean) => { imports = new Map(); fijas = rules.load(ROOT).filter((r: { always: boolean }) => r.always).map((r: { file: string; body: string }) => ({ path: r.file, content: r.body })); + reglas = permissions.load(ROOT); // Como el hook de Claude: los avisos solo al iniciar, no tras compactar (ya están en el resumen). - avisos = inicio ? avisosDeInicio() : ""; + avisos = inicio ? hook("session-start", {}).stdout.trim() : ""; }; pi.on("session_start", async (_event, ctx) => { refrescar(true); + aprobadas = new Set(); // Si la persona no ve este indicador, la capa del kit no está activa (proyecto sin confianza - // o Pi corriendo con --no-extensions). + // o Pi corriendo con --no-extensions): ni contexto ni guardrails. if (ctx.hasUI) ctx.ui.setStatus("dev-starter-kit", "🧰 dev-kit"); }); @@ -65,4 +111,38 @@ export default function (pi: ExtensionAPI) { for (const regla of fijas) if (!files.some((f) => f.path === regla.path)) files.push(regla); if (opts.sections && avisos) opts.sections["dev-starter-kit"] = avisos; }); + + // Corre también para las llamadas anidadas (p. ej. desde codemode). + pi.on("tool_call", async (event, ctx) => { + const input = event.input as Input; + const claude = comoClaude(event.toolName, input, ctx.cwd); + const v = claude ? preToolUse(claude[0], claude[1]) : {}; + if (v.block) return { block: true, reason: v.block }; + + const d = permissions.decide(reglas, event.toolName, input, { root: ROOT, cwd: ctx.cwd, home: os.homedir() }); + if (d.decision === "deny") return { block: true, reason: `Denegado por .claude/settings.json (${d.rule}).` }; + + const desconocida = !KNOWN.has(event.toolName) && !aprobadas.has(event.toolName); + const pregunta = + v.ask || + (d.decision === "ask" ? `Pide confirmación según .claude/settings.json (${d.rule}).` : "") || + (desconocida ? `La herramienta ${event.toolName} no es del kit (MCP u otra extensión) y corre con tus permisos. ¿La permites en esta sesión?` : ""); + if (pregunta) { + if (!ctx.hasUI) return { block: true, reason: `Requiere aprobación y no hay interfaz para pedirla: ${pregunta}` }; + if (!(await ctx.ui.confirm("Dev Starter Kit", pregunta))) return { block: true, reason: "La persona no lo aprobó." }; + if (desconocida) aprobadas.add(event.toolName); + } + if (v.context) contexto.set(event.toolCallId, v.context); + }); + + // Lo que en Claude hace PostToolUse: el contexto que pidió el hook y el formateo tras editar. + pi.on("tool_result", async (event, ctx) => { + const extra = contexto.get(event.toolCallId); + contexto.delete(event.toolCallId); + if (!event.isError && (event.toolName === "write" || event.toolName === "edit")) { + const claude = comoClaude(event.toolName, event.input as Input, ctx.cwd); + if (claude) hook("post-edit", { file_path: claude[1].file_path }); + } + if (extra) return { content: [...event.content, { type: "text", text: `ℹ️ ${extra}` }] }; + }); } diff --git a/test/pi.test.cjs b/test/pi.test.cjs index ab89b72..afec29a 100644 --- a/test/pi.test.cjs +++ b/test/pi.test.cjs @@ -16,6 +16,7 @@ const INSTALL = path.join(__dirname, "..", "install.js"); const HARNESS = ` import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { fauxAssistantMessage, fauxProvider, fauxText, fauxToolCall, getCurrentSystemPrompt, getCurrentTools } from "@earendil-works/pi-ai"; +import { Type } from "typebox"; import * as fs from "node:fs"; import * as path from "node:path"; @@ -37,11 +38,15 @@ export default function (pi: ExtensionAPI) { return fauxAssistantMessage(step.tool ? fauxToolCall(step.tool, step.args ?? {}) : fauxText(step.text ?? "ok")); })); pi.registerProvider(faux.provider); + // Una herramienta que el kit no conoce, como la de un servidor MCP u otra extensión. + pi.registerTool({ name: "eco", label: "eco", description: "Repite un texto", parameters: Type.Object({}), async execute() { + return { content: [{ type: "text", text: "eco" }], details: {} }; + } }); } `; // Instala el kit en un proyecto NestJS temporal y corre Pi una vez con los pasos dados. -function correrPi({ install = ["--stack", "backend/nestjs", "--pi"], prompt = "hola", piArgs = [], steps = [{ text: "ok" }], antes } = {}) { +function correrPi({ install = ["--stack", "backend/nestjs", "--pi"], prompt = "hola", piArgs = [], steps = [{ text: "ok" }], antes, home } = {}) { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "kit-pi-")); const agente = path.join(os.tmpdir(), `kit-pi-agente-${path.basename(dir)}`); fs.mkdirSync(agente); @@ -57,7 +62,7 @@ function correrPi({ install = ["--stack", "backend/nestjs", "--pi"], prompt = "h input: "", encoding: "utf8", timeout: 60000, - env: { ...process.env, PI_CODING_AGENT_DIR: agente, HARNESS_OUT: agente, HARNESS_STEPS: JSON.stringify(steps), PI_OFFLINE: "1", PI_SKIP_VERSION_CHECK: "1", PI_TELEMETRY: "0" }, + env: { ...process.env, ...(home ? { HOME: home } : {}), PI_CODING_AGENT_DIR: agente, HARNESS_OUT: agente, HARNESS_STEPS: JSON.stringify(steps), PI_OFFLINE: "1", PI_SKIP_VERSION_CHECK: "1", PI_TELEMETRY: "0" }, }); assert.equal(r.status, 0, r.stdout + r.stderr); const leer = (f) => fs.readFileSync(path.join(agente, f), "utf8"); @@ -96,3 +101,45 @@ test("sin --pi, Pi ve las líneas @ sin expandir y no carga las skills", { skip assert.doesNotMatch(res.prompt, /tdd<\/name>/); limpiar(res); }); + +test("los guardrails del kit funcionan en Pi: hook-handler, settings.json y herramientas desconocidas", { skip }, () => { + // HOME falso: la prueba de ~/.ssh nunca toca tus llaves reales. + const home = fs.mkdtempSync(path.join(os.tmpdir(), "kit-pi-home-")); + fs.mkdirSync(path.join(home, ".ssh")); + fs.writeFileSync(path.join(home, ".ssh", "id_rsa"), "LLAVE-FALSA\n"); + const secreto = "'AKIA" + "QWERTYUIOPASDFGH'"; + const pasos = [ + ["read .env", { tool: "read", args: { path: ".env" } }, true, /Lectura bloqueada/], + ["read .env.example", { tool: "read", args: { path: ".env.example" } }, false], + ["grep sobre .env desde bash", { tool: "bash", args: { command: "grep SECRETO .env" } }, true, /Lectura bloqueada/], + ["push --force", { tool: "bash", args: { command: "git push --force origin main" } }, true, /push --force a rama protegida/], + ["sudo", { tool: "bash", args: { command: "sudo ls" } }, true, /sudo/], + ["instalar sin interfaz", { tool: "bash", args: { command: "npm install left-pad@1.3.0 --dry-run" } }, true, /Requiere aprobación y no hay interfaz/], + ["write con secreto", { tool: "write", args: { path: "secreto.ts", content: `const k = ${secreto};\n` } }, true, /Posible secreto/], + ["edit con secreto", { tool: "edit", args: { path: "src/app.ts", edits: [{ oldText: "1", newText: secreto }] } }, true, /Posible secreto/], + ["read ~/.ssh", { tool: "read", args: { path: "~/.ssh/id_rsa" } }, true, /settings\.json \(Read\(~\/\.ssh\/\*\*\)\)/], + ["regla local del equipo", { tool: "bash", args: { command: "terraform destroy -auto-approve" } }, true, /terraform destroy/], + ["herramienta desconocida", { tool: "eco", args: {} }, true, /eco no es del kit/], + ["editar un .env de ejemplo", { tool: "write", args: { path: ".env.example", content: "X=\n" } }, false, /archivo de entorno/], + ]; + const res = correrPi({ + home, + steps: [...pasos.map((p) => p[1]), { text: "fin" }], + antes: (dir) => { + fs.writeFileSync(path.join(dir, ".env"), "SECRETO=1\n"); + fs.mkdirSync(path.join(dir, "src")); + fs.writeFileSync(path.join(dir, "src", "app.ts"), "export const x = 1;\n"); + fs.writeFileSync(path.join(dir, ".claude", "settings.local.json"), JSON.stringify({ permissions: { deny: ["Bash(terraform destroy*)"] } })); + }, + }); + assert.equal(res.log.length, pasos.length); + pasos.forEach(([nombre, , bloqueado, re], i) => { + assert.equal(res.log[i].error, bloqueado, `${nombre}: ${res.log[i].text}`); + if (re) assert.match(res.log[i].text, re, nombre); + }); + assert.ok(res.log.every((l) => !/LLAVE-FALSA|SECRETO=1/.test(l.text)), "ningún secreto llegó al modelo"); + assert.ok(!fs.existsSync(path.join(res.dir, "secreto.ts"))); + assert.equal(fs.readFileSync(path.join(res.dir, "src", "app.ts"), "utf8"), "export const x = 1;\n"); + fs.rmSync(home, { recursive: true, force: true }); + limpiar(res); +}); From 41eced0e3c4e169e162f1dada29624bbc15b8cce Mon Sep 17 00:00:00 2001 From: lacasoft Date: Fri, 30 Oct 2026 00:30:26 +0100 Subject: [PATCH 4/4] =?UTF-8?q?chore:=20release=20v3.3.0=20=E2=80=94=20sop?= =?UTF-8?q?orte=20opcional=20para=20Pi=20Agent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit README con la sección También con Pi Agent (requisitos, equivalencias, contexto, seguridad, confianza del proyecto, modelos probados y privacidad), baseline y pins a v3.3.0. --- CHANGELOG.md | 30 +++++++++++-- README.md | 79 ++++++++++++++++++++++++++++++----- package.json | 2 +- shared/.claude/CLAUDE.base.md | 2 +- 4 files changed, 97 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 93d1f1d..d03e315 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,33 @@ Formato basado en [Keep a Changelog](https://keepachangelog.com/) y [SemVer](htt ## [No publicado] +## [3.3.0] - 2026-10-30 + +Soporte opcional para [Pi Agent](https://github.com/earendil-works/pi) ≥ 1.4.0: contexto y guardrails. Para Claude Code no cambia nada. + +### Notas de actualización +- `npx -y github:lacasoft/dev-starter-kit#v3.3.0 --update --yes` trae los helpers nuevos y los ajustes de las skills. Para sumar Pi, agrega `--pi`; desde ese momento `--update` la recuerda. +- En Pi, la capa solo actúa si el proyecto es de confianza: verás `🧰 dev-kit` abajo. Sin ella, Pi no aplica ningún guardrail. + ### Added +- **Guardrails de seguridad en Pi.** Pi no tiene sistema de permisos. Con `--pi`, antes de cada herramienta, también en las llamadas anidadas desde codemode, la extensión aplica: + - **Las reglas de los hooks del kit** (`hook-handler.cjs`, el mismo que usa Claude). Traduce todas sus salidas: + - `exit 2` y `deny` bloquean, con el motivo para el modelo. + - `ask` se confirma con la persona y, sin interfaz (`pi -p`), se bloquea. + - `additionalContext` se anexa al resultado. + - Tras `write`/`edit` corre el formateo de `post-edit`. + - Las herramientas de Pi se traducen a la forma de Claude; por ejemplo, `edit` manda `edits[].newText`, que se escanea como `new_string`. + - **Las reglas `deny` y `ask` de `.claude/settings.json` y `settings.local.json`**, con el helper nuevo `claude-permissions.cjs`, que las evalúa como Claude Code: `Bash(…)` con comodines y comandos compuestos, rutas estilo gitignore con `~/`, `//` y `/`, y reglas de MCP. Así se cubren lecturas que ningún hook revisaba, como `~/.ssh` y `~/.aws`, también vía `grep`, `find` y `ls`, además de las reglas propias del equipo. + - **Una confirmación, una vez por sesión, para cada herramienta que el kit no conoce** (MCP u otra extensión). Sin interfaz, se bloquea. +- README: sección *También con Pi Agent*, con requisitos, equivalencias, contexto, seguridad (guardrails, no aislamiento), confianza del proyecto, modelos probados y privacidad. + +### Fixed +- **Leer un `.env` real desde la shell ya no esquiva el hook `pre-read`** (también en Claude Code). Al probar los guardrails con un modelo real, ante "lee el .env", Sonnet usó `grep … .env` en vez de la herramienta de lectura y devolvió el secreto. `pre-bash` ahora deniega los comandos que vuelcan o cargan un `.env` real: `cat`, `grep`, `head`, `sed`, `awk`, `source`/`.`, una redirección `<`, etc. Los `.env.example`/`.sample`/`.template` y comandos como `cp .env.example .env` o `--env-file .env` siguen permitidos. `pre-read` usa la misma regla. + +### Changed +- La baseline (§6.1) y el aviso final del instalador explican los guardrails en Pi y sus límites: el escaneo de secretos no ve lo que un script escribe desde `bash`, y para garantías reales hace falta un contenedor o una VM. + +### Base para Pi Agent (incluida en esta versión) - **Base para Pi Agent (`--pi`)**: primera parte del soporte para [Pi Agent](https://github.com/earendil-works/pi) ≥ 1.4.0, el mismo enfoque que system-design-kit 1.2.0. Es una capa aditiva: sin `--pi` el instalador hace exactamente lo de antes. Con `--pi`: - `.pi/settings.json` carga las skills desde `.claude/skills` y el comando `/status` desde `.claude/commands/kit/status.md`, sin copias. `/swarm` no se carga, porque depende de los subagentes de Claude Code. Si el archivo ya existe se fusiona, y si no es JSON válido no se toca. - `.pi/extensions/dev-starter-kit.ts`: @@ -14,9 +40,7 @@ Formato basado en [Keep a Changelog](https://keepachangelog.com/) y [SemVer](htt - Muestra `🧰 dev-kit` en la barra de estado cuando la capa está activa. - Helpers nuevos, zero-dep: `claude-imports.cjs`, el mismo de system-design-kit (reglas de Claude Code; nunca incluye `.env` reales ni `~/.ssh` o `~/.aws`), y `claude-rules.cjs`, que lee `.claude/rules/` como Claude Code (con o sin `paths:`). - El manifiesto registra la capa, así que `--update` la recuerda. Al terminar, el instalador explica la confianza del proyecto, `PI_TELEMETRY=0`, por qué conviene instalar ripgrep y fd, y que en Pi no hay subagentes. -- CI: job que prueba la capa dentro de Pi 1.4.0 real con su proveedor simulado, sin modelo ni API key. - -### Changed +- CI: job que prueba la capa dentro de Pi 1.4.0 real con su proveedor simulado, sin modelo ni API key: contexto, `/status`, monorepo y guardrails. - Dentro de Pi (`PI_CODING_AGENT=true`), el aviso de inicio de `hook-handler.cjs` sugiere `/skill:project-context`. - Las 9 skills que nombran archivos del proyecto aclaran que sus rutas son relativas a la raíz del proyecto. Pi le dice al modelo que las referencias de una skill son relativas a la carpeta de la skill, y en las pruebas un modelo pequeño buscaba los archivos ahí. - `swarm-orchestration` y la sección 3 de la baseline dicen que los subagentes son solo de Claude Code. `loop-task` avisa que en Pi todavía no tiene su puerta. diff --git a/README.md b/README.md index a20dfa4..0fbb822 100644 --- a/README.md +++ b/README.md @@ -16,9 +16,9 @@ Configuración `.claude` unificada para backend, frontend, mobile y blockchain. │ ├── agents/ # 14 agentes (5 core + 9 especialistas) — ver "Agentes incluidos" │ ├── skills/ # 19 skills (flujo + contexto + ingeniería + seguridad) — ver "Skills incluidas" │ ├── commands/ # /swarm, /kit:status -│ ├── helpers/ # claude-imports, claude-rules, hook-handler, loop, statusline +│ ├── helpers/ # claude-imports, claude-permissions, claude-rules, hook-handler, loop, statusline │ └── templates/ # PROJECT.template.md (definición del proyecto) -├── shared/.pi/extensions/ # capa opcional para Pi Agent (--pi): imports de CLAUDE.md, reglas y avisos +├── shared/.pi/extensions/ # capa opcional para Pi Agent (--pi): contexto de CLAUDE.md y guardrails ├── stacks/ # OVERLAYS: 13 stacks seleccionables + un `_common` por categoría │ ├── backend/{_common,nestjs,express,fastapi,django,php,spring,dotnet} │ ├── frontend/{_common,angular,react,nextjs} @@ -35,7 +35,7 @@ Dentro de **cualquier proyecto** (nuevo o heredado), sin clonar nada: ```bash npx -y github:lacasoft/dev-starter-kit # interactivo npx -y github:lacasoft/dev-starter-kit --yes --all # desatendido (todo) -npx -y github:lacasoft/dev-starter-kit#v3.2.0 # fijar una versión (recomendado) +npx -y github:lacasoft/dev-starter-kit#v3.3.0 # fijar una versión (recomendado) ``` Toma siempre la última versión de `master` (o el tag indicado). Repo **público (MIT)**. @@ -48,7 +48,7 @@ node /ruta/al/kit/install.js Flags: `--stack backend/nestjs` (fuerza stack; en monorepo, uno por carpeta: `--stack backend/nestjs:apps/api,frontend/nextjs:apps/web`) · `--yes` (no interactivo, conservador) · `--all` (acepta TODO: externos + deps; combínalo con `--yes` para desatendido total) · -`--no-external` · `--update`/`--force` (trae la última capa base sin pisar lo que hayas editado) · `--dry-run` (simula) · `--help`. +`--no-external` · `--pi` (añade la capa para [Pi Agent](#también-con-pi-agent)) · `--update`/`--force` (trae la última capa base sin pisar lo que hayas editado) · `--dry-run` (simula) · `--help`. `--no-flow` se sigue aceptando para no romper scripts, pero ya no hace nada. El instalador: @@ -68,7 +68,8 @@ El instalador: - Se instalan con **versión exacta** y solo versiones con 7+ días publicadas (`npm install -E --before=…`), la misma regla §6.1 que el kit exige al agente. - El `pre-commit` corre lint-staged con lo que el proyecto **ya tiene** (prettier, eslint, `tsc --noEmit` si hay `tsconfig.json`) y se escribe `.lintstagedrc.mjs`. Si no hay nada que revisar, no se instala lint-staged ni se deja `pre-commit` (sin configuración bloquearía todos los commits); el mensaje se valida igual con commitlint. Una configuración de lint-staged existente se respeta. 8. Indica los **plugins de Claude Code** a añadir (shared + por-stack), incluido el de **code intelligence (LSP)** del stack: go-to-definition, referencias y errores de tipos en vivo para el agente. El binario del language server lo instalas tú; el comando exacto sale en pantalla. -9. Imprime un **resumen del proyecto**: stack y lenguaje, framework, paquetes, motores de datos, volumen de código, tests, calidad, entrega (CI/contenedores), git y entorno. Lo marcado con ⚠️ es un hueco frente a la baseline del kit. +9. Con `--pi`, añade la **capa de Pi Agent** (`.pi/`): ver [También con Pi Agent](#también-con-pi-agent). +10. Imprime un **resumen del proyecto**: stack y lenguaje, framework, paquetes, motores de datos, volumen de código, tests, calidad, entrega (CI/contenedores), git y entorno. Lo marcado con ⚠️ es un hueco frente a la baseline del kit. ### Sin TTY (CI, agentes, tuberías) @@ -202,7 +203,7 @@ un comando que toque el sistema, hay que validar que sea seguro. No es solo una | Nivel | Qué cubre | Ejemplos | | --- | --- | --- | -| `deny` | Destructivo, irreversible o fuera del proyecto | `curl … \| sh` · `sudo` · `npm i -g` · `npm publish` · escribir en `/etc`, `/usr` · `dd of=/dev/…` · `chmod 777` · leer `~/.ssh` · `--no-verify` | +| `deny` | Destructivo, irreversible o fuera del proyecto | `curl … \| sh` · `sudo` · `npm i -g` · `npm publish` · escribir en `/etc`, `/usr` · `dd of=/dev/…` · `chmod 777` · leer `~/.ssh` · leer un `.env` real (`cat`, `grep`, `source`…) · `--no-verify` | | `ask` | Mete código de terceros nuevo → lo confirma la persona | `npm install ` · `pip install ` · `npx`/`pnpm dlx`/`yarn dlx`/`bunx`/`uvx`/`pipx run` · `claude mcp add` · `claude-code-templates --agent/--skill` | | libre | No añade superficie de ataque | `npm ci` · `npm install` (del lockfile) · `pip install -r requirements.txt` · `npx --no-install` | @@ -211,9 +212,11 @@ publisher oficial, **versión pineada** (nunca `@latest`/`@alpha`), **publicada seguridad), scripts `pre/postinstall`, y si algo del repo ya lo cubre. Si un comando se bloquea, la respuesta correcta es replantearlo, no esquivar el hook. Como defensa en profundidad, `settings.json` deniega además `sudo`, `publish`, `chmod 777` y la lectura de `~/.ssh` y `~/.aws` por permisos, para el caso de que los hooks estén desactivados. +En [Pi Agent](#también-con-pi-agent), con `--pi`, la extensión del kit aplica estas mismas reglas. Los archivos `.env` reales (también `.env.production`, `.env.local`, `.envrc`, en cualquier carpeta) -no se leen: lo impide el hook `pre-read`. Los ejemplos (`.env.example`, `.env.sample`, +no se leen: lo impide el hook `pre-read`, y `pre-bash` si se intenta desde la shell (`cat`, `grep`, +`head`, `sed`, `source`, una redirección `<`…). Los ejemplos (`.env.example`, `.env.sample`, `.env.template`) sí, porque documentan las variables sin valores. **Regla para cualquier externo** (§6.1.7): nada de pago, que exija API key o cuenta de un tercero, @@ -277,6 +280,59 @@ cubre lo que usábamos. Si un proyecto lo tenía: - El resto (servidores en `.mcp.json`, `.claude-flow/`, `.swarm/`, sus archivos en `.claude/`) se **enumera** con el comando para quitarlo, pero no se borra: no lo instaló el kit directamente. +## También con Pi Agent + +[Pi](https://github.com/earendil-works/pi) es un agente de código open source (MIT) que funciona con +modelos de varios proveedores, también locales. Con `--pi`, el kit añade una capa para Pi que reutiliza +las mismas skills, reglas y helpers: no hay una segunda copia que mantener. + +```bash +npx -y github:lacasoft/dev-starter-kit#v3.3.0 --pi # proyecto nuevo +npx -y github:lacasoft/dev-starter-kit#v3.3.0 --update --pi --yes # proyecto que ya usa el kit +``` + +**Requisitos:** Pi 1.4.0 o superior (paquete `@earendil-works/pi-coding-agent`, pide Node.js 22.19+), +ripgrep y fd instalados con el gestor de paquetes de tu sistema (si faltan, Pi descarga la última versión +sin fijarla) y un modelo que use herramientas de forma confiable. + +| En Claude Code | En Pi | +|---|---| +| `/project-context`, `/tdd`, `/owasp-review`… | `/skill:project-context`, `/skill:tdd`, `/skill:owasp-review`… | +| `/kit:status` | `/status` | +| `CLAUDE.md` con `@imports` y reglas de `.claude/rules/` | La extensión expande los imports y añade las reglas | +| Hooks `PreToolUse` y permisos de `settings.json` | La extensión aplica las mismas reglas (ver abajo) | +| `/swarm`, subagentes de `.claude/agents/` | No disponibles: Pi no tiene subagentes nativos | +| `/loop-task` | Todavía no: su puerta es un hook `Stop` de Claude | + +**Contexto.** Pi lee `CLAUDE.md`, pero no resuelve sus `@imports` ni carga `.claude/rules/`. La extensión +expande `CLAUDE.project.md` y la baseline, añade las reglas que Claude carga siempre (las del stack en un +proyecto de un solo stack y las propias del equipo) y los avisos del inicio de sesión. En un monorepo, las +reglas por carpeta (`paths:`) todavía no se cargan en Pi. + +**Seguridad en Pi.** Pi no tiene sistema de permisos: sin la extensión, ejecuta cualquier herramienta sin +preguntar. Con `--pi`, antes de cada herramienta, incluidas las llamadas anidadas desde codemode, la extensión aplica: +- las mismas reglas que los hooks del kit en Claude (`hook-handler.cjs`): lo destructivo se bloquea, los + secretos en `write`/`edit` también, y lo que mete código de terceros se confirma contigo; +- las reglas `deny` y `ask` de `.claude/settings.json` y `settings.local.json`, también las de tu equipo; +- una confirmación, una vez por sesión, para cada herramienta que el kit no conoce (MCP u otra extensión). + +Sin interfaz (`pi -p`), lo que requiere confirmación se bloquea. Son **guardrails, no aislamiento**: +Pi corre con los permisos de tu usuario y las reglas reconocen patrones, no intenciones. Un script +(`python -c …`) puede leer un archivo que la regla no vio, y el escaneo de secretos no ve lo que un +script escribe. Para garantías reales, un contenedor o una VM. Lo mismo vale para Claude Code. + +**Confianza del proyecto.** Pi solo carga `.pi/` si confías en la carpeta, y `pi --no-extensions` la +apaga. Con la capa activa verás `🧰 dev-kit` abajo; si no lo ves, no hay contexto del kit ni guardrails. + +**Modelos probados.** Con Claude Sonnet 5.5 (API de Anthropic), `/skill:project-context` funcionó de punta a +punta en un proyecto NestJS. En system-design-kit, un modelo local pequeño (qwen3 8B) usa herramientas, +pero no sigue bien el método de las skills. Puedes usar cualquier otro modelo. Para usar modelos de +Claude en Pi necesitas una API key de Anthropic (pago por uso): la suscripción de Claude es solo para +Claude Code y las apps de Anthropic. + +**Privacidad.** Pi envía telemetría anónima de instalación por defecto; `PI_TELEMETRY=0` la apaga. El +kit no añade ninguna. + ## Componentes externos (curados, ver `components.json`) **Integrados por stack** (vía `claude-code-templates`, solo los marcados `integrar`): @@ -362,7 +418,7 @@ recibir la copia vieja. La solución buena es la misma que exige la baseline §6 versión** — y de paso hace la instalación reproducible: ```bash -npx -y github:lacasoft/dev-starter-kit#v3.2.0 --update --yes # por tag +npx -y github:lacasoft/dev-starter-kit#v3.3.0 --update --yes # por tag npx -y github:lacasoft/dev-starter-kit# --update --yes # por commit (el SHA del tag) ``` @@ -378,9 +434,10 @@ Repo **público (MIT)** distribuido por `npx github:` → la rama **`master` deb de helpers del árbol, los stacks por categoría, el número de descartados y que los ejemplos pineados (`#vX.Y.Z`) apunten a la versión de `package.json`. Si reescribes una de esas frases, actualiza el patrón en `scripts/validate.cjs` — la comprobación no debe perderse en silencio. -- **Tests**: `npm test` (`node:test`, sin dependencias) — guards del hook-handler y `--update`. +- **Tests**: `npm test` (`node:test`, sin dependencias) — guards del hook-handler, `--update` y los helpers. + `PI_BIN=$(command -v pi) npm test` corre además la capa de Pi dentro de Pi real, con su modelo simulado (sin API key). - **CI** (`.github/workflows/ci.yml`): valida + tests + smoke-test del instalador en los **13 stacks** - (dry-run) y en un monorepo multi-stack, en cada PR. La lista sale de `scripts/stacks.cjs`, que la deriva de `install.js`. + (dry-run) y en un monorepo multi-stack, en cada PR, más un job que prueba la capa de Pi dentro de Pi 1.4.0. La lista sale de `scripts/stacks.cjs`, que la deriva de `install.js`. - **Cómo añadir** agentes, skills, stacks o externos: ver [CONTRIBUTING.md](CONTRIBUTING.md). - **Versionado**: SemVer en `package.json` + `CHANGELOG.md`, con tag anotado por versión - (`v3.2.0`). Instala siempre pineando (`#v3.2.0`): es reproducible y evita la cache de npx. + (`v3.3.0`). Instala siempre pineando (`#v3.3.0`): es reproducible y evita la cache de npx. diff --git a/package.json b/package.json index c7dce4b..fc9f3c6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@lacasoft/dev-starter-kit", - "version": "3.2.0", + "version": "3.3.0", "private": true, "description": "Base .claude coherente (agentes, skills, helpers, orquestación multiagente nativa) instalable en cualquier proyecto lacasoft (backend/frontend/mobile/blockchain).", "bin": { diff --git a/shared/.claude/CLAUDE.base.md b/shared/.claude/CLAUDE.base.md index 4c69c0b..646f930 100644 --- a/shared/.claude/CLAUDE.base.md +++ b/shared/.claude/CLAUDE.base.md @@ -135,7 +135,7 @@ Nunca, sin autorización explícita de la persona en ese mismo momento: - Saltarse controles de seguridad (`--no-verify`, desactivar hooks, `chmod 777`). - Publicar o desplegar hacia fuera (`npm publish`, deploy a producción). -Esto no es solo documentación: el hook `pre-bash` (`helpers/hook-handler.cjs`) **deniega** lo destructivo y **pide confirmación** ante instalaciones de terceros. Si un comando tuyo se bloquea, la respuesta correcta es replantear el comando, no buscar cómo esquivar el hook. +Esto no es solo documentación: el hook `pre-bash` (`helpers/hook-handler.cjs`) **deniega** lo destructivo y **pide confirmación** ante instalaciones de terceros (en Pi Agent, la extensión del kit aplica las mismas reglas). Si un comando tuyo se bloquea, la respuesta correcta es replantear el comando, no buscar cómo esquivar el hook. ## 7. Calidad