Merge harness hardening: security guardrails, DX commands, release channels, MCP scaffold
This commit is contained in:
@@ -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" };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Commit Guard Extension
|
||||
*
|
||||
* WHAT: On `git commit -m …`, validates the message against Conventional
|
||||
* Commits and scans it for secrets. Invalid/secret → confirm (UI) or
|
||||
* block (no UI). Non-`-m` commits (editor) are left alone.
|
||||
*
|
||||
* CONFIGURE: edit COMMIT_TYPES below. Secret patterns live in
|
||||
* extensions/lib/secrets.ts.
|
||||
*
|
||||
* DISABLE: remove/rename this file from the package `extensions/` folder.
|
||||
*/
|
||||
|
||||
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||
import { scanSecrets } from "./lib/secrets.ts";
|
||||
import { audit } from "./lib/audit.ts";
|
||||
|
||||
// Conventional Commits types accepted in the header. Extend as needed.
|
||||
const COMMIT_TYPES = ["feat", "fix", "chore", "docs", "refactor", "test", "perf", "build", "ci", "style", "revert"];
|
||||
const HEADER_RE = new RegExp(`^(${COMMIT_TYPES.join("|")})(\\(.+\\))?!?: .+`);
|
||||
|
||||
/** Extract the -m / --message value from a `git commit` command, if present. */
|
||||
function extractCommitMessage(command: string): string | null {
|
||||
// Matches -m "msg", -m 'msg', --message=msg, -m msg (best effort).
|
||||
const m =
|
||||
command.match(/(?:-m|--message)[=\s]+"([^"]*)"/) ??
|
||||
command.match(/(?:-m|--message)[=\s]+'([^']*)'/) ??
|
||||
command.match(/(?:-m|--message)[=\s]+(\S+)/);
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
|
||||
export default function commitGuardExtension(pi: ExtensionAPI) {
|
||||
pi.on("tool_call", async (event, ctx) => {
|
||||
if (event.toolName !== "bash") return undefined;
|
||||
const command = (event.input.command as string | undefined) ?? "";
|
||||
if (!/\bgit\s+commit\b/.test(command)) return undefined;
|
||||
|
||||
const message = extractCommitMessage(command);
|
||||
if (message === null) return undefined; // editor-based commit; not our concern
|
||||
|
||||
const problems: string[] = [];
|
||||
if (!HEADER_RE.test(message)) problems.push("не Conventional Commits");
|
||||
const secretHits = scanSecrets(message);
|
||||
if (secretHits.length > 0) problems.push(`секрет (${secretHits.join(", ")})`);
|
||||
|
||||
if (problems.length === 0) return undefined;
|
||||
|
||||
audit({ kind: "guardrail_block", guardrail: "commit-guard", problems, hasSecret: secretHits.length > 0 });
|
||||
|
||||
if (ctx.hasUI) {
|
||||
const choice = await ctx.ui.select(
|
||||
`⚠️ Проблема с сообщением коммита: ${problems.join("; ")}.\nФормат: type(scope): описание. Всё равно закоммитить?`,
|
||||
["Нет", "Да"],
|
||||
);
|
||||
if (choice === "Да") return undefined;
|
||||
}
|
||||
|
||||
return { block: true, reason: `Commit message rejected: ${problems.join("; ")}` };
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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(() => {});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
@@ -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(", ")})` };
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user