Compare commits
9 Commits
295f620710
...
v0.2.1
| Author | SHA1 | Date | |
|---|---|---|---|
| 1df4ea1b72 | |||
| de006ccf0c | |||
| be2d445a09 | |||
| 38f4ecf81f | |||
| 2995823a0f | |||
| f4ebdd745a | |||
| 3f987cecc5 | |||
| 5f8f5e8bf9 | |||
| 7e50043984 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,3 +1,4 @@
|
||||
node_modules/
|
||||
*.log
|
||||
.DS_Store
|
||||
package-lock.json
|
||||
|
||||
38
CHANGELOG.md
Normal file
38
CHANGELOG.md
Normal file
@@ -0,0 +1,38 @@
|
||||
# Changelog
|
||||
|
||||
Формат — [Keep a Changelog](https://keepachangelog.com/ru/1.1.0/), версии — [SemVer](https://semver.org/lang/ru/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.2.1] — 2026-07-16
|
||||
|
||||
### Fixed
|
||||
- Установочный one-liner: `git.codelab.vc` — это Gitea/Forgejo, а не GitLab; правильный
|
||||
raw-путь — `/raw/branch/stable/install.sh` (старый `/-/raw/main/` отдавал 404, «установка
|
||||
одной командой» не работала). Скрипт теперь берётся с канала `stable` (vetted).
|
||||
|
||||
## [0.2.0] — 2026-07-16
|
||||
|
||||
### Fixed
|
||||
- `install.sh` проверяет наличие `git` (нужен для `pi install git:...`) — на машине без git
|
||||
установка падала позже с непонятной ошибкой. Найдено при прогоне в чистой cps-песочнице.
|
||||
|
||||
### 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*`.
|
||||
35
README.md
35
README.md
@@ -13,7 +13,7 @@ guardrails и скиллы со знанием того, где у нас код
|
||||
### Установка (одной командой)
|
||||
|
||||
```bash
|
||||
curl -fsSL https://git.codelab.vc/ai/pi-kit/-/raw/main/install.sh | bash
|
||||
curl -fsSL https://git.codelab.vc/ai/pi-kit/raw/branch/stable/install.sh | bash
|
||||
```
|
||||
|
||||
Скрипт проверит Node.js (>= 20), поставит Pi (если нет), установит этот пакет, **спросит
|
||||
@@ -29,12 +29,12 @@ Pi провайдер-агностичен — модель/провайдера
|
||||
Профиль можно задать заранее (для CI/неинтерактивной установки):
|
||||
|
||||
```bash
|
||||
PI_KIT_PROFILE=backend curl -fsSL https://git.codelab.vc/ai/pi-kit/-/raw/main/install.sh | bash
|
||||
PI_KIT_PROFILE=backend curl -fsSL https://git.codelab.vc/ai/pi-kit/raw/branch/stable/install.sh | bash
|
||||
```
|
||||
|
||||
### Профили и скилы
|
||||
|
||||
Общее для всех: команды `/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):
|
||||
|
||||
47
RELEASING.md
Normal file
47
RELEASING.md
Normal file
@@ -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
|
||||
```
|
||||
@@ -8,5 +8,6 @@
|
||||
"не пушить в main",
|
||||
"MR обязателен"
|
||||
],
|
||||
"remoteConfigUrl": null
|
||||
"remoteConfigUrl": null,
|
||||
"auditEndpoint": null
|
||||
}
|
||||
|
||||
35
config/mcp.json
Normal file
35
config/mcp.json
Normal file
@@ -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}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
47
docs/mcp.md
Normal file
47
docs/mcp.md
Normal file
@@ -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` |
|
||||
|
||||
<!-- TODO: сверить с корпоративной политикой — какие MCP-серверы одобрены, разрешён ли uvx/npx
|
||||
на машинах, или нужен предустановленный/зеркалированный образ. -->
|
||||
|
||||
## Безопасность
|
||||
|
||||
MCP-серверы — сторонние процессы с полным доступом к твоим токенам. Включай только одобренные
|
||||
образы. Инструменты MCP проходят через guardrails pi (permission-gate/audit) как обычные вызовы.
|
||||
51
extensions/audit-log.ts
Normal file
51
extensions/audit-log.ts
Normal file
@@ -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" };
|
||||
});
|
||||
}
|
||||
60
extensions/commit-guard.ts
Normal file
60
extensions/commit-guard.ts
Normal file
@@ -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("; ")}` };
|
||||
});
|
||||
}
|
||||
118
extensions/kit-cli.ts
Normal file
118
extensions/kit-cli.ts
Normal file
@@ -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<string, unknown> {
|
||||
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<void> {
|
||||
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);
|
||||
},
|
||||
});
|
||||
}
|
||||
47
extensions/lib/audit.ts
Normal file
47
extensions/lib/audit.ts
Normal file
@@ -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(() => {});
|
||||
}
|
||||
}
|
||||
49
extensions/lib/secrets.ts
Normal file
49
extensions/lib/secrets.ts
Normal file
@@ -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;
|
||||
}
|
||||
51
extensions/llm-redaction.ts
Normal file
51
extensions/llm-redaction.ts
Normal file
@@ -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<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
||||
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 };
|
||||
});
|
||||
}
|
||||
156
extensions/mcp-bridge.ts
Normal file
156
extensions/mcp-bridge.ts
Normal file
@@ -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<string, string>;
|
||||
}
|
||||
|
||||
type Status = "disabled" | "connected" | "error" | "no-sdk" | "missing-env";
|
||||
interface ServerState {
|
||||
description: string;
|
||||
status: Status;
|
||||
tools: string[];
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
function readJson(file: string): Record<string, unknown> {
|
||||
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<string, string>,
|
||||
company: Record<string, unknown>,
|
||||
): Record<string, string> | null {
|
||||
const out: Record<string, string> = {};
|
||||
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<string, ServerSpec>;
|
||||
const state = new Map<string, ServerState>();
|
||||
const closers: Array<() => Promise<void> | 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<string, string>,
|
||||
});
|
||||
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<string, unknown> });
|
||||
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();
|
||||
});
|
||||
}
|
||||
46
extensions/secret-scanner.ts
Normal file
46
extensions/secret-scanner.ts
Normal file
@@ -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<string, unknown>;
|
||||
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(", ")})` };
|
||||
});
|
||||
}
|
||||
30
install.sh
30
install.sh
@@ -3,7 +3,7 @@
|
||||
# pi-kit bootstrap — one-shot setup for new engineers. Idempotent: safe to re-run.
|
||||
#
|
||||
# Usage:
|
||||
# curl -fsSL https://git.codelab.vc/ai/pi-kit/-/raw/main/install.sh | bash
|
||||
# curl -fsSL https://git.codelab.vc/ai/pi-kit/raw/branch/stable/install.sh | bash
|
||||
# # or, from a checkout:
|
||||
# ./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"
|
||||
@@ -48,6 +52,13 @@ if [ "$NODE_MAJOR" -lt "$MIN_NODE_MAJOR" ]; then
|
||||
fi
|
||||
info "Node.js $(node -v) — ок."
|
||||
|
||||
# --- 1b. git (required by `pi install git:...`) ------------------------------
|
||||
if ! command -v git >/dev/null 2>&1; then
|
||||
err "git не найден. Установи git (macOS: xcode-select --install; Ubuntu: apt install git) и запусти скрипт снова."
|
||||
exit 1
|
||||
fi
|
||||
info "git $(git --version | awk '{print $3}') — ок."
|
||||
|
||||
# --- 2. Pi ------------------------------------------------------------------
|
||||
if command -v pi >/dev/null 2>&1; then
|
||||
info "Pi уже установлен ($(pi --version 2>/dev/null || echo 'версия неизвестна'))."
|
||||
@@ -316,15 +327,16 @@ fi
|
||||
cat <<EOF
|
||||
|
||||
────────────────────────────────────────────────────────
|
||||
Готово! Профиль: ${PROFILE}
|
||||
Готово! Профиль: ${PROFILE} Канал: ${PI_KIT_CHANNEL}
|
||||
|
||||
Запуск: pi
|
||||
Обновление: pi update --extensions
|
||||
Обновление: pi update --extensions (следует за каналом ${PI_KIT_CHANNEL})
|
||||
Окружение: ${ENV_FILE} (TRACKER_URL/JIRA_TOKEN/GITLAB_TOKEN/ключ провайдера)
|
||||
Сменить профиль: PI_KIT_PROFILE=backend ./install.sh (или перезапусти и выбери)
|
||||
Команды: /bugfix <тикет> /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}
|
||||
────────────────────────────────────────────────────────
|
||||
|
||||
10
package.json
10
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mvideo/pi-kit",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Корпоративный Pi-пакет: шаблоны задач, guardrails и скиллы для команды.",
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
80
test/guardrails.test.ts
Normal file
80
test/guardrails.test.ts
Normal file
@@ -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();
|
||||
Reference in New Issue
Block a user