@aliou/pi-guardrails 0.7.7 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,115 @@
1
+ import type { AgentTool, ThinkingLevel } from "@mariozechner/pi-agent-core";
2
+ import type { Model } from "@mariozechner/pi-ai";
3
+ import type { Skill, ToolDefinition } from "@mariozechner/pi-coding-agent";
4
+
5
+ /**
6
+ * Configuration for a subagent.
7
+ */
8
+ export interface SubagentConfig {
9
+ /** Subagent name (for logging and run ID) */
10
+ name: string;
11
+
12
+ /** Model instance to use */
13
+ // biome-ignore lint/suspicious/noExplicitAny: Model type requires any for generic API
14
+ model: Model<any>;
15
+
16
+ /** System prompt for the subagent */
17
+ systemPrompt: string;
18
+
19
+ /** Built-in tools (AgentTool[]) - e.g., from createReadOnlyTools() */
20
+ tools?: AgentTool[];
21
+
22
+ /** Custom tools (ToolDefinition[]) - e.g., GitHub tools */
23
+ customTools?: ToolDefinition[];
24
+
25
+ /** Skills to load into system prompt */
26
+ skills?: Skill[];
27
+
28
+ /** Thinking level. Default: "low" */
29
+ thinkingLevel?: ThinkingLevel;
30
+
31
+ /** Logging options */
32
+ logging?: {
33
+ /** Enable logging. Default: false */
34
+ enabled: boolean;
35
+ /** Include raw events in debug.jsonl. Default: false */
36
+ debug?: boolean;
37
+ };
38
+ }
39
+
40
+ /**
41
+ * Tool call state for tracking subagent tool executions.
42
+ */
43
+ export interface SubagentToolCall {
44
+ toolCallId: string;
45
+ toolName: string;
46
+ args: Record<string, unknown>;
47
+ status: "running" | "done" | "error";
48
+ /** Epoch ms when tool execution started */
49
+ startedAt?: number;
50
+ /** Epoch ms when tool execution ended */
51
+ endedAt?: number;
52
+ /** Duration in milliseconds (set when ended) */
53
+ durationMs?: number;
54
+ result?: unknown;
55
+ error?: string;
56
+ /** Partial result from tool updates (for progress display) */
57
+ partialResult?: {
58
+ content: Array<{ type: string; text?: string }>;
59
+ details?: unknown;
60
+ };
61
+ }
62
+
63
+ /**
64
+ * Usage/cost information from the model response.
65
+ */
66
+ export interface SubagentUsage {
67
+ /** Input tokens from API (if available) */
68
+ inputTokens?: number;
69
+ /** Output tokens from API (if available) */
70
+ outputTokens?: number;
71
+ /** Cache read tokens (if available) */
72
+ cacheReadTokens?: number;
73
+ /** Cache write tokens (if available) */
74
+ cacheWriteTokens?: number;
75
+ /** Estimated tokens from response length (chars/4) */
76
+ estimatedTokens: number;
77
+ /** LLM cost in USD (if available) */
78
+ llmCost?: number;
79
+ /** Tool/API cost in USD (e.g., Exa, GitHub) */
80
+ toolCost?: number;
81
+ /** Total cost in USD (llmCost + toolCost) */
82
+ totalCost?: number;
83
+ }
84
+
85
+ /**
86
+ * Result from executing a subagent.
87
+ */
88
+ export interface SubagentResult {
89
+ /** Final text content from the subagent */
90
+ content: string;
91
+
92
+ /** Whether the subagent was aborted */
93
+ aborted: boolean;
94
+
95
+ /** Final tool call states */
96
+ toolCalls: SubagentToolCall[];
97
+
98
+ /** Total subagent execution duration in milliseconds */
99
+ totalDurationMs: number;
100
+
101
+ /** Error message if the subagent failed */
102
+ error?: string;
103
+
104
+ /** Unique run identifier */
105
+ runId: string;
106
+
107
+ /** Usage/cost information */
108
+ usage: SubagentUsage;
109
+ }
110
+
111
+ /** Callback for text streaming updates */
112
+ export type OnTextUpdate = (delta: string, accumulated: string) => void;
113
+
114
+ /** Callback for tool execution updates */
115
+ export type OnToolUpdate = (toolCalls: SubagentToolCall[]) => void;
@@ -4,7 +4,7 @@ export const GUARDRAILS_BLOCKED_EVENT = "guardrails:blocked";
4
4
  export const GUARDRAILS_DANGEROUS_EVENT = "guardrails:dangerous";
