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:
Aleksey Shakhmatov
2026-08-06 11:22:53 +03:00
parent 1df4ea1b72
commit c068dfae0c
21 changed files with 4970 additions and 131 deletions
+26 -8
View File
@@ -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);
}
}
+112
View File
@@ -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 };
}