feat(extensions): protected-paths, permission-gate, company-context
- protected-paths: glob-based block of write/edit to secrets/keys/.git/prod configs - permission-gate: confirm dangerous bash (rm -rf, force push, push to protected branch, prod kubectl, docker prune, sudo) - company-context: loads config/company.json (+ optional remoteConfigUrl w/ 2.5s timeout), injects corporate context via before_agent_start, registers /kit-config - config path resolved via import.meta.url, not cwd; text template in company-context.md
This commit is contained in:
23
extensions/company-context.md
Normal file
23
extensions/company-context.md
Normal file
@@ -0,0 +1,23 @@
|
||||
# Корпоративный контекст
|
||||
|
||||
Ты работаешь как инженерный ассистент внутри нашей компании. Учитывай это во всех задачах.
|
||||
|
||||
**Кто мы:** продуктовая инженерная команда. Основной стек — **Go** (микросервисы).
|
||||
|
||||
**Где код:** git-хост — `{{gitHost}}`.
|
||||
Карта репозиториев: {{repoMap}}
|
||||
Подробнее — скилл `repo-map`.
|
||||
|
||||
**Трекер задач:** {{trackerUrl}}
|
||||
Как читать тикеты и работать с задачами — скилл `jira-workflow`.
|
||||
|
||||
**Документация:** {{docsUrl}}
|
||||
Как искать и куда писать — скилл `docs-map`.
|
||||
|
||||
**Стандарты Go** (структура сервиса, линтеры, тесты, MR) — скилл `go-standards`.
|
||||
|
||||
**Базовые правила:**
|
||||
{{rules}}
|
||||
|
||||
Не выдумывай внутренние адреса и факты: если чего-то нет в этом контексте или в конфиге —
|
||||
скажи об этом прямо. Точные действующие значения можно посмотреть командой `/kit-config`.
|
||||
171
extensions/company-context.ts
Normal file
171
extensions/company-context.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* Company Context Extension
|
||||
*
|
||||
* WHAT: On session start, loads config/company.json (the single source of
|
||||
* truth). If `remoteConfigUrl` is set, tries to fetch a fresh config
|
||||
* from there with a short timeout and falls back silently to the local
|
||||
* file on any error. Renders a short corporate context from
|
||||
* `company-context.md` and injects it into the system prompt. Also
|
||||
* registers the `/kit-config` command for debugging effective values.
|
||||
*
|
||||
* CONFIGURE: Edit config/company.json (gitHost, trackerUrl, docsUrl, repoMap,
|
||||
* rules, remoteConfigUrl). Edit the wording in company-context.md —
|
||||
* non-programmers can safely change text between the {{placeholders}}.
|
||||
*
|
||||
* DISABLE: Remove/rename this file from the package `extensions/` folder,
|
||||
* 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;
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
// Effective config and where it came from. Refreshed on every session start.
|
||||
let effective: CompanyConfig = readLocalConfig();
|
||||
let source: "local" | "remote" = "local";
|
||||
|
||||
pi.on("session_start", async (_event, ctx) => {
|
||||
effective = readLocalConfig();
|
||||
source = "local";
|
||||
|
||||
if (effective.remoteConfigUrl) {
|
||||
const remote = await fetchRemoteConfig(effective.remoteConfigUrl);
|
||||
if (remote) {
|
||||
// Remote wins, but keep the local remoteConfigUrl so a bad remote
|
||||
// value can't orphan future refreshes.
|
||||
effective = { ...remote, remoteConfigUrl: effective.remoteConfigUrl };
|
||||
source = "remote";
|
||||
}
|
||||
}
|
||||
|
||||
if (ctx.hasUI) {
|
||||
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}` };
|
||||
});
|
||||
|
||||
pi.registerCommand("kit-config", {
|
||||
description: "Показать действующие корпоративные значения pi-kit и их источник",
|
||||
handler: async (_args, ctx) => {
|
||||
const origin =
|
||||
source === "remote"
|
||||
? `remote (${effective.remoteConfigUrl})`
|
||||
: `local (${CONFIG_PATH})`;
|
||||
const lines = [
|
||||
`Источник: ${origin}`,
|
||||
"",
|
||||
`gitHost: ${effective.gitHost}`,
|
||||
`trackerUrl: ${effective.trackerUrl}`,
|
||||
`docsUrl: ${effective.docsUrl}`,
|
||||
`repoMap: ${effective.repoMap}`,
|
||||
`remoteConfigUrl: ${effective.remoteConfigUrl ?? "(не задан)"}`,
|
||||
"rules:",
|
||||
...(effective.rules.length > 0 ? effective.rules.map((r) => ` - ${r}`) : [" (нет)"]),
|
||||
];
|
||||
|
||||
if (ctx.hasUI) {
|
||||
await ctx.ui.select("Действующая конфигурация pi-kit", lines);
|
||||
} else {
|
||||
ctx.ui.notify(lines.join("\n"), "info");
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
61
extensions/permission-gate.ts
Normal file
61
extensions/permission-gate.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Permission Gate Extension
|
||||
*
|
||||
* WHAT: Asks for confirmation before running dangerous `bash` commands.
|
||||
* In non-interactive mode (no UI) such commands are blocked by default.
|
||||
* Based on the official permission-gate example, with more patterns.
|
||||
*
|
||||
* CONFIGURE: Edit the DANGEROUS_PATTERNS constant below. Each entry has a
|
||||
* regex and a human-readable reason shown in the confirmation prompt.
|
||||
*
|
||||
* DISABLE: Remove/rename this file from the package `extensions/` folder,
|
||||
* or exclude it via the package manifest / settings.
|
||||
*/
|
||||
|
||||
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dangerous command patterns. Add new entries here.
|
||||
// Matching is intentionally broad; the user confirms, so false positives are
|
||||
// cheap while misses can be costly.
|
||||
// ---------------------------------------------------------------------------
|
||||
const DANGEROUS_PATTERNS: Array<{ re: RegExp; reason: string }> = [
|
||||
{ re: /\brm\s+(-\w*r\w*f|-\w*f\w*r|--recursive)/i, reason: "recursive force delete (rm -rf)" },
|
||||
{ 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|release\/[^\s]+)\b/i,
|
||||
reason: "push to a protected branch (main/master/release/*)",
|
||||
},
|
||||
{
|
||||
re: /\bkubectl\b[^\n]*(--context[=\s]?\S*prod|--namespace[=\s]?\S*prod|\bctx-prod\b)/i,
|
||||
reason: "kubectl against a production context/namespace",
|
||||
},
|
||||
{ re: /\bdocker\s+system\s+prune\b/i, reason: "docker system prune (removes containers/images/volumes)" },
|
||||
{ re: /\bsudo\b/i, reason: "elevated privileges (sudo)" },
|
||||
];
|
||||
|
||||
export default function permissionGateExtension(pi: ExtensionAPI) {
|
||||
pi.on("tool_call", async (event, ctx) => {
|
||||
if (event.toolName !== "bash") return undefined;
|
||||
|
||||
const command = (event.input.command as string | undefined) ?? "";
|
||||
const hit = DANGEROUS_PATTERNS.find((p) => p.re.test(command));
|
||||
if (!hit) return undefined;
|
||||
|
||||
if (!ctx.hasUI) {
|
||||
// Non-interactive mode: cannot ask, so block by default.
|
||||
return { block: true, reason: `Dangerous command blocked (${hit.reason}); no UI for confirmation` };
|
||||
}
|
||||
|
||||
const choice = await ctx.ui.select(
|
||||
`⚠️ Опасная команда — ${hit.reason}:\n\n ${command}\n\nВыполнить?`,
|
||||
["Нет", "Да"],
|
||||
);
|
||||
|
||||
if (choice !== "Да") {
|
||||
return { block: true, reason: `Blocked by user (${hit.reason})` };
|
||||
}
|
||||
|
||||
return undefined;
|
||||
});
|
||||
}
|
||||
92
extensions/protected-paths.ts
Normal file
92
extensions/protected-paths.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Protected Paths Extension
|
||||
*
|
||||
* WHAT: Blocks `write` and `edit` tool calls that target sensitive files
|
||||
* (secrets, keys, VCS internals, production config). Based on the
|
||||
* official protected-paths example, extended with glob matching.
|
||||
*
|
||||
* CONFIGURE: Edit the PROTECTED_PATHS constant below. Patterns are globs:
|
||||
* `*` matches within one path segment,
|
||||
* `**` matches across segments,
|
||||
* `?` matches a single character.
|
||||
* A leading `**/` means "in any directory".
|
||||
*
|
||||
* DISABLE: Remove/rename this file from the package `extensions/` folder,
|
||||
* or exclude it via the package manifest / settings.
|
||||
*/
|
||||
|
||||
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Protected path patterns. Add new entries here to widen protection.
|
||||
// Keep this list conservative — it blocks writes agent-wide.
|
||||
// ---------------------------------------------------------------------------
|
||||
const PROTECTED_PATHS: string[] = [
|
||||
".env*", // .env, .env.local, .env.production, ...
|
||||
"**/secrets/**", // anything under a secrets/ directory
|
||||
"**/*.pem", // TLS/cert private material
|
||||
"**/*.key", // private keys
|
||||
".git/**", // VCS internals
|
||||
// Production environment config files (adjust to your conventions):
|
||||
"**/*.prod.yml",
|
||||
"**/*.prod.yaml",
|
||||
"**/*.production.yml",
|
||||
"**/*.production.yaml",
|
||||
"**/values-prod.yaml", // Helm prod values
|
||||
"**/config/prod*", // config/prod.json, config/production.*, ...
|
||||
];
|
||||
|
||||
/**
|
||||
* Compile a glob pattern into a RegExp that matches a path suffix, so the
|
||||
* pattern matches regardless of whether the path is absolute or relative.
|
||||
*/
|
||||
function globToRegExp(glob: string): RegExp {
|
||||
let g = glob;
|
||||
if (g.startsWith("**/")) {
|
||||
g = g.slice(3); // the "(^|/)" prefix already means "in any directory"
|
||||
}
|
||||
let re = "";
|
||||
for (let i = 0; i < g.length; i++) {
|
||||
const c = g[i];
|
||||
if (c === "*") {
|
||||
if (g[i + 1] === "*") {
|
||||
re += ".*";
|
||||
i++;
|
||||
} else {
|
||||
re += "[^/]*";
|
||||
}
|
||||
} else if (c === "?") {
|
||||
re += "[^/]";
|
||||
} else if ("\\^$+.()|{}[]".includes(c)) {
|
||||
re += `\\${c}`;
|
||||
} else {
|
||||
re += c;
|
||||
}
|
||||
}
|
||||
return new RegExp(`(^|/)${re}$`);
|
||||
}
|
||||
|
||||
const COMPILED = PROTECTED_PATHS.map((p) => ({ glob: p, re: globToRegExp(p) }));
|
||||
|
||||
export default function protectedPathsExtension(pi: ExtensionAPI) {
|
||||
pi.on("tool_call", async (event, ctx) => {
|
||||
if (event.toolName !== "write" && event.toolName !== "edit") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const raw = event.input.path as string | undefined;
|
||||
if (!raw) return undefined;
|
||||
|
||||
const path = raw.replace(/\\/g, "/"); // normalize Windows separators
|
||||
const hit = COMPILED.find((c) => c.re.test(path));
|
||||
|
||||
if (hit) {
|
||||
if (ctx.hasUI) {
|
||||
ctx.ui.notify(`Blocked write to protected path: ${raw} (matches "${hit.glob}")`, "warning");
|
||||
}
|
||||
return { block: true, reason: `Path "${raw}" is protected (pattern "${hit.glob}")` };
|
||||
}
|
||||
|
||||
return undefined;
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user