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:
@@ -2,7 +2,9 @@
|
||||
|
||||
Ты работаешь как инженерный ассистент внутри нашей компании. Учитывай это во всех задачах.
|
||||
|
||||
**Кто мы:** продуктовая инженерная команда. Основной стек — **Go** (микросервисы).
|
||||
**Кто мы:** продуктовая инженерная команда. Язык/стек зависит от репозитория и профиля —
|
||||
для конкретного языка используй скилл `<lang>-standards` (go/typescript/python/rust/kotlin/
|
||||
swift; нужный подключён по профилю сотрудника).
|
||||
|
||||
**Где код:** git-хост — `{{gitHost}}`.
|
||||
Карта репозиториев: {{repoMap}}
|
||||
@@ -10,12 +12,11 @@
|
||||
|
||||
**Трекер задач:** {{trackerUrl}}
|
||||
Как читать тикеты и работать с задачами — скилл `jira-workflow`.
|
||||
> Если в окружении задан `TRACKER_URL`, он приоритетнее значения `trackerUrl` из конфига.
|
||||
|
||||
**Документация:** {{docsUrl}}
|
||||
Как искать и куда писать — скилл `docs-map`.
|
||||
|
||||
**Стандарты Go** (структура сервиса, линтеры, тесты, MR) — скилл `go-standards`.
|
||||
|
||||
**Базовые правила:**
|
||||
{{rules}}
|
||||
|
||||
|
||||
@@ -16,109 +16,33 @@
|
||||
* or exclude it via the package manifest / settings.
|
||||
*/
|
||||
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||
|
||||
interface CompanyConfig {
|
||||
gitHost: string;
|
||||
trackerUrl: string;
|
||||
docsUrl: string;
|
||||
repoMap: string;
|
||||
rules: string[];
|
||||
remoteConfigUrl: string | null;
|
||||
}
|
||||
import {
|
||||
readLocalConfig,
|
||||
fetchRemoteConfig,
|
||||
loadTemplate,
|
||||
renderContext,
|
||||
DEFAULT_CONFIG,
|
||||
type CompanyConfig,
|
||||
} from "./lib/company-config.ts";
|
||||
|
||||
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||||
const CONFIG_PATH = path.join(HERE, "..", "config", "company.json");
|
||||
const TEMPLATE_PATH = path.join(HERE, "company-context.md");
|
||||
const REMOTE_TIMEOUT_MS = 2500;
|
||||
const MAX_CONTEXT_LINES = 40;
|
||||
|
||||
const DEFAULT_CONFIG: CompanyConfig = {
|
||||
gitHost: "TODO",
|
||||
trackerUrl: "TODO",
|
||||
docsUrl: "TODO",
|
||||
repoMap: "TODO",
|
||||
rules: [],
|
||||
remoteConfigUrl: null,
|
||||
};
|
||||
|
||||
/** Read and validate the local config file, falling back to defaults. */
|
||||
function readLocalConfig(): CompanyConfig {
|
||||
try {
|
||||
const raw = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf8"));
|
||||
return normalize(raw);
|
||||
} catch {
|
||||
return { ...DEFAULT_CONFIG };
|
||||
}
|
||||
}
|
||||
|
||||
/** Keep only known keys and coerce types, so junk in remote/local JSON is ignored. */
|
||||
function normalize(raw: Record<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) {
|
||||
const template = loadTemplate();
|
||||
const template = loadTemplate(TEMPLATE_PATH);
|
||||
|
||||
// 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";
|
||||
// Rendered once per session; injected by before_agent_start.
|
||||
let rendered: string | null = null;
|
||||
|
||||
pi.on("session_start", async (_event, ctx) => {
|
||||
effective = readLocalConfig();
|
||||
effective = readLocalConfig(CONFIG_PATH);
|
||||
source = "local";
|
||||
|
||||
if (effective.remoteConfigUrl) {
|
||||
@@ -131,15 +55,26 @@ export default function companyContextExtension(pi: ExtensionAPI) {
|
||||
}
|
||||
}
|
||||
|
||||
const { text, truncated } = renderContext(template, effective);
|
||||
rendered = text;
|
||||
|
||||
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) => {
|
||||
const rendered = renderContext(template, effective);
|
||||
if (!rendered) return undefined;
|
||||
return { systemPrompt: `${event.systemPrompt}\n\n${rendered}` };
|
||||
// session_start may not have fired (rare); render on demand as a fallback.
|
||||
const context = rendered ?? renderContext(template, effective).text;
|
||||
if (!context) return undefined;
|
||||
return { systemPrompt: `${event.systemPrompt}\n\n${context}` };
|
||||
});
|
||||
|
||||
pi.registerCommand("kit-config", {
|
||||
|
||||
@@ -75,6 +75,13 @@ export default function kitCliExtension(pi: ExtensionAPI) {
|
||||
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 задан`));
|
||||
// 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)
|
||||
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).
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
@@ -16,12 +17,35 @@ 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;
|
||||
const POST_ATTEMPTS = 3;
|
||||
|
||||
export interface AuditEntry {
|
||||
kind: string; // e.g. "guardrail_block", "command", "session_start"
|
||||
[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. */
|
||||
export function audit(entry: AuditEntry): void {
|
||||
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;
|
||||
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(() => {});
|
||||
postWithRetry(url, line);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
// ---------------------------------------------------------------------------
|
||||
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)" },
|
||||
// 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]*\b(main|master)\b/i, reason: "push to main/master" },
|
||||
// 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.
|
||||
{ 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)" },
|
||||
// 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]*\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,
|
||||
reason: "kubectl against a production context/namespace",
|
||||
|
||||
Reference in New Issue
Block a user