@blogic-cz/agent-tools 0.1.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/LICENSE +21 -0
- package/README.md +236 -0
- package/package.json +70 -0
- package/schemas/agent-tools.schema.json +319 -0
- package/src/az-tool/build.ts +295 -0
- package/src/az-tool/config.ts +33 -0
- package/src/az-tool/errors.ts +26 -0
- package/src/az-tool/extract-option-value.ts +12 -0
- package/src/az-tool/index.ts +181 -0
- package/src/az-tool/security.ts +130 -0
- package/src/az-tool/service.ts +292 -0
- package/src/az-tool/types.ts +67 -0
- package/src/config/index.ts +12 -0
- package/src/config/loader.ts +170 -0
- package/src/config/types.ts +82 -0
- package/src/credential-guard/claude-hook.ts +28 -0
- package/src/credential-guard/index.ts +435 -0
- package/src/db-tool/config-service.ts +38 -0
- package/src/db-tool/errors.ts +40 -0
- package/src/db-tool/index.ts +91 -0
- package/src/db-tool/schema.ts +69 -0
- package/src/db-tool/security.ts +116 -0
- package/src/db-tool/service.ts +605 -0
- package/src/db-tool/types.ts +33 -0
- package/src/gh-tool/config.ts +7 -0
- package/src/gh-tool/errors.ts +47 -0
- package/src/gh-tool/index.ts +140 -0
- package/src/gh-tool/issue.ts +361 -0
- package/src/gh-tool/pr/commands.ts +432 -0
- package/src/gh-tool/pr/core.ts +497 -0
- package/src/gh-tool/pr/helpers.ts +84 -0
- package/src/gh-tool/pr/index.ts +19 -0
- package/src/gh-tool/pr/review.ts +571 -0
- package/src/gh-tool/repo.ts +147 -0
- package/src/gh-tool/service.ts +192 -0
- package/src/gh-tool/types.ts +97 -0
- package/src/gh-tool/workflow.ts +542 -0
- package/src/index.ts +1 -0
- package/src/k8s-tool/errors.ts +21 -0
- package/src/k8s-tool/index.ts +151 -0
- package/src/k8s-tool/service.ts +227 -0
- package/src/k8s-tool/types.ts +9 -0
- package/src/logs-tool/errors.ts +29 -0
- package/src/logs-tool/index.ts +176 -0
- package/src/logs-tool/service.ts +323 -0
- package/src/logs-tool/types.ts +40 -0
- package/src/session-tool/config.ts +55 -0
- package/src/session-tool/errors.ts +38 -0
- package/src/session-tool/index.ts +270 -0
- package/src/session-tool/service.ts +210 -0
- package/src/session-tool/types.ts +28 -0
- package/src/shared/bun.ts +59 -0
- package/src/shared/cli.ts +38 -0
- package/src/shared/error-renderer.ts +42 -0
- package/src/shared/exec.ts +62 -0
- package/src/shared/format.ts +27 -0
- package/src/shared/index.ts +16 -0
- package/src/shared/throttle.ts +35 -0
- package/src/shared/types.ts +25 -0
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Credential Guard
|
|
3
|
+
*
|
|
4
|
+
* Security patterns and functions for detecting sensitive files and secrets.
|
|
5
|
+
* Used by AI coding agent hooks/plugins.
|
|
6
|
+
*
|
|
7
|
+
* Security layers:
|
|
8
|
+
* 1. Path-based blocking (files that should never be read)
|
|
9
|
+
* 2. Content scanning (detect secrets in write operations)
|
|
10
|
+
* 3. Dangerous bash command detection
|
|
11
|
+
* 4. CLI tool blocking (must use wrapper tools)
|
|
12
|
+
*
|
|
13
|
+
* Note: This is a convenience layer. Real security should be enforced
|
|
14
|
+
* at infrastructure level (K8s RBAC, file permissions, etc.)
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { CliToolOverride, CredentialGuardConfig } from "../config/types.ts";
|
|
18
|
+
|
|
19
|
+
// ============================================================================
|
|
20
|
+
// TYPES
|
|
21
|
+
// ============================================================================
|
|
22
|
+
|
|
23
|
+
/** Input format received by hooks/plugins. */
|
|
24
|
+
export type HookInput = {
|
|
25
|
+
tool: string;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/** Output format received by hooks/plugins. */
|
|
29
|
+
export type HookOutput = {
|
|
30
|
+
args: Record<string, unknown>;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
type BlockedCliTool = {
|
|
34
|
+
pattern: RegExp;
|
|
35
|
+
name: string;
|
|
36
|
+
wrapper: string;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/** Object returned by createCredentialGuard */
|
|
40
|
+
export type CredentialGuard = {
|
|
41
|
+
handleToolExecuteBefore: (input: HookInput, output: HookOutput) => void;
|
|
42
|
+
detectSecrets: (content: string) => { name: string; match: string } | null;
|
|
43
|
+
isPathAllowed: (filePath: string) => boolean;
|
|
44
|
+
isPathBlocked: (filePath: string) => boolean;
|
|
45
|
+
isDangerousBashCommand: (command: string) => boolean;
|
|
46
|
+
getBlockedCliTool: (command: string) => { name: string; wrapper: string } | null;
|
|
47
|
+
isGhCommandAllowed: (command: string) => boolean;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
// ============================================================================
|
|
51
|
+
// DEFAULT PATTERNS
|
|
52
|
+
// ============================================================================
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Paths that should NEVER be accessed by AI agents.
|
|
56
|
+
* These patterns match files containing credentials, keys, and secrets.
|
|
57
|
+
*/
|
|
58
|
+
const DEFAULT_BLOCKED_PATH_PATTERNS: RegExp[] = [
|
|
59
|
+
/\.env$/,
|
|
60
|
+
/\.env\.[^.]+$/, // .env.local, .env.production, etc.
|
|
61
|
+
/\.pem$/,
|
|
62
|
+
/\.key$/,
|
|
63
|
+
/\.p12$/,
|
|
64
|
+
/\.pfx$/,
|
|
65
|
+
/\/secrets?\//i,
|
|
66
|
+
/^secrets?\//i,
|
|
67
|
+
/\/credentials?\//i,
|
|
68
|
+
/^credentials?\//i,
|
|
69
|
+
/\.aws\//,
|
|
70
|
+
/\.ssh\//,
|
|
71
|
+
/\.kube\//,
|
|
72
|
+
/kubeconfig/i,
|
|
73
|
+
/\.sentryclirc$/,
|
|
74
|
+
];
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Exceptions - files that match blocked patterns but are safe to access.
|
|
78
|
+
* Only truly generic defaults (no project-specific paths).
|
|
79
|
+
*/
|
|
80
|
+
const DEFAULT_ALLOWED_PATH_PATTERNS: RegExp[] = [
|
|
81
|
+
/\.env\.example$/,
|
|
82
|
+
/\.env\.template$/,
|
|
83
|
+
/\.env\.sample$/,
|
|
84
|
+
];
|
|
85
|
+
|
|
86
|
+
/** Patterns to detect secrets in content. */
|
|
87
|
+
const SECRET_PATTERNS = [
|
|
88
|
+
{
|
|
89
|
+
name: "AWS Access Key",
|
|
90
|
+
pattern: /(?:AKIA|ABIA|ACCA|ASIA)[A-Z0-9]{16}/,
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
name: "GitHub Token",
|
|
94
|
+
pattern: /gh[ps]_[A-Za-z0-9]{36}/,
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
name: "GitHub PAT",
|
|
98
|
+
pattern: /github_pat_[A-Za-z0-9]{22}_[A-Za-z0-9]{59}/,
|
|
99
|
+
},
|
|
100
|
+
{ name: "OpenAI Key", pattern: /sk-[A-Za-z0-9]{48}/ },
|
|
101
|
+
{
|
|
102
|
+
name: "Generic API Key",
|
|
103
|
+
pattern: /(?:api[_-]?key|apikey)["\s:=]+["']?([A-Za-z0-9_-]{20,})["']?/i,
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
name: "Generic Secret",
|
|
107
|
+
pattern:
|
|
108
|
+
/(?:secret|token|password|passwd|pwd)[" \t:=]+["']?(?!\$\{|process\.env|z\.|generate|create|read|get|fetch|import|export|const|function|return|Schema)[^\s"']{32,}["']?/i,
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
name: "Priv" + "ate Key",
|
|
112
|
+
pattern: new RegExp("-----BEGIN.*PRIVATE KEY-----"),
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
name: "JWT Token",
|
|
116
|
+
pattern: /(?:["'=:\s]|^)eyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/,
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
name: "Azure SAS Token",
|
|
120
|
+
pattern: /[?&]sig=[A-Za-z0-9%+/=]{20,}/,
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
name: "GCP Service Account Key",
|
|
124
|
+
pattern: /"type"\s*:\s*"service_account"/,
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
name: "Slack Webhook URL",
|
|
128
|
+
pattern: /https:\/\/hooks\.slack\.com\/services\/T[A-Z0-9]+\/B[A-Z0-9]+\/[A-Za-z0-9]+/,
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
name: "Discord Webhook URL",
|
|
132
|
+
pattern: /https:\/\/discord(?:app)?\.com\/api\/webhooks\/\d+\/[A-Za-z0-9_-]+/,
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
name: "Database URL",
|
|
136
|
+
pattern: /(?:postgres(?:ql)?|mysql|mongodb):\/\/(?!\$\{)[^:]+:(?!\$\{)[^@]+@/,
|
|
137
|
+
},
|
|
138
|
+
];
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Dangerous bash patterns that might expose secrets.
|
|
142
|
+
*/
|
|
143
|
+
const DEFAULT_DANGEROUS_BASH_PATTERNS: RegExp[] = [
|
|
144
|
+
/printenv/i,
|
|
145
|
+
/(?:^|&&|\||;)\s*env(?:\s|$)/i,
|
|
146
|
+
/\bcat\s+\S*\.env/i,
|
|
147
|
+
/\bcat\s+\S*\.pem/i,
|
|
148
|
+
/\bcat\s+\S*\.key/i,
|
|
149
|
+
/\bcat\s+\S*secret/i,
|
|
150
|
+
/\bcat\s+\S*credential/i,
|
|
151
|
+
/\bcat\s+\S*\/\.ssh\//i,
|
|
152
|
+
/\bcat\s+\S*\/\.aws\//i,
|
|
153
|
+
];
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* CLI tools that must use wrapper tools for security and audit.
|
|
157
|
+
*/
|
|
158
|
+
const DEFAULT_BLOCKED_CLI_TOOLS: BlockedCliTool[] = [
|
|
159
|
+
{
|
|
160
|
+
pattern: /(?:^|[;&|]\s*)gh\s/,
|
|
161
|
+
name: "gh",
|
|
162
|
+
wrapper: "agent-tools-gh",
|
|
163
|
+
},
|
|
164
|
+
{
|
|
165
|
+
pattern: /(?:^|[;&|]\s*)kubectl\s/,
|
|
166
|
+
name: "kubectl",
|
|
167
|
+
wrapper: "agent-tools-k8s",
|
|
168
|
+
},
|
|
169
|
+
{
|
|
170
|
+
pattern: /(?:^|[;&|]\s*)psql\s/,
|
|
171
|
+
name: "psql",
|
|
172
|
+
wrapper: "agent-tools-db",
|
|
173
|
+
},
|
|
174
|
+
{
|
|
175
|
+
pattern: /(?:^|[;&|]\s*)az\s/,
|
|
176
|
+
name: "az",
|
|
177
|
+
wrapper: "agent-tools-az",
|
|
178
|
+
},
|
|
179
|
+
{
|
|
180
|
+
pattern: /(?:^|[;&|]\s*)curl\s.*dev\.azure\.com/,
|
|
181
|
+
name: "curl (Azure DevOps)",
|
|
182
|
+
wrapper: "agent-tools-az",
|
|
183
|
+
},
|
|
184
|
+
];
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Read-only gh subcommands safe on external repos with -R flag.
|
|
188
|
+
*/
|
|
189
|
+
const GH_ALLOWED_READONLY_SUBCOMMANDS = [
|
|
190
|
+
"issue list",
|
|
191
|
+
"issue view",
|
|
192
|
+
"issue search",
|
|
193
|
+
"pr list",
|
|
194
|
+
"pr view",
|
|
195
|
+
"pr diff",
|
|
196
|
+
"pr checks",
|
|
197
|
+
"release list",
|
|
198
|
+
"release view",
|
|
199
|
+
"repo view",
|
|
200
|
+
"search issues",
|
|
201
|
+
"search prs",
|
|
202
|
+
"search repos",
|
|
203
|
+
];
|
|
204
|
+
|
|
205
|
+
// ============================================================================
|
|
206
|
+
// HELPERS
|
|
207
|
+
// ============================================================================
|
|
208
|
+
|
|
209
|
+
function escapeRegex(s: string): string {
|
|
210
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Extract file path from hook arguments. */
|
|
214
|
+
export function extractFilePath(args: Record<string, unknown>): string {
|
|
215
|
+
return (args.filePath as string) || (args.file_path as string) || (args.path as string) || "";
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Extract content from hook arguments. */
|
|
219
|
+
export function extractContent(args: Record<string, unknown>): string {
|
|
220
|
+
return (args.content as string) || (args.newString as string) || "";
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Extract command from hook arguments. */
|
|
224
|
+
export function extractCommand(args: Record<string, unknown>): string {
|
|
225
|
+
return (args.command as string) || "";
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// ============================================================================
|
|
229
|
+
// FACTORY
|
|
230
|
+
// ============================================================================
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Create a credential guard with optional extra patterns merged into defaults.
|
|
234
|
+
*
|
|
235
|
+
* @param config - Optional overrides. Arrays are concatenated with defaults (not replaced).
|
|
236
|
+
* @returns Object with all guard functions bound to the merged pattern sets.
|
|
237
|
+
*/
|
|
238
|
+
export function createCredentialGuard(config?: CredentialGuardConfig): CredentialGuard {
|
|
239
|
+
const blockedPathPatterns = [
|
|
240
|
+
...DEFAULT_BLOCKED_PATH_PATTERNS,
|
|
241
|
+
...(config?.additionalBlockedPaths ?? []).map((p) => new RegExp(p)),
|
|
242
|
+
];
|
|
243
|
+
|
|
244
|
+
const allowedPathPatterns = [
|
|
245
|
+
...DEFAULT_ALLOWED_PATH_PATTERNS,
|
|
246
|
+
...(config?.additionalAllowedPaths ?? []).map((p) => new RegExp(p)),
|
|
247
|
+
];
|
|
248
|
+
|
|
249
|
+
const dangerousBashPatterns = [
|
|
250
|
+
...DEFAULT_DANGEROUS_BASH_PATTERNS,
|
|
251
|
+
...(config?.additionalDangerousBashPatterns ?? []).map((p) => new RegExp(p)),
|
|
252
|
+
];
|
|
253
|
+
|
|
254
|
+
const blockedCliTools: BlockedCliTool[] = [
|
|
255
|
+
...DEFAULT_BLOCKED_CLI_TOOLS,
|
|
256
|
+
...(config?.additionalBlockedCliTools ?? []).map(
|
|
257
|
+
(override: CliToolOverride): BlockedCliTool => ({
|
|
258
|
+
pattern: new RegExp(`(?:^|[;&|]\\s*)${escapeRegex(override.tool)}\\s`),
|
|
259
|
+
name: override.tool,
|
|
260
|
+
wrapper: override.suggestion,
|
|
261
|
+
}),
|
|
262
|
+
),
|
|
263
|
+
];
|
|
264
|
+
|
|
265
|
+
function isPathAllowed(filePath: string): boolean {
|
|
266
|
+
const normalizedPath = filePath.replace(/\\/g, "/");
|
|
267
|
+
return allowedPathPatterns.some((pattern) => pattern.test(normalizedPath));
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function isPathBlocked(filePath: string): boolean {
|
|
271
|
+
const normalizedPath = filePath.replace(/\\/g, "/");
|
|
272
|
+
|
|
273
|
+
for (const pattern of allowedPathPatterns) {
|
|
274
|
+
if (pattern.test(normalizedPath)) {
|
|
275
|
+
return false;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
for (const pattern of blockedPathPatterns) {
|
|
280
|
+
if (pattern.test(normalizedPath)) {
|
|
281
|
+
return true;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
return false;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function detectSecrets(content: string): { name: string; match: string } | null {
|
|
289
|
+
for (const { name, pattern } of SECRET_PATTERNS) {
|
|
290
|
+
const match = content.match(pattern);
|
|
291
|
+
if (match) {
|
|
292
|
+
const redacted = match[0].substring(0, 8) + "..." + match[0].substring(match[0].length - 4);
|
|
293
|
+
return { name, match: redacted };
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
return null;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function isDangerousBashCommand(command: string): boolean {
|
|
300
|
+
return dangerousBashPatterns.some((pattern) => pattern.test(command));
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function isGhCommandAllowed(command: string): boolean {
|
|
304
|
+
if (!/ -R\s+\S+/.test(command) && !/ --repo\s+\S+/.test(command)) {
|
|
305
|
+
return false;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const ghMatch = command.match(/(?:^|[;&|]\s*)gh\s+(\S+(?:\s+\S+)?)/);
|
|
309
|
+
if (!ghMatch) {
|
|
310
|
+
return false;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const subcommand = ghMatch[1];
|
|
314
|
+
|
|
315
|
+
return GH_ALLOWED_READONLY_SUBCOMMANDS.some(
|
|
316
|
+
(allowed) => subcommand === allowed || subcommand.startsWith(`${allowed} `),
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function allGhCommandsAllowed(command: string): boolean {
|
|
321
|
+
const segments = command.split(/[;&|\n]+/);
|
|
322
|
+
const ghSegments = segments.filter((s) => /\bgh\s/.test(s));
|
|
323
|
+
if (ghSegments.length === 0) return false;
|
|
324
|
+
return ghSegments.every((segment) => isGhCommandAllowed(segment.trim()));
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function getBlockedCliTool(command: string): { name: string; wrapper: string } | null {
|
|
328
|
+
for (const { pattern, name, wrapper } of blockedCliTools) {
|
|
329
|
+
if (pattern.test(command)) {
|
|
330
|
+
if (name === "gh" && allGhCommandsAllowed(command)) {
|
|
331
|
+
return null;
|
|
332
|
+
}
|
|
333
|
+
return { name, wrapper };
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
return null;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function handleToolExecuteBefore(input: HookInput, output: HookOutput): void {
|
|
340
|
+
const tool = input.tool;
|
|
341
|
+
const args = output.args;
|
|
342
|
+
|
|
343
|
+
const filePath = extractFilePath(args);
|
|
344
|
+
|
|
345
|
+
if ((tool === "read" || tool === "write" || tool === "edit") && filePath) {
|
|
346
|
+
if (isPathBlocked(filePath)) {
|
|
347
|
+
throw new Error(
|
|
348
|
+
`\u{1F6AB} Access blocked: "${filePath}" is a sensitive file.\n\n` +
|
|
349
|
+
`This file may contain credentials or secrets.\n` +
|
|
350
|
+
`If you need this file's content, ask the user to provide relevant parts.\n\n` +
|
|
351
|
+
`Think this should be allowed? See https://github.com/blogic-cz/agent-tools — fork, extend the guard, and submit a PR.`,
|
|
352
|
+
);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
if (tool === "write" || tool === "edit") {
|
|
357
|
+
if (!isPathAllowed(filePath)) {
|
|
358
|
+
const content = extractContent(args);
|
|
359
|
+
|
|
360
|
+
if (content) {
|
|
361
|
+
const detected = detectSecrets(content);
|
|
362
|
+
if (detected) {
|
|
363
|
+
throw new Error(
|
|
364
|
+
`\u{1F6AB} Secret detected: Potential ${detected.name} found in content.\n\n` +
|
|
365
|
+
`Matched: ${detected.match}\n\n` +
|
|
366
|
+
`Never commit secrets to code. Use environment variables or secret managers.\n\n` +
|
|
367
|
+
`Think this is a false positive? See https://github.com/blogic-cz/agent-tools — fork, fix the pattern, and submit a PR.`,
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
if (tool === "bash") {
|
|
375
|
+
const command = extractCommand(args);
|
|
376
|
+
|
|
377
|
+
if (isDangerousBashCommand(command)) {
|
|
378
|
+
throw new Error(
|
|
379
|
+
`\u{1F6AB} Command blocked: This command might expose secrets.\n\n` +
|
|
380
|
+
`Command: ${command}\n\n` +
|
|
381
|
+
`If you need environment info, ask the user directly.\n\n` +
|
|
382
|
+
`Think this is wrong? See https://github.com/blogic-cz/agent-tools — fork, adjust the patterns, and submit a PR.`,
|
|
383
|
+
);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const blockedTool = getBlockedCliTool(command);
|
|
387
|
+
if (blockedTool) {
|
|
388
|
+
throw new Error(
|
|
389
|
+
`\u{1F6AB} Direct ${blockedTool.name} usage blocked.\n\n` +
|
|
390
|
+
`AI agents must use wrapper tools for security and audit.\n\n` +
|
|
391
|
+
`Use instead: ${blockedTool.wrapper}\n\n` +
|
|
392
|
+
`Run with --help for documentation.\n\n` +
|
|
393
|
+
`Think this tool should be allowed? See https://github.com/blogic-cz/agent-tools — fork, extend the whitelist, and submit a PR.`,
|
|
394
|
+
);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
return {
|
|
400
|
+
handleToolExecuteBefore,
|
|
401
|
+
detectSecrets,
|
|
402
|
+
isPathAllowed,
|
|
403
|
+
isPathBlocked,
|
|
404
|
+
isDangerousBashCommand,
|
|
405
|
+
getBlockedCliTool,
|
|
406
|
+
isGhCommandAllowed,
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// ============================================================================
|
|
411
|
+
// TOP-LEVEL EXPORTS (default guard, no config)
|
|
412
|
+
// ============================================================================
|
|
413
|
+
|
|
414
|
+
const defaultGuard = createCredentialGuard();
|
|
415
|
+
|
|
416
|
+
/** Handle tool execution with default guard (no extra config). */
|
|
417
|
+
export const handleToolExecuteBefore = defaultGuard.handleToolExecuteBefore;
|
|
418
|
+
|
|
419
|
+
/** Detect secrets in content with default guard. */
|
|
420
|
+
export const detectSecrets = defaultGuard.detectSecrets;
|
|
421
|
+
|
|
422
|
+
/** Check if a path is in the allowed exceptions list (default guard). */
|
|
423
|
+
export const isPathAllowed = defaultGuard.isPathAllowed;
|
|
424
|
+
|
|
425
|
+
/** Check if a path should be blocked (default guard). */
|
|
426
|
+
export const isPathBlocked = defaultGuard.isPathBlocked;
|
|
427
|
+
|
|
428
|
+
/** Check if a bash command might expose secrets (default guard). */
|
|
429
|
+
export const isDangerousBashCommand = defaultGuard.isDangerousBashCommand;
|
|
430
|
+
|
|
431
|
+
/** Get blocked CLI tool info (default guard). */
|
|
432
|
+
export const getBlockedCliTool = defaultGuard.getBlockedCliTool;
|
|
433
|
+
|
|
434
|
+
/** Check if a gh command is allowed (default guard). */
|
|
435
|
+
export const isGhCommandAllowed = defaultGuard.isGhCommandAllowed;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { Effect, Layer, ServiceMap } from "effect";
|
|
2
|
+
|
|
3
|
+
import { ConfigService, getToolConfig } from "../config";
|
|
4
|
+
import type { DatabaseConfig } from "../config";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* DbConfigService wraps the resolved DatabaseConfig for the selected profile.
|
|
8
|
+
* Returns undefined when no database config is available — the service layer
|
|
9
|
+
* returns a no-op implementation that produces clear error messages.
|
|
10
|
+
*
|
|
11
|
+
* Usage:
|
|
12
|
+
* const dbConfig = yield* DbConfigService;
|
|
13
|
+
* if (!dbConfig) { // no config }
|
|
14
|
+
*/
|
|
15
|
+
export class DbConfigService extends ServiceMap.Service<
|
|
16
|
+
DbConfigService,
|
|
17
|
+
DatabaseConfig | undefined
|
|
18
|
+
>()("@agent-tools/DbConfigService") {}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Creates a DbConfigService layer that resolves the database config
|
|
22
|
+
* from agent-tools.json5 using the given profile.
|
|
23
|
+
* Succeeds with undefined when no config is found (allows --help to work).
|
|
24
|
+
*/
|
|
25
|
+
export function makeDbConfigLayer(profile?: string) {
|
|
26
|
+
return Layer.effect(
|
|
27
|
+
DbConfigService,
|
|
28
|
+
Effect.gen(function* () {
|
|
29
|
+
const config = yield* ConfigService;
|
|
30
|
+
const dbConfig = getToolConfig<DatabaseConfig>(config, "database", profile);
|
|
31
|
+
return dbConfig;
|
|
32
|
+
}),
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const DbConfigServiceLayer = makeDbConfigLayer();
|
|
37
|
+
|
|
38
|
+
export const TUNNEL_CHECK_INTERVAL_MS = 100;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { Schema } from "effect";
|
|
2
|
+
|
|
3
|
+
export class DbConnectionError extends Schema.TaggedErrorClass<DbConnectionError>()(
|
|
4
|
+
"DbConnectionError",
|
|
5
|
+
{
|
|
6
|
+
message: Schema.String,
|
|
7
|
+
environment: Schema.String,
|
|
8
|
+
},
|
|
9
|
+
) {}
|
|
10
|
+
|
|
11
|
+
export class DbQueryError extends Schema.TaggedErrorClass<DbQueryError>()("DbQueryError", {
|
|
12
|
+
message: Schema.String,
|
|
13
|
+
sql: Schema.String,
|
|
14
|
+
stderr: Schema.optionalKey(Schema.String),
|
|
15
|
+
}) {}
|
|
16
|
+
|
|
17
|
+
export class DbTunnelError extends Schema.TaggedErrorClass<DbTunnelError>()("DbTunnelError", {
|
|
18
|
+
message: Schema.String,
|
|
19
|
+
port: Schema.Number,
|
|
20
|
+
}) {}
|
|
21
|
+
|
|
22
|
+
export class DbParseError extends Schema.TaggedErrorClass<DbParseError>()("DbParseError", {
|
|
23
|
+
message: Schema.String,
|
|
24
|
+
rawOutput: Schema.String,
|
|
25
|
+
}) {}
|
|
26
|
+
|
|
27
|
+
export class DbMutationBlockedError extends Schema.TaggedErrorClass<DbMutationBlockedError>()(
|
|
28
|
+
"DbMutationBlockedError",
|
|
29
|
+
{
|
|
30
|
+
message: Schema.String,
|
|
31
|
+
environment: Schema.String,
|
|
32
|
+
},
|
|
33
|
+
) {}
|
|
34
|
+
|
|
35
|
+
export type DbError =
|
|
36
|
+
| DbConnectionError
|
|
37
|
+
| DbMutationBlockedError
|
|
38
|
+
| DbParseError
|
|
39
|
+
| DbQueryError
|
|
40
|
+
| DbTunnelError;
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { Command, Flag } from "effect/unstable/cli";
|
|
3
|
+
import { BunRuntime, BunServices } from "@effect/platform-bun";
|
|
4
|
+
import { Console, Effect, Layer, Option } from "effect";
|
|
5
|
+
|
|
6
|
+
import type { SchemaMode } from "./types";
|
|
7
|
+
|
|
8
|
+
import { formatOption, formatOutput, renderCauseToStderr, VERSION } from "../shared";
|
|
9
|
+
import { ConfigServiceLayer } from "../config";
|
|
10
|
+
import { makeDbConfigLayer } from "./config-service";
|
|
11
|
+
import { DbService } from "./service";
|
|
12
|
+
|
|
13
|
+
// Extract --profile from argv before @effect/cli parsing
|
|
14
|
+
// so we can build the correct config layer.
|
|
15
|
+
const profileIndex = process.argv.indexOf("--profile");
|
|
16
|
+
const profileArg = profileIndex !== -1 ? process.argv[profileIndex + 1] : undefined;
|
|
17
|
+
|
|
18
|
+
const sqlCommand = Command.make(
|
|
19
|
+
"sql",
|
|
20
|
+
{
|
|
21
|
+
env: Flag.string("env").pipe(
|
|
22
|
+
Flag.withDescription("Target database environment name (e.g. local, test, prod)"),
|
|
23
|
+
),
|
|
24
|
+
sql: Flag.string("sql").pipe(Flag.withDescription("SQL query to execute")),
|
|
25
|
+
format: formatOption,
|
|
26
|
+
profile: Flag.optional(Flag.string("profile")).pipe(
|
|
27
|
+
Flag.withDescription("Database profile name from agent-tools.json5 (if multiple configured)"),
|
|
28
|
+
),
|
|
29
|
+
},
|
|
30
|
+
({ env, sql, format }) =>
|
|
31
|
+
Effect.gen(function* () {
|
|
32
|
+
const db = yield* DbService;
|
|
33
|
+
const result = yield* db.executeQuery(env, sql);
|
|
34
|
+
yield* Console.log(formatOutput(result, format));
|
|
35
|
+
}),
|
|
36
|
+
).pipe(Command.withDescription("Execute a SQL query"));
|
|
37
|
+
|
|
38
|
+
const schemaCommand = Command.make(
|
|
39
|
+
"schema",
|
|
40
|
+
{
|
|
41
|
+
env: Flag.string("env").pipe(
|
|
42
|
+
Flag.withDescription("Target database environment name (e.g. local, test, prod)"),
|
|
43
|
+
),
|
|
44
|
+
mode: Flag.choice("mode", ["tables", "columns", "full", "relationships"]).pipe(
|
|
45
|
+
Flag.withDescription(
|
|
46
|
+
"Schema introspection mode: tables (list all), columns (show columns for --table), full (all tables with columns), relationships (foreign keys)",
|
|
47
|
+
),
|
|
48
|
+
),
|
|
49
|
+
table: Flag.string("table").pipe(
|
|
50
|
+
Flag.withDescription("Table name (required for --mode columns)"),
|
|
51
|
+
Flag.optional,
|
|
52
|
+
),
|
|
53
|
+
format: formatOption,
|
|
54
|
+
profile: Flag.optional(Flag.string("profile")).pipe(
|
|
55
|
+
Flag.withDescription("Database profile name from agent-tools.json5 (if multiple configured)"),
|
|
56
|
+
),
|
|
57
|
+
},
|
|
58
|
+
({ env, mode, table, format }) =>
|
|
59
|
+
Effect.gen(function* () {
|
|
60
|
+
const db = yield* DbService;
|
|
61
|
+
const result = yield* db.executeSchemaQuery(
|
|
62
|
+
env,
|
|
63
|
+
mode as SchemaMode,
|
|
64
|
+
Option.getOrUndefined(table),
|
|
65
|
+
);
|
|
66
|
+
yield* Console.log(formatOutput(result, format));
|
|
67
|
+
}),
|
|
68
|
+
).pipe(Command.withDescription("Introspect database schema (tables, columns, relationships)"));
|
|
69
|
+
|
|
70
|
+
const mainCommand = Command.make("db-tool", {}).pipe(
|
|
71
|
+
Command.withDescription("Database Query Tool for Coding Agents"),
|
|
72
|
+
Command.withSubcommands([sqlCommand, schemaCommand]),
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
const cli = Command.run(mainCommand, {
|
|
76
|
+
version: VERSION,
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
const dbConfigLayer = makeDbConfigLayer(profileArg);
|
|
80
|
+
|
|
81
|
+
const MainLayer = DbService.layer.pipe(
|
|
82
|
+
Layer.provide(dbConfigLayer),
|
|
83
|
+
Layer.provideMerge(ConfigServiceLayer),
|
|
84
|
+
Layer.provideMerge(BunServices.layer),
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
const program = cli.pipe(Effect.provide(MainLayer), Effect.tapCause(renderCauseToStderr));
|
|
88
|
+
|
|
89
|
+
BunRuntime.runMain(program, {
|
|
90
|
+
disableErrorReporting: true,
|
|
91
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SQL queries for schema introspection.
|
|
3
|
+
*
|
|
4
|
+
* NOTE: The `columns` query uses string interpolation for the table name.
|
|
5
|
+
* This is a known limitation — callers must validate the table name
|
|
6
|
+
* via `isValidTableName()` before calling `getColumns()`.
|
|
7
|
+
*/
|
|
8
|
+
export const SCHEMA_QUERIES = {
|
|
9
|
+
tables: `
|
|
10
|
+
SELECT tablename as name
|
|
11
|
+
FROM pg_tables
|
|
12
|
+
WHERE schemaname = 'public'
|
|
13
|
+
ORDER BY tablename
|
|
14
|
+
`,
|
|
15
|
+
columns: (tableName: string) => {
|
|
16
|
+
const escapedTableName = tableName.replaceAll("'", "''");
|
|
17
|
+
|
|
18
|
+
return `
|
|
19
|
+
SELECT
|
|
20
|
+
c.column_name as name,
|
|
21
|
+
c.data_type as type,
|
|
22
|
+
c.is_nullable = 'YES' as nullable,
|
|
23
|
+
c.column_default as default_value,
|
|
24
|
+
COALESCE(
|
|
25
|
+
(SELECT true FROM information_schema.table_constraints tc
|
|
26
|
+
JOIN information_schema.key_column_usage kcu ON tc.constraint_name = kcu.constraint_name
|
|
27
|
+
WHERE tc.table_name = c.table_name
|
|
28
|
+
AND tc.table_schema = c.table_schema
|
|
29
|
+
AND kcu.column_name = c.column_name
|
|
30
|
+
AND tc.constraint_type = 'PRIMARY KEY'),
|
|
31
|
+
false
|
|
32
|
+
) as is_primary_key
|
|
33
|
+
FROM information_schema.columns c
|
|
34
|
+
WHERE c.table_name = '${escapedTableName}'
|
|
35
|
+
AND c.table_schema = 'public'
|
|
36
|
+
ORDER BY c.ordinal_position
|
|
37
|
+
`;
|
|
38
|
+
},
|
|
39
|
+
relationships: `
|
|
40
|
+
SELECT
|
|
41
|
+
tc.table_name as from_table,
|
|
42
|
+
kcu.column_name as from_column,
|
|
43
|
+
ccu.table_name as to_table,
|
|
44
|
+
ccu.column_name as to_column,
|
|
45
|
+
tc.constraint_name
|
|
46
|
+
FROM information_schema.table_constraints tc
|
|
47
|
+
JOIN information_schema.key_column_usage kcu
|
|
48
|
+
ON tc.constraint_name = kcu.constraint_name
|
|
49
|
+
AND tc.table_schema = kcu.table_schema
|
|
50
|
+
JOIN information_schema.constraint_column_usage ccu
|
|
51
|
+
ON ccu.constraint_name = tc.constraint_name
|
|
52
|
+
AND ccu.table_schema = tc.table_schema
|
|
53
|
+
WHERE tc.constraint_type = 'FOREIGN KEY'
|
|
54
|
+
AND tc.table_schema = 'public'
|
|
55
|
+
ORDER BY tc.table_name, kcu.column_name
|
|
56
|
+
`,
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export function getTableNames(): string {
|
|
60
|
+
return SCHEMA_QUERIES.tables;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function getColumns(tableName: string): string {
|
|
64
|
+
return SCHEMA_QUERIES.columns(tableName);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function getRelationships(): string {
|
|
68
|
+
return SCHEMA_QUERIES.relationships;
|
|
69
|
+
}
|