Files
pi-kit/extensions/permission-gate.ts
T
Aleksey Shakhmatov c068dfae0c 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)
2026-08-06 11:22:53 +03:00

72 lines
3.5 KiB
TypeScript

/**
* 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 }> = [
// 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",
},
{ 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;
});
}