Compare commits

9 Commits

Author SHA1 Message Date
1df4ea1b72 fix(docs): correct install one-liner raw URL (Gitea, not GitLab) + v0.2.1
git.codelab.vc is Gitea/Forgejo: raw path is /raw/branch/<ref>/, the GitLab-style
/-/raw/main/ returned 404 so the one-command install was broken. The one-liner
now also fetches the script from the stable channel (vetted), not main.
2026-07-16 15:24:47 +03:00
de006ccf0c chore(release): v0.2.0 2026-07-16 15:17:05 +03:00
be2d445a09 fix(install): require git alongside Node (pi install git: needs it)
Found in a clean cps sandbox run: a machine without git passes the Node check
but fails later inside 'pi install git:...' with an unclear error. Now checked
up front with an actionable message. CHANGELOG updated.

Decision on optionalDependencies (@modelcontextprotocol/sdk): KEEP. The heavy
install observed in the sandbox (~317 packages) is dominated by npm>=7
auto-installing the Pi peerDependencies stack, not the MCP SDK; removing the
SDK saves little and would make MCP enablement fragile across pi updates.
2026-07-16 15:12:12 +03:00
38f4ecf81f Merge harness hardening: security guardrails, DX commands, release channels, MCP scaffold 2026-07-16 14:36:29 +03:00
2995823a0f feat(mcp): scaffold Jira/Confluence/GitLab MCP bridge (disabled by default)
Pi has no built-in MCP; this adds a bridge extension + declarative registry.
- config/mcp.json: jira/confluence/gitlab servers, all enabled:false; URLs from
  company.json ({config:...}), tokens from env ({env:...}) — nothing hardcoded
- extensions/mcp-bridge.ts: resolves env templates, and for enabled servers
  connects via MCP SDK (dynamic import) and registers their tools; /mcp-status
  command; graceful no-op when disabled or SDK absent
- docs/mcp.md: how to enable, prerequisites (VPN, tokens, uvx/npx, SDK), security
- package.json: @modelcontextprotocol/sdk as optionalDependency; ignore package-lock

Verified offline: extension loads, /mcp-status lists 3 servers (disabled), env
resolver builds gitlab API URL from company.json gitHost + GITLAB_TOKEN. Live
connect needs VPN + a token + authorization to run the server package.
2026-07-16 14:33:21 +03:00
f4ebdd745a docs(readme): document new commands, guardrails, channels
- command table: /kit-doctor, /kit-help
- guardrails: secret-scanner, commit-guard, llm-redaction, audit-log; permission-gate updated to release-*/v*
- install/update: channels (stable/beta), PI_KIT_CHANNEL, link to RELEASING.md
- maintainer: extensions/lib note, npm test, releases section
2026-07-16 13:51:56 +03:00
3f987cecc5 feat(release): install channels (stable/beta) + RELEASING/CHANGELOG
- install.sh installs a moving channel ref: git:...@stable by default, PI_KIT_CHANNEL=beta|vX.Y.Z to override; pi update follows the channel
- cheat sheet shows channel + new commands (/kit-doctor, /kit-help)
- RELEASING.md: channel/tag release + rollback process; CHANGELOG.md (Keep a Changelog)
- supersedes earlier 'no ref = latest' for controlled rollout

Verified: install.sh syntax + channel resolution (stable default, beta/tag override).
2026-07-16 13:50:27 +03:00
5f8f5e8bf9 feat(dx): /kit-doctor and /kit-help commands
- /kit-doctor: environment health check (node, kit version/channel, config source+values, env vars presence, env.sh, loaded command/skill counts) with pass/warn summary; read-only, offline-safe
- /kit-help: catalog of package commands + skills (filtered to package path) + static guardrail list

Verified live via pi RPC: both commands register and render.
2026-07-16 13:48:31 +03:00
7e50043984 feat(security): secret-scanner, commit-guard, llm-redaction, audit-log
- lib/secrets.ts: shared SECRET_PATTERNS + scanSecrets/redact (AWS/GitLab/GitHub/Slack tokens, private keys, JWT, generic credentials)
- lib/audit.ts: local JSONL audit (~/.config/pi-kit/audit.jsonl, 600), optional PI_KIT_AUDIT_URL POST; never logs secret values
- secret-scanner: block write/edit whose content matches a secret (complements protected-paths)
- commit-guard: enforce Conventional Commits on 'git commit -m' + scan message for secrets
- llm-redaction: redact secrets from outbound payload via before_provider_request
- audit-log: session_start + slash-command usage; wires company.json auditEndpoint into env
- config: auditEndpoint (null); package.json: npm test; test/guardrails.test.ts (16 cases, all pass)

Verified: all extensions load via pi (lib/ not loaded as extensions); npm test green.
2026-07-16 13:46:22 +03:00
18 changed files with 882 additions and 22 deletions

1
.gitignore vendored
View File

@@ -1,3 +1,4 @@
node_modules/
*.log
.DS_Store
package-lock.json

38
CHANGELOG.md Normal file
View 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*`.

View File

@@ -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
View 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
```

View File

@@ -8,5 +8,6 @@
"не пушить в main",
"MR обязателен"
],
"remoteConfigUrl": null
"remoteConfigUrl": null,
"auditEndpoint": null
}

35
config/mcp.json Normal file
View 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
View 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
View 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" };
});
}

View 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("; ")}.\ормат: type(scope): описание. Всё равно закоммитить?`,
["Нет", "Да"],
);
if (choice === "Да") return undefined;
}
return { block: true, reason: `Commit message rejected: ${problems.join("; ")}` };
});
}

118
extensions/kit-cli.ts Normal file
View 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
View 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
View 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;
}

View 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
View 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();
});
}

View 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(", ")})` };
});
}

View File

@@ -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}
────────────────────────────────────────────────────────

View File

@@ -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
View 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();