diff --git a/extensions/company-context.md b/extensions/company-context.md new file mode 100644 index 0000000..e7fad38 --- /dev/null +++ b/extensions/company-context.md @@ -0,0 +1,23 @@ +# Корпоративный контекст + +Ты работаешь как инженерный ассистент внутри нашей компании. Учитывай это во всех задачах. + +**Кто мы:** продуктовая инженерная команда. Основной стек — **Go** (микросервисы). + +**Где код:** git-хост — `{{gitHost}}`. +Карта репозиториев: {{repoMap}} +Подробнее — скилл `repo-map`. + +**Трекер задач:** {{trackerUrl}} +Как читать тикеты и работать с задачами — скилл `jira-workflow`. + +**Документация:** {{docsUrl}} +Как искать и куда писать — скилл `docs-map`. + +**Стандарты Go** (структура сервиса, линтеры, тесты, MR) — скилл `go-standards`. + +**Базовые правила:** +{{rules}} + +Не выдумывай внутренние адреса и факты: если чего-то нет в этом контексте или в конфиге — +скажи об этом прямо. Точные действующие значения можно посмотреть командой `/kit-config`. diff --git a/extensions/company-context.ts b/extensions/company-context.ts new file mode 100644 index 0000000..d3ef659 --- /dev/null +++ b/extensions/company-context.ts @@ -0,0 +1,171 @@ +/** + * Company Context Extension + * + * WHAT: On session start, loads config/company.json (the single source of + * truth). If `remoteConfigUrl` is set, tries to fetch a fresh config + * from there with a short timeout and falls back silently to the local + * file on any error. Renders a short corporate context from + * `company-context.md` and injects it into the system prompt. Also + * registers the `/kit-config` command for debugging effective values. + * + * CONFIGURE: Edit config/company.json (gitHost, trackerUrl, docsUrl, repoMap, + * rules, remoteConfigUrl). Edit the wording in company-context.md — + * non-programmers can safely change text between the {{placeholders}}. + * + * DISABLE: Remove/rename this file from the package `extensions/` folder, + * or exclude it via the package manifest / settings. + */ + +import * as fs from "node:fs"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; + +interface CompanyConfig { + gitHost: string; + trackerUrl: string; + docsUrl: string; + repoMap: string; + rules: string[]; + remoteConfigUrl: string | null; +} + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const CONFIG_PATH = path.join(HERE, "..", "config", "company.json"); +const TEMPLATE_PATH = path.join(HERE, "company-context.md"); +const REMOTE_TIMEOUT_MS = 2500; +const MAX_CONTEXT_LINES = 40; + +const DEFAULT_CONFIG: CompanyConfig = { + gitHost: "TODO", + trackerUrl: "TODO", + docsUrl: "TODO", + repoMap: "TODO", + rules: [], + remoteConfigUrl: null, +}; + +/** Read and validate the local config file, falling back to defaults. */ +function readLocalConfig(): CompanyConfig { + try { + const raw = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf8")); + return normalize(raw); + } catch { + return { ...DEFAULT_CONFIG }; + } +} + +/** Keep only known keys and coerce types, so junk in remote/local JSON is ignored. */ +function normalize(raw: Record): CompanyConfig { + const str = (v: unknown, d: string) => (typeof v === "string" ? v : d); + return { + gitHost: str(raw.gitHost, DEFAULT_CONFIG.gitHost), + trackerUrl: str(raw.trackerUrl, DEFAULT_CONFIG.trackerUrl), + docsUrl: str(raw.docsUrl, DEFAULT_CONFIG.docsUrl), + repoMap: str(raw.repoMap, DEFAULT_CONFIG.repoMap), + rules: Array.isArray(raw.rules) ? raw.rules.filter((r): r is string => typeof r === "string") : [], + remoteConfigUrl: typeof raw.remoteConfigUrl === "string" ? raw.remoteConfigUrl : null, + }; +} + +/** Try to fetch a fresh config from a URL. Returns null on any failure. */ +async function fetchRemoteConfig(url: string): Promise { + try { + const res = await fetch(url, { signal: AbortSignal.timeout(REMOTE_TIMEOUT_MS) }); + if (!res.ok) return null; + const raw = (await res.json()) as Record; + return normalize(raw); + } catch { + return null; // network error, timeout, bad JSON — fall back silently + } +} + +/** Load the context template once; fall back to a minimal inline template. */ +function loadTemplate(): string { + try { + return fs.readFileSync(TEMPLATE_PATH, "utf8"); + } catch { + return [ + "# Корпоративный контекст", + "git-хост: {{gitHost}}", + "трекер: {{trackerUrl}}", + "документация: {{docsUrl}}", + "репозитории: {{repoMap}}", + "правила:", + "{{rules}}", + ].join("\n"); + } +} + +/** Render the corporate context from the template + effective config. */ +function renderContext(template: string, cfg: CompanyConfig): string { + const rulesBlock = cfg.rules.length > 0 ? cfg.rules.map((r) => `- ${r}`).join("\n") : "- (правила не заданы)"; + const rendered = template + .replaceAll("{{gitHost}}", cfg.gitHost) + .replaceAll("{{trackerUrl}}", cfg.trackerUrl) + .replaceAll("{{docsUrl}}", cfg.docsUrl) + .replaceAll("{{repoMap}}", cfg.repoMap) + .replaceAll("{{rules}}", rulesBlock); + // Safety cap: never inject more than MAX_CONTEXT_LINES lines. + return rendered.split("\n").slice(0, MAX_CONTEXT_LINES).join("\n").trimEnd(); +} + +export default function companyContextExtension(pi: ExtensionAPI) { + const template = loadTemplate(); + + // Effective config and where it came from. Refreshed on every session start. + let effective: CompanyConfig = readLocalConfig(); + let source: "local" | "remote" = "local"; + + pi.on("session_start", async (_event, ctx) => { + effective = readLocalConfig(); + source = "local"; + + if (effective.remoteConfigUrl) { + const remote = await fetchRemoteConfig(effective.remoteConfigUrl); + if (remote) { + // Remote wins, but keep the local remoteConfigUrl so a bad remote + // value can't orphan future refreshes. + effective = { ...remote, remoteConfigUrl: effective.remoteConfigUrl }; + source = "remote"; + } + } + + if (ctx.hasUI) { + ctx.ui.notify(`pi-kit: корпоративный контекст загружен (источник: ${source})`, "info"); + } + }); + + pi.on("before_agent_start", async (event) => { + const rendered = renderContext(template, effective); + if (!rendered) return undefined; + return { systemPrompt: `${event.systemPrompt}\n\n${rendered}` }; + }); + + pi.registerCommand("kit-config", { + description: "Показать действующие корпоративные значения pi-kit и их источник", + handler: async (_args, ctx) => { + const origin = + source === "remote" + ? `remote (${effective.remoteConfigUrl})` + : `local (${CONFIG_PATH})`; + const lines = [ + `Источник: ${origin}`, + "", + `gitHost: ${effective.gitHost}`, + `trackerUrl: ${effective.trackerUrl}`, + `docsUrl: ${effective.docsUrl}`, + `repoMap: ${effective.repoMap}`, + `remoteConfigUrl: ${effective.remoteConfigUrl ?? "(не задан)"}`, + "rules:", + ...(effective.rules.length > 0 ? effective.rules.map((r) => ` - ${r}`) : [" (нет)"]), + ]; + + if (ctx.hasUI) { + await ctx.ui.select("Действующая конфигурация pi-kit", lines); + } else { + ctx.ui.notify(lines.join("\n"), "info"); + } + }, + }); +} diff --git a/extensions/permission-gate.ts b/extensions/permission-gate.ts new file mode 100644 index 0000000..9887003 --- /dev/null +++ b/extensions/permission-gate.ts @@ -0,0 +1,61 @@ +/** + * Permission Gate Extension + * + * WHAT: Asks for confirmation before running dangerous `bash` commands. + * In non-interactive mode (no UI) such commands are blocked by default. + * Based on the official permission-gate example, with more patterns. + * + * CONFIGURE: Edit the DANGEROUS_PATTERNS constant below. Each entry has a + * regex and a human-readable reason shown in the confirmation prompt. + * + * DISABLE: Remove/rename this file from the package `extensions/` folder, + * or exclude it via the package manifest / settings. + */ + +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; + +// --------------------------------------------------------------------------- +// Dangerous command patterns. Add new entries here. +// Matching is intentionally broad; the user confirms, so false positives are +// cheap while misses can be costly. +// --------------------------------------------------------------------------- +const DANGEROUS_PATTERNS: Array<{ re: RegExp; reason: string }> = [ + { re: /\brm\s+(-\w*r\w*f|-\w*f\w*r|--recursive)/i, reason: "recursive force delete (rm -rf)" }, + { re: /\bgit\s+push\b[^\n]*(--force\b|--force-with-lease\b|\s-f\b)/i, reason: "force push" }, + { + re: /\bgit\s+push\b[^\n]*\b(main|master|release\/[^\s]+)\b/i, + reason: "push to a protected branch (main/master/release/*)", + }, + { + re: /\bkubectl\b[^\n]*(--context[=\s]?\S*prod|--namespace[=\s]?\S*prod|\bctx-prod\b)/i, + reason: "kubectl against a production context/namespace", + }, + { re: /\bdocker\s+system\s+prune\b/i, reason: "docker system prune (removes containers/images/volumes)" }, + { re: /\bsudo\b/i, reason: "elevated privileges (sudo)" }, +]; + +export default function permissionGateExtension(pi: ExtensionAPI) { + pi.on("tool_call", async (event, ctx) => { + if (event.toolName !== "bash") return undefined; + + const command = (event.input.command as string | undefined) ?? ""; + const hit = DANGEROUS_PATTERNS.find((p) => p.re.test(command)); + if (!hit) return undefined; + + if (!ctx.hasUI) { + // Non-interactive mode: cannot ask, so block by default. + return { block: true, reason: `Dangerous command blocked (${hit.reason}); no UI for confirmation` }; + } + + const choice = await ctx.ui.select( + `⚠️ Опасная команда — ${hit.reason}:\n\n ${command}\n\nВыполнить?`, + ["Нет", "Да"], + ); + + if (choice !== "Да") { + return { block: true, reason: `Blocked by user (${hit.reason})` }; + } + + return undefined; + }); +} diff --git a/extensions/protected-paths.ts b/extensions/protected-paths.ts new file mode 100644 index 0000000..60df568 --- /dev/null +++ b/extensions/protected-paths.ts @@ -0,0 +1,92 @@ +/** + * Protected Paths Extension + * + * WHAT: Blocks `write` and `edit` tool calls that target sensitive files + * (secrets, keys, VCS internals, production config). Based on the + * official protected-paths example, extended with glob matching. + * + * CONFIGURE: Edit the PROTECTED_PATHS constant below. Patterns are globs: + * `*` matches within one path segment, + * `**` matches across segments, + * `?` matches a single character. + * A leading `**/` means "in any directory". + * + * DISABLE: Remove/rename this file from the package `extensions/` folder, + * or exclude it via the package manifest / settings. + */ + +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; + +// --------------------------------------------------------------------------- +// Protected path patterns. Add new entries here to widen protection. +// Keep this list conservative — it blocks writes agent-wide. +// --------------------------------------------------------------------------- +const PROTECTED_PATHS: string[] = [ + ".env*", // .env, .env.local, .env.production, ... + "**/secrets/**", // anything under a secrets/ directory + "**/*.pem", // TLS/cert private material + "**/*.key", // private keys + ".git/**", // VCS internals + // Production environment config files (adjust to your conventions): + "**/*.prod.yml", + "**/*.prod.yaml", + "**/*.production.yml", + "**/*.production.yaml", + "**/values-prod.yaml", // Helm prod values + "**/config/prod*", // config/prod.json, config/production.*, ... +]; + +/** + * Compile a glob pattern into a RegExp that matches a path suffix, so the + * pattern matches regardless of whether the path is absolute or relative. + */ +function globToRegExp(glob: string): RegExp { + let g = glob; + if (g.startsWith("**/")) { + g = g.slice(3); // the "(^|/)" prefix already means "in any directory" + } + let re = ""; + for (let i = 0; i < g.length; i++) { + const c = g[i]; + if (c === "*") { + if (g[i + 1] === "*") { + re += ".*"; + i++; + } else { + re += "[^/]*"; + } + } else if (c === "?") { + re += "[^/]"; + } else if ("\\^$+.()|{}[]".includes(c)) { + re += `\\${c}`; + } else { + re += c; + } + } + return new RegExp(`(^|/)${re}$`); +} + +const COMPILED = PROTECTED_PATHS.map((p) => ({ glob: p, re: globToRegExp(p) })); + +export default function protectedPathsExtension(pi: ExtensionAPI) { + pi.on("tool_call", async (event, ctx) => { + if (event.toolName !== "write" && event.toolName !== "edit") { + return undefined; + } + + const raw = event.input.path as string | undefined; + if (!raw) return undefined; + + const path = raw.replace(/\\/g, "/"); // normalize Windows separators + const hit = COMPILED.find((c) => c.re.test(path)); + + if (hit) { + if (ctx.hasUI) { + ctx.ui.notify(`Blocked write to protected path: ${raw} (matches "${hit.glob}")`, "warning"); + } + return { block: true, reason: `Path "${raw}" is protected (pattern "${hit.glob}")` }; + } + + return undefined; + }); +}