omegon 0.6.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.
- package/.gitattributes +3 -0
- package/AGENTS.md +16 -0
- package/LICENSE +15 -0
- package/README.md +289 -0
- package/bin/pi.mjs +30 -0
- package/extensions/00-secrets/index.ts +1126 -0
- package/extensions/01-auth/auth.ts +401 -0
- package/extensions/01-auth/index.ts +289 -0
- package/extensions/auto-compact.ts +42 -0
- package/extensions/bootstrap/deps.ts +291 -0
- package/extensions/bootstrap/index.ts +811 -0
- package/extensions/chronos/chronos.sh +487 -0
- package/extensions/chronos/index.ts +148 -0
- package/extensions/cleave/assessment.ts +754 -0
- package/extensions/cleave/bridge.ts +31 -0
- package/extensions/cleave/conflicts.ts +250 -0
- package/extensions/cleave/dispatcher.ts +808 -0
- package/extensions/cleave/guardrails.ts +426 -0
- package/extensions/cleave/index.ts +3121 -0
- package/extensions/cleave/lifecycle-emitter.ts +20 -0
- package/extensions/cleave/openspec.ts +811 -0
- package/extensions/cleave/planner.ts +260 -0
- package/extensions/cleave/review.ts +579 -0
- package/extensions/cleave/skills.ts +355 -0
- package/extensions/cleave/types.ts +261 -0
- package/extensions/cleave/workspace.ts +861 -0
- package/extensions/cleave/worktree.ts +243 -0
- package/extensions/core-renderers.ts +253 -0
- package/extensions/dashboard/context-gauge.ts +58 -0
- package/extensions/dashboard/file-watch.ts +14 -0
- package/extensions/dashboard/footer.ts +1145 -0
- package/extensions/dashboard/git.ts +185 -0
- package/extensions/dashboard/index.ts +478 -0
- package/extensions/dashboard/memory-audit.ts +34 -0
- package/extensions/dashboard/overlay-data.ts +705 -0
- package/extensions/dashboard/overlay.ts +365 -0
- package/extensions/dashboard/render-utils.ts +54 -0
- package/extensions/dashboard/types.ts +191 -0
- package/extensions/dashboard/uri-helper.ts +45 -0
- package/extensions/debug.ts +69 -0
- package/extensions/defaults.ts +282 -0
- package/extensions/design-tree/dashboard-state.ts +161 -0
- package/extensions/design-tree/design-card.ts +362 -0
- package/extensions/design-tree/index.ts +2130 -0
- package/extensions/design-tree/lifecycle-emitter.ts +41 -0
- package/extensions/design-tree/tree.ts +1607 -0
- package/extensions/design-tree/types.ts +163 -0
- package/extensions/distill.ts +127 -0
- package/extensions/effort/index.ts +395 -0
- package/extensions/effort/tiers.ts +146 -0
- package/extensions/effort/types.ts +105 -0
- package/extensions/lib/git-state.ts +227 -0
- package/extensions/lib/local-models.ts +157 -0
- package/extensions/lib/model-preferences.ts +51 -0
- package/extensions/lib/model-routing.ts +720 -0
- package/extensions/lib/operator-fallback.ts +205 -0
- package/extensions/lib/operator-profile.ts +360 -0
- package/extensions/lib/slash-command-bridge.ts +253 -0
- package/extensions/lib/typebox-helpers.ts +16 -0
- package/extensions/local-inference/index.ts +727 -0
- package/extensions/mcp-bridge/README.md +220 -0
- package/extensions/mcp-bridge/index.ts +951 -0
- package/extensions/mcp-bridge/lib.ts +365 -0
- package/extensions/mcp-bridge/mcp.json +3 -0
- package/extensions/mcp-bridge/package.json +11 -0
- package/extensions/model-budget.ts +752 -0
- package/extensions/offline-driver.ts +403 -0
- package/extensions/openspec/archive-gate.ts +164 -0
- package/extensions/openspec/branch-cleanup.ts +64 -0
- package/extensions/openspec/dashboard-state.ts +50 -0
- package/extensions/openspec/index.ts +1917 -0
- package/extensions/openspec/lifecycle-emitter.ts +65 -0
- package/extensions/openspec/lifecycle-files.ts +70 -0
- package/extensions/openspec/lifecycle.ts +50 -0
- package/extensions/openspec/reconcile.ts +187 -0
- package/extensions/openspec/spec.ts +1385 -0
- package/extensions/openspec/types.ts +98 -0
- package/extensions/project-memory/DESIGN-global-mind.md +198 -0
- package/extensions/project-memory/README.md +202 -0
- package/extensions/project-memory/api-types.ts +382 -0
- package/extensions/project-memory/compaction-policy.ts +29 -0
- package/extensions/project-memory/core.ts +164 -0
- package/extensions/project-memory/embeddings.ts +230 -0
- package/extensions/project-memory/extraction-v2.ts +861 -0
- package/extensions/project-memory/factstore.ts +2177 -0
- package/extensions/project-memory/index.ts +3459 -0
- package/extensions/project-memory/injection-metrics.ts +91 -0
- package/extensions/project-memory/jsonl-io.ts +12 -0
- package/extensions/project-memory/lifecycle.ts +331 -0
- package/extensions/project-memory/migration.ts +293 -0
- package/extensions/project-memory/package.json +9 -0
- package/extensions/project-memory/sci-renderers.ts +7 -0
- package/extensions/project-memory/template.ts +103 -0
- package/extensions/project-memory/triggers.ts +52 -0
- package/extensions/project-memory/types.ts +102 -0
- package/extensions/render/composition/fonts/Inter-Bold.ttf +0 -0
- package/extensions/render/composition/fonts/Inter-Regular.ttf +0 -0
- package/extensions/render/composition/fonts/Tomorrow-Bold.ttf +0 -0
- package/extensions/render/composition/fonts/Tomorrow-Regular.ttf +0 -0
- package/extensions/render/composition/package-lock.json +534 -0
- package/extensions/render/composition/package.json +22 -0
- package/extensions/render/composition/render.mjs +246 -0
- package/extensions/render/composition/test-comp.tsx +87 -0
- package/extensions/render/composition/types.ts +24 -0
- package/extensions/render/excalidraw/UPSTREAM.md +81 -0
- package/extensions/render/excalidraw/elements.ts +764 -0
- package/extensions/render/excalidraw/index.ts +66 -0
- package/extensions/render/excalidraw/types.ts +223 -0
- package/extensions/render/excalidraw-renderer/pyproject.toml +8 -0
- package/extensions/render/excalidraw-renderer/render_excalidraw.py +182 -0
- package/extensions/render/excalidraw-renderer/render_template.html +59 -0
- package/extensions/render/index.ts +830 -0
- package/extensions/render/native-diagrams/index.ts +57 -0
- package/extensions/render/native-diagrams/motifs.ts +542 -0
- package/extensions/render/native-diagrams/raster.ts +8 -0
- package/extensions/render/native-diagrams/scene.ts +75 -0
- package/extensions/render/native-diagrams/spec.ts +204 -0
- package/extensions/render/native-diagrams/svg.ts +116 -0
- package/extensions/sci-ui.ts +304 -0
- package/extensions/session-log.ts +174 -0
- package/extensions/shared-state.ts +146 -0
- package/extensions/spinner-verbs.ts +91 -0
- package/extensions/style.ts +281 -0
- package/extensions/terminal-title.ts +191 -0
- package/extensions/tool-profile/index.ts +291 -0
- package/extensions/tool-profile/profiles.ts +290 -0
- package/extensions/types.d.ts +9 -0
- package/extensions/vault/index.ts +185 -0
- package/extensions/version-check.ts +90 -0
- package/extensions/view/index.ts +859 -0
- package/extensions/view/uri-resolver.ts +148 -0
- package/extensions/web-search/index.ts +182 -0
- package/extensions/web-search/providers.ts +121 -0
- package/extensions/web-ui/index.ts +110 -0
- package/extensions/web-ui/server.ts +265 -0
- package/extensions/web-ui/state.ts +462 -0
- package/extensions/web-ui/static/index.html +145 -0
- package/extensions/web-ui/types.ts +284 -0
- package/package.json +76 -0
- package/prompts/init.md +75 -0
- package/prompts/new-repo.md +54 -0
- package/prompts/oci-login.md +56 -0
- package/prompts/status.md +50 -0
- package/settings.json +4 -0
- package/skills/cleave/SKILL.md +218 -0
- package/skills/git/SKILL.md +209 -0
- package/skills/git/_reference/ci-validation.md +204 -0
- package/skills/oci/SKILL.md +338 -0
- package/skills/openspec/SKILL.md +346 -0
- package/skills/pi-extensions/SKILL.md +191 -0
- package/skills/pi-tui/SKILL.md +517 -0
- package/skills/python/SKILL.md +189 -0
- package/skills/rust/SKILL.md +268 -0
- package/skills/security/SKILL.md +206 -0
- package/skills/style/SKILL.md +264 -0
- package/skills/typescript/SKILL.md +225 -0
- package/skills/vault/SKILL.md +102 -0
- package/themes/alpharius-legacy.json +85 -0
- package/themes/alpharius.conf +59 -0
- package/themes/alpharius.json +88 -0
|
@@ -0,0 +1,1126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Secrets Extension
|
|
3
|
+
*
|
|
4
|
+
* Resolves secrets from user-configured sources (env vars, shell commands, keychains)
|
|
5
|
+
* without duplicating or storing secret values. Provides:
|
|
6
|
+
*
|
|
7
|
+
* Layer 1: resolveSecret() — extensions call this to get secrets from user-configured recipes
|
|
8
|
+
* Layer 2: Output redaction — scrubs known secret values from tool results before they reach the agent
|
|
9
|
+
* Layer 3: Tool guard — blocks/confirms tool calls that access sensitive paths or secret stores
|
|
10
|
+
* Layer 4: Recipe file — stores resolution recipes, never literal secrets
|
|
11
|
+
* Layer 5: Local model scrub — redacts secrets from outbound ask_local_model prompts
|
|
12
|
+
* Layer 6: Audit log — append-only record of all guard decisions
|
|
13
|
+
*
|
|
14
|
+
* Commands: /secrets list, /secrets configure <name>, /secrets rm <name>, /secrets test <name>
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { ExtensionAPI } from "@cwilson613/pi-coding-agent";
|
|
18
|
+
import { existsSync, readFileSync, writeFileSync, appendFileSync, mkdirSync, readdirSync } from "fs";
|
|
19
|
+
import { join, resolve } from "path";
|
|
20
|
+
import { homedir } from "os";
|
|
21
|
+
import { execSync, execFileSync } from "child_process";
|
|
22
|
+
|
|
23
|
+
// ============================================================================
|
|
24
|
+
// Config
|
|
25
|
+
// ============================================================================
|
|
26
|
+
|
|
27
|
+
const SECRETS_DIR = join(homedir(), ".pi", "agent");
|
|
28
|
+
const SECRETS_FILE = join(SECRETS_DIR, "secrets.json");
|
|
29
|
+
const AUDIT_LOG_FILE = join(SECRETS_DIR, "secrets-audit.jsonl");
|
|
30
|
+
|
|
31
|
+
/** Fallback secrets not tied to a specific extension */
|
|
32
|
+
const BUILTIN_SECRETS: Record<string, string> = {};
|
|
33
|
+
|
|
34
|
+
/** Fallback config vars not tied to a specific extension */
|
|
35
|
+
const BUILTIN_CONFIGS: Record<string, { description: string; default?: string }> = {};
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Scan extension directories for annotations:
|
|
39
|
+
* // @secret NAME "description"
|
|
40
|
+
* // @config NAME "description" [default: value]
|
|
41
|
+
*
|
|
42
|
+
* @secret — sensitive values (API keys, tokens) that need redaction and guarded access
|
|
43
|
+
* @config — non-sensitive env var overrides (paths, URLs, feature flags) surfaced in /secrets list
|
|
44
|
+
*/
|
|
45
|
+
function scanAnnotations(): {
|
|
46
|
+
secrets: Record<string, string>;
|
|
47
|
+
configs: Record<string, { description: string; default?: string }>;
|
|
48
|
+
} {
|
|
49
|
+
const secrets: Record<string, string> = { ...BUILTIN_SECRETS };
|
|
50
|
+
const configs: Record<string, { description: string; default?: string }> = { ...BUILTIN_CONFIGS };
|
|
51
|
+
const secretPattern = /^\/\/\s*@secret\s+([A-Z_][A-Z0-9_]*)\s+"([^"]+)"/;
|
|
52
|
+
const configPattern = /^\/\/\s*@config\s+([A-Z_][A-Z0-9_]*)\s+"([^"]+)"(?:\s+\[default:\s*([^\]]*)\])?/;
|
|
53
|
+
|
|
54
|
+
// Extension directories to scan
|
|
55
|
+
const extensionDirs = [
|
|
56
|
+
join(homedir(), ".pi", "agent", "extensions"),
|
|
57
|
+
join(homedir(), ".pi", "agent", "git"), // Omegon and other git packages
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
// Also scan project-local extensions
|
|
61
|
+
try {
|
|
62
|
+
const cwd = process.cwd();
|
|
63
|
+
const projectDir = join(cwd, ".pi", "extensions");
|
|
64
|
+
if (existsSync(projectDir)) extensionDirs.push(projectDir);
|
|
65
|
+
} catch {}
|
|
66
|
+
|
|
67
|
+
function scanFile(filePath: string) {
|
|
68
|
+
try {
|
|
69
|
+
const content = readFileSync(filePath, "utf-8");
|
|
70
|
+
// Only scan the first 30 lines for annotations (they should be at the top)
|
|
71
|
+
const lines = content.split("\n").slice(0, 30);
|
|
72
|
+
for (const line of lines) {
|
|
73
|
+
const secretMatch = line.match(secretPattern);
|
|
74
|
+
if (secretMatch) {
|
|
75
|
+
secrets[secretMatch[1]] = secretMatch[2];
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
const configMatch = line.match(configPattern);
|
|
79
|
+
if (configMatch) {
|
|
80
|
+
configs[configMatch[1]] = {
|
|
81
|
+
description: configMatch[2],
|
|
82
|
+
default: configMatch[3]?.trim(),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
} catch {}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function walkDir(dir: string) {
|
|
90
|
+
if (!existsSync(dir)) return;
|
|
91
|
+
try {
|
|
92
|
+
const entries = readdirSync(dir, { withFileTypes: true });
|
|
93
|
+
for (const entry of entries) {
|
|
94
|
+
const fullPath = join(dir, entry.name);
|
|
95
|
+
if (entry.isFile() && (entry.name.endsWith(".ts") || entry.name.endsWith(".js"))) {
|
|
96
|
+
scanFile(fullPath);
|
|
97
|
+
} else if (entry.isDirectory() && !entry.name.startsWith(".") && entry.name !== "node_modules") {
|
|
98
|
+
walkDir(fullPath);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
} catch {}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
for (const dir of extensionDirs) {
|
|
105
|
+
walkDir(dir);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return { secrets, configs };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Discovered annotations — scanned once at load time */
|
|
112
|
+
const { secrets: KNOWN_SECRETS, configs: KNOWN_CONFIGS } = scanAnnotations();
|
|
113
|
+
|
|
114
|
+
// ============================================================================
|
|
115
|
+
// Recipe types
|
|
116
|
+
// ============================================================================
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Recipe format:
|
|
120
|
+
* - "!command args" → shell command, stdout is the secret
|
|
121
|
+
* - "ENV_VAR_NAME" → read from environment variable
|
|
122
|
+
* - "literal:value" → literal value (discouraged, warned about)
|
|
123
|
+
*/
|
|
124
|
+
type RecipeMap = Record<string, string>;
|
|
125
|
+
|
|
126
|
+
// ============================================================================
|
|
127
|
+
// State — resolved secrets cached in memory, never written to disk
|
|
128
|
+
// ============================================================================
|
|
129
|
+
|
|
130
|
+
let recipes: RecipeMap = {};
|
|
131
|
+
const resolvedCache = new Map<string, string>();
|
|
132
|
+
|
|
133
|
+
// ============================================================================
|
|
134
|
+
// Core: Recipe loading
|
|
135
|
+
// ============================================================================
|
|
136
|
+
|
|
137
|
+
function loadRecipes(): RecipeMap {
|
|
138
|
+
if (!existsSync(SECRETS_FILE)) return {};
|
|
139
|
+
try {
|
|
140
|
+
const raw = readFileSync(SECRETS_FILE, "utf-8");
|
|
141
|
+
return JSON.parse(raw) as RecipeMap;
|
|
142
|
+
} catch {
|
|
143
|
+
return {};
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function saveRecipes(r: RecipeMap): void {
|
|
148
|
+
mkdirSync(SECRETS_DIR, { recursive: true });
|
|
149
|
+
writeFileSync(SECRETS_FILE, JSON.stringify(r, null, 2) + "\n", { mode: 0o600 });
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ============================================================================
|
|
153
|
+
// Core: Secret resolution
|
|
154
|
+
// ============================================================================
|
|
155
|
+
|
|
156
|
+
function executeRecipe(recipe: string): string | undefined {
|
|
157
|
+
// Shell command
|
|
158
|
+
if (recipe.startsWith("!")) {
|
|
159
|
+
try {
|
|
160
|
+
const cmd = recipe.slice(1).trim();
|
|
161
|
+
const result = execSync(cmd, {
|
|
162
|
+
encoding: "utf-8",
|
|
163
|
+
timeout: 10_000,
|
|
164
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
165
|
+
}).trim();
|
|
166
|
+
return result || undefined;
|
|
167
|
+
} catch {
|
|
168
|
+
return undefined;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Literal value (discouraged)
|
|
173
|
+
if (recipe.startsWith("literal:")) {
|
|
174
|
+
return recipe.slice(8);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Environment variable name
|
|
178
|
+
return process.env[recipe] || undefined;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Resolve a secret by name. Resolution order:
|
|
183
|
+
* 1. In-memory cache (already resolved this session)
|
|
184
|
+
* 2. process.env[name] — always checked first for CI/container compat
|
|
185
|
+
* 3. Recipe from secrets.json
|
|
186
|
+
* 4. undefined — caller handles missing secret gracefully
|
|
187
|
+
*/
|
|
188
|
+
export function resolveSecret(name: string): string | undefined {
|
|
189
|
+
// Check cache
|
|
190
|
+
const cached = resolvedCache.get(name);
|
|
191
|
+
if (cached) return cached;
|
|
192
|
+
|
|
193
|
+
// Always check env first (CI, containers, explicit overrides)
|
|
194
|
+
const envVal = process.env[name];
|
|
195
|
+
if (envVal) {
|
|
196
|
+
resolvedCache.set(name, envVal);
|
|
197
|
+
return envVal;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Check recipe
|
|
201
|
+
const recipe = recipes[name];
|
|
202
|
+
if (!recipe) return undefined;
|
|
203
|
+
|
|
204
|
+
const value = executeRecipe(recipe);
|
|
205
|
+
if (value) {
|
|
206
|
+
resolvedCache.set(name, value);
|
|
207
|
+
}
|
|
208
|
+
return value;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ============================================================================
|
|
212
|
+
// Layer 6: Audit log
|
|
213
|
+
// ============================================================================
|
|
214
|
+
|
|
215
|
+
function logGuardDecision(event: {
|
|
216
|
+
tool: string;
|
|
217
|
+
target: string;
|
|
218
|
+
action: "blocked" | "allowed" | "confirmed";
|
|
219
|
+
reason: string;
|
|
220
|
+
}): void {
|
|
221
|
+
try {
|
|
222
|
+
mkdirSync(SECRETS_DIR, { recursive: true });
|
|
223
|
+
const entry = JSON.stringify({ ...event, timestamp: new Date().toISOString() }) + "\n";
|
|
224
|
+
appendFileSync(AUDIT_LOG_FILE, entry, { mode: 0o600 });
|
|
225
|
+
} catch {}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// ============================================================================
|
|
229
|
+
// Layer 2: Output redaction
|
|
230
|
+
// ============================================================================
|
|
231
|
+
|
|
232
|
+
/** Minimum secret length for redaction. Shorter values cause too many false positives. */
|
|
233
|
+
const MIN_REDACT_LENGTH = 4;
|
|
234
|
+
|
|
235
|
+
function redactString(input: string, secrets: Array<{ name: string; value: string }>): string {
|
|
236
|
+
let result = input;
|
|
237
|
+
for (const { name, value } of secrets) {
|
|
238
|
+
if (value.length < MIN_REDACT_LENGTH) continue;
|
|
239
|
+
|
|
240
|
+
// Escape regex special characters in the secret value
|
|
241
|
+
const escaped = value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
242
|
+
const marker = `[REDACTED:${name}]`;
|
|
243
|
+
|
|
244
|
+
// Replace all occurrences of the full value
|
|
245
|
+
result = result.replace(new RegExp(escaped, "g"), marker);
|
|
246
|
+
|
|
247
|
+
// Also check for base64-encoded form of the secret
|
|
248
|
+
try {
|
|
249
|
+
const b64 = Buffer.from(value).toString("base64");
|
|
250
|
+
if (b64.length >= 8) {
|
|
251
|
+
const b64Escaped = b64.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
252
|
+
result = result.replace(new RegExp(b64Escaped, "g"), `[REDACTED:${name}:base64]`);
|
|
253
|
+
}
|
|
254
|
+
} catch {}
|
|
255
|
+
|
|
256
|
+
// Also redact partial prefixes for very long secrets (40+ chars)
|
|
257
|
+
// Use only first 12 chars to reduce false positives from standard prefixes
|
|
258
|
+
if (value.length > 40) {
|
|
259
|
+
const partialEscaped = value.slice(0, 12).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
260
|
+
result = result.replace(new RegExp(partialEscaped, "g"), marker);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
return result;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function getRedactableSecrets(): Array<{ name: string; value: string }> {
|
|
267
|
+
return Array.from(resolvedCache.entries())
|
|
268
|
+
.filter(([_, v]) => v.length >= MIN_REDACT_LENGTH)
|
|
269
|
+
.map(([name, value]) => ({ name, value }));
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function redactContent(content: any[]): any[] {
|
|
273
|
+
const secrets = getRedactableSecrets();
|
|
274
|
+
if (secrets.length === 0) return content;
|
|
275
|
+
|
|
276
|
+
return content.map((block: any) => {
|
|
277
|
+
if (block.type === "text" && typeof block.text === "string") {
|
|
278
|
+
const redacted = redactString(block.text, secrets);
|
|
279
|
+
if (redacted !== block.text) {
|
|
280
|
+
return { ...block, text: redacted };
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
return block;
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// ============================================================================
|
|
288
|
+
// Layer 3: Sensitive path detection
|
|
289
|
+
// ============================================================================
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Sensitive path patterns. Each entry defines:
|
|
293
|
+
* - pattern: regex to match against resolved absolute paths
|
|
294
|
+
* - description: human-readable label for UI prompts
|
|
295
|
+
* - action: "block" = hard block (no override), "confirm" = prompt user
|
|
296
|
+
*/
|
|
297
|
+
const SENSITIVE_PATH_PATTERNS: Array<{
|
|
298
|
+
pattern: RegExp;
|
|
299
|
+
description: string;
|
|
300
|
+
action: "block" | "confirm";
|
|
301
|
+
}> = [
|
|
302
|
+
// The recipe store itself — hard block, only /secrets commands may access
|
|
303
|
+
{ pattern: /\.pi\/agent\/secrets\.json$/i, description: "secrets recipe store", action: "block" },
|
|
304
|
+
// The audit log — hard block, prevent tampering
|
|
305
|
+
{ pattern: /\.pi\/agent\/secrets-audit\.jsonl$/i, description: "secrets audit log", action: "block" },
|
|
306
|
+
// Dotenv files
|
|
307
|
+
{ pattern: /\.env(\.[a-z]+)?$/i, description: "dotenv file", action: "confirm" },
|
|
308
|
+
// SSH keys and config
|
|
309
|
+
{ pattern: /\.ssh\/(id_[a-z0-9]+|config|known_hosts|authorized_keys)(\.pub)?$/i, description: "SSH credential/config", action: "confirm" },
|
|
310
|
+
// AWS credentials
|
|
311
|
+
{ pattern: /\.aws\/(credentials|config)$/i, description: "AWS credentials", action: "confirm" },
|
|
312
|
+
// GCP credentials
|
|
313
|
+
{ pattern: /\.config\/gcloud\/.*(credentials|tokens|properties)/i, description: "GCP credentials", action: "confirm" },
|
|
314
|
+
// Azure credentials
|
|
315
|
+
{ pattern: /\.azure\/(credentials|accessTokens\.json)/i, description: "Azure credentials", action: "confirm" },
|
|
316
|
+
// GPG private data
|
|
317
|
+
{ pattern: /\.gnupg\/(secring|trustdb|private-keys)/i, description: "GPG private data", action: "confirm" },
|
|
318
|
+
// Netrc
|
|
319
|
+
{ pattern: /\.(netrc|curlrc)$/i, description: "netrc/curlrc file", action: "confirm" },
|
|
320
|
+
// Generic credential files
|
|
321
|
+
{ pattern: /\bcredentials?\.(json|yaml|yml|toml|xml)$/i, description: "credentials file", action: "confirm" },
|
|
322
|
+
// Token/secret/key files
|
|
323
|
+
{ pattern: /\b(token|secret|private[_-]?key)\.(json|pem|key|txt)$/i, description: "token/key file", action: "confirm" },
|
|
324
|
+
// Docker config (contains registry auth tokens)
|
|
325
|
+
{ pattern: /\.docker\/config\.json$/i, description: "Docker config (may contain auth)", action: "confirm" },
|
|
326
|
+
// NPM auth
|
|
327
|
+
{ pattern: /\.npmrc$/i, description: "npm config (may contain auth tokens)", action: "confirm" },
|
|
328
|
+
// Kubernetes config
|
|
329
|
+
{ pattern: /\.kube\/config$/i, description: "Kubernetes config", action: "confirm" },
|
|
330
|
+
];
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Check if a path matches any sensitive path pattern.
|
|
334
|
+
* Returns the matching entry or undefined.
|
|
335
|
+
*/
|
|
336
|
+
function matchSensitivePath(filePath: string): { description: string; action: "block" | "confirm" } | undefined {
|
|
337
|
+
// Normalize: resolve to absolute, then check patterns
|
|
338
|
+
let normalized: string;
|
|
339
|
+
try {
|
|
340
|
+
normalized = resolve(filePath);
|
|
341
|
+
} catch {
|
|
342
|
+
normalized = filePath;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
for (const entry of SENSITIVE_PATH_PATTERNS) {
|
|
346
|
+
if (entry.pattern.test(normalized) || entry.pattern.test(filePath)) {
|
|
347
|
+
return { description: entry.description, action: entry.action };
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
return undefined;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// ============================================================================
|
|
354
|
+
// Layer 3: Bash guard patterns
|
|
355
|
+
// ============================================================================
|
|
356
|
+
|
|
357
|
+
const SECRET_ACCESS_PATTERNS = [
|
|
358
|
+
// ── Direct secret store access ──
|
|
359
|
+
// macOS Keychain
|
|
360
|
+
/\bsecurity\s+find-generic-password/i,
|
|
361
|
+
/\bsecurity\s+find-internet-password/i,
|
|
362
|
+
// 1Password
|
|
363
|
+
/\bop\s+(read|get|item)\b/i,
|
|
364
|
+
// pass (GPG password store)
|
|
365
|
+
/\bpass\s+(show|ls)\b/i,
|
|
366
|
+
// Vault
|
|
367
|
+
/\bvault\s+(read|kv\s+get)\b/i,
|
|
368
|
+
|
|
369
|
+
// ── Environment variable dumping ──
|
|
370
|
+
// Targeted env access with secret-adjacent keywords
|
|
371
|
+
/\benv\b.*\b(key|token|secret|password|credential)/i,
|
|
372
|
+
/\bprintenv\b.*\b(key|token|secret|password|credential)/i,
|
|
373
|
+
// Echo/printf of known secret env vars
|
|
374
|
+
/\b(echo|printf)\s+.*\$[A-Z_]*(KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL)/i,
|
|
375
|
+
// Full env dumps (these can leak all injected secrets)
|
|
376
|
+
/\bnode\s+-e\s+.*process\.env/i,
|
|
377
|
+
/\bpython[23]?\s+-c\s+.*os\.environ/i,
|
|
378
|
+
/\bruby\s+-e\s+.*ENV/i,
|
|
379
|
+
/\bperl\s+-e\s+.*%ENV/i,
|
|
380
|
+
|
|
381
|
+
// ── File readers on sensitive paths ──
|
|
382
|
+
// cat/less/more/head/tail/bat on secret-adjacent files
|
|
383
|
+
/\b(cat|less|more|head|tail|bat|batcat)\b.*(secrets?\.json|\bcredentials?\b|\.env\b)/i,
|
|
384
|
+
// jq on secret files
|
|
385
|
+
/\bjq\b.*\b(secrets?\.json|credentials?)\b/i,
|
|
386
|
+
// sed/awk/grep reading secret files
|
|
387
|
+
/\b(sed|awk|grep)\b.*\b(secrets?\.json|credentials?)\b/i,
|
|
388
|
+
// Our own secrets file — match the specific path
|
|
389
|
+
/\.pi\/agent\/secrets\.json/i,
|
|
390
|
+
// Writing to secrets file (via tee, redirect, etc.)
|
|
391
|
+
/>\s*.*\.pi\/agent\/secrets\.json/i,
|
|
392
|
+
// AWS/GCP credential file access
|
|
393
|
+
/\b(cat|less|more|head|tail)\b.*\.(aws|gcloud)\/(credentials|config)/i,
|
|
394
|
+
|
|
395
|
+
// ── Command wrapping (shell indirection) ──
|
|
396
|
+
// sh/bash/zsh -c wrapping with secret-adjacent content
|
|
397
|
+
/\b(sh|bash|zsh)\s+-c\s+.*\b(security|op\s+read|pass\s+show|vault\s+read|keychain|credential|secret)/i,
|
|
398
|
+
// Python/Ruby/Node/Perl subprocess wrappers accessing secret stores
|
|
399
|
+
/\b(python[23]?|ruby|node|perl)\b.*\b(security\s+find|op\s+read|find-generic-password|secrets?\.json)/i,
|
|
400
|
+
// Base64 decode piped to shell (obfuscation technique)
|
|
401
|
+
/\bbase64\s+(-d|--decode)\b.*\|\s*(sh|bash|zsh)\b/i,
|
|
402
|
+
// eval with encoded content
|
|
403
|
+
/\beval\b.*\$\(.*base64/i,
|
|
404
|
+
];
|
|
405
|
+
|
|
406
|
+
function isSecretAccessCommand(command: string): boolean {
|
|
407
|
+
return SECRET_ACCESS_PATTERNS.some((p) => p.test(command));
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// ============================================================================
|
|
411
|
+
// Clipboard helpers — read secret values without showing them on screen
|
|
412
|
+
// ============================================================================
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Detect which clipboard read command is available.
|
|
416
|
+
* Returns the command string or undefined if none found.
|
|
417
|
+
*/
|
|
418
|
+
function detectClipboardCommand(): string | undefined {
|
|
419
|
+
// macOS
|
|
420
|
+
try { execSync("which pbpaste", { stdio: "pipe" }); return "pbpaste"; } catch {}
|
|
421
|
+
// Linux (X11)
|
|
422
|
+
try { execSync("which xclip", { stdio: "pipe" }); return "xclip -selection clipboard -o"; } catch {}
|
|
423
|
+
// Linux (X11/Wayland)
|
|
424
|
+
try { execSync("which xsel", { stdio: "pipe" }); return "xsel --clipboard --output"; } catch {}
|
|
425
|
+
// Wayland
|
|
426
|
+
try { execSync("which wl-paste", { stdio: "pipe" }); return "wl-paste --no-newline"; } catch {}
|
|
427
|
+
return undefined;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* Read the current clipboard contents. Returns undefined on failure.
|
|
432
|
+
*/
|
|
433
|
+
function readClipboard(): string | undefined {
|
|
434
|
+
const cmd = detectClipboardCommand();
|
|
435
|
+
if (!cmd) return undefined;
|
|
436
|
+
try {
|
|
437
|
+
return execSync(cmd, { encoding: "utf-8", timeout: 5_000, stdio: ["pipe", "pipe", "pipe"] }).trim();
|
|
438
|
+
} catch {
|
|
439
|
+
return undefined;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* Prompt the user to provide a secret value via clipboard (preferred) or
|
|
445
|
+
* fallback to direct input if clipboard is unavailable.
|
|
446
|
+
*
|
|
447
|
+
* The clipboard flow avoids showing the secret on screen — the user copies
|
|
448
|
+
* the value first, then confirms, and we read it from the clipboard.
|
|
449
|
+
*/
|
|
450
|
+
async function promptForSecretValue(
|
|
451
|
+
ctx: any,
|
|
452
|
+
secretName: string,
|
|
453
|
+
promptMessage: string,
|
|
454
|
+
): Promise<string | undefined> {
|
|
455
|
+
const clipCmd = detectClipboardCommand();
|
|
456
|
+
|
|
457
|
+
if (clipCmd) {
|
|
458
|
+
// Clipboard-based flow — value never shown on screen
|
|
459
|
+
const confirmed = await ctx.ui.confirm(
|
|
460
|
+
`🔐 ${secretName}`,
|
|
461
|
+
`${promptMessage}\n\n` +
|
|
462
|
+
`Copy the value to your clipboard, then confirm.\n` +
|
|
463
|
+
`The value will be read from the clipboard and will not be displayed.`
|
|
464
|
+
);
|
|
465
|
+
if (!confirmed) return undefined;
|
|
466
|
+
|
|
467
|
+
const value = readClipboard();
|
|
468
|
+
if (!value) {
|
|
469
|
+
ctx.ui.notify(`❌ Clipboard is empty or unreadable. Copy the value and try again.`, "error");
|
|
470
|
+
return undefined;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// Show length confirmation without revealing the value
|
|
474
|
+
const charDesc = `${value.length} character${value.length !== 1 ? "s" : ""}`;
|
|
475
|
+
const confirmValue = await ctx.ui.confirm(
|
|
476
|
+
`🔐 ${secretName}`,
|
|
477
|
+
`Read ${charDesc} from clipboard. Use this value?`
|
|
478
|
+
);
|
|
479
|
+
if (!confirmValue) return undefined;
|
|
480
|
+
|
|
481
|
+
return value;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// Fallback: no clipboard command available — warn and use direct input
|
|
485
|
+
ctx.ui.notify(
|
|
486
|
+
`⚠️ No clipboard command found (pbpaste, xclip, xsel, wl-paste).\n` +
|
|
487
|
+
`The value will be visible as you type.`,
|
|
488
|
+
"warning"
|
|
489
|
+
);
|
|
490
|
+
return ctx.ui.input(promptMessage);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// ============================================================================
|
|
494
|
+
// macOS Keychain helpers
|
|
495
|
+
// ============================================================================
|
|
496
|
+
|
|
497
|
+
const KEYCHAIN_ACCOUNT = "pi-kit";
|
|
498
|
+
const KEYCHAIN_SERVICE_PREFIX = "pi-kit/";
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* Store a value in the macOS login keychain under service "pi-kit/<name>".
|
|
502
|
+
* macOS will prompt Touch ID / password / smart card automatically if the
|
|
503
|
+
* keychain is locked — the OS owns the auth flow, we just call the command.
|
|
504
|
+
*
|
|
505
|
+
* Uses execFileSync (no shell) to avoid bash interpreting $, `, \, ! in
|
|
506
|
+
* the secret value. JSON.stringify + execSync was silently eating characters
|
|
507
|
+
* like $FOO (expanded as empty variable).
|
|
508
|
+
*/
|
|
509
|
+
function storeInKeychain(secretName: string, value: string): void {
|
|
510
|
+
// Use -U to update if item already exists
|
|
511
|
+
execFileSync("security", [
|
|
512
|
+
"add-generic-password",
|
|
513
|
+
"-U",
|
|
514
|
+
"-a", KEYCHAIN_ACCOUNT,
|
|
515
|
+
"-s", KEYCHAIN_SERVICE_PREFIX + secretName,
|
|
516
|
+
"-w", value,
|
|
517
|
+
], { stdio: ["pipe", "pipe", "pipe"], timeout: 30_000 });
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function keychainRecipe(secretName: string): string {
|
|
521
|
+
return `!security find-generic-password -a ${JSON.stringify(KEYCHAIN_ACCOUNT)} -ws ${JSON.stringify(KEYCHAIN_SERVICE_PREFIX + secretName)}`;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// ============================================================================
|
|
525
|
+
// Extension
|
|
526
|
+
// ============================================================================
|
|
527
|
+
|
|
528
|
+
export default function (pi: ExtensionAPI) {
|
|
529
|
+
// Load recipes on init
|
|
530
|
+
recipes = loadRecipes();
|
|
531
|
+
|
|
532
|
+
// Warn about literal recipes at load time
|
|
533
|
+
const literalRecipes = Object.entries(recipes).filter(([_, r]) => r.startsWith("literal:"));
|
|
534
|
+
|
|
535
|
+
// Pre-resolve all configured secrets at init time (Layer 1)
|
|
536
|
+
// Resolved values are injected into process.env so other extensions
|
|
537
|
+
// can keep using process.env.X without importing from this module.
|
|
538
|
+
// This means the secrets extension MUST load before other extensions
|
|
539
|
+
// that consume secrets (pi loads extensions in alphabetical order by
|
|
540
|
+
// directory name, so "secrets" loads before "web-search" etc.)
|
|
541
|
+
for (const name of Object.keys(recipes)) {
|
|
542
|
+
const value = resolveSecret(name);
|
|
543
|
+
if (value && !process.env[name]) {
|
|
544
|
+
process.env[name] = value;
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
// Also track known secrets already in env (for CI compat + redaction)
|
|
548
|
+
for (const name of Object.keys(KNOWN_SECRETS)) {
|
|
549
|
+
if (process.env[name] && !resolvedCache.has(name)) {
|
|
550
|
+
resolvedCache.set(name, process.env[name]!);
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
555
|
+
const resolved = Array.from(resolvedCache.keys());
|
|
556
|
+
const failed = Object.keys(recipes).filter(k => !resolvedCache.has(k));
|
|
557
|
+
|
|
558
|
+
if (resolved.length > 0) {
|
|
559
|
+
// Don't leak secret names to the agent — just show count
|
|
560
|
+
ctx.ui.notify(
|
|
561
|
+
`🔐 ${resolved.length} secret${resolved.length !== 1 ? "s" : ""} resolved`,
|
|
562
|
+
"info"
|
|
563
|
+
);
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
// Surface failures prominently — don't let broken secrets go unnoticed
|
|
567
|
+
if (failed.length > 0) {
|
|
568
|
+
ctx.ui.notify(
|
|
569
|
+
`❌ ${failed.length} secret${failed.length !== 1 ? "s" : ""} failed to resolve.\n` +
|
|
570
|
+
`Run /secrets list to see details, then /secrets configure <name> to fix.`,
|
|
571
|
+
"error"
|
|
572
|
+
);
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
// Warn about literal recipes (plaintext secrets in secrets.json)
|
|
576
|
+
if (literalRecipes.length > 0) {
|
|
577
|
+
ctx.ui.notify(
|
|
578
|
+
`⚠️ ${literalRecipes.length} secret${literalRecipes.length !== 1 ? "s" : ""} stored as plaintext literal${literalRecipes.length !== 1 ? "s" : ""}.\n` +
|
|
579
|
+
`Run /secrets configure <name> to migrate to Keychain or another secure backend.`,
|
|
580
|
+
"error"
|
|
581
|
+
);
|
|
582
|
+
}
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
// ──────────────────────────────────────────────────────────────
|
|
586
|
+
// Layer 2: Redact secrets from tool results
|
|
587
|
+
// ──────────────────────────────────────────────────────────────
|
|
588
|
+
|
|
589
|
+
pi.on("tool_result", async (event, _ctx) => {
|
|
590
|
+
if (!event.content || resolvedCache.size === 0) return undefined;
|
|
591
|
+
|
|
592
|
+
const redacted = redactContent(event.content);
|
|
593
|
+
// Only return if we actually changed something
|
|
594
|
+
const changed = redacted.some(
|
|
595
|
+
(block: any, i: number) =>
|
|
596
|
+
block.type === "text" &&
|
|
597
|
+
event.content[i]?.type === "text" &&
|
|
598
|
+
block.text !== (event.content[i] as any).text
|
|
599
|
+
);
|
|
600
|
+
|
|
601
|
+
if (changed) {
|
|
602
|
+
return { content: redacted };
|
|
603
|
+
}
|
|
604
|
+
return undefined;
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
// ──────────────────────────────────────────────────────────────
|
|
608
|
+
// Layer 3: Tool guards (read, grep, find, ls, bash, write, edit, ask_local_model)
|
|
609
|
+
// ──────────────────────────────────────────────────────────────
|
|
610
|
+
|
|
611
|
+
/**
|
|
612
|
+
* Helper: given a tool name, a sensitive path match, and the context,
|
|
613
|
+
* handle the block/confirm logic and return the appropriate ToolCallEventResult.
|
|
614
|
+
*/
|
|
615
|
+
async function handleSensitivePathAccess(
|
|
616
|
+
toolName: string,
|
|
617
|
+
filePath: string,
|
|
618
|
+
match: { description: string; action: "block" | "confirm" },
|
|
619
|
+
ctx: any,
|
|
620
|
+
): Promise<{ block: boolean; reason: string } | undefined> {
|
|
621
|
+
if (match.action === "block") {
|
|
622
|
+
logGuardDecision({ tool: toolName, target: filePath, action: "blocked", reason: match.description });
|
|
623
|
+
return {
|
|
624
|
+
block: true,
|
|
625
|
+
reason: `🔐 Blocked: ${toolName} access to ${match.description} (${filePath}). Use /secrets commands to manage secrets.`,
|
|
626
|
+
};
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
// action === "confirm"
|
|
630
|
+
if (!ctx.hasUI) {
|
|
631
|
+
logGuardDecision({ tool: toolName, target: filePath, action: "blocked", reason: `${match.description} (no UI)` });
|
|
632
|
+
return {
|
|
633
|
+
block: true,
|
|
634
|
+
reason: `🔐 Blocked: ${toolName} access to ${match.description} (no UI for confirmation)`,
|
|
635
|
+
};
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
const choice = await ctx.ui.select(
|
|
639
|
+
`🔐 This ${toolName} accesses a sensitive file:\n\n` +
|
|
640
|
+
` ${filePath}\n (${match.description})\n\nAllow?`,
|
|
641
|
+
["Yes, allow this time", "No, block it"]
|
|
642
|
+
);
|
|
643
|
+
|
|
644
|
+
if (choice !== "Yes, allow this time") {
|
|
645
|
+
logGuardDecision({ tool: toolName, target: filePath, action: "blocked", reason: `${match.description} (user denied)` });
|
|
646
|
+
return { block: true, reason: `🔐 Blocked by user: ${match.description}` };
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
logGuardDecision({ tool: toolName, target: filePath, action: "confirmed", reason: match.description });
|
|
650
|
+
return undefined;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
pi.on("tool_call", async (event, ctx) => {
|
|
654
|
+
// ── Guard: read tool ──
|
|
655
|
+
if (event.toolName === "read") {
|
|
656
|
+
const path = (event.input as any).path as string;
|
|
657
|
+
if (path) {
|
|
658
|
+
const match = matchSensitivePath(path);
|
|
659
|
+
if (match) {
|
|
660
|
+
return handleSensitivePathAccess("read", path, match, ctx);
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
return undefined;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
// ── Guard: write/edit tools — block writes to secrets.json and audit log ──
|
|
667
|
+
if (event.toolName === "write" || event.toolName === "edit") {
|
|
668
|
+
const path = (event.input as any).path as string;
|
|
669
|
+
if (path) {
|
|
670
|
+
const match = matchSensitivePath(path);
|
|
671
|
+
if (match && match.action === "block") {
|
|
672
|
+
logGuardDecision({ tool: event.toolName, target: path, action: "blocked", reason: match.description });
|
|
673
|
+
return {
|
|
674
|
+
block: true,
|
|
675
|
+
reason: `🔐 Blocked: use /secrets configure to manage secret recipes, not direct file ${event.toolName}s.`,
|
|
676
|
+
};
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
return undefined;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
// ── Guard: grep tool ──
|
|
683
|
+
if (event.toolName === "grep") {
|
|
684
|
+
const input = event.input as any;
|
|
685
|
+
const searchPath = input.path as string | undefined;
|
|
686
|
+
const pattern = input.pattern as string | undefined;
|
|
687
|
+
|
|
688
|
+
// Check if grep target path is sensitive
|
|
689
|
+
if (searchPath) {
|
|
690
|
+
const match = matchSensitivePath(searchPath);
|
|
691
|
+
if (match) {
|
|
692
|
+
return handleSensitivePathAccess("grep", searchPath, match, ctx);
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
// Check if the grep pattern itself contains a known secret value
|
|
697
|
+
if (pattern && resolvedCache.size > 0) {
|
|
698
|
+
for (const [name, value] of resolvedCache.entries()) {
|
|
699
|
+
if (value.length >= MIN_REDACT_LENGTH && pattern.includes(value)) {
|
|
700
|
+
logGuardDecision({ tool: "grep", target: `pattern containing ${name}`, action: "blocked", reason: "grep pattern contains secret value" });
|
|
701
|
+
return {
|
|
702
|
+
block: true,
|
|
703
|
+
reason: `🔐 Blocked: grep pattern contains the value of secret ${name}. Don't search for secret values directly.`,
|
|
704
|
+
};
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
return undefined;
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
// ── Guard: find tool ──
|
|
712
|
+
if (event.toolName === "find") {
|
|
713
|
+
const input = event.input as any;
|
|
714
|
+
const searchPath = input.path as string | undefined;
|
|
715
|
+
|
|
716
|
+
if (searchPath) {
|
|
717
|
+
const match = matchSensitivePath(searchPath);
|
|
718
|
+
if (match) {
|
|
719
|
+
return handleSensitivePathAccess("find", searchPath, match, ctx);
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
return undefined;
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
// ── Guard: ls tool ──
|
|
726
|
+
if (event.toolName === "ls") {
|
|
727
|
+
const input = event.input as any;
|
|
728
|
+
const lsPath = input.path as string | undefined;
|
|
729
|
+
|
|
730
|
+
if (lsPath) {
|
|
731
|
+
const match = matchSensitivePath(lsPath);
|
|
732
|
+
if (match) {
|
|
733
|
+
return handleSensitivePathAccess("ls", lsPath, match, ctx);
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
return undefined;
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
// ── Guard: bash tool ──
|
|
740
|
+
if (event.toolName === "bash") {
|
|
741
|
+
const command = (event.input as any).command as string;
|
|
742
|
+
if (isSecretAccessCommand(command)) {
|
|
743
|
+
if (!ctx.hasUI) {
|
|
744
|
+
logGuardDecision({ tool: "bash", target: command, action: "blocked", reason: "secret store access (no UI)" });
|
|
745
|
+
return {
|
|
746
|
+
block: true,
|
|
747
|
+
reason: "🔐 Blocked: command accesses secret store (no UI for confirmation)",
|
|
748
|
+
};
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
const choice = await ctx.ui.select(
|
|
752
|
+
`🔐 This command accesses a secret store:\n\n ${command}\n\nAllow?`,
|
|
753
|
+
["Yes, allow this time", "No, block it"]
|
|
754
|
+
);
|
|
755
|
+
|
|
756
|
+
if (choice !== "Yes, allow this time") {
|
|
757
|
+
logGuardDecision({ tool: "bash", target: command, action: "blocked", reason: "secret store access (user denied)" });
|
|
758
|
+
return { block: true, reason: "🔐 Blocked by user: secret store access" };
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
logGuardDecision({ tool: "bash", target: command, action: "confirmed", reason: "secret store access" });
|
|
762
|
+
}
|
|
763
|
+
return undefined;
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
// ── Layer 5: Scrub secrets from local model prompts ──
|
|
767
|
+
if (event.toolName === "ask_local_model") {
|
|
768
|
+
const input = event.input as any;
|
|
769
|
+
if (!input.prompt || resolvedCache.size === 0) return undefined;
|
|
770
|
+
|
|
771
|
+
const secrets = getRedactableSecrets();
|
|
772
|
+
const cleanPrompt = redactString(input.prompt, secrets);
|
|
773
|
+
const cleanSystem = input.system ? redactString(input.system, secrets) : input.system;
|
|
774
|
+
|
|
775
|
+
if (cleanPrompt !== input.prompt || cleanSystem !== input.system) {
|
|
776
|
+
logGuardDecision({ tool: "ask_local_model", target: "(prompt)", action: "blocked", reason: "prompt contains secret values" });
|
|
777
|
+
return {
|
|
778
|
+
block: true,
|
|
779
|
+
reason:
|
|
780
|
+
"🔐 Blocked: prompt to local model contains secret values. " +
|
|
781
|
+
"Remove sensitive data before delegating to local inference.",
|
|
782
|
+
};
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
return undefined;
|
|
787
|
+
});
|
|
788
|
+
|
|
789
|
+
// ──────────────────────────────────────────────────────────────
|
|
790
|
+
// Commands: /secrets list | configure | rm | test
|
|
791
|
+
// ──────────────────────────────────────────────────────────────
|
|
792
|
+
|
|
793
|
+
pi.registerCommand("secrets", {
|
|
794
|
+
description: "Manage secret resolution recipes: list, configure <name>, rm <name>, test <name>",
|
|
795
|
+
getArgumentCompletions: (prefix: string) => {
|
|
796
|
+
const parts = prefix.split(/\s+/);
|
|
797
|
+
if (parts.length <= 1) {
|
|
798
|
+
// Complete subcommand
|
|
799
|
+
const subs = ["list", "configure", "rm", "test"];
|
|
800
|
+
const filtered = subs.filter(s => s.startsWith(parts[0] || ""));
|
|
801
|
+
return filtered.length > 0 ? filtered.map(s => ({ value: s, label: s })) : null;
|
|
802
|
+
}
|
|
803
|
+
const sub = parts[0];
|
|
804
|
+
if (sub === "configure" || sub === "rm" || sub === "test") {
|
|
805
|
+
// Complete secret name
|
|
806
|
+
const namePrefix = parts.slice(1).join(" ");
|
|
807
|
+
const allNames = [
|
|
808
|
+
...Object.keys(KNOWN_SECRETS),
|
|
809
|
+
...Object.keys(recipes).filter(k => !(k in KNOWN_SECRETS)),
|
|
810
|
+
];
|
|
811
|
+
const filtered = allNames.filter(n => n.startsWith(namePrefix));
|
|
812
|
+
return filtered.length > 0
|
|
813
|
+
? filtered.map(n => ({ value: `${sub} ${n}`, label: `${n} ${KNOWN_SECRETS[n] || "custom"}` }))
|
|
814
|
+
: null;
|
|
815
|
+
}
|
|
816
|
+
return null;
|
|
817
|
+
},
|
|
818
|
+
handler: async (args, ctx) => {
|
|
819
|
+
const parts = (args || "").trim().split(/\s+/);
|
|
820
|
+
const subcommand = parts[0] || "list";
|
|
821
|
+
const secretName = parts.slice(1).join(" ");
|
|
822
|
+
|
|
823
|
+
switch (subcommand) {
|
|
824
|
+
case "list": {
|
|
825
|
+
const lines: string[] = ["Secret recipes (~/.pi/agent/secrets.json):", ""];
|
|
826
|
+
|
|
827
|
+
for (const [name, desc] of Object.entries(KNOWN_SECRETS)) {
|
|
828
|
+
const recipe = recipes[name];
|
|
829
|
+
const resolved = resolvedCache.has(name);
|
|
830
|
+
const source = recipe
|
|
831
|
+
? recipe.startsWith("!")
|
|
832
|
+
? `command: ${recipe.slice(1, 40)}${recipe.length > 41 ? "..." : ""}`
|
|
833
|
+
: recipe.startsWith("literal:")
|
|
834
|
+
? "⚠️ literal value (insecure — run /secrets configure to migrate)"
|
|
835
|
+
: `env: ${recipe}`
|
|
836
|
+
: resolved
|
|
837
|
+
? "env (auto-detected)"
|
|
838
|
+
: "not configured";
|
|
839
|
+
|
|
840
|
+
const status = resolved ? "✅" : "❌";
|
|
841
|
+
lines.push(` ${status} ${name}`);
|
|
842
|
+
lines.push(` ${desc}`);
|
|
843
|
+
lines.push(` Source: ${source}`);
|
|
844
|
+
lines.push("");
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
// Show any non-known secrets
|
|
848
|
+
for (const name of Object.keys(recipes)) {
|
|
849
|
+
if (name in KNOWN_SECRETS) continue;
|
|
850
|
+
const recipe = recipes[name];
|
|
851
|
+
const resolved = resolvedCache.has(name);
|
|
852
|
+
const status = resolved ? "✅" : "❌";
|
|
853
|
+
lines.push(` ${status} ${name} (custom)`);
|
|
854
|
+
lines.push(
|
|
855
|
+
` Source: ${recipe.startsWith("!") ? `command: ${recipe.slice(1, 40)}` : recipe.startsWith("literal:") ? "⚠️ literal (insecure)" : `env: ${recipe}`}`
|
|
856
|
+
);
|
|
857
|
+
lines.push("");
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
// Show @config entries
|
|
861
|
+
const configEntries = Object.entries(KNOWN_CONFIGS);
|
|
862
|
+
if (configEntries.length > 0) {
|
|
863
|
+
lines.push("", "Configuration overrides (@config):", "");
|
|
864
|
+
for (const [name, { description, default: defaultVal }] of configEntries) {
|
|
865
|
+
const envVal = process.env[name];
|
|
866
|
+
const isOverridden = !!envVal && envVal !== defaultVal;
|
|
867
|
+
const status = isOverridden ? "⚙️" : " ";
|
|
868
|
+
lines.push(` ${status} ${name}`);
|
|
869
|
+
lines.push(` ${description}`);
|
|
870
|
+
if (defaultVal) {
|
|
871
|
+
lines.push(` Default: ${defaultVal}`);
|
|
872
|
+
}
|
|
873
|
+
if (isOverridden) {
|
|
874
|
+
lines.push(` Override: ${envVal}`);
|
|
875
|
+
} else if (!envVal && !defaultVal) {
|
|
876
|
+
lines.push(` Value: (not set)`);
|
|
877
|
+
}
|
|
878
|
+
lines.push("");
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
ctx.ui.notify(lines.join("\n"), "info");
|
|
883
|
+
break;
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
case "configure": {
|
|
887
|
+
if (!secretName) {
|
|
888
|
+
ctx.ui.notify("Usage: /secrets configure <NAME>", "error");
|
|
889
|
+
return;
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
if (!ctx.hasUI) {
|
|
893
|
+
ctx.ui.notify("Cannot configure secrets without interactive UI", "error");
|
|
894
|
+
return;
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
const desc = KNOWN_SECRETS[secretName] || "Custom secret";
|
|
898
|
+
const currentRecipe = recipes[secretName];
|
|
899
|
+
|
|
900
|
+
// Check which backends are available
|
|
901
|
+
let hasOp = false;
|
|
902
|
+
let hasKeychain = false;
|
|
903
|
+
try { execSync("which op", { stdio: "pipe" }); hasOp = true; } catch {}
|
|
904
|
+
try { execSync("which security", { stdio: "pipe" }); hasKeychain = true; } catch {}
|
|
905
|
+
|
|
906
|
+
const options: string[] = [];
|
|
907
|
+
|
|
908
|
+
if (hasKeychain) {
|
|
909
|
+
options.push("macOS Keychain (recommended)");
|
|
910
|
+
}
|
|
911
|
+
if (hasOp) {
|
|
912
|
+
options.push("1Password — read via op CLI");
|
|
913
|
+
}
|
|
914
|
+
options.push(
|
|
915
|
+
`Environment variable — reads $${secretName} at runtime`,
|
|
916
|
+
"Shell command — custom command (stdout = secret value)",
|
|
917
|
+
);
|
|
918
|
+
if (!hasKeychain) {
|
|
919
|
+
options.push("Paste value — enter the value now (⚠️ stored in plaintext)");
|
|
920
|
+
}
|
|
921
|
+
if (currentRecipe) {
|
|
922
|
+
options.push("Remove this secret's recipe");
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
const statusLine = currentRecipe
|
|
926
|
+
? `Current: ${currentRecipe.startsWith("literal:") ? "literal (hidden)" : currentRecipe}`
|
|
927
|
+
: "Not configured";
|
|
928
|
+
|
|
929
|
+
const choice = await ctx.ui.select(
|
|
930
|
+
`Configure: ${secretName}\n${desc}\n${statusLine}\n\nChoose how to resolve this secret:`,
|
|
931
|
+
options
|
|
932
|
+
);
|
|
933
|
+
|
|
934
|
+
if (!choice) return;
|
|
935
|
+
|
|
936
|
+
if (choice.startsWith("macOS Keychain")) {
|
|
937
|
+
// Unified Keychain flow: try to read existing → if missing, prompt and store → verify
|
|
938
|
+
const service = KEYCHAIN_SERVICE_PREFIX + secretName;
|
|
939
|
+
|
|
940
|
+
// 1. Try to read from keychain first
|
|
941
|
+
let existing: string | undefined;
|
|
942
|
+
try {
|
|
943
|
+
existing = execFileSync("security", [
|
|
944
|
+
"find-generic-password", "-a", KEYCHAIN_ACCOUNT, "-ws", service,
|
|
945
|
+
], { encoding: "utf-8", timeout: 10_000, stdio: ["pipe", "pipe", "pipe"] }).trim();
|
|
946
|
+
} catch {
|
|
947
|
+
// Not found — that's fine, we'll create it
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
if (existing) {
|
|
951
|
+
// Found it — confirm use or replace
|
|
952
|
+
const masked = existing.length > 8
|
|
953
|
+
? existing.slice(0, 4) + "•".repeat(Math.min(existing.length - 4, 16)) + ` (${existing.length} chars)`
|
|
954
|
+
: "•".repeat(existing.length) + ` (${existing.length} chars)`;
|
|
955
|
+
|
|
956
|
+
const action = await ctx.ui.select(
|
|
957
|
+
`Found existing Keychain entry for "${service}":\n ${masked}\n\nUse this value?`,
|
|
958
|
+
["Yes, use it", "Replace with a new value", "Cancel"]
|
|
959
|
+
);
|
|
960
|
+
if (!action || action === "Cancel") return;
|
|
961
|
+
|
|
962
|
+
if (action === "Replace with a new value") {
|
|
963
|
+
const val = await promptForSecretValue(ctx, secretName, `Provide the new value for ${secretName}:`);
|
|
964
|
+
if (!val) return;
|
|
965
|
+
try {
|
|
966
|
+
storeInKeychain(secretName, val);
|
|
967
|
+
} catch (e: any) {
|
|
968
|
+
ctx.ui.notify(`❌ Failed to store in Keychain: ${e.message}`, "error");
|
|
969
|
+
return;
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
} else {
|
|
973
|
+
// Not in keychain — prompt for value and store it
|
|
974
|
+
const val = await promptForSecretValue(
|
|
975
|
+
ctx,
|
|
976
|
+
secretName,
|
|
977
|
+
`No Keychain entry found for "${service}".\n\n` +
|
|
978
|
+
`Provide the value for ${secretName} — it will be stored in your login keychain\n` +
|
|
979
|
+
`(protected by Touch ID / password).`
|
|
980
|
+
);
|
|
981
|
+
if (!val) return;
|
|
982
|
+
|
|
983
|
+
try {
|
|
984
|
+
storeInKeychain(secretName, val);
|
|
985
|
+
} catch (e: any) {
|
|
986
|
+
ctx.ui.notify(`❌ Failed to store in Keychain: ${e.message}`, "error");
|
|
987
|
+
return;
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
// Set recipe to read from keychain
|
|
992
|
+
recipes[secretName] = keychainRecipe(secretName);
|
|
993
|
+
|
|
994
|
+
} else if (choice.startsWith("1Password")) {
|
|
995
|
+
const ref = await ctx.ui.input(
|
|
996
|
+
`Enter the 1Password item reference for ${secretName}:\n\n` +
|
|
997
|
+
`Format: op://vault/item/field\n` +
|
|
998
|
+
`Example: op://Private/API Keys/brave-search`
|
|
999
|
+
);
|
|
1000
|
+
if (!ref) return;
|
|
1001
|
+
recipes[secretName] = ref.startsWith("op://") ? `!op read "${ref}"` : `!op read "op://${ref}"`;
|
|
1002
|
+
} else if (choice.startsWith("Environment variable")) {
|
|
1003
|
+
recipes[secretName] = secretName;
|
|
1004
|
+
} else if (choice.startsWith("Shell command")) {
|
|
1005
|
+
const cmd = await ctx.ui.input(
|
|
1006
|
+
`Enter shell command for ${secretName}:\n\n` +
|
|
1007
|
+
`The command's stdout will be used as the secret value.\n` +
|
|
1008
|
+
`Examples:\n` +
|
|
1009
|
+
` security find-generic-password -ws 'service-name'\n` +
|
|
1010
|
+
` op read "op://vault/item/field"\n` +
|
|
1011
|
+
` cat ~/.config/some-tool/token`
|
|
1012
|
+
);
|
|
1013
|
+
if (!cmd) return;
|
|
1014
|
+
recipes[secretName] = cmd.startsWith("!") ? cmd : `!${cmd}`;
|
|
1015
|
+
} else if (choice.startsWith("Paste value")) {
|
|
1016
|
+
const val = await promptForSecretValue(
|
|
1017
|
+
ctx,
|
|
1018
|
+
secretName,
|
|
1019
|
+
`⚠️ This will be stored in plaintext in ~/.pi/agent/secrets.json.\n` +
|
|
1020
|
+
`Consider using Keychain instead.`
|
|
1021
|
+
);
|
|
1022
|
+
if (!val) return;
|
|
1023
|
+
recipes[secretName] = `literal:${val}`;
|
|
1024
|
+
} else if (choice.startsWith("Remove")) {
|
|
1025
|
+
delete recipes[secretName];
|
|
1026
|
+
resolvedCache.delete(secretName);
|
|
1027
|
+
saveRecipes(recipes);
|
|
1028
|
+
ctx.ui.notify(`Removed recipe for ${secretName}`, "info");
|
|
1029
|
+
return;
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
saveRecipes(recipes);
|
|
1033
|
+
|
|
1034
|
+
// Verify it actually resolves — this is the moment of truth
|
|
1035
|
+
resolvedCache.delete(secretName);
|
|
1036
|
+
const value = resolveSecret(secretName);
|
|
1037
|
+
if (value) {
|
|
1038
|
+
process.env[secretName] = value;
|
|
1039
|
+
const masked = value.length > 8
|
|
1040
|
+
? value.slice(0, 4) + "•".repeat(Math.min(value.length - 4, 16)) + ` (${value.length} chars)`
|
|
1041
|
+
: "•".repeat(value.length) + ` (${value.length} chars)`;
|
|
1042
|
+
ctx.ui.notify(`✅ ${secretName} configured and verified: ${masked}`, "info");
|
|
1043
|
+
} else {
|
|
1044
|
+
// Don't just warn — this is a failure. Remove the broken recipe.
|
|
1045
|
+
delete recipes[secretName];
|
|
1046
|
+
saveRecipes(recipes);
|
|
1047
|
+
ctx.ui.notify(
|
|
1048
|
+
`❌ ${secretName} failed to resolve after configuration. Recipe removed.\n` +
|
|
1049
|
+
`Try again with /secrets configure ${secretName}`,
|
|
1050
|
+
"error"
|
|
1051
|
+
);
|
|
1052
|
+
}
|
|
1053
|
+
break;
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
case "rm":
|
|
1057
|
+
case "remove":
|
|
1058
|
+
case "delete": {
|
|
1059
|
+
if (!secretName) {
|
|
1060
|
+
ctx.ui.notify("Usage: /secrets rm <NAME>", "error");
|
|
1061
|
+
return;
|
|
1062
|
+
}
|
|
1063
|
+
if (recipes[secretName]) {
|
|
1064
|
+
delete recipes[secretName];
|
|
1065
|
+
resolvedCache.delete(secretName);
|
|
1066
|
+
saveRecipes(recipes);
|
|
1067
|
+
ctx.ui.notify(`Removed recipe for ${secretName}`, "info");
|
|
1068
|
+
} else {
|
|
1069
|
+
ctx.ui.notify(`No recipe found for ${secretName}`, "error");
|
|
1070
|
+
}
|
|
1071
|
+
break;
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
case "test": {
|
|
1075
|
+
if (!secretName) {
|
|
1076
|
+
ctx.ui.notify("Usage: /secrets test <NAME>", "error");
|
|
1077
|
+
return;
|
|
1078
|
+
}
|
|
1079
|
+
const recipe = recipes[secretName];
|
|
1080
|
+
if (!recipe && !process.env[secretName]) {
|
|
1081
|
+
ctx.ui.notify(`No recipe or env var found for ${secretName}`, "error");
|
|
1082
|
+
return;
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
// Re-resolve (bypass cache)
|
|
1086
|
+
resolvedCache.delete(secretName);
|
|
1087
|
+
const value = resolveSecret(secretName);
|
|
1088
|
+
if (value) {
|
|
1089
|
+
// Show masked value: first 4 chars + masked rest
|
|
1090
|
+
const masked =
|
|
1091
|
+
value.length > 8
|
|
1092
|
+
? value.slice(0, 4) + "•".repeat(Math.min(value.length - 4, 20)) + ` (${value.length} chars)`
|
|
1093
|
+
: "•".repeat(value.length) + ` (${value.length} chars)`;
|
|
1094
|
+
ctx.ui.notify(`✅ ${secretName} resolved: ${masked}`, "info");
|
|
1095
|
+
} else {
|
|
1096
|
+
const source = recipe || `env:${secretName}`;
|
|
1097
|
+
ctx.ui.notify(`❌ ${secretName} failed to resolve from: ${source}`, "error");
|
|
1098
|
+
}
|
|
1099
|
+
break;
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
default:
|
|
1103
|
+
ctx.ui.notify(
|
|
1104
|
+
"Usage: /secrets <list|configure|rm|test> [name]\n\n" +
|
|
1105
|
+
" /secrets list — show all configured secrets\n" +
|
|
1106
|
+
" /secrets configure <NAME> — set up resolution for a secret\n" +
|
|
1107
|
+
" /secrets rm <NAME> — remove a secret recipe\n" +
|
|
1108
|
+
" /secrets test <NAME> — test if a secret resolves",
|
|
1109
|
+
"info"
|
|
1110
|
+
);
|
|
1111
|
+
}
|
|
1112
|
+
},
|
|
1113
|
+
});
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
// ============================================================================
|
|
1117
|
+
// Exports for testing
|
|
1118
|
+
// ============================================================================
|
|
1119
|
+
|
|
1120
|
+
export {
|
|
1121
|
+
matchSensitivePath,
|
|
1122
|
+
isSecretAccessCommand,
|
|
1123
|
+
redactString,
|
|
1124
|
+
MIN_REDACT_LENGTH,
|
|
1125
|
+
SENSITIVE_PATH_PATTERNS,
|
|
1126
|
+
};
|