fix(review): address review findings (guardrails, prompts, tests, docs)
- permission-gate: block refs/tags/v* pushes, rm -r -f separated flags, git reset --hard, git clean (verified against actual bypasses) - prompts: /bugfix /feature /review no longer hardcode go-standards — reference profile-gated <lang>-standards instead - company-context: drop hardcoded Go stack, note TRACKER_URL priority, warn on context truncation instead of silently dropping rules - repo-map/docs-map: graceful degradation when config values are TODO - /kit-doctor: warn on unfilled config fields (repoMap/trackerUrl/docsUrl) - audit: retry POSTs to endpoint (3 attempts, backoff), still best-effort - install.sh: remove TODO course URL from cheat sheet - tests: expand guardrails (43 node checks), add shell tests for create-mr.sh (scp/https origin parse, GITLAB_HOST override, protected branch refusal), cover company-context lib (normalize/fetch/truncation) and mcp-bridge - commit package-lock.json for reproducible installs - document npm test Node >= 22.6 requirement (type stripping)
This commit is contained in:
@@ -1,4 +1,3 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
*.log
|
*.log
|
||||||
.DS_Store
|
.DS_Store
|
||||||
package-lock.json
|
|
||||||
|
|||||||
@@ -4,6 +4,26 @@
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- permission-gate: закрыты обходы — push тега через `refs/tags/v*`/`tags/v*`, раздельные
|
||||||
|
флаги `rm -r -f`; добавлены `git reset --hard` и `git clean`.
|
||||||
|
- Промпты `/bugfix /feature /review` больше не ссылаются жёстко на `go-standards` — теперь
|
||||||
|
ссылаются на языковой стандарт по профилю (`<lang>-standards`).
|
||||||
|
- `company-context.md`: убран хардкод «основной стек — Go»; зафиксирован приоритет
|
||||||
|
`TRACKER_URL` (env) над `trackerUrl` (config).
|
||||||
|
- Обрезка корпоративного контекста (лимит строк) теперь предупреждает вместо тихой потери правил.
|
||||||
|
- Убраны TODO-значения из вывода install.sh; `/kit-doctor` предупреждает о незаполненных
|
||||||
|
полях конфига (repoMap/trackerUrl/docsUrl).
|
||||||
|
- Пример конфига в README приведён в соответствие с реальным `config/company.json`.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Тесты: company-context (normalize/fetch/truncation), mcp-bridge (disabled-путь),
|
||||||
|
табличные кейсы permission-gate, shell-тест `create-mr.sh` (парсинг remote, dry-run).
|
||||||
|
- `package-lock.json` закоммичен (воспроизводимая установка).
|
||||||
|
- Audit-endpoint: повторные попытки POST (до 3, с backoff), по-прежнему best-effort.
|
||||||
|
- Явная проверка версии Node в тестах: `npm test` требует ≥ 22.6 (type-stripping),
|
||||||
|
документировано в README/RELEASING.
|
||||||
|
|
||||||
## [0.2.1] — 2026-07-16
|
## [0.2.1] — 2026-07-16
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -88,8 +88,9 @@ pi update --extensions
|
|||||||
### Guardrails (встроенная защита)
|
### Guardrails (встроенная защита)
|
||||||
|
|
||||||
- **protected-paths** — блокирует запись/редактирование секретов, ключей, `.git/`, прод-конфигов (по пути).
|
- **protected-paths** — блокирует запись/редактирование секретов, ключей, `.git/`, прод-конфигов (по пути).
|
||||||
- **permission-gate** — переспрашивает перед опасными командами (`rm -rf`, force push,
|
- **permission-gate** — переспрашивает перед опасными командами (`rm -rf`/`rm -r -f`, force push,
|
||||||
push в `main`/`master`/`release-*`, тег `v*` (прод-деплой), прод-`kubectl`, `docker system prune`, `sudo`).
|
push в `main`/`master`/`release-*`, тег `v*` (в т.ч. через `refs/tags/` — прод-деплой),
|
||||||
|
`git reset --hard`, `git clean`, прод-`kubectl`, `docker system prune`, `sudo`).
|
||||||
- **secret-scanner** — блокирует запись контента, похожего на секрет (по содержимому).
|
- **secret-scanner** — блокирует запись контента, похожего на секрет (по содержимому).
|
||||||
- **commit-guard** — требует Conventional Commits и сканирует сообщение коммита на секреты.
|
- **commit-guard** — требует Conventional Commits и сканирует сообщение коммита на секреты.
|
||||||
- **llm-redaction** — вырезает секреты из payload перед отправкой в LLM.
|
- **llm-redaction** — вырезает секреты из payload перед отправкой в LLM.
|
||||||
@@ -112,7 +113,11 @@ pi-kit/
|
|||||||
├── extensions/ # TS-расширения (guardrails + инъекция контекста)
|
├── extensions/ # TS-расширения (guardrails + инъекция контекста)
|
||||||
│ ├── company-context.ts + company-context.md # контекст сессии + /kit-config
|
│ ├── company-context.ts + company-context.md # контекст сессии + /kit-config
|
||||||
│ ├── protected-paths.ts
|
│ ├── protected-paths.ts
|
||||||
│ └── permission-gate.ts
|
│ ├── permission-gate.ts
|
||||||
|
│ ├── secret-scanner.ts commit-guard.ts llm-redaction.ts audit-log.ts
|
||||||
|
│ ├── mcp-bridge.ts # Jira/Confluence/GitLab MCP (по умолч. выкл.)
|
||||||
|
│ ├── kit-cli.ts # /kit-doctor, /kit-help
|
||||||
|
│ └── lib/ # общий код (secrets, audit, company-config)
|
||||||
├── skills/ # SKILL.md-скиллы (jira-workflow/repo-map/docs-map/go-standards)
|
├── skills/ # SKILL.md-скиллы (jira-workflow/repo-map/docs-map/go-standards)
|
||||||
├── prompts/ # шаблоны команд (/bugfix, /feature, /review, /rfc)
|
├── prompts/ # шаблоны команд (/bugfix, /feature, /review, /rfc)
|
||||||
└── install.sh # bootstrap для новых сотрудников
|
└── install.sh # bootstrap для новых сотрудников
|
||||||
@@ -129,15 +134,19 @@ pi-kit/
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"gitHost": "git.codelab.vc",
|
"gitHost": "gitlab.tech.mvideo.ru",
|
||||||
"trackerUrl": "TODO",
|
"trackerUrl": "https://jira.mvideo.ru",
|
||||||
"docsUrl": "TODO",
|
"docsUrl": "https://wiki.mvideo.ru",
|
||||||
"repoMap": "TODO: где какой код лежит",
|
"repoMap": "TODO: где какой код лежит",
|
||||||
"rules": ["не коммитить секреты", "не пушить в main", "MR обязателен"],
|
"rules": ["не коммитить секреты", "не пушить в main", "MR обязателен"],
|
||||||
"remoteConfigUrl": null
|
"remoteConfigUrl": null
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> Значения выше — текущие действующие; в этом файле хранится **единственный** источник правды
|
||||||
|
> (адрес самого репозитория pi-kit — `git.codelab.vc` — к нему отношения не имеет, он живёт
|
||||||
|
> только в `install.sh`).
|
||||||
|
|
||||||
- Меняешь значение → правишь **только этот файл**. Скиллы и шаблоны на него ссылаются через
|
- Меняешь значение → правишь **только этот файл**. Скиллы и шаблоны на него ссылаются через
|
||||||
корпоративный контекст сессии, который инжектит `company-context.ts`.
|
корпоративный контекст сессии, который инжектит `company-context.ts`.
|
||||||
- **`remoteConfigUrl`**: если указать URL, при старте сессии `company-context.ts` скачает свежий
|
- **`remoteConfigUrl`**: если указать URL, при старте сессии `company-context.ts` скачает свежий
|
||||||
@@ -200,7 +209,7 @@ pi-kit/
|
|||||||
```bash
|
```bash
|
||||||
# из корня репозитория
|
# из корня репозитория
|
||||||
pi install ./ # установить пакет из локальной папки
|
pi install ./ # установить пакет из локальной папки
|
||||||
npm test # прогнать тесты guardrails
|
npm test # прогнать тесты guardrails и shell-скриптов
|
||||||
pi # запустить: расширения загрузятся, команды /bugfix и т.д. появятся
|
pi # запустить: расширения загрузятся, команды /bugfix и т.д. появятся
|
||||||
|
|
||||||
# опциональный строгий typecheck (нужны peer-типы Pi):
|
# опциональный строгий typecheck (нужны peer-типы Pi):
|
||||||
@@ -208,6 +217,9 @@ npm install
|
|||||||
npm run typecheck
|
npm run typecheck
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> ⚠️ `npm test` требует **Node.js ≥ 22.6** (type-stripping). Для сотрудников в `install.sh`
|
||||||
|
> достаточно Node ≥ 20 — это требование тест-харнеса мейнтейнеров, не установки.
|
||||||
|
|
||||||
Минимальный чек-лист MR: расширения загружаются без ошибок; `/kit-config` показывает значения;
|
Минимальный чек-лист MR: расширения загружаются без ошибок; `/kit-config` показывает значения;
|
||||||
запись в `.env` реально блокируется; изменение `config/company.json` отражается в `/kit-config`
|
запись в `.env` реально блокируется; изменение `config/company.json` отражается в `/kit-config`
|
||||||
без других правок.
|
без других правок.
|
||||||
|
|||||||
+3
-2
@@ -15,8 +15,9 @@
|
|||||||
## Как выпустить релиз
|
## Как выпустить релиз
|
||||||
|
|
||||||
1. Собрать изменения в `main` через MR с ревью (правило репозитория).
|
1. Собрать изменения в `main` через MR с ревью (правило репозитория).
|
||||||
2. Прогнать проверки: `npm test` (guardrails), `npm run typecheck` (при наличии peer-типов),
|
2. Прогнать проверки: `npm test` (guardrails + shell-скрипты; нужен Node ≥ 22.6),
|
||||||
`pi install ./` + `/kit-doctor`.
|
`npm run typecheck` (при наличии peer-типов), `pi install ./` + `/kit-doctor`
|
||||||
|
(должно быть без предупреждений о TODO-значениях конфига).
|
||||||
3. Обновить версию в `package.json` и запись в `CHANGELOG.md` (раздел из `Unreleased`).
|
3. Обновить версию в `package.json` и запись в `CHANGELOG.md` (раздел из `Unreleased`).
|
||||||
4. Поставить тег на нужный коммит `main`:
|
4. Поставить тег на нужный коммит `main`:
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -2,7 +2,9 @@
|
|||||||
|
|
||||||
Ты работаешь как инженерный ассистент внутри нашей компании. Учитывай это во всех задачах.
|
Ты работаешь как инженерный ассистент внутри нашей компании. Учитывай это во всех задачах.
|
||||||
|
|
||||||
**Кто мы:** продуктовая инженерная команда. Основной стек — **Go** (микросервисы).
|
**Кто мы:** продуктовая инженерная команда. Язык/стек зависит от репозитория и профиля —
|
||||||
|
для конкретного языка используй скилл `<lang>-standards` (go/typescript/python/rust/kotlin/
|
||||||
|
swift; нужный подключён по профилю сотрудника).
|
||||||
|
|
||||||
**Где код:** git-хост — `{{gitHost}}`.
|
**Где код:** git-хост — `{{gitHost}}`.
|
||||||
Карта репозиториев: {{repoMap}}
|
Карта репозиториев: {{repoMap}}
|
||||||
@@ -10,12 +12,11 @@
|
|||||||
|
|
||||||
**Трекер задач:** {{trackerUrl}}
|
**Трекер задач:** {{trackerUrl}}
|
||||||
Как читать тикеты и работать с задачами — скилл `jira-workflow`.
|
Как читать тикеты и работать с задачами — скилл `jira-workflow`.
|
||||||
|
> Если в окружении задан `TRACKER_URL`, он приоритетнее значения `trackerUrl` из конфига.
|
||||||
|
|
||||||
**Документация:** {{docsUrl}}
|
**Документация:** {{docsUrl}}
|
||||||
Как искать и куда писать — скилл `docs-map`.
|
Как искать и куда писать — скилл `docs-map`.
|
||||||
|
|
||||||
**Стандарты Go** (структура сервиса, линтеры, тесты, MR) — скилл `go-standards`.
|
|
||||||
|
|
||||||
**Базовые правила:**
|
**Базовые правила:**
|
||||||
{{rules}}
|
{{rules}}
|
||||||
|
|
||||||
|
|||||||
@@ -16,109 +16,33 @@
|
|||||||
* or exclude it via the package manifest / settings.
|
* or exclude it via the package manifest / settings.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import * as fs from "node:fs";
|
|
||||||
import * as path from "node:path";
|
import * as path from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||||
|
import {
|
||||||
interface CompanyConfig {
|
readLocalConfig,
|
||||||
gitHost: string;
|
fetchRemoteConfig,
|
||||||
trackerUrl: string;
|
loadTemplate,
|
||||||
docsUrl: string;
|
renderContext,
|
||||||
repoMap: string;
|
DEFAULT_CONFIG,
|
||||||
rules: string[];
|
type CompanyConfig,
|
||||||
remoteConfigUrl: string | null;
|
} from "./lib/company-config.ts";
|
||||||
}
|
|
||||||
|
|
||||||
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||||||
const CONFIG_PATH = path.join(HERE, "..", "config", "company.json");
|
const CONFIG_PATH = path.join(HERE, "..", "config", "company.json");
|
||||||
const TEMPLATE_PATH = path.join(HERE, "company-context.md");
|
const TEMPLATE_PATH = path.join(HERE, "company-context.md");
|
||||||
const REMOTE_TIMEOUT_MS = 2500;
|
|
||||||
const MAX_CONTEXT_LINES = 40;
|
|
||||||
|
|
||||||
const DEFAULT_CONFIG: CompanyConfig = {
|
|
||||||
gitHost: "TODO",
|
|
||||||
trackerUrl: "TODO",
|
|
||||||
docsUrl: "TODO",
|
|
||||||
repoMap: "TODO",
|
|
||||||
rules: [],
|
|
||||||
remoteConfigUrl: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Read and validate the local config file, falling back to defaults. */
|
|
||||||
function readLocalConfig(): CompanyConfig {
|
|
||||||
try {
|
|
||||||
const raw = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf8"));
|
|
||||||
return normalize(raw);
|
|
||||||
} catch {
|
|
||||||
return { ...DEFAULT_CONFIG };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Keep only known keys and coerce types, so junk in remote/local JSON is ignored. */
|
|
||||||
function normalize(raw: Record<string, unknown>): CompanyConfig {
|
|
||||||
const str = (v: unknown, d: string) => (typeof v === "string" ? v : d);
|
|
||||||
return {
|
|
||||||
gitHost: str(raw.gitHost, DEFAULT_CONFIG.gitHost),
|
|
||||||
trackerUrl: str(raw.trackerUrl, DEFAULT_CONFIG.trackerUrl),
|
|
||||||
docsUrl: str(raw.docsUrl, DEFAULT_CONFIG.docsUrl),
|
|
||||||
repoMap: str(raw.repoMap, DEFAULT_CONFIG.repoMap),
|
|
||||||
rules: Array.isArray(raw.rules) ? raw.rules.filter((r): r is string => typeof r === "string") : [],
|
|
||||||
remoteConfigUrl: typeof raw.remoteConfigUrl === "string" ? raw.remoteConfigUrl : null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Try to fetch a fresh config from a URL. Returns null on any failure. */
|
|
||||||
async function fetchRemoteConfig(url: string): Promise<CompanyConfig | null> {
|
|
||||||
try {
|
|
||||||
const res = await fetch(url, { signal: AbortSignal.timeout(REMOTE_TIMEOUT_MS) });
|
|
||||||
if (!res.ok) return null;
|
|
||||||
const raw = (await res.json()) as Record<string, unknown>;
|
|
||||||
return normalize(raw);
|
|
||||||
} catch {
|
|
||||||
return null; // network error, timeout, bad JSON — fall back silently
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Load the context template once; fall back to a minimal inline template. */
|
|
||||||
function loadTemplate(): string {
|
|
||||||
try {
|
|
||||||
return fs.readFileSync(TEMPLATE_PATH, "utf8");
|
|
||||||
} catch {
|
|
||||||
return [
|
|
||||||
"# Корпоративный контекст",
|
|
||||||
"git-хост: {{gitHost}}",
|
|
||||||
"трекер: {{trackerUrl}}",
|
|
||||||
"документация: {{docsUrl}}",
|
|
||||||
"репозитории: {{repoMap}}",
|
|
||||||
"правила:",
|
|
||||||
"{{rules}}",
|
|
||||||
].join("\n");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Render the corporate context from the template + effective config. */
|
|
||||||
function renderContext(template: string, cfg: CompanyConfig): string {
|
|
||||||
const rulesBlock = cfg.rules.length > 0 ? cfg.rules.map((r) => `- ${r}`).join("\n") : "- (правила не заданы)";
|
|
||||||
const rendered = template
|
|
||||||
.replaceAll("{{gitHost}}", cfg.gitHost)
|
|
||||||
.replaceAll("{{trackerUrl}}", cfg.trackerUrl)
|
|
||||||
.replaceAll("{{docsUrl}}", cfg.docsUrl)
|
|
||||||
.replaceAll("{{repoMap}}", cfg.repoMap)
|
|
||||||
.replaceAll("{{rules}}", rulesBlock);
|
|
||||||
// Safety cap: never inject more than MAX_CONTEXT_LINES lines.
|
|
||||||
return rendered.split("\n").slice(0, MAX_CONTEXT_LINES).join("\n").trimEnd();
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function companyContextExtension(pi: ExtensionAPI) {
|
export default function companyContextExtension(pi: ExtensionAPI) {
|
||||||
const template = loadTemplate();
|
const template = loadTemplate(TEMPLATE_PATH);
|
||||||
|
|
||||||
// Effective config and where it came from. Refreshed on every session start.
|
// Effective config and where it came from. Refreshed on every session start.
|
||||||
let effective: CompanyConfig = readLocalConfig();
|
let effective: CompanyConfig = { ...DEFAULT_CONFIG };
|
||||||
let source: "local" | "remote" = "local";
|
let source: "local" | "remote" = "local";
|
||||||
|
// Rendered once per session; injected by before_agent_start.
|
||||||
|
let rendered: string | null = null;
|
||||||
|
|
||||||
pi.on("session_start", async (_event, ctx) => {
|
pi.on("session_start", async (_event, ctx) => {
|
||||||
effective = readLocalConfig();
|
effective = readLocalConfig(CONFIG_PATH);
|
||||||
source = "local";
|
source = "local";
|
||||||
|
|
||||||
if (effective.remoteConfigUrl) {
|
if (effective.remoteConfigUrl) {
|
||||||
@@ -131,15 +55,26 @@ export default function companyContextExtension(pi: ExtensionAPI) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { text, truncated } = renderContext(template, effective);
|
||||||
|
rendered = text;
|
||||||
|
|
||||||
if (ctx.hasUI) {
|
if (ctx.hasUI) {
|
||||||
ctx.ui.notify(`pi-kit: корпоративный контекст загружен (источник: ${source})`, "info");
|
if (truncated) {
|
||||||
|
ctx.ui.notify(
|
||||||
|
`pi-kit: корпоративный контекст обрезан до ${text.split("\n").length} строк (лимит) — часть правил не попала в контекст.`,
|
||||||
|
"warning",
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
ctx.ui.notify(`pi-kit: корпоративный контекст загружен (источник: ${source})`, "info");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
pi.on("before_agent_start", async (event) => {
|
pi.on("before_agent_start", async (event) => {
|
||||||
const rendered = renderContext(template, effective);
|
// session_start may not have fired (rare); render on demand as a fallback.
|
||||||
if (!rendered) return undefined;
|
const context = rendered ?? renderContext(template, effective).text;
|
||||||
return { systemPrompt: `${event.systemPrompt}\n\n${rendered}` };
|
if (!context) return undefined;
|
||||||
|
return { systemPrompt: `${event.systemPrompt}\n\n${context}` };
|
||||||
});
|
});
|
||||||
|
|
||||||
pi.registerCommand("kit-config", {
|
pi.registerCommand("kit-config", {
|
||||||
|
|||||||
@@ -75,6 +75,13 @@ export default function kitCliExtension(pi: ExtensionAPI) {
|
|||||||
const cfgOk = typeof cfg.gitHost === "string" && cfg.gitHost !== "TODO";
|
const cfgOk = typeof cfg.gitHost === "string" && cfg.gitHost !== "TODO";
|
||||||
lines.push((cfgOk ? ok : warn)(`config: gitHost=${cfg.gitHost ?? "?"}, tracker=${cfg.trackerUrl ?? "?"}, docs=${cfg.docsUrl ?? "?"}`));
|
lines.push((cfgOk ? ok : warn)(`config: gitHost=${cfg.gitHost ?? "?"}, tracker=${cfg.trackerUrl ?? "?"}, docs=${cfg.docsUrl ?? "?"}`));
|
||||||
if (cfg.remoteConfigUrl) lines.push(ok(`remoteConfigUrl задан`));
|
if (cfg.remoteConfigUrl) lines.push(ok(`remoteConfigUrl задан`));
|
||||||
|
// TODO-placeholder values are a readiness signal, not an error: warn so
|
||||||
|
// maintainers notice they haven't filled the single source of truth yet.
|
||||||
|
for (const [field, value] of Object.entries({ repoMap: cfg.repoMap, trackerUrl: cfg.trackerUrl, docsUrl: cfg.docsUrl })) {
|
||||||
|
if (typeof value !== "string" || value === "TODO" || value.trim() === "" || value.startsWith("TODO")) {
|
||||||
|
lines.push(warn(`config: ${field} не заполнен (TODO) — скиллы repo-map/docs-map/jira будут просить уточнить`));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Env vars (presence only, never values)
|
// Env vars (presence only, never values)
|
||||||
for (const v of ["TRACKER_URL", "JIRA_TOKEN", "GITLAB_TOKEN"]) {
|
for (const v of ["TRACKER_URL", "JIRA_TOKEN", "GITLAB_TOKEN"]) {
|
||||||
|
|||||||
+26
-8
@@ -4,7 +4,8 @@
|
|||||||
* WHAT: appends JSONL events to ~/.config/pi-kit/audit.jsonl (chmod 600).
|
* WHAT: appends JSONL events to ~/.config/pi-kit/audit.jsonl (chmod 600).
|
||||||
* Never logs secret values — only pattern names, paths, command shape.
|
* Never logs secret values — only pattern names, paths, command shape.
|
||||||
* If PI_KIT_AUDIT_URL is set (or company.json auditEndpoint, wired by
|
* 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.
|
* audit-log.ts into that env var), each event is also POSTed best-effort
|
||||||
|
* with a few retries. Posting never blocks or breaks the agent.
|
||||||
*
|
*
|
||||||
* DISABLE: remove the audit-log.ts extension, or set PI_KIT_AUDIT_OFF=1.
|
* DISABLE: remove the audit-log.ts extension, or set PI_KIT_AUDIT_OFF=1.
|
||||||
*/
|
*/
|
||||||
@@ -16,12 +17,35 @@ import * as path from "node:path";
|
|||||||
const DIR = path.join(os.homedir(), ".config", "pi-kit");
|
const DIR = path.join(os.homedir(), ".config", "pi-kit");
|
||||||
const FILE = path.join(DIR, "audit.jsonl");
|
const FILE = path.join(DIR, "audit.jsonl");
|
||||||
const POST_TIMEOUT_MS = 1500;
|
const POST_TIMEOUT_MS = 1500;
|
||||||
|
const POST_ATTEMPTS = 3;
|
||||||
|
|
||||||
export interface AuditEntry {
|
export interface AuditEntry {
|
||||||
kind: string; // e.g. "guardrail_block", "command", "session_start"
|
kind: string; // e.g. "guardrail_block", "command", "session_start"
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** POST a line best-effort with a couple of retries (never awaited). */
|
||||||
|
function postWithRetry(url: string, body: string): void {
|
||||||
|
const attempt = (n: number): Promise<void> =>
|
||||||
|
fetch(url, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body,
|
||||||
|
signal: AbortSignal.timeout(POST_TIMEOUT_MS),
|
||||||
|
})
|
||||||
|
.then((res) => {
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (n < POST_ATTEMPTS) {
|
||||||
|
const backoff = 200 * 2 ** (n - 1);
|
||||||
|
return new Promise<void>((resolve) => setTimeout(resolve, backoff)).then(() => attempt(n + 1));
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
});
|
||||||
|
attempt(1).catch(() => {}); // fire-and-forget; failures are ignored
|
||||||
|
}
|
||||||
|
|
||||||
/** Append an audit entry locally and optionally forward it to an endpoint. */
|
/** Append an audit entry locally and optionally forward it to an endpoint. */
|
||||||
export function audit(entry: AuditEntry): void {
|
export function audit(entry: AuditEntry): void {
|
||||||
if (process.env.PI_KIT_AUDIT_OFF === "1") return;
|
if (process.env.PI_KIT_AUDIT_OFF === "1") return;
|
||||||
@@ -36,12 +60,6 @@ export function audit(entry: AuditEntry): void {
|
|||||||
|
|
||||||
const url = process.env.PI_KIT_AUDIT_URL;
|
const url = process.env.PI_KIT_AUDIT_URL;
|
||||||
if (url) {
|
if (url) {
|
||||||
// Best-effort, fire-and-forget. Never awaited; failures are ignored.
|
postWithRetry(url, line);
|
||||||
fetch(url, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: line,
|
|
||||||
signal: AbortSignal.timeout(POST_TIMEOUT_MS),
|
|
||||||
}).catch(() => {});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
/**
|
||||||
|
* Shared pure helpers for the company-context extension (config loading,
|
||||||
|
* normalization, remote fetch, template rendering). Kept free of the pi API
|
||||||
|
* so it can be unit-tested directly (see test/guardrails.test.ts).
|
||||||
|
*
|
||||||
|
* CONFIGURE: values live in config/company.json; wording in company-context.md.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as fs from "node:fs";
|
||||||
|
|
||||||
|
export interface CompanyConfig {
|
||||||
|
gitHost: string;
|
||||||
|
trackerUrl: string;
|
||||||
|
docsUrl: string;
|
||||||
|
repoMap: string;
|
||||||
|
rules: string[];
|
||||||
|
remoteConfigUrl: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_CONFIG: CompanyConfig = {
|
||||||
|
gitHost: "TODO",
|
||||||
|
trackerUrl: "TODO",
|
||||||
|
docsUrl: "TODO",
|
||||||
|
repoMap: "TODO",
|
||||||
|
rules: [],
|
||||||
|
remoteConfigUrl: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MAX_CONTEXT_LINES = 40;
|
||||||
|
|
||||||
|
/** Keep only known keys and coerce types, so junk in remote/local JSON is ignored. */
|
||||||
|
export function normalize(raw: Record<string, unknown>): CompanyConfig {
|
||||||
|
const str = (v: unknown, d: string) => (typeof v === "string" ? v : d);
|
||||||
|
return {
|
||||||
|
gitHost: str(raw.gitHost, DEFAULT_CONFIG.gitHost),
|
||||||
|
trackerUrl: str(raw.trackerUrl, DEFAULT_CONFIG.trackerUrl),
|
||||||
|
docsUrl: str(raw.docsUrl, DEFAULT_CONFIG.docsUrl),
|
||||||
|
repoMap: str(raw.repoMap, DEFAULT_CONFIG.repoMap),
|
||||||
|
rules: Array.isArray(raw.rules) ? raw.rules.filter((r): r is string => typeof r === "string") : [],
|
||||||
|
remoteConfigUrl: typeof raw.remoteConfigUrl === "string" ? raw.remoteConfigUrl : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read and validate a local config file, falling back to defaults on any error. */
|
||||||
|
export function readLocalConfig(configPath: string): CompanyConfig {
|
||||||
|
try {
|
||||||
|
const raw = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
||||||
|
return normalize(raw);
|
||||||
|
} catch {
|
||||||
|
return { ...DEFAULT_CONFIG };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Try to fetch a fresh config from a URL. Returns null on any failure.
|
||||||
|
* `fetchImpl` is injectable for tests (defaults to the global fetch).
|
||||||
|
*/
|
||||||
|
export async function fetchRemoteConfig(
|
||||||
|
url: string,
|
||||||
|
fetchImpl: typeof fetch = fetch,
|
||||||
|
timeoutMs = 2500,
|
||||||
|
): Promise<CompanyConfig | null> {
|
||||||
|
try {
|
||||||
|
const res = await fetchImpl(url, { signal: AbortSignal.timeout(timeoutMs) });
|
||||||
|
if (!res.ok) return null;
|
||||||
|
const raw = (await res.json()) as Record<string, unknown>;
|
||||||
|
return normalize(raw);
|
||||||
|
} catch {
|
||||||
|
return null; // network error, timeout, bad JSON — fall back silently
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read the context template; fall back to a minimal inline template. */
|
||||||
|
export function loadTemplate(templatePath: string): string {
|
||||||
|
try {
|
||||||
|
return fs.readFileSync(templatePath, "utf8");
|
||||||
|
} catch {
|
||||||
|
return [
|
||||||
|
"# Корпоративный контекст",
|
||||||
|
"git-хост: {{gitHost}}",
|
||||||
|
"трекер: {{trackerUrl}}",
|
||||||
|
"документация: {{docsUrl}}",
|
||||||
|
"репозитории: {{repoMap}}",
|
||||||
|
"правила:",
|
||||||
|
"{{rules}}",
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RenderedContext {
|
||||||
|
text: string;
|
||||||
|
truncated: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render the corporate context from the template + effective config.
|
||||||
|
* Reports whether the output was capped at MAX_CONTEXT_LINES so callers can
|
||||||
|
* warn instead of silently dropping rules.
|
||||||
|
*/
|
||||||
|
export function renderContext(template: string, cfg: CompanyConfig): RenderedContext {
|
||||||
|
const rulesBlock = cfg.rules.length > 0 ? cfg.rules.map((r) => `- ${r}`).join("\n") : "- (правила не заданы)";
|
||||||
|
const rendered = template
|
||||||
|
.replaceAll("{{gitHost}}", cfg.gitHost)
|
||||||
|
.replaceAll("{{trackerUrl}}", cfg.trackerUrl)
|
||||||
|
.replaceAll("{{docsUrl}}", cfg.docsUrl)
|
||||||
|
.replaceAll("{{repoMap}}", cfg.repoMap)
|
||||||
|
.replaceAll("{{rules}}", rulesBlock);
|
||||||
|
const lines = rendered.split("\n");
|
||||||
|
const truncated = lines.length > MAX_CONTEXT_LINES;
|
||||||
|
const text = lines.slice(0, MAX_CONTEXT_LINES).join("\n").trimEnd();
|
||||||
|
return { text, truncated };
|
||||||
|
}
|
||||||
@@ -20,14 +20,22 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|||||||
// cheap while misses can be costly.
|
// cheap while misses can be costly.
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
const DANGEROUS_PATTERNS: Array<{ re: RegExp; reason: string }> = [
|
const DANGEROUS_PATTERNS: Array<{ re: RegExp; reason: string }> = [
|
||||||
|
// rm: single-token forms (-rf, -fr) and --recursive.
|
||||||
{ re: /\brm\s+(-\w*r\w*f|-\w*f\w*r|--recursive)/i, reason: "recursive force delete (rm -rf)" },
|
{ re: /\brm\s+(-\w*r\w*f|-\w*f\w*r|--recursive)/i, reason: "recursive force delete (rm -rf)" },
|
||||||
|
// rm: separated flags (rm -r -f, rm -f -r) — one flag token with r, another with f.
|
||||||
|
{ re: /\brm\b(?=[^;\n]*-[^\s]*r)(?=[^;\n]*-[^\s]*f)/i, reason: "recursive force delete (rm -r -f)" },
|
||||||
{ re: /\bgit\s+push\b[^\n]*(--force\b|--force-with-lease\b|\s-f\b)/i, reason: "force push" },
|
{ re: /\bgit\s+push\b[^\n]*(--force\b|--force-with-lease\b|\s-f\b)/i, reason: "force push" },
|
||||||
{ re: /\bgit\s+push\b[^\n]*\b(main|master)\b/i, reason: "push to main/master" },
|
{ re: /\bgit\s+push\b[^\n]*\b(main|master)\b/i, reason: "push to main/master" },
|
||||||
// Deploy convention: a `release-*` branch push deploys to STAGE, and a `v*`
|
// Deploy convention: a `release-*` branch push deploys to STAGE, and a `v*`
|
||||||
// tag push deploys to PROD. Guard both the branch and the tag pushes.
|
// tag push deploys to PROD. Guard both the branch and the tag pushes.
|
||||||
{ re: /\bgit\s+push\b[^\n]*\brelease[-/]\S+/i, reason: "push to a release-* branch (deploys to stage)" },
|
{ re: /\bgit\s+push\b[^\n]*\brelease[-/]\S+/i, reason: "push to a release-* branch (deploys to stage)" },
|
||||||
{ re: /\bgit\s+push\b[^\n]*(--tags\b|--follow-tags\b)/i, reason: "push tags (a v* tag deploys to PROD)" },
|
{ re: /\bgit\s+push\b[^\n]*(--tags\b|--follow-tags\b)/i, reason: "push tags (a v* tag deploys to PROD)" },
|
||||||
|
// v* tag pushes: plain `v1.2.3`, and ref-style `tags/v1.2.3` / `refs/tags/v1.2.3`
|
||||||
|
// which would otherwise slip past the plain-tag pattern.
|
||||||
{ re: /\bgit\s+push\b[^\n]*\sv\d[\w.-]*/i, reason: "push a v* version tag (deploys to PROD)" },
|
{ re: /\bgit\s+push\b[^\n]*\sv\d[\w.-]*/i, reason: "push a v* version tag (deploys to PROD)" },
|
||||||
|
{ re: /\bgit\s+push\b[^\n]*\b(?:refs\/)?tags?\/v\d[\w.-]*/i, reason: "push a v* version tag via refs/tags/ (deploys to PROD)" },
|
||||||
|
{ re: /\bgit\s+reset\s+--hard\b/i, reason: "git reset --hard (destroys uncommitted changes)" },
|
||||||
|
{ re: /\bgit\s+clean\b/i, reason: "git clean (removes untracked files)" },
|
||||||
{
|
{
|
||||||
re: /\bkubectl\b[^\n]*(--context[=\s]?\S*prod|--namespace[=\s]?\S*prod|\bctx-prod\b)/i,
|
re: /\bkubectl\b[^\n]*(--context[=\s]?\S*prod|--namespace[=\s]?\S*prod|\bctx-prod\b)/i,
|
||||||
reason: "kubectl against a production context/namespace",
|
reason: "kubectl against a production context/namespace",
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ PI_KIT_CHANNEL="${PI_KIT_CHANNEL:-stable}"
|
|||||||
PI_KIT_REPO="git:${PI_KIT_HOST}@${PI_KIT_CHANNEL}"
|
PI_KIT_REPO="git:${PI_KIT_HOST}@${PI_KIT_CHANNEL}"
|
||||||
|
|
||||||
MIN_NODE_MAJOR=20
|
MIN_NODE_MAJOR=20
|
||||||
INTERNAL_COURSE_URL="TODO: ссылка на внутренний курс по Pi"
|
|
||||||
SETTINGS="$HOME/.pi/agent/settings.json"
|
SETTINGS="$HOME/.pi/agent/settings.json"
|
||||||
|
|
||||||
# Public skills installed for EVERYONE (repo:skill), via `npx skills add`.
|
# Public skills installed for EVERYONE (repo:skill), via `npx skills add`.
|
||||||
@@ -337,7 +336,5 @@ cat <<EOF
|
|||||||
Команды: /bugfix <тикет> /feature <тикет> /review [фокус] /rfc <тема>
|
Команды: /bugfix <тикет> /feature <тикет> /review [фокус] /rfc <тема>
|
||||||
/kit-config /kit-doctor /kit-help
|
/kit-config /kit-doctor /kit-help
|
||||||
Проверка: /kit-doctor — health-check окружения
|
Проверка: /kit-doctor — health-check окружения
|
||||||
|
|
||||||
Внутренний курс по Pi: ${INTERNAL_COURSE_URL}
|
|
||||||
────────────────────────────────────────────────────────
|
────────────────────────────────────────────────────────
|
||||||
EOF
|
EOF
|
||||||
|
|||||||
Generated
+4541
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -28,7 +28,7 @@
|
|||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"test": "node --experimental-strip-types test/guardrails.test.ts"
|
"test": "node --experimental-strip-types test/guardrails.test.ts && bash test/shell.test.sh"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@earendil-works/pi-ai": "*",
|
"@earendil-works/pi-ai": "*",
|
||||||
|
|||||||
+5
-3
@@ -12,13 +12,15 @@ argument-hint: "<ключ-тикета>"
|
|||||||
и убедись, что он действительно воспроизводится. Если воспроизвести не удаётся — остановись
|
и убедись, что он действительно воспроизводится. Если воспроизвести не удаётся — остановись
|
||||||
и напиши, какой информации не хватает.
|
и напиши, какой информации не хватает.
|
||||||
3. **Напиши падающий тест.** Добавь тест, который сейчас падает и фиксирует некорректное
|
3. **Напиши падающий тест.** Добавь тест, который сейчас падает и фиксирует некорректное
|
||||||
поведение. Следуй нашим стандартам тестирования из скилла `go-standards`.
|
поведение. Следуй нашему языковому стандарту кода и тестирования — скилл `<lang>-standards`
|
||||||
|
для языка репозитория (например, `go-standards` или `typescript-standards`; он подключён
|
||||||
|
по моему профилю, бери его из корпоративного контекста).
|
||||||
4. **Почини.** Внеси минимальные изменения, которые делают тест зелёным. Не рефактори лишнего.
|
4. **Почини.** Внеси минимальные изменения, которые делают тест зелёным. Не рефактори лишнего.
|
||||||
5. **Прогони проверки.** Запусти линтеры и весь набор тестов по инструкции из скилла
|
5. **Прогони проверки.** Запусти линтеры и весь набор тестов по инструкции из скилла
|
||||||
`go-standards`. Все проверки должны пройти.
|
`<lang>-standards`. Все проверки должны пройти.
|
||||||
6. **Подготовь описание MR.** Сформируй текст merge request: что было не так, что изменено,
|
6. **Подготовь описание MR.** Сформируй текст merge request: что было не так, что изменено,
|
||||||
как проверено, и ссылка на тикет `$ARGUMENTS`. Формат описания и ссылки — по скиллу
|
как проверено, и ссылка на тикет `$ARGUMENTS`. Формат описания и ссылки — по скиллу
|
||||||
`jira-workflow` и `go-standards`. MR не создавай и не пушь без моего подтверждения.
|
`jira-workflow` и языковому стандарту. MR не создавай и не пушь без моего подтверждения.
|
||||||
|
|
||||||
Соблюдай корпоративные правила из контекста сессии (в том числе: не коммитить секреты,
|
Соблюдай корпоративные правила из контекста сессии (в том числе: не коммитить секреты,
|
||||||
не пушить в защищённые ветки, MR обязателен).
|
не пушить в защищённые ветки, MR обязателен).
|
||||||
|
|||||||
+4
-2
@@ -17,9 +17,11 @@ argument-hint: "<ключ-тикета>"
|
|||||||
|
|
||||||
## Фаза 2. Реализация (только после подтверждения)
|
## Фаза 2. Реализация (только после подтверждения)
|
||||||
|
|
||||||
5. Реализуй строго по согласованному `PLAN.md`, следуя стандартам из скилла `go-standards`.
|
5. Реализуй строго по согласованному `PLAN.md`, следуя нашему языковому стандарту — скилл
|
||||||
|
`<lang>-standards` для языка репозитория (например, `go-standards` или `typescript-standards`;
|
||||||
|
он подключён по моему профилю, бери его из корпоративного контекста).
|
||||||
6. На каждый заметный кусок функциональности добавляй тесты.
|
6. На каждый заметный кусок функциональности добавляй тесты.
|
||||||
7. Прогони линтеры и тесты (см. `go-standards`) — всё должно быть зелёным.
|
7. Прогони линтеры и тесты (см. `<lang>-standards`) — всё должно быть зелёным.
|
||||||
8. Подготовь описание MR со ссылкой на тикет `$ARGUMENTS`. MR не пушь без моего подтверждения.
|
8. Подготовь описание MR со ссылкой на тикет `$ARGUMENTS`. MR не пушь без моего подтверждения.
|
||||||
|
|
||||||
Соблюдай корпоративные правила из контекста сессии (не коммитить секреты, не пушить в
|
Соблюдай корпоративные правила из контекста сессии (не коммитить секреты, не пушить в
|
||||||
|
|||||||
+5
-3
@@ -1,16 +1,18 @@
|
|||||||
---
|
---
|
||||||
description: Ревью текущего диффа по стандартам Go с заданным фокусом
|
description: Ревью текущего диффа по корпоративным стандартам с заданным фокусом
|
||||||
argument-hint: "[фокус: например безопасность, конкурентность, ошибки]"
|
argument-hint: "[фокус: например безопасность, конкурентность, ошибки]"
|
||||||
---
|
---
|
||||||
Проведи ревью **текущего диффа** (незакоммиченные изменения и/или изменения ветки
|
Проведи ревью **текущего диффа** (незакоммиченные изменения и/или изменения ветки
|
||||||
относительно основной) по нашим стандартам Go.
|
относительно основной) по нашим корпоративным стандартам кода — скилл `<lang>-standards`
|
||||||
|
для языка репозитория (например, `go-standards` или `typescript-standards`; он подключён
|
||||||
|
по моему профилю, бери его из корпоративного контекста).
|
||||||
|
|
||||||
Фокус ревью: **$ARGUMENTS**. Если фокус выше пустой — оценивай общее качество кода.
|
Фокус ревью: **$ARGUMENTS**. Если фокус выше пустой — оценивай общее качество кода.
|
||||||
|
|
||||||
Порядок:
|
Порядок:
|
||||||
|
|
||||||
1. Определи, что именно изменилось (`git status`, `git diff`, при необходимости `git diff main...`).
|
1. Определи, что именно изменилось (`git status`, `git diff`, при необходимости `git diff main...`).
|
||||||
2. Оцени изменения по скиллу `go-standards`: структура, идиоматичность, обработка ошибок,
|
2. Оцени изменения по языковому стандарту: структура, идиоматичность, обработка ошибок,
|
||||||
конкурентность, тесты, линтеры. Если задан фокус — удели ему особое внимание.
|
конкурентность, тесты, линтеры. Если задан фокус — удели ему особое внимание.
|
||||||
3. Выдай замечания списком, сгруппированные по важности: **Блокеры → Важное → Мелочи/стиль**.
|
3. Выдай замечания списком, сгруппированные по важности: **Блокеры → Важное → Мелочи/стиль**.
|
||||||
Для каждого замечания укажи файл и строку, объясни проблему и предложи конкретную правку.
|
Для каждого замечания укажи файл и строку, объясни проблему и предложи конкретную правку.
|
||||||
|
|||||||
@@ -27,6 +27,12 @@ description: Используй, когда нужно найти или нап
|
|||||||
1. Начни с портала документации (адрес — из корпоративного контекста сессии).
|
1. Начни с портала документации (адрес — из корпоративного контекста сессии).
|
||||||
2. `<!-- TODO: описать, где встроенный поиск, теги/лейблы, как называть страницы -->`
|
2. `<!-- TODO: описать, где встроенный поиск, теги/лейблы, как называть страницы -->`
|
||||||
|
|
||||||
|
### Если разделы ещё не заполнены (TODO)
|
||||||
|
|
||||||
|
Пока в скилле и конфиге стоят TODO/пустые значения — **не выдумывай структуру документации**.
|
||||||
|
Скажи пользователю, что карта документации не заполнена, и спроси, где искать или куда
|
||||||
|
писать; можно предложить уточнить у владельца документации.
|
||||||
|
|
||||||
## Куда писать новое
|
## Куда писать новое
|
||||||
|
|
||||||
<!-- TODO: заполнить -->
|
<!-- TODO: заполнить -->
|
||||||
|
|||||||
@@ -15,6 +15,16 @@ description: Используй, когда нужно сориентирова
|
|||||||
|
|
||||||
Адрес git-хоста также берётся из корпоративного контекста сессии.
|
Адрес git-хоста также берётся из корпоративного контекста сессии.
|
||||||
|
|
||||||
|
### Если карта ещё не заполнена (TODO)
|
||||||
|
|
||||||
|
Пока в `repoMap` стоит TODO/пусто — **не выдумывай расположение кода**. Вместо этого:
|
||||||
|
|
||||||
|
1. Скажи пользователю прямо, что карта репозиториев не заполнена (можно предложить команду
|
||||||
|
`/kit-config` для проверки действующего значения).
|
||||||
|
2. Определи репозиторий по факту: откуда запущена сессия (`git remote origin`), упоминания
|
||||||
|
в тикете, поиск по git-хосту из корпоративного контекста.
|
||||||
|
3. Если найти не удалось — спроси пользователя, не догадывайся.
|
||||||
|
|
||||||
## Соглашения
|
## Соглашения
|
||||||
|
|
||||||
<!-- TODO: заполнить реальными значениями компании -->
|
<!-- TODO: заполнить реальными значениями компании -->
|
||||||
|
|||||||
+110
-4
@@ -5,8 +5,18 @@
|
|||||||
*
|
*
|
||||||
* Extensions only `import type` the Pi API, so they run here with node's
|
* Extensions only `import type` the Pi API, so they run here with node's
|
||||||
* type-stripping and mocked pi/ctx objects.
|
* type-stripping and mocked pi/ctx objects.
|
||||||
|
*
|
||||||
|
* NOTE: type stripping requires Node.js >= 22.6 (install.sh's >= 20 applies to
|
||||||
|
* employees installing pi, not to this test harness).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
const [NODE_MAJOR, NODE_MINOR] = process.versions.node.split(".").map(Number);
|
||||||
|
if (NODE_MAJOR < 22 || (NODE_MAJOR === 22 && NODE_MINOR < 6)) {
|
||||||
|
console.error(`npm test требует Node.js >= 22.6 (type stripping), у тебя ${process.version}.`);
|
||||||
|
console.error("Это ограничение тест-харнеса, а не install.sh (для сотрудников достаточно >= 20).");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
process.env.PI_KIT_AUDIT_OFF = "1"; // don't touch the real audit log during tests
|
process.env.PI_KIT_AUDIT_OFF = "1"; // don't touch the real audit log during tests
|
||||||
|
|
||||||
import protectedPaths from "../extensions/protected-paths.ts";
|
import protectedPaths from "../extensions/protected-paths.ts";
|
||||||
@@ -14,7 +24,10 @@ import permissionGate from "../extensions/permission-gate.ts";
|
|||||||
import secretScanner from "../extensions/secret-scanner.ts";
|
import secretScanner from "../extensions/secret-scanner.ts";
|
||||||
import commitGuard from "../extensions/commit-guard.ts";
|
import commitGuard from "../extensions/commit-guard.ts";
|
||||||
import llmRedaction from "../extensions/llm-redaction.ts";
|
import llmRedaction from "../extensions/llm-redaction.ts";
|
||||||
|
import companyContext from "../extensions/company-context.ts";
|
||||||
|
import mcpBridge from "../extensions/mcp-bridge.ts";
|
||||||
import { redact, scanSecrets } from "../extensions/lib/secrets.ts";
|
import { redact, scanSecrets } from "../extensions/lib/secrets.ts";
|
||||||
|
import { normalize, renderContext, fetchRemoteConfig, MAX_CONTEXT_LINES } from "../extensions/lib/company-config.ts";
|
||||||
|
|
||||||
let fails = 0;
|
let fails = 0;
|
||||||
function check(name: string, cond: boolean) {
|
function check(name: string, cond: boolean) {
|
||||||
@@ -29,6 +42,13 @@ function grab(ext: (pi: any) => void, evName: string): any {
|
|||||||
return handler;
|
return handler;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Collect registerCommand calls (for extensions that register commands). */
|
||||||
|
function grabCommands(ext: (pi: any) => void): string[] {
|
||||||
|
const names: string[] = [];
|
||||||
|
ext({ on() {}, registerCommand: (name: string) => names.push(name) });
|
||||||
|
return names;
|
||||||
|
}
|
||||||
|
|
||||||
const noUI: any = { hasUI: false };
|
const noUI: any = { hasUI: false };
|
||||||
const AKIA = "AKIA" + "ABCDEFGHIJKLMNOP";
|
const AKIA = "AKIA" + "ABCDEFGHIJKLMNOP";
|
||||||
const GLPAT = "glpat-" + "aBcDeF1234567890ghXY12"; // 22 chars after prefix (>=20)
|
const GLPAT = "glpat-" + "aBcDeF1234567890ghXY12"; // 22 chars after prefix (>=20)
|
||||||
@@ -40,12 +60,35 @@ async function run() {
|
|||||||
// --- protected-paths ---
|
// --- protected-paths ---
|
||||||
const pp = grab(protectedPaths, "tool_call");
|
const pp = grab(protectedPaths, "tool_call");
|
||||||
check("protected: block .env", !!(await pp({ toolName: "write", input: { path: ".env" } }, noUI))?.block);
|
check("protected: block .env", !!(await pp({ toolName: "write", input: { path: ".env" } }, noUI))?.block);
|
||||||
|
check("protected: block secrets/ file", !!(await pp({ toolName: "write", input: { path: "deploy/secrets/db.sh" } }, noUI))?.block);
|
||||||
|
check("protected: block .git internals", !!(await pp({ toolName: "edit", input: { path: ".git/config" } }, noUI))?.block);
|
||||||
check("protected: allow src/app.go", !(await pp({ toolName: "write", input: { path: "src/app.go" } }, noUI))?.block);
|
check("protected: allow src/app.go", !(await pp({ toolName: "write", input: { path: "src/app.go" } }, noUI))?.block);
|
||||||
|
|
||||||
// --- permission-gate ---
|
// --- permission-gate (table: command -> should block?) ---
|
||||||
const pg = grab(permissionGate, "tool_call");
|
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);
|
const permCases: Array<[string, string, boolean]> = [
|
||||||
check("perm: allow ls", !(await pg({ toolName: "bash", input: { command: "ls -la" } }, noUI))?.block);
|
["perm: block push v1.2.3 tag", "git push origin v1.2.3", true],
|
||||||
|
["perm: block push tags/v1.2.3", "git push origin tags/v1.2.3", true],
|
||||||
|
["perm: block push refs/tags/v1.2.3", "git push origin refs/tags/v1.2.3", true],
|
||||||
|
["perm: block push main", "git push origin main", true],
|
||||||
|
["perm: block push HEAD:refs/heads/main", "git push origin HEAD:refs/heads/main", true],
|
||||||
|
["perm: block push release branch", "git push origin release-42", true],
|
||||||
|
["perm: block force push", "git push --force origin feature/x", true],
|
||||||
|
["perm: block push --tags", "git push origin --tags", true],
|
||||||
|
["perm: block rm -rf", "rm -rf /tmp/x", true],
|
||||||
|
["perm: block rm -r -f separated", "rm -r -f /tmp/x", true],
|
||||||
|
["perm: block rm -f -r separated", "rm -f -r -- /tmp/x", true],
|
||||||
|
["perm: block rm -fr", "rm -fr /tmp/x", true],
|
||||||
|
["perm: block git reset --hard", "git reset --hard HEAD~1", true],
|
||||||
|
["perm: block git clean -fdx", "git clean -fdx", true],
|
||||||
|
["perm: allow rm -i single", "rm -i /tmp/notes.txt", false],
|
||||||
|
["perm: allow ls", "ls -la", false],
|
||||||
|
["perm: allow push feature branch", "git push origin feature/PROJ-1", false],
|
||||||
|
];
|
||||||
|
for (const [name, cmd, blocked] of permCases) {
|
||||||
|
const res = await pg({ toolName: "bash", input: { command: cmd } }, noUI);
|
||||||
|
check(name, blocked ? !!res?.block : !res?.block);
|
||||||
|
}
|
||||||
|
|
||||||
// --- secret-scanner ---
|
// --- secret-scanner ---
|
||||||
const ss = grab(secretScanner, "tool_call");
|
const ss = grab(secretScanner, "tool_call");
|
||||||
@@ -68,11 +111,74 @@ async function run() {
|
|||||||
const redacted = JSON.stringify(out?.messages ?? []);
|
const redacted = JSON.stringify(out?.messages ?? []);
|
||||||
check("redact: token removed from payload", !redacted.includes(GLPAT) && redacted.includes("REDACTED"));
|
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);
|
check("redact: no-op when clean", (await lr({ payload: { messages: [{ role: "user", content: "hello" }] } })) === undefined);
|
||||||
|
{
|
||||||
|
const deep = { messages: [{ content: [{ type: "text", text: `k ${AKIA}` }] }] };
|
||||||
|
const r: any = await lr({ payload: deep });
|
||||||
|
check("redact: deep object arrays", !!r && !JSON.stringify(r.messages).includes(AKIA));
|
||||||
|
}
|
||||||
|
|
||||||
// --- lib units ---
|
// --- lib units: secrets ---
|
||||||
check("scanSecrets finds jwt", scanSecrets(JWT).length > 0);
|
check("scanSecrets finds jwt", scanSecrets(JWT).length > 0);
|
||||||
check("redact keeps clean text", redact("just normal text") === "just normal text");
|
check("redact keeps clean text", redact("just normal text") === "just normal text");
|
||||||
|
|
||||||
|
// --- lib units: company-config ---
|
||||||
|
check("normalize: junk input falls back to defaults", normalize({ foo: 1, gitHost: 42, rules: "nope" }).gitHost === "TODO");
|
||||||
|
check("normalize: drops non-string rules", JSON.stringify(normalize({ rules: [1, "keep", null] }).rules) === '["keep"]');
|
||||||
|
check("normalize: keeps known fields", normalize({ gitHost: "h", trackerUrl: "t", docsUrl: "d", repoMap: "m" }).gitHost === "h");
|
||||||
|
check("renderContext: replaces placeholders", renderContext("host={{gitHost}}", normalize({ gitHost: "gh" })).text === "host=gh");
|
||||||
|
check("renderContext: rules block", renderContext("{{rules}}", normalize({ rules: ["a", "b"] })).text.includes("- a\n- b"));
|
||||||
|
check("renderContext: no truncation for short template", !renderContext("# t\n{{rules}}", normalize({})).truncated);
|
||||||
|
{
|
||||||
|
const longTemplate = Array.from({ length: MAX_CONTEXT_LINES + 10 }, (_, i) => `line ${i}`).join("\n");
|
||||||
|
const r = renderContext(longTemplate, normalize({}));
|
||||||
|
check("renderContext: truncates long template", r.truncated && r.text.split("\n").length === MAX_CONTEXT_LINES);
|
||||||
|
}
|
||||||
|
{
|
||||||
|
const okFetch: any = async () => ({ ok: true, json: async () => ({ gitHost: "remote", rules: [1, "a"] }) });
|
||||||
|
const remote = await fetchRemoteConfig("https://x", okFetch);
|
||||||
|
check("fetchRemoteConfig: success normalizes", remote?.gitHost === "remote" && JSON.stringify(remote.rules) === '["a"]');
|
||||||
|
}
|
||||||
|
{
|
||||||
|
const badFetch: any = async () => ({ ok: false });
|
||||||
|
check("fetchRemoteConfig: !ok returns null", (await fetchRemoteConfig("https://x", badFetch)) === null);
|
||||||
|
}
|
||||||
|
{
|
||||||
|
const throwFetch: any = async () => { throw new Error("net"); };
|
||||||
|
check("fetchRemoteConfig: throw returns null", (await fetchRemoteConfig("https://x", throwFetch)) === null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- company-context extension (no remote configured) ---
|
||||||
|
// Must use ONE extension instance: the rendered context is cached per-instance.
|
||||||
|
{
|
||||||
|
let sessionHandler: any;
|
||||||
|
let beforeAgentHandler: any;
|
||||||
|
companyContext({
|
||||||
|
on(ev: string, fn: any) {
|
||||||
|
if (ev === "session_start") sessionHandler = fn;
|
||||||
|
if (ev === "before_agent_start") beforeAgentHandler = fn;
|
||||||
|
},
|
||||||
|
registerCommand() {},
|
||||||
|
} as any);
|
||||||
|
await sessionHandler({ reason: "test" }, noUI);
|
||||||
|
const inj = await beforeAgentHandler({ systemPrompt: "base" });
|
||||||
|
check("company-context: injects corporate block", typeof inj?.systemPrompt === "string" && inj.systemPrompt.includes("Корпоративный контекст"));
|
||||||
|
check("company-context: preserves base prompt", inj?.systemPrompt.startsWith("base"));
|
||||||
|
check("company-context: registers /kit-config", grabCommands(companyContext).includes("kit-config"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- mcp-bridge: disabled servers must not connect or error ---
|
||||||
|
{
|
||||||
|
const registered: string[] = [];
|
||||||
|
const tools: string[] = [];
|
||||||
|
await mcpBridge({
|
||||||
|
registerCommand: (name: string) => registered.push(name),
|
||||||
|
registerTool: () => tools.push("x"),
|
||||||
|
on() {},
|
||||||
|
} as any);
|
||||||
|
check("mcp-bridge: registers /mcp-status", registered.includes("mcp-status"));
|
||||||
|
check("mcp-bridge: no tools with all servers disabled", tools.length === 0);
|
||||||
|
}
|
||||||
|
|
||||||
console.log(fails === 0 ? "\nALL PASS" : `\n${fails} FAILED`);
|
console.log(fails === 0 ? "\nALL PASS" : `\n${fails} FAILED`);
|
||||||
process.exit(fails === 0 ? 0 : 1);
|
process.exit(fails === 0 ? 0 : 1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Shell smoke tests for skills/jira-workflow/scripts/create-mr.sh.
|
||||||
|
# Network-free: uses MR_DRY_RUN=1 and a throwaway git repo.
|
||||||
|
# Run via `npm test` (after guardrails.test.ts) or directly: bash test/shell.test.sh
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
SCRIPT="$ROOT/skills/jira-workflow/scripts/create-mr.sh"
|
||||||
|
FAILS=0
|
||||||
|
|
||||||
|
check() { # <name> <expected-substring> <actual>
|
||||||
|
local name="$1" expected="$2" actual="$3"
|
||||||
|
if grep -qF -- "$expected" <<<"$actual"; then
|
||||||
|
echo "PASS $name"
|
||||||
|
else
|
||||||
|
echo "FAIL $name (expected substring: $expected)"
|
||||||
|
echo "--- actual ---"
|
||||||
|
echo "$actual"
|
||||||
|
FAILS=$((FAILS + 1))
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Fresh throwaway repo with an empty commit.
|
||||||
|
DIR="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$DIR"' EXIT
|
||||||
|
cd "$DIR"
|
||||||
|
git init -q
|
||||||
|
git config user.email test@example.com
|
||||||
|
git config user.name Test
|
||||||
|
git remote add origin git@gitlab.tech.mvideo.ru:ai/foo.git
|
||||||
|
git commit --allow-empty -q -m "chore: init"
|
||||||
|
git checkout -q -b feature/PROJ-1-smoke
|
||||||
|
|
||||||
|
# 1. scp-like origin → API URL with encoded namespace.
|
||||||
|
OUT="$(GITLAB_TOKEN=dummy MR_DRY_RUN=1 bash "$SCRIPT" "Test MR" 2>&1)"
|
||||||
|
check "create-mr: scp-like origin parse" "POST https://gitlab.tech.mvideo.ru/api/v4/projects/ai%2Ffoo/merge_requests" "$OUT"
|
||||||
|
check "create-mr: dry-run shows would-push" "would push: git push -u origin feature/PROJ-1-smoke" "$OUT"
|
||||||
|
|
||||||
|
# 2. https-like origin → same API shape, different host.
|
||||||
|
git remote set-url origin "https://git.codelab.vc/ai/bar.git"
|
||||||
|
OUT="$(GITLAB_TOKEN=dummy MR_DRY_RUN=1 bash "$SCRIPT" "Test MR" 2>&1)"
|
||||||
|
check "create-mr: https origin parse" "POST https://git.codelab.vc/api/v4/projects/ai%2Fbar/merge_requests" "$OUT"
|
||||||
|
|
||||||
|
# 3. GITLAB_HOST overrides the derived host.
|
||||||
|
OUT="$(GITLAB_TOKEN=dummy GITLAB_HOST=gitlab.example.com MR_DRY_RUN=1 bash "$SCRIPT" "Test MR" 2>&1)"
|
||||||
|
check "create-mr: GITLAB_HOST override" "POST https://gitlab.example.com/api/v4/projects/ai%2Fbar/merge_requests" "$OUT"
|
||||||
|
|
||||||
|
# 4. Refuses to create MR from a protected/release source branch.
|
||||||
|
git checkout -q -b release-42
|
||||||
|
OUT="$(GITLAB_TOKEN=dummy MR_DRY_RUN=1 bash "$SCRIPT" "Test MR" 2>&1 || true)"
|
||||||
|
check "create-mr: refuses release-* source" "Refusing: source branch" "$OUT"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
if [ "$FAILS" -eq 0 ]; then
|
||||||
|
echo "shell tests: ALL PASS"
|
||||||
|
exit 0
|
||||||
|
else
|
||||||
|
echo "shell tests: $FAILS FAILED"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
+1
-1
@@ -14,5 +14,5 @@
|
|||||||
"allowImportingTsExtensions": true,
|
"allowImportingTsExtensions": true,
|
||||||
"verbatimModuleSyntax": false
|
"verbatimModuleSyntax": false
|
||||||
},
|
},
|
||||||
"include": ["extensions/**/*.ts"]
|
"include": ["extensions/**/*.ts", "test/**/*.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user