diff --git a/.gitignore b/.gitignore index 3c45938..aa37f95 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ node_modules/ *.log .DS_Store +package-lock.json diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..62dceb8 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,25 @@ +# Changelog + +Формат — [Keep a Changelog](https://keepachangelog.com/ru/1.1.0/), версии — [SemVer](https://semver.org/lang/ru/). + +## [Unreleased] + +### Added +- Security guardrails: `secret-scanner` (блок записи секретов), `commit-guard` + (Conventional Commits + скан секретов в сообщении), `llm-redaction` (редакция + секретов перед отправкой в LLM), `audit-log` (локальный аудит + опциональный endpoint). +- DX-команды: `/kit-doctor` (health-check окружения), `/kit-help` (каталог возможностей). +- Каналы установки (`stable`/`beta`) и процесс релизов (`RELEASING.md`); `PI_KIT_CHANNEL`. +- `config/company.json`: поле `auditEndpoint`. +- Тест-харнес guardrails: `npm test`. + +## [0.1.0] + +### Added +- Базовый пакет: extensions (`company-context`, `protected-paths`, `permission-gate`), + скилы (jira-workflow, repo-map, docs-map, go/rust/typescript/python/kotlin/swift-standards), + команды `/bugfix` `/feature` `/review` `/rfc` `/kit-config`. +- `install.sh` с выбором профиля, гейтингом скилов, установкой публичных скилов и + интерактивной настройкой окружения (`~/.config/pi-kit/env.sh`). +- `config/company.json` как единый источник правды + `remoteConfigUrl`. +- Конвенции M.Video: URL (jira/wiki/gitlab), Conventional Commits, деплой `release-*`/`v*`. diff --git a/README.md b/README.md index 4a7b4a8..a3b5a33 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ PI_KIT_PROFILE=backend curl -fsSL https://git.codelab.vc/ai/pi-kit/-/raw/main/in ### Профили и скилы -Общее для всех: команды `/bugfix /feature /review /rfc /kit-config`, guardrails, скилы +Общее для всех: команды `/bugfix /feature /review /rfc /kit-config /kit-doctor /kit-help`, guardrails, скилы `jira-workflow`, `repo-map`, `docs-map`, и публичные `grill-me`, `grill-with-docs`, `code-review`, `diagnosing-bugs`. Дополнительно по профилю: @@ -47,19 +47,22 @@ PI_KIT_PROFILE=backend curl -fsSL https://git.codelab.vc/ai/pi-kit/-/raw/main/in Сменить профиль позже: `PI_KIT_PROFILE=frontend ./install.sh` (или перезапусти установку). -Либо вручную: +Либо вручную (канал `stable` по умолчанию): ```bash -pi install git:git.codelab.vc/ai/pi-kit +pi install git:git.codelab.vc/ai/pi-kit@stable ``` -### Обновление +### Обновление и каналы ```bash pi update --extensions ``` -Пакет установлен без версионного ref, поэтому обновление всегда тянет актуальный `main`. +Установка идёт по **каналу** — движущемуся git-ref: `stable` (проверенные релизы, по умолчанию) +или `beta` (обкатка). `pi update --extensions` следует за твоим каналом. Сменить канал: +`PI_KIT_CHANNEL=beta ./install.sh` (или закрепиться на теге: `PI_KIT_CHANNEL=v1.2.3`). +Процесс релизов — в [`RELEASING.md`](RELEASING.md). ### Команды @@ -70,6 +73,8 @@ pi update --extensions | `/review [фокус]` | Ревью текущего диффа по стандартам Go (с фокусом, если задан) | | `/rfc <тема>` | Черновик RFC по типовому шаблону | | `/kit-config` | Показать действующие корпоративные значения и их источник (local/remote) | +| `/kit-doctor` | Health-check окружения (node, версия/канал, конфиг, env, токены) | +| `/kit-help` | Каталог возможностей: команды, скилы, guardrails | ### Скиллы @@ -82,9 +87,13 @@ pi update --extensions ### Guardrails (встроенная защита) -- **protected-paths** — блокирует запись/редактирование секретов, ключей, `.git/`, прод-конфигов. +- **protected-paths** — блокирует запись/редактирование секретов, ключей, `.git/`, прод-конфигов (по пути). - **permission-gate** — переспрашивает перед опасными командами (`rm -rf`, force push, - push в `main`/`master`/`release/*`, прод-`kubectl`, `docker system prune`, `sudo`). + push в `main`/`master`/`release-*`, тег `v*` (прод-деплой), прод-`kubectl`, `docker system prune`, `sudo`). +- **secret-scanner** — блокирует запись контента, похожего на секрет (по содержимому). +- **commit-guard** — требует Conventional Commits и сканирует сообщение коммита на секреты. +- **llm-redaction** — вырезает секреты из payload перед отправкой в LLM. +- **audit-log** — локальный аудит срабатываний в `~/.config/pi-kit/audit.jsonl` (опц. endpoint). --- @@ -177,13 +186,21 @@ pi-kit/ - Файл в `extensions/*.ts`, `export default function (pi: ExtensionAPI) {}`. - В шапке каждого расширения — блок: **что делает / как настроить / как отключить**. - Пути к файлам пакета резолвь через `import.meta.url`, не через `cwd`. +- Общий код расширений — в `extensions/lib/*.ts` (не `index.ts` → Pi не грузит как расширение). - Pi-библиотеки — только в `peerDependencies` (`"*"`), не бандлить. +- Guardrails/секреты покрыты тестами — `npm test` (`test/guardrails.test.ts`). + +### Релизы + +Раскатка по каналам (`stable`/`beta`) и semver-тегам — процесс в [`RELEASING.md`](RELEASING.md). +`CHANGELOG.md` обновляется в каждом релизе. Аудит-endpoint — поле `auditEndpoint` в `config/company.json`. ### Локальная проверка перед MR ```bash # из корня репозитория pi install ./ # установить пакет из локальной папки +npm test # прогнать тесты guardrails pi # запустить: расширения загрузятся, команды /bugfix и т.д. появятся # опциональный строгий typecheck (нужны peer-типы Pi): diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 0000000..c2a0b50 --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,47 @@ +# Релизы и каналы `@mvideo/pi-kit` + +Пакет исполняет код на машинах всех разработчиков, поэтому раскатка контролируется +**каналами** и **semver-тегами**, а не «всегда latest». + +## Каналы + +- **`stable`** — ветка, на которую сотрудники установлены по умолчанию + (`git:git.codelab.vc/ai/pi-kit@stable`). Указывает на проверенный релиз. +- **`beta`** — ветка для обкатки перед `stable` (по желанию). Установка: `PI_KIT_CHANNEL=beta`. +- Можно закрепиться на конкретный тег: `PI_KIT_CHANNEL=v1.2.3`. + +`pi update --extensions` тянет текущее состояние канала, на который установлен пользователь. + +## Как выпустить релиз + +1. Собрать изменения в `main` через MR с ревью (правило репозитория). +2. Прогнать проверки: `npm test` (guardrails), `npm run typecheck` (при наличии peer-типов), + `pi install ./` + `/kit-doctor`. +3. Обновить версию в `package.json` и запись в `CHANGELOG.md` (раздел из `Unreleased`). +4. Поставить тег на нужный коммит `main`: + ```bash + git tag -a vX.Y.Z -m "vX.Y.Z" + git push origin vX.Y.Z + ``` +5. Перевести канал на этот тег (fast-forward ветки канала): + ```bash + git branch -f beta vX.Y.Z # сначала на beta для обкатки + git push origin beta + # после обкатки: + git branch -f stable vX.Y.Z + git push origin stable + ``` +6. Сотрудники получат обновление обычным `pi update --extensions`. + +## Semver + +- **patch** (`vX.Y.Z+1`) — фиксы guardrails/скилов/шаблонов без изменения поведения команд. +- **minor** — новые скилы/команды/расширения, обратно совместимо. +- **major** — несовместимые изменения (удаление команд, смена формата `config/company.json`). + +## Откат + +Перевести `stable` на предыдущий тег и запушить: +```bash +git branch -f stable vX.Y.(Z-1) && git push origin stable +``` diff --git a/config/company.json b/config/company.json index a0fee83..df62030 100644 --- a/config/company.json +++ b/config/company.json @@ -8,5 +8,6 @@ "не пушить в main", "MR обязателен" ], - "remoteConfigUrl": null + "remoteConfigUrl": null, + "auditEndpoint": null } diff --git a/config/mcp.json b/config/mcp.json new file mode 100644 index 0000000..68b891a --- /dev/null +++ b/config/mcp.json @@ -0,0 +1,35 @@ +{ + "//": "MCP server registry consumed by extensions/mcp-bridge.ts. All servers are DISABLED by default — enable one, set its token env var, then restart pi. URLs are pulled from config/company.json (single source of truth); tokens from env. Templates: {config:KEY} → company.json value, {env:VAR} → environment variable. Requires VPN/creds and the launcher (uvx/npx) to actually connect.", + "servers": { + "jira": { + "enabled": false, + "description": "Jira (Server/DC) через mcp-atlassian (Python, запускается uvx)", + "command": "uvx", + "args": ["mcp-atlassian"], + "env": { + "JIRA_URL": "{config:trackerUrl}", + "JIRA_PERSONAL_TOKEN": "{env:JIRA_TOKEN}" + } + }, + "confluence": { + "enabled": false, + "description": "Confluence (Server/DC) через mcp-atlassian", + "command": "uvx", + "args": ["mcp-atlassian"], + "env": { + "CONFLUENCE_URL": "{config:docsUrl}", + "CONFLUENCE_PERSONAL_TOKEN": "{env:CONFLUENCE_TOKEN}" + } + }, + "gitlab": { + "enabled": false, + "description": "GitLab (self-hosted) через @zereight/mcp-gitlab (запускается npx)", + "command": "npx", + "args": ["-y", "@zereight/mcp-gitlab"], + "env": { + "GITLAB_API_URL": "https://{config:gitHost}/api/v4", + "GITLAB_PERSONAL_ACCESS_TOKEN": "{env:GITLAB_TOKEN}" + } + } + } +} diff --git a/docs/mcp.md b/docs/mcp.md new file mode 100644 index 0000000..04b4341 --- /dev/null +++ b/docs/mcp.md @@ -0,0 +1,47 @@ +# MCP-интеграции (Jira / Confluence / GitLab) + +Pi не имеет встроенного MCP — его добавляет расширение `extensions/mcp-bridge.ts`. Оно читает +`config/mcp.json`, поднимает **включённые** серверы (stdio) и регистрирует их инструменты в pi. +**По умолчанию все серверы выключены.** Статус — команда `/mcp-status`. + +## Как устроено + +- Серверы объявлены в `config/mcp.json`. URL берутся из `config/company.json` (`{config:trackerUrl}`, + `{config:docsUrl}`, `{config:gitHost}`), токены — из окружения (`{env:JIRA_TOKEN}` и т.д.). + В коде и конфиге нет захардкоженных адресов и секретов. +- Bridge подставляет значения при запуске сервера. Если URL/токен не заданы — сервер помечается + `missing-env` и пропускается. + +## Предпосылки (нужны только при включении) + +- **VPN** и доступ к `jira.mvideo.ru` / `wiki.mvideo.ru` / `gitlab.tech.mvideo.ru`. +- MCP SDK: `npm i @modelcontextprotocol/sdk` в пакете (в `optionalDependencies`; ставится только + при включении MCP). +- Лаунчеры серверов: `uvx` (для `mcp-atlassian`) и/или `npx` (для `@zereight/mcp-gitlab`). +- Токены в окружении (см. `~/.config/pi-kit/env.sh`). + +## Включение + +1. В `config/mcp.json` поставь `"enabled": true` нужному серверу. +2. Задай токен в окружении: + - Jira: `JIRA_TOKEN` (PAT Server/DC). + - Confluence: `CONFLUENCE_TOKEN` (PAT Server/DC). + - GitLab: `GITLAB_TOKEN` (scope `api`). +3. Установи SDK (если ещё нет): `npm i @modelcontextprotocol/sdk`. +4. Перезапусти `pi`. Проверь: `/mcp-status` → `connected` и число tools. + +## Серверы + +| Сервер | Пакет | URL из | Токен | +|---|---|---|---| +| `jira` | `mcp-atlassian` (uvx) | `trackerUrl` | `JIRA_TOKEN` | +| `confluence` | `mcp-atlassian` (uvx) | `docsUrl` | `CONFLUENCE_TOKEN` | +| `gitlab` | `@zereight/mcp-gitlab` (npx) | `gitHost` → `/api/v4` | `GITLAB_TOKEN` | + + + +## Безопасность + +MCP-серверы — сторонние процессы с полным доступом к твоим токенам. Включай только одобренные +образы. Инструменты MCP проходят через guardrails pi (permission-gate/audit) как обычные вызовы. diff --git a/extensions/audit-log.ts b/extensions/audit-log.ts new file mode 100644 index 0000000..2b097f2 --- /dev/null +++ b/extensions/audit-log.ts @@ -0,0 +1,51 @@ +/** + * Audit Log Extension + * + * WHAT: Records session starts and slash-command usage to the local audit log + * (~/.config/pi-kit/audit.jsonl). Guardrail blocks are recorded by the + * guardrail extensions themselves via the shared audit() helper. If an + * audit endpoint is configured it is forwarded best-effort (see below). + * + * CONFIGURE: set `auditEndpoint` in config/company.json, or the PI_KIT_AUDIT_URL + * env var, to forward events. Turn everything off with PI_KIT_AUDIT_OFF=1. + * + * DISABLE: remove/rename this file from the package `extensions/` folder. + */ + +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"; +import { audit } from "./lib/audit.ts"; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const CONFIG_PATH = path.join(HERE, "..", "config", "company.json"); + +/** Wire company.json `auditEndpoint` into the env var read by audit() (unless already set). */ +function initEndpointFromConfig(): void { + if (process.env.PI_KIT_AUDIT_URL) return; + try { + const cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf8")); + if (typeof cfg.auditEndpoint === "string" && cfg.auditEndpoint) { + process.env.PI_KIT_AUDIT_URL = cfg.auditEndpoint; + } + } catch { + // No config / unreadable — local-only auditing. + } +} + +export default function auditLogExtension(pi: ExtensionAPI) { + initEndpointFromConfig(); + + pi.on("session_start", async (event) => { + audit({ kind: "session_start", reason: (event as { reason?: string }).reason }); + }); + + // Record slash-command usage (name only, no arguments — args may contain secrets). + pi.on("input", async (event) => { + const text = (event as { text?: string }).text ?? ""; + const m = text.match(/^\/([a-z0-9:_-]+)/i); + if (m) audit({ kind: "command", command: m[1] }); + return { action: "continue" }; + }); +} diff --git a/extensions/commit-guard.ts b/extensions/commit-guard.ts new file mode 100644 index 0000000..d5d29e4 --- /dev/null +++ b/extensions/commit-guard.ts @@ -0,0 +1,60 @@ +/** + * Commit Guard Extension + * + * WHAT: On `git commit -m …`, validates the message against Conventional + * Commits and scans it for secrets. Invalid/secret → confirm (UI) or + * block (no UI). Non-`-m` commits (editor) are left alone. + * + * CONFIGURE: edit COMMIT_TYPES below. Secret patterns live in + * extensions/lib/secrets.ts. + * + * DISABLE: remove/rename this file from the package `extensions/` folder. + */ + +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; +import { scanSecrets } from "./lib/secrets.ts"; +import { audit } from "./lib/audit.ts"; + +// Conventional Commits types accepted in the header. Extend as needed. +const COMMIT_TYPES = ["feat", "fix", "chore", "docs", "refactor", "test", "perf", "build", "ci", "style", "revert"]; +const HEADER_RE = new RegExp(`^(${COMMIT_TYPES.join("|")})(\\(.+\\))?!?: .+`); + +/** Extract the -m / --message value from a `git commit` command, if present. */ +function extractCommitMessage(command: string): string | null { + // Matches -m "msg", -m 'msg', --message=msg, -m msg (best effort). + const m = + command.match(/(?:-m|--message)[=\s]+"([^"]*)"/) ?? + command.match(/(?:-m|--message)[=\s]+'([^']*)'/) ?? + command.match(/(?:-m|--message)[=\s]+(\S+)/); + return m ? m[1] : null; +} + +export default function commitGuardExtension(pi: ExtensionAPI) { + pi.on("tool_call", async (event, ctx) => { + if (event.toolName !== "bash") return undefined; + const command = (event.input.command as string | undefined) ?? ""; + if (!/\bgit\s+commit\b/.test(command)) return undefined; + + const message = extractCommitMessage(command); + if (message === null) return undefined; // editor-based commit; not our concern + + const problems: string[] = []; + if (!HEADER_RE.test(message)) problems.push("не Conventional Commits"); + const secretHits = scanSecrets(message); + if (secretHits.length > 0) problems.push(`секрет (${secretHits.join(", ")})`); + + if (problems.length === 0) return undefined; + + audit({ kind: "guardrail_block", guardrail: "commit-guard", problems, hasSecret: secretHits.length > 0 }); + + if (ctx.hasUI) { + const choice = await ctx.ui.select( + `⚠️ Проблема с сообщением коммита: ${problems.join("; ")}.\nФормат: type(scope): описание. Всё равно закоммитить?`, + ["Нет", "Да"], + ); + if (choice === "Да") return undefined; + } + + return { block: true, reason: `Commit message rejected: ${problems.join("; ")}` }; + }); +} diff --git a/extensions/kit-cli.ts b/extensions/kit-cli.ts new file mode 100644 index 0000000..d9d886a --- /dev/null +++ b/extensions/kit-cli.ts @@ -0,0 +1,118 @@ +/** + * Kit CLI Extension + * + * WHAT: developer-experience commands for the corporate kit. + * /kit-doctor — environment health check (node, kit version, config, env vars, + * env.sh, loaded commands/skills). Read-only, offline-safe. + * /kit-help — catalog of everything this package provides (commands, skills, + * guardrails). + * + * CONFIGURE: nothing to configure. Values are read from config/company.json and + * the environment. (`/kit-config` — in company-context.ts — shows the + * effective corporate config.) + * + * DISABLE: remove/rename this file from the package `extensions/` folder. + */ + +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const PKG_ROOT = path.join(HERE, ".."); +const CONFIG_PATH = path.join(PKG_ROOT, "config", "company.json"); +const ENV_FILE = path.join(os.homedir(), ".config", "pi-kit", "env.sh"); + +const PROVIDER_KEYS = ["ANTHROPIC_API_KEY", "OPENAI_API_KEY", "OPENROUTER_API_KEY", "GEMINI_API_KEY", "GOOGLE_API_KEY"]; + +// Guardrail extensions don't register commands, so list them statically here. +const GUARDRAILS: Array<[string, string]> = [ + ["protected-paths", "блок записи в секреты/ключи/.git/прод-конфиги (по пути)"], + ["permission-gate", "подтверждение опасных bash (rm -rf, force push, release-*/v* деплой, sudo …)"], + ["secret-scanner", "блок записи контента, похожего на секрет"], + ["commit-guard", "Conventional Commits + скан секретов в сообщении коммита"], + ["llm-redaction", "редакция секретов из payload перед отправкой в LLM"], + ["audit-log", "локальный аудит (~/.config/pi-kit/audit.jsonl)"], +]; + +function readVersion(): string { + try { + return JSON.parse(fs.readFileSync(path.join(PKG_ROOT, "package.json"), "utf8")).version ?? "?"; + } catch { + return "?"; + } +} + +function readConfig(): Record { + try { + return JSON.parse(fs.readFileSync(CONFIG_PATH, "utf8")); + } catch { + return {}; + } +} + +/** Show lines in a dismissable selector (UI) or as a notification (no UI). */ +async function show(ctx: any, title: string, lines: string[]): Promise { + if (ctx.hasUI) await ctx.ui.select(title, lines); + else ctx.ui.notify(`${title}\n${lines.join("\n")}`, "info"); +} + +export default function kitCliExtension(pi: ExtensionAPI) { + pi.registerCommand("kit-doctor", { + description: "Проверка окружения pi-kit (node, версия, конфиг, env, токены)", + handler: async (_args, ctx) => { + const ok = (s: string) => `✓ ${s}`; + const warn = (s: string) => `⚠ ${s}`; + const cfg = readConfig(); + const lines: string[] = []; + + lines.push(ok(`Node.js ${process.version}`)); + lines.push(ok(`pi-kit v${readVersion()}${process.env.PI_KIT_CHANNEL ? ` (канал: ${process.env.PI_KIT_CHANNEL})` : ""}`)); + + // Config + const cfgOk = typeof cfg.gitHost === "string" && cfg.gitHost !== "TODO"; + lines.push((cfgOk ? ok : warn)(`config: gitHost=${cfg.gitHost ?? "?"}, tracker=${cfg.trackerUrl ?? "?"}, docs=${cfg.docsUrl ?? "?"}`)); + if (cfg.remoteConfigUrl) lines.push(ok(`remoteConfigUrl задан`)); + + // Env vars (presence only, never values) + for (const v of ["TRACKER_URL", "JIRA_TOKEN", "GITLAB_TOKEN"]) { + lines.push((process.env[v] ? ok : warn)(`${v} ${process.env[v] ? "задан" : "не задан"}`)); + } + const provider = PROVIDER_KEYS.find((k) => process.env[k]); + lines.push((provider ? ok : warn)(provider ? `LLM-ключ: ${provider}` : "LLM-ключ провайдера не найден")); + + // env.sh + lines.push((fs.existsSync(ENV_FILE) ? ok : warn)(`env.sh ${fs.existsSync(ENV_FILE) ? "есть" : "нет"} (${ENV_FILE})`)); + + // Loaded commands/skills + const cmds = pi.getCommands(); + const skills = cmds.filter((c) => c.source === "skill").length; + lines.push(ok(`команд загружено: ${cmds.length} (скилов: ${skills})`)); + + const warns = lines.filter((l) => l.startsWith("⚠")).length; + lines.push("", warns === 0 ? "Итог: всё ок." : `Итог: предупреждений — ${warns}.`); + await show(ctx, "pi-kit doctor", lines); + }, + }); + + pi.registerCommand("kit-help", { + description: "Каталог возможностей pi-kit (команды, скилы, guardrails)", + handler: async (_args, ctx) => { + const cmds = pi.getCommands(); + const mine = cmds.filter((c) => c.sourceInfo?.path?.startsWith(PKG_ROOT)); + const bySource = (src: string) => + mine + .filter((c) => c.source === src) + .sort((a, b) => a.name.localeCompare(b.name)) + .map((c) => ` /${c.name}${c.description ? ` — ${c.description}` : ""}`); + + const lines: string[] = []; + lines.push("— Команды —", ...bySource("extension"), ...bySource("prompt")); + lines.push("", "— Скилы —", ...bySource("skill")); + lines.push("", "— Guardrails (расширения) —", ...GUARDRAILS.map(([n, d]) => ` ${n} — ${d}`)); + await show(ctx, "pi-kit help", lines); + }, + }); +} diff --git a/extensions/lib/audit.ts b/extensions/lib/audit.ts new file mode 100644 index 0000000..77873c9 --- /dev/null +++ b/extensions/lib/audit.ts @@ -0,0 +1,47 @@ +/** + * Shared local audit log used by the guardrail extensions. + * + * WHAT: appends JSONL events to ~/.config/pi-kit/audit.jsonl (chmod 600). + * Never logs secret values — only pattern names, paths, command shape. + * If PI_KIT_AUDIT_URL is set (or company.json auditEndpoint, wired by + * audit-log.ts into that env var), each event is also POSTed best-effort. + * + * DISABLE: remove the audit-log.ts extension, or set PI_KIT_AUDIT_OFF=1. + */ + +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; + +const DIR = path.join(os.homedir(), ".config", "pi-kit"); +const FILE = path.join(DIR, "audit.jsonl"); +const POST_TIMEOUT_MS = 1500; + +export interface AuditEntry { + kind: string; // e.g. "guardrail_block", "command", "session_start" + [key: string]: unknown; +} + +/** Append an audit entry locally and optionally forward it to an endpoint. */ +export function audit(entry: AuditEntry): void { + if (process.env.PI_KIT_AUDIT_OFF === "1") return; + const line = JSON.stringify({ t: new Date().toISOString(), ...entry }); + + try { + fs.mkdirSync(DIR, { recursive: true, mode: 0o700 }); + fs.appendFileSync(FILE, `${line}\n`, { mode: 0o600 }); + } catch { + // Auditing must never break the agent; swallow local write errors. + } + + const url = process.env.PI_KIT_AUDIT_URL; + if (url) { + // Best-effort, fire-and-forget. Never awaited; failures are ignored. + fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: line, + signal: AbortSignal.timeout(POST_TIMEOUT_MS), + }).catch(() => {}); + } +} diff --git a/extensions/lib/secrets.ts b/extensions/lib/secrets.ts new file mode 100644 index 0000000..cd241a9 --- /dev/null +++ b/extensions/lib/secrets.ts @@ -0,0 +1,49 @@ +/** + * Shared secret detection/redaction used by secret-scanner, commit-guard and + * llm-redaction extensions. + * + * CONFIGURE: add entries to SECRET_PATTERNS below. Each pattern has a `name` + * (shown in warnings/audit, never the secret itself) and a `re` (no global + * flag — the global flag is added on demand for redaction). + */ + +export interface SecretPattern { + name: string; + re: RegExp; +} + +// Patterns are deliberately conservative to limit false positives. The user +// still confirms interactively, so a rare false positive is cheap. +export const SECRET_PATTERNS: SecretPattern[] = [ + { name: "aws-access-key-id", re: /\bAKIA[0-9A-Z]{16}\b/ }, + { name: "gitlab-pat", re: /\bglpat-[0-9A-Za-z_-]{20,}\b/ }, + { name: "github-pat", re: /\bghp_[0-9A-Za-z]{36}\b/ }, + { name: "github-fine-grained-pat", re: /\bgithub_pat_[0-9A-Za-z_]{22,}\b/ }, + { name: "slack-token", re: /\bxox[baprs]-[0-9A-Za-z-]{10,}\b/ }, + { name: "private-key-block", re: /-----BEGIN (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----/ }, + { name: "jwt", re: /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/ }, + // Generic "secret: value" / "token=value" assignments with a non-trivial value. + { name: "generic-credential", re: /\b(?:password|passwd|secret|token|api[_-]?key)\s*[:=]\s*["']?[^\s"']{8,}["']?/i }, +]; + +/** Return the names of secret patterns found in `text` (empty if none). */ +export function scanSecrets(text: string): string[] { + if (!text) return []; + const hits: string[] = []; + for (const p of SECRET_PATTERNS) { + // Use a non-stateful copy (no global flag) to avoid lastIndex surprises. + if (p.re.test(text)) hits.push(p.name); + } + return hits; +} + +/** Replace every secret occurrence with a labelled placeholder. */ +export function redact(text: string): string { + if (!text) return text; + let out = text; + for (const p of SECRET_PATTERNS) { + const g = new RegExp(p.re.source, p.re.flags.includes("g") ? p.re.flags : `${p.re.flags}g`); + out = out.replace(g, `«REDACTED:${p.name}»`); + } + return out; +} diff --git a/extensions/llm-redaction.ts b/extensions/llm-redaction.ts new file mode 100644 index 0000000..85a7c39 --- /dev/null +++ b/extensions/llm-redaction.ts @@ -0,0 +1,51 @@ +/** + * LLM Redaction Extension + * + * WHAT: Before each provider request, redacts secrets from the outbound + * payload so tokens/keys never leave the machine in a prompt. Uses the + * `before_provider_request` hook and returns a modified payload. + * + * CONFIGURE: secret patterns live in extensions/lib/secrets.ts. + * + * DISABLE: remove/rename this file from the package `extensions/` folder. + */ + +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; +import { redact } from "./lib/secrets.ts"; +import { audit } from "./lib/audit.ts"; + +/** Recursively redact secrets in every string within a JSON-like value. */ +function redactDeep(value: unknown, counter: { n: number }): unknown { + if (typeof value === "string") { + const cleaned = redact(value); + if (cleaned !== value) counter.n++; + return cleaned; + } + if (Array.isArray(value)) return value.map((v) => redactDeep(v, counter)); + if (value && typeof value === "object") { + const out: Record = {}; + for (const [k, v] of Object.entries(value as Record)) { + out[k] = redactDeep(v, counter); + } + return out; + } + return value; +} + +export default function llmRedactionExtension(pi: ExtensionAPI) { + pi.on("before_provider_request", async (event) => { + const payload = event.payload; + if (!payload) return undefined; + + const counter = { n: 0 }; + // Only rewrite the message content to avoid disturbing payload metadata. + const messages = (payload as { messages?: unknown }).messages; + if (messages === undefined) return undefined; + + const redactedMessages = redactDeep(messages, counter); + if (counter.n === 0) return undefined; + + audit({ kind: "redaction", occurrences: counter.n }); + return { ...payload, messages: redactedMessages }; + }); +} diff --git a/extensions/mcp-bridge.ts b/extensions/mcp-bridge.ts new file mode 100644 index 0000000..61b1aaf --- /dev/null +++ b/extensions/mcp-bridge.ts @@ -0,0 +1,156 @@ +/** + * MCP Bridge Extension + * + * WHAT: Pi has no built-in MCP. This bridge reads config/mcp.json, and for each + * ENABLED server launches it (stdio) via the MCP SDK and registers its + * tools into pi. URLs come from config/company.json ({config:KEY}), tokens + * from the environment ({env:VAR}) — nothing hardcoded. All servers are + * disabled by default; `/mcp-status` shows their state. + * + * ENABLE: set a server's "enabled": true in config/mcp.json, provide its token + * env var, install the MCP SDK once (`npm i @modelcontextprotocol/sdk` in + * the package, or globally) and the launcher (uvx/npx), then restart pi. + * Requires network access to the corporate services (VPN). + * + * DISABLE: keep servers disabled, or remove/rename this file. + */ + +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"; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const MCP_CONFIG = path.join(HERE, "..", "config", "mcp.json"); +const COMPANY_CONFIG = path.join(HERE, "..", "config", "company.json"); +const CONNECT_TIMEOUT_MS = 8000; + +interface ServerSpec { + enabled?: boolean; + description?: string; + command: string; + args?: string[]; + env?: Record; +} + +type Status = "disabled" | "connected" | "error" | "no-sdk" | "missing-env"; +interface ServerState { + description: string; + status: Status; + tools: string[]; + detail?: string; +} + +function readJson(file: string): Record { + try { + return JSON.parse(fs.readFileSync(file, "utf8")); + } catch { + return {}; + } +} + +/** Replace {config:KEY} and {env:VAR} tokens; returns null if any resolve empty. */ +function resolveEnv( + env: Record, + company: Record, +): Record | null { + const out: Record = {}; + for (const [key, template] of Object.entries(env)) { + let missing = false; + const value = template.replace(/\{(config|env):([^}]+)\}/g, (_m, kind, name) => { + const v = kind === "config" ? company[name] : process.env[name]; + if (typeof v !== "string" || v === "" || v === "TODO") missing = true; + return typeof v === "string" ? v : ""; + }); + if (missing) return null; + out[key] = value; + } + return out; +} + +export default async function mcpBridgeExtension(pi: ExtensionAPI) { + const mcpCfg = readJson(MCP_CONFIG); + const company = readJson(COMPANY_CONFIG); + const servers = (mcpCfg.servers ?? {}) as Record; + const state = new Map(); + const closers: Array<() => Promise | void> = []; + + // Seed status for every configured server. + for (const [key, spec] of Object.entries(servers)) { + state.set(key, { description: spec.description ?? key, status: spec.enabled ? "error" : "disabled", tools: [] }); + } + + pi.registerCommand("mcp-status", { + description: "Показать сконфигурированные MCP-серверы (Jira/Confluence/GitLab) и их статус", + handler: async (_args, ctx) => { + const lines: string[] = []; + for (const [key, s] of state) { + const t = s.tools.length ? ` — ${s.tools.length} tools` : ""; + lines.push(`${key}: ${s.status}${t}${s.detail ? ` (${s.detail})` : ""}`); + lines.push(` ${s.description}`); + } + if (lines.length === 0) lines.push("MCP-серверы не сконфигурированы (config/mcp.json)."); + lines.push("", "Включить: enabled:true в config/mcp.json + токен в env + перезапуск pi."); + if (ctx.hasUI) await ctx.ui.select("MCP-серверы", lines); + else ctx.ui.notify(lines.join("\n"), "info"); + }, + }); + + // Connect enabled servers. Nothing runs here when all are disabled (default). + for (const [key, spec] of Object.entries(servers)) { + if (!spec.enabled) continue; + const st = state.get(key)!; + + const env = resolveEnv(spec.env ?? {}, company); + if (env === null) { + st.status = "missing-env"; + st.detail = "нет URL/токена (config/env)"; + continue; + } + + try { + // Dynamically import so a missing SDK never breaks loading for others. + const { Client } = await import("@modelcontextprotocol/sdk/client/index.js"); + const { StdioClientTransport } = await import("@modelcontextprotocol/sdk/client/stdio.js"); + + const transport = new StdioClientTransport({ + command: spec.command, + args: spec.args ?? [], + env: { ...process.env, ...env } as Record, + }); + const client = new Client({ name: `pi-kit-${key}`, version: "0.1.0" }, { capabilities: {} }); + await Promise.race([ + client.connect(transport), + new Promise((_r, reject) => setTimeout(() => reject(new Error("timeout")), CONNECT_TIMEOUT_MS)), + ]); + closers.push(() => client.close().catch(() => {})); + + const { tools } = await client.listTools(); + for (const tool of tools ?? []) { + const toolName = `${key}_${tool.name}`; + pi.registerTool({ + name: toolName, + label: toolName, + description: tool.description ?? `${key} MCP tool ${tool.name}`, + // MCP inputSchema is JSON Schema; pi accepts a JSON-Schema-shaped object. + parameters: (tool.inputSchema as unknown) ?? { type: "object", properties: {} }, + async execute(_id: string, params: unknown) { + const res: any = await client.callTool({ name: tool.name, arguments: (params ?? {}) as Record }); + return { content: res.content ?? [{ type: "text", text: JSON.stringify(res) }], details: {} }; + }, + }); + st.tools.push(toolName); + } + st.status = "connected"; + // Connection status is visible via /mcp-status (no ctx in the factory). + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + st.status = /Cannot find( package)?|ERR_MODULE_NOT_FOUND|@modelcontextprotocol/.test(msg) ? "no-sdk" : "error"; + st.detail = st.status === "no-sdk" ? "установи @modelcontextprotocol/sdk" : msg.slice(0, 80); + } + } + + pi.on("session_shutdown", async () => { + for (const close of closers) await close(); + }); +} diff --git a/extensions/secret-scanner.ts b/extensions/secret-scanner.ts new file mode 100644 index 0000000..d885aea --- /dev/null +++ b/extensions/secret-scanner.ts @@ -0,0 +1,46 @@ +/** + * Secret Scanner Extension + * + * WHAT: Blocks `write`/`edit` tool calls whose content looks like a secret + * (API keys, tokens, private keys, credentials). Complements + * protected-paths (which blocks by path) by scanning content. + * + * CONFIGURE: edit SECRET_PATTERNS in extensions/lib/secrets.ts. + * + * DISABLE: remove/rename this file from the package `extensions/` folder. + */ + +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; +import { scanSecrets } from "./lib/secrets.ts"; +import { audit } from "./lib/audit.ts"; + +export default function secretScannerExtension(pi: ExtensionAPI) { + pi.on("tool_call", async (event, ctx) => { + if (event.toolName !== "write" && event.toolName !== "edit") return undefined; + + // Scan every string field of the input except the path (content, new_string, + // etc.) so we don't depend on a specific tool's field naming. + const input = event.input as Record; + const parts: string[] = []; + for (const [key, value] of Object.entries(input)) { + if (key === "path") continue; + if (typeof value === "string") parts.push(value); + } + + const hits = scanSecrets(parts.join("\n")); + if (hits.length === 0) return undefined; + + const path = typeof input.path === "string" ? input.path : "(unknown)"; + audit({ kind: "guardrail_block", guardrail: "secret-scanner", tool: event.toolName, path, patterns: hits }); + + if (ctx.hasUI) { + const choice = await ctx.ui.select( + `⚠️ Похоже на секрет (${hits.join(", ")}) в записи → ${path}\nВсё равно записать?`, + ["Нет", "Да"], + ); + if (choice === "Да") return undefined; + } + + return { block: true, reason: `Possible secret in content (${hits.join(", ")})` }; + }); +} diff --git a/install.sh b/install.sh index 5200103..008af7e 100755 --- a/install.sh +++ b/install.sh @@ -14,10 +14,14 @@ set -euo pipefail # --------------------------------------------------------------------------- -# The ONLY place the repository address appears. No version ref on purpose, so -# `pi update --extensions` always pulls the latest main. +# The ONLY place the repository address appears. Employees install a CHANNEL +# (a moving git ref): `stable` (default, vetted releases) or `beta`. Maintainers +# fast-forward the channel branch to a tagged release. `pi update --extensions` +# then follows that channel. Override with PI_KIT_CHANNEL=beta (or a tag like v1.2.3). # --------------------------------------------------------------------------- -PI_KIT_REPO="git:git.codelab.vc/ai/pi-kit" +PI_KIT_HOST="git.codelab.vc/ai/pi-kit" +PI_KIT_CHANNEL="${PI_KIT_CHANNEL:-stable}" +PI_KIT_REPO="git:${PI_KIT_HOST}@${PI_KIT_CHANNEL}" MIN_NODE_MAJOR=20 INTERNAL_COURSE_URL="TODO: ссылка на внутренний курс по Pi" @@ -316,15 +320,16 @@ fi cat < /feature <тикет> - /review [фокус] /rfc <тема> /kit-config - Проверка: /kit-config — покажет действующие корпоративные значения + Сменить канал: PI_KIT_CHANNEL=beta ./install.sh + Команды: /bugfix <тикет> /feature <тикет> /review [фокус] /rfc <тема> + /kit-config /kit-doctor /kit-help + Проверка: /kit-doctor — health-check окружения Внутренний курс по Pi: ${INTERNAL_COURSE_URL} ──────────────────────────────────────────────────────── diff --git a/package.json b/package.json index 050dcdb..7b68926 100644 --- a/package.json +++ b/package.json @@ -27,15 +27,19 @@ ] }, "scripts": { - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "test": "node --experimental-strip-types test/guardrails.test.ts" }, "peerDependencies": { - "@earendil-works/pi-coding-agent": "*", "@earendil-works/pi-ai": "*", + "@earendil-works/pi-coding-agent": "*", "@earendil-works/pi-tui": "*", "typebox": "*" }, "dependencies": {}, + "optionalDependencies": { + "@modelcontextprotocol/sdk": "^1.29.0" + }, "devDependencies": { "typescript": "^5" } diff --git a/test/guardrails.test.ts b/test/guardrails.test.ts new file mode 100644 index 0000000..0cd2c4d --- /dev/null +++ b/test/guardrails.test.ts @@ -0,0 +1,80 @@ +/** + * Behavioral tests for the guardrail extensions. No test framework — run with: + * node --experimental-strip-types test/guardrails.test.ts + * (or `npm test`). Exits non-zero on any failure. + * + * Extensions only `import type` the Pi API, so they run here with node's + * type-stripping and mocked pi/ctx objects. + */ + +process.env.PI_KIT_AUDIT_OFF = "1"; // don't touch the real audit log during tests + +import protectedPaths from "../extensions/protected-paths.ts"; +import permissionGate from "../extensions/permission-gate.ts"; +import secretScanner from "../extensions/secret-scanner.ts"; +import commitGuard from "../extensions/commit-guard.ts"; +import llmRedaction from "../extensions/llm-redaction.ts"; +import { redact, scanSecrets } from "../extensions/lib/secrets.ts"; + +let fails = 0; +function check(name: string, cond: boolean) { + console.log(`${cond ? "PASS" : "FAIL"} ${name}`); + if (!cond) fails++; +} + +/** Capture the handler an extension registers for a given event. */ +function grab(ext: (pi: any) => void, evName: string): any { + let handler: any; + ext({ on: (ev: string, fn: any) => { if (ev === evName) handler = fn; }, registerCommand() {} }); + return handler; +} + +const noUI: any = { hasUI: false }; +const AKIA = "AKIA" + "ABCDEFGHIJKLMNOP"; +const GLPAT = "glpat-" + "aBcDeF1234567890ghXY12"; // 22 chars after prefix (>=20) +const PRIVKEY = "-----BEGIN RSA PRIVATE KEY-----"; +const JWT = + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U"; + +async function run() { + // --- protected-paths --- + const pp = grab(protectedPaths, "tool_call"); + check("protected: block .env", !!(await pp({ toolName: "write", input: { path: ".env" } }, noUI))?.block); + check("protected: allow src/app.go", !(await pp({ toolName: "write", input: { path: "src/app.go" } }, noUI))?.block); + + // --- permission-gate --- + const pg = grab(permissionGate, "tool_call"); + check("perm: block push v* tag", !!(await pg({ toolName: "bash", input: { command: "git push origin v1.2.3" } }, noUI))?.block); + check("perm: allow ls", !(await pg({ toolName: "bash", input: { command: "ls -la" } }, noUI))?.block); + + // --- secret-scanner --- + const ss = grab(secretScanner, "tool_call"); + check("secret: block write AKIA", !!(await ss({ toolName: "write", input: { path: "cfg.txt", content: `key=${AKIA}` } }, noUI))?.block); + check("secret: block edit private key", !!(await ss({ toolName: "edit", input: { path: "id_rsa", new_string: PRIVKEY } }, noUI))?.block); + check("secret: allow clean content", !(await ss({ toolName: "write", input: { path: "a.go", content: "package main" } }, noUI))?.block); + check("secret: ignore non-write tool", (await ss({ toolName: "bash", input: { command: "echo hi" } }, noUI)) === undefined); + + // --- commit-guard --- + const cg = grab(commitGuard, "tool_call"); + check("commit: block non-conventional", !!(await cg({ toolName: "bash", input: { command: 'git commit -m "fixed stuff"' } }, noUI))?.block); + check("commit: allow conventional", !(await cg({ toolName: "bash", input: { command: 'git commit -m "feat(auth): add login"' } }, noUI))?.block); + check("commit: block secret in message", !!(await cg({ toolName: "bash", input: { command: `git commit -m "chore: token ${GLPAT}"` } }, noUI))?.block); + check("commit: ignore editor commit", (await cg({ toolName: "bash", input: { command: "git commit" } }, noUI)) === undefined); + + // --- llm-redaction --- + const lr = grab(llmRedaction, "before_provider_request"); + const payload = { model: "x", messages: [{ role: "user", content: `here is my token ${GLPAT}` }] }; + const out: any = await lr({ payload }); + const redacted = JSON.stringify(out?.messages ?? []); + check("redact: token removed from payload", !redacted.includes(GLPAT) && redacted.includes("REDACTED")); + check("redact: no-op when clean", (await lr({ payload: { messages: [{ role: "user", content: "hello" }] } })) === undefined); + + // --- lib units --- + check("scanSecrets finds jwt", scanSecrets(JWT).length > 0); + check("redact keeps clean text", redact("just normal text") === "just normal text"); + + console.log(fails === 0 ? "\nALL PASS" : `\n${fails} FAILED`); + process.exit(fails === 0 ? 0 : 1); +} + +run();