5
5
 
6
6
  export interface GuardrailsBlockedEvent {
7
- feature: "protectEnvFiles" | "permissionGate";
7
+ feature: "policies" | "permissionGate";
8
8
  toolName: string;
9
9
  input: Record<string, unknown>;
10
10
  reason: string;
@@ -15,7 +15,13 @@ import type {
15
15
  } from "../config";
16
16
  import { pendingWarnings } from "./warnings";
17
17
 
18
- export const CURRENT_VERSION = "0.6.0-20260204";
18
+ /**
19
+ * Config schema version.
20
+ *
21
+ * Keep this independent from package.json version.
22
+ * Bump only when config schema/default migration markers change.
23
+ */
24
+ export const CURRENT_VERSION = "0.8.0-20260228";
19
25
 
20
26
  /**
21
27
  * Check if a config needs migration (no version field = v0).
@@ -78,6 +84,105 @@ export function migrateV0(config: GuardrailsConfig): GuardrailsConfig {
78
84
  return migrated;
79
85
  }
80
86
 
87
+ /**
88
+ * Check if a config still uses deprecated envFiles/protectEnvFiles fields.
89
+ */
90
+ export function needsEnvFilesToPoliciesMigration(
91
+ config: GuardrailsConfig,
92
+ ): boolean {
93
+ const raw = config as Record<string, unknown>;
94
+ if (raw.envFiles !== undefined) return true;
95
+
96
+ const features = raw.features as Record<string, unknown> | undefined;
97
+ return features?.protectEnvFiles !== undefined;
98
+ }
99
+
100
+ /**
101
+ * Migrate deprecated envFiles/protectEnvFiles fields to policies.
102
+ */
103
+ export function migrateEnvFilesToPolicies(
104
+ config: GuardrailsConfig,
105
+ ): GuardrailsConfig {
106
+ const migrated = structuredClone(config);
107
+ const raw = migrated as Record<string, unknown>;
108
+ const features = raw.features as Record<string, unknown> | undefined;
109
+ const envFiles = raw.envFiles as Record<string, unknown> | undefined;
110
+
111
+ if (features?.protectEnvFiles !== undefined) {
112
+ features.policies = features.protectEnvFiles;
113
+ delete features.protectEnvFiles;
114
+ }
115
+
116
+ if (envFiles) {
117
+ const rule: Record<string, unknown> = {
118
+ id: "secret-files",
119
+ description: "Files containing secrets (migrated from envFiles)",
120
+ protection: "noAccess",
121
+ };
122
+
123
+ if (envFiles.protectedPatterns) {
124
+ rule.patterns = envFiles.protectedPatterns;
125
+ }
126
+ if (envFiles.allowedPatterns) {
127
+ rule.allowedPatterns = envFiles.allowedPatterns;
128
+ }
129
+ if (envFiles.onlyBlockIfExists !== undefined) {
130
+ rule.onlyIfExists = envFiles.onlyBlockIfExists;
131
+ }
132
+ if (typeof envFiles.blockMessage === "string") {
133
+ rule.blockMessage = envFiles.blockMessage;
134
+ }
135
+
136
+ if (Array.isArray(envFiles.protectedDirectories)) {
137
+ const dirs = envFiles.protectedDirectories as Array<
138
+ Record<string, unknown>
139
+ >;
140
+ const patterns = Array.isArray(rule.patterns)
141
+ ? ([...rule.patterns] as Array<Record<string, unknown>>)
142
+ : [];
143
+
144
+ for (const dir of dirs) {
145
+ const dirPattern = dir.pattern;
146
+ if (typeof dirPattern !== "string" || dirPattern.trim() === "") {
147
+ continue;
148
+ }
149
+
150
+ const normalized = dirPattern.endsWith("/**")
151
+ ? dirPattern
152
+ : `${dirPattern}/**`;
153
+ patterns.push({ pattern: normalized, regex: dir.regex });
154
+ }
155
+
156
+ if (patterns.length > 0) {
157
+ rule.patterns = patterns;
158
+ }
159
+ }
160
+
161
+ if (Array.isArray(envFiles.protectedTools)) {
162
+ pendingWarnings.push(
163
+ "[guardrails] envFiles.protectedTools is deprecated and has no direct policies equivalent. " +
164
+ "The migrated secret-files rule uses protection=noAccess.",
165
+ );
166
+ }
167
+
168
+ if (!Array.isArray(rule.patterns) || rule.patterns.length === 0) {
169
+ rule.patterns = [
170
+ { pattern: ".env" },
171
+ { pattern: ".env.local" },
172
+ { pattern: ".env.production" },
173
+ { pattern: ".env.prod" },
174
+ { pattern: ".dev.vars" },
175
+ ];
176
+ }
177
+
178
+ raw.policies = { rules: [rule] };
179
+ delete raw.envFiles;
180
+ }
181
+
182
+ raw.version = CURRENT_VERSION;
183
+ return migrated as GuardrailsConfig;
184
+ }
185
+
81
186
  /**
82
187
  * Migrate a string[] or PatternConfig[] to PatternConfig[] with regex: true.
83
188
  * Handles mixed arrays (some already migrated, some still strings).
@@ -1,220 +0,0 @@
1
- import { stat } from "node:fs/promises";
2
- import { resolve } from "node:path";
3
- import { parse } from "@aliou/sh";
4
- import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
5
- import type { ResolvedConfig } from "../config";
6
- import { emitBlocked } from "../utils/events";
7
- import { expandGlob, hasGlobChars } from "../utils/glob-expander";
8
- import { type CompiledPattern, compileFilePatterns } from "../utils/matching";
9
- import { walkCommands, wordToString } from "../utils/shell-utils";
10
-
11
- /**
12
- * Prevents accessing .env files unless they match an allowed pattern.
13
- * Protects sensitive environment files from being accessed accidentally.
14
- *
15
- * Uses AST-based parsing for bash commands to extract file references,
16
- * with glob expansion via `fd` when args contain shell glob characters.
17
- */
18
-
19
- async function fileExists(filePath: string): Promise<boolean> {
20
- try {
21
- await stat(resolve(filePath));
22
- return true;
23
- } catch {
24
- return false;
25
- }
26
- }
27
-
28
- async function isProtectedEnvFile(
29
- filePath: string,
30
- protectedPatterns: CompiledPattern[],
31
- allowedPatterns: CompiledPattern[],
32
- dirPatterns: CompiledPattern[],
33
- onlyBlockIfExists: boolean,
34
- ): Promise<boolean> {
35
- const isProtected = protectedPatterns.some((p) => p.test(filePath));
36
- if (!isProtected) return false;
37
-
38
- const isAllowed = allowedPatterns.some((p) => p.test(filePath));
39
- if (isAllowed) return false;
40
-
41
- // Check protected directories (if any configured)
42
- if (dirPatterns.length > 0) {
43
- const inProtectedDir = dirPatterns.some((p) => p.test(filePath));
44
- if (inProtectedDir) {
45
- return onlyBlockIfExists ? await fileExists(filePath) : true;
46
- }
47
- }
48
-
49
- return onlyBlockIfExists ? await fileExists(filePath) : true;
50
- }
51
-
52
- /**
53
- * Extract file references from a bash command using AST parsing.
54
- * Falls back to regex extraction on parse failure.
55
- */
56
- async function extractBashFileTargets(command: string): Promise<string[]> {
57
- try {
58
- const { ast } = parse(command);
59
- const files: string[] = [];
60
-
61
- walkCommands(ast, (cmd) => {
62
- const words = (cmd.words ?? []).map(wordToString);
63
- // Skip command name (words[0]), check args for env file references
64
- for (let i = 1; i < words.length; i++) {
65
- const arg = words[i] as string;
66
- if (isEnvLikeReference(arg)) {
67
- files.push(arg);
68
- }
69
- }
70
-
71
- // Also check redirect targets
72
- for (const redir of cmd.redirects ?? []) {
73
- const target = wordToString(redir.target);
74
- if (isEnvLikeReference(target)) {
75
- files.push(target);
76
- }
77
- }
78
- return false;
79
- });
80
-
81
- // Expand globs
82
- const expanded: string[] = [];
83
- for (const file of files) {
84
- if (hasGlobChars(file)) {
85
- const matches = await expandGlob(file);
86
- if (matches.length > 0) {
87
- expanded.push(...matches);
88
- } else {
89
- // Expansion returned nothing -- could be fd not found or no matches.
90
- // Keep original as-is so pattern matching can still catch it.
91
- expanded.push(file);
92
- }
93
- } else {
94
- expanded.push(file);
95
- }
96
- }
97
-
98
- return expanded;
99
- } catch {
100
- // Fallback: regex extraction from raw string
101
- return extractEnvFilesRegex(command);
102
- }
103
- }
104
-
105
- /**
106
- * Check if a string looks like an env file reference.
107
- * Matches anything containing ".env" as a path component.
108
- */
109
- function isEnvLikeReference(arg: string): boolean {
110
- // Must contain ".env" somewhere
111
- if (!arg.includes(".env") && !arg.includes(".dev.vars")) return false;
112
- // Skip flags
113
- if (arg.startsWith("-") && !arg.startsWith("-/") && !arg.startsWith("-."))
114
- return false;
115
- return true;
116
- }
117
-
118
- /**
119
- * Fallback regex extraction for env file references in bash commands.
120
- */
121
- function extractEnvFilesRegex(command: string): string[] {
122
- const files: string[] = [];
123
- const envFileRegex =
124
- /(?:^|\s|[<>|;&"'`])([^\s<>|;&"'`]*\.env[^\s<>|;&"'`]*)(?:\s|$|[<>|;&"'`])/gi;
125
-
126
- for (const match of command.matchAll(envFileRegex)) {
127
- const file = match[1];
128
- if (file) files.push(file);
129
- }
130
-
131
- return files;
132
- }
133
-
134
- interface ToolProtectionRule {
135
- tools: string[];
136
- extractTargets: (input: Record<string, unknown>) => Promise<string[]>;
137
- shouldBlock: (target: string) => Promise<boolean>;
138
- blockMessage: (target: string) => string;
139
- }
140
-
141
- export function setupProtectEnvFilesHook(
142
- pi: ExtensionAPI,
143
- config: ResolvedConfig,
144
- ) {
145
- if (!config.features.protectEnvFiles) return;
146
-
147
- const protectedPatterns = compileFilePatterns(
148
- config.envFiles.protectedPatterns,
149
- );
150
- const allowedPatterns = compileFilePatterns(config.envFiles.allowedPatterns);
151
- const dirPatterns = compileFilePatterns(config.envFiles.protectedDirectories);
152
-
153
- const shouldBlock = (target: string) =>
154
- isProtectedEnvFile(
155
- target,
156
- protectedPatterns,
157
- allowedPatterns,
158
- dirPatterns,
159
- config.envFiles.onlyBlockIfExists,
160
- );
161
-
162
- const protectionRules: ToolProtectionRule[] = [
163
- {
164
- tools: config.envFiles.protectedTools.filter((t) =>
165
- ["read", "write", "edit", "grep", "find", "ls"].includes(t),
166
- ),
167
- extractTargets: async (input) => {
168
- const path = String(input.file_path ?? input.path ?? "");
169
- return path ? [path] : [];
170
- },
171
- shouldBlock,
172
- blockMessage: (target) =>
173
- config.envFiles.blockMessage.replace("{file}", target),
174
- },
175
- {
176
- tools: config.envFiles.protectedTools.includes("bash") ? ["bash"] : [],
177
- extractTargets: async (input) => {
178
- const command = String(input.command ?? "");
179
- return extractBashFileTargets(command);
180
- },
181
- shouldBlock,
182
- blockMessage: (target) =>
183
- `Command references protected file ${target}. ` +
184
- config.envFiles.blockMessage.replace("{file}", target),
185
- },
186
- ];
187
-
188
- // Build lookup: tool name -> rule
189
- const rulesByTool = new Map<string, ToolProtectionRule>();
190
- for (const rule of protectionRules) {
191
- for (const tool of rule.tools) {
192
- rulesByTool.set(tool, rule);
193
- }
194
- }
195
-
196
- pi.on("tool_call", async (event, ctx) => {
197
- const rule = rulesByTool.get(event.toolName);
198
- if (!rule) return;
199
-
200
- const targets = await rule.extractTargets(event.input);
201
-
202
- for (const target of targets) {
203
- if (await rule.shouldBlock(target)) {
204
- ctx.ui.notify(`Blocked access to protected file: ${target}`, "warning");
205
-
206
- const reason = rule.blockMessage(target);
207
-
208
- emitBlocked(pi, {
209
- feature: "protectEnvFiles",
210
- toolName: event.toolName,
211
- input: event.input,
212
- reason,
213
- });
214
-
215
- return { block: true, reason };
216
- }
217
- }
218
- return;
219
- });
220
- }