autoforge-ai 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/.claude/commands/check-code.md +32 -0
- package/.claude/commands/checkpoint.md +40 -0
- package/.claude/commands/create-spec.md +613 -0
- package/.claude/commands/expand-project.md +234 -0
- package/.claude/commands/gsd-to-autoforge-spec.md +10 -0
- package/.claude/commands/review-pr.md +75 -0
- package/.claude/templates/app_spec.template.txt +331 -0
- package/.claude/templates/coding_prompt.template.md +265 -0
- package/.claude/templates/initializer_prompt.template.md +354 -0
- package/.claude/templates/testing_prompt.template.md +146 -0
- package/.env.example +64 -0
- package/LICENSE.md +676 -0
- package/README.md +423 -0
- package/agent.py +444 -0
- package/api/__init__.py +10 -0
- package/api/database.py +536 -0
- package/api/dependency_resolver.py +449 -0
- package/api/migration.py +156 -0
- package/auth.py +83 -0
- package/autoforge_paths.py +315 -0
- package/autonomous_agent_demo.py +293 -0
- package/bin/autoforge.js +3 -0
- package/client.py +607 -0
- package/env_constants.py +27 -0
- package/examples/OPTIMIZE_CONFIG.md +230 -0
- package/examples/README.md +531 -0
- package/examples/org_config.yaml +172 -0
- package/examples/project_allowed_commands.yaml +139 -0
- package/lib/cli.js +791 -0
- package/mcp_server/__init__.py +1 -0
- package/mcp_server/feature_mcp.py +988 -0
- package/package.json +53 -0
- package/parallel_orchestrator.py +1800 -0
- package/progress.py +247 -0
- package/prompts.py +427 -0
- package/pyproject.toml +17 -0
- package/rate_limit_utils.py +132 -0
- package/registry.py +614 -0
- package/requirements-prod.txt +14 -0
- package/security.py +959 -0
- package/server/__init__.py +17 -0
- package/server/main.py +261 -0
- package/server/routers/__init__.py +32 -0
- package/server/routers/agent.py +177 -0
- package/server/routers/assistant_chat.py +327 -0
- package/server/routers/devserver.py +309 -0
- package/server/routers/expand_project.py +239 -0
- package/server/routers/features.py +746 -0
- package/server/routers/filesystem.py +514 -0
- package/server/routers/projects.py +524 -0
- package/server/routers/schedules.py +356 -0
- package/server/routers/settings.py +127 -0
- package/server/routers/spec_creation.py +357 -0
- package/server/routers/terminal.py +453 -0
- package/server/schemas.py +593 -0
- package/server/services/__init__.py +36 -0
- package/server/services/assistant_chat_session.py +496 -0
- package/server/services/assistant_database.py +304 -0
- package/server/services/chat_constants.py +57 -0
- package/server/services/dev_server_manager.py +557 -0
- package/server/services/expand_chat_session.py +399 -0
- package/server/services/process_manager.py +657 -0
- package/server/services/project_config.py +475 -0
- package/server/services/scheduler_service.py +683 -0
- package/server/services/spec_chat_session.py +502 -0
- package/server/services/terminal_manager.py +756 -0
- package/server/utils/__init__.py +1 -0
- package/server/utils/process_utils.py +134 -0
- package/server/utils/project_helpers.py +32 -0
- package/server/utils/validation.py +54 -0
- package/server/websocket.py +903 -0
- package/start.py +456 -0
- package/ui/dist/assets/index-8W_wmZzz.js +168 -0
- package/ui/dist/assets/index-B47Ubhox.css +1 -0
- package/ui/dist/assets/vendor-flow-CVNK-_lx.js +7 -0
- package/ui/dist/assets/vendor-query-BUABzP5o.js +1 -0
- package/ui/dist/assets/vendor-radix-DTNNCg2d.js +45 -0
- package/ui/dist/assets/vendor-react-qkC6yhPU.js +1 -0
- package/ui/dist/assets/vendor-utils-COeKbHgx.js +2 -0
- package/ui/dist/assets/vendor-xterm-DP_gxef0.js +16 -0
- package/ui/dist/index.html +23 -0
- package/ui/dist/ollama.png +0 -0
- package/ui/dist/vite.svg +6 -0
- package/ui/package.json +57 -0
package/security.py
ADDED
|
@@ -0,0 +1,959 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Security Hooks for Autonomous Coding Agent
|
|
3
|
+
==========================================
|
|
4
|
+
|
|
5
|
+
Pre-tool-use hooks that validate bash commands for security.
|
|
6
|
+
Uses an allowlist approach - only explicitly permitted commands can run.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
import shlex
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Optional
|
|
15
|
+
|
|
16
|
+
import yaml
|
|
17
|
+
|
|
18
|
+
# Logger for security-related events (fallback parsing, validation failures, etc.)
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
# Regex pattern for valid pkill process names (no regex metacharacters allowed)
|
|
22
|
+
# Matches alphanumeric names with dots, underscores, and hyphens
|
|
23
|
+
VALID_PROCESS_NAME_PATTERN = re.compile(r"^[A-Za-z0-9._-]+$")
|
|
24
|
+
|
|
25
|
+
# Allowed commands for development tasks
|
|
26
|
+
# Minimal set needed for the autonomous coding demo
|
|
27
|
+
ALLOWED_COMMANDS = {
|
|
28
|
+
# File inspection
|
|
29
|
+
"ls",
|
|
30
|
+
"cat",
|
|
31
|
+
"head",
|
|
32
|
+
"tail",
|
|
33
|
+
"wc",
|
|
34
|
+
"grep",
|
|
35
|
+
# File operations (agent uses SDK tools for most file ops, but cp/mkdir needed occasionally)
|
|
36
|
+
"cp",
|
|
37
|
+
"mkdir",
|
|
38
|
+
"chmod", # For making scripts executable; validated separately
|
|
39
|
+
# Directory
|
|
40
|
+
"pwd",
|
|
41
|
+
# Output
|
|
42
|
+
"echo",
|
|
43
|
+
# Node.js development
|
|
44
|
+
"npm",
|
|
45
|
+
"npx",
|
|
46
|
+
"pnpm", # Project uses pnpm
|
|
47
|
+
"node",
|
|
48
|
+
# Version control
|
|
49
|
+
"git",
|
|
50
|
+
# Docker (for PostgreSQL)
|
|
51
|
+
"docker",
|
|
52
|
+
# Process management
|
|
53
|
+
"ps",
|
|
54
|
+
"lsof",
|
|
55
|
+
"sleep",
|
|
56
|
+
"kill", # Kill by PID
|
|
57
|
+
"pkill", # For killing dev servers; validated separately
|
|
58
|
+
# Network/API testing
|
|
59
|
+
"curl",
|
|
60
|
+
# File operations
|
|
61
|
+
"mv",
|
|
62
|
+
"rm", # Use with caution
|
|
63
|
+
"touch",
|
|
64
|
+
# Shell scripts
|
|
65
|
+
"sh",
|
|
66
|
+
"bash",
|
|
67
|
+
# Script execution
|
|
68
|
+
"init.sh", # Init scripts; validated separately
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
# Commands that need additional validation even when in the allowlist
|
|
72
|
+
COMMANDS_NEEDING_EXTRA_VALIDATION = {"pkill", "chmod", "init.sh"}
|
|
73
|
+
|
|
74
|
+
# Commands that are NEVER allowed, even with user approval
|
|
75
|
+
# These commands can cause permanent system damage or security breaches
|
|
76
|
+
BLOCKED_COMMANDS = {
|
|
77
|
+
# Disk operations
|
|
78
|
+
"dd",
|
|
79
|
+
"mkfs",
|
|
80
|
+
"fdisk",
|
|
81
|
+
"parted",
|
|
82
|
+
# System control
|
|
83
|
+
"shutdown",
|
|
84
|
+
"reboot",
|
|
85
|
+
"poweroff",
|
|
86
|
+
"halt",
|
|
87
|
+
"init",
|
|
88
|
+
# Ownership changes
|
|
89
|
+
"chown",
|
|
90
|
+
"chgrp",
|
|
91
|
+
# System services
|
|
92
|
+
"systemctl",
|
|
93
|
+
"service",
|
|
94
|
+
"launchctl",
|
|
95
|
+
# Network security
|
|
96
|
+
"iptables",
|
|
97
|
+
"ufw",
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
# Sensitive directories (relative to home) that should never be exposed.
|
|
101
|
+
# Used by both the EXTRA_READ_PATHS validator (client.py) and the filesystem
|
|
102
|
+
# browser API (server/routers/filesystem.py) to block credential/key directories.
|
|
103
|
+
# This is the single source of truth -- import from here in both places.
|
|
104
|
+
#
|
|
105
|
+
# SENSITIVE_DIRECTORIES is the union of the previous filesystem browser blocklist
|
|
106
|
+
# (filesystem.py) and the previous EXTRA_READ_PATHS blocklist (client.py).
|
|
107
|
+
# Some entries are new to each consumer -- this is intentional for defense-in-depth.
|
|
108
|
+
SENSITIVE_DIRECTORIES = {
|
|
109
|
+
".ssh",
|
|
110
|
+
".aws",
|
|
111
|
+
".azure",
|
|
112
|
+
".kube",
|
|
113
|
+
".gnupg",
|
|
114
|
+
".gpg",
|
|
115
|
+
".password-store",
|
|
116
|
+
".docker",
|
|
117
|
+
".config/gcloud",
|
|
118
|
+
".config/gh",
|
|
119
|
+
".npmrc",
|
|
120
|
+
".pypirc",
|
|
121
|
+
".netrc",
|
|
122
|
+
".terraform",
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
# Commands that trigger emphatic warnings but CAN be approved (Phase 3)
|
|
126
|
+
# For now, these are blocked like BLOCKED_COMMANDS until Phase 3 implements approval
|
|
127
|
+
DANGEROUS_COMMANDS = {
|
|
128
|
+
# Privilege escalation
|
|
129
|
+
"sudo",
|
|
130
|
+
"su",
|
|
131
|
+
"doas",
|
|
132
|
+
# Cloud CLIs (can modify production infrastructure)
|
|
133
|
+
"aws",
|
|
134
|
+
"gcloud",
|
|
135
|
+
"az",
|
|
136
|
+
# Container and orchestration
|
|
137
|
+
"kubectl",
|
|
138
|
+
"docker-compose",
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def split_command_segments(command_string: str) -> list[str]:
|
|
143
|
+
"""
|
|
144
|
+
Split a compound command into individual command segments.
|
|
145
|
+
|
|
146
|
+
Handles command chaining (&&, ||, ;) but not pipes (those are single commands).
|
|
147
|
+
|
|
148
|
+
Args:
|
|
149
|
+
command_string: The full shell command
|
|
150
|
+
|
|
151
|
+
Returns:
|
|
152
|
+
List of individual command segments
|
|
153
|
+
"""
|
|
154
|
+
import re
|
|
155
|
+
|
|
156
|
+
# Split on && and || while preserving the ability to handle each segment
|
|
157
|
+
# This regex splits on && or || that aren't inside quotes
|
|
158
|
+
segments = re.split(r"\s*(?:&&|\|\|)\s*", command_string)
|
|
159
|
+
|
|
160
|
+
# Further split on semicolons
|
|
161
|
+
result = []
|
|
162
|
+
for segment in segments:
|
|
163
|
+
sub_segments = re.split(r'(?<!["\'])\s*;\s*(?!["\'])', segment)
|
|
164
|
+
for sub in sub_segments:
|
|
165
|
+
sub = sub.strip()
|
|
166
|
+
if sub:
|
|
167
|
+
result.append(sub)
|
|
168
|
+
|
|
169
|
+
return result
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _extract_primary_command(segment: str) -> str | None:
|
|
173
|
+
"""
|
|
174
|
+
Fallback command extraction when shlex fails.
|
|
175
|
+
|
|
176
|
+
Extracts the first word that looks like a command, handling cases
|
|
177
|
+
like complex docker exec commands with nested quotes.
|
|
178
|
+
|
|
179
|
+
Args:
|
|
180
|
+
segment: The command segment to parse
|
|
181
|
+
|
|
182
|
+
Returns:
|
|
183
|
+
The primary command name, or None if extraction fails
|
|
184
|
+
"""
|
|
185
|
+
# Remove leading whitespace
|
|
186
|
+
segment = segment.lstrip()
|
|
187
|
+
|
|
188
|
+
if not segment:
|
|
189
|
+
return None
|
|
190
|
+
|
|
191
|
+
# Skip env var assignments at start (VAR=value cmd)
|
|
192
|
+
words = segment.split()
|
|
193
|
+
while words and "=" in words[0] and not words[0].startswith("="):
|
|
194
|
+
words = words[1:]
|
|
195
|
+
|
|
196
|
+
if not words:
|
|
197
|
+
return None
|
|
198
|
+
|
|
199
|
+
# Extract first token (the command)
|
|
200
|
+
first_word = words[0]
|
|
201
|
+
|
|
202
|
+
# Match valid command characters (alphanumeric, dots, underscores, hyphens, slashes)
|
|
203
|
+
match = re.match(r"^([a-zA-Z0-9_./-]+)", first_word)
|
|
204
|
+
if match:
|
|
205
|
+
cmd = match.group(1)
|
|
206
|
+
return os.path.basename(cmd)
|
|
207
|
+
|
|
208
|
+
return None
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def extract_commands(command_string: str) -> list[str]:
|
|
212
|
+
"""
|
|
213
|
+
Extract command names from a shell command string.
|
|
214
|
+
|
|
215
|
+
Handles pipes, command chaining (&&, ||, ;), and subshells.
|
|
216
|
+
Returns the base command names (without paths).
|
|
217
|
+
|
|
218
|
+
Args:
|
|
219
|
+
command_string: The full shell command
|
|
220
|
+
|
|
221
|
+
Returns:
|
|
222
|
+
List of command names found in the string
|
|
223
|
+
"""
|
|
224
|
+
commands = []
|
|
225
|
+
|
|
226
|
+
# shlex doesn't treat ; as a separator, so we need to pre-process
|
|
227
|
+
|
|
228
|
+
# Split on semicolons that aren't inside quotes (simple heuristic)
|
|
229
|
+
# This handles common cases like "echo hello; ls"
|
|
230
|
+
segments = re.split(r'(?<!["\'])\s*;\s*(?!["\'])', command_string)
|
|
231
|
+
|
|
232
|
+
for segment in segments:
|
|
233
|
+
segment = segment.strip()
|
|
234
|
+
if not segment:
|
|
235
|
+
continue
|
|
236
|
+
|
|
237
|
+
try:
|
|
238
|
+
tokens = shlex.split(segment)
|
|
239
|
+
except ValueError:
|
|
240
|
+
# Malformed command (unclosed quotes, etc.)
|
|
241
|
+
# Try fallback extraction instead of blocking entirely
|
|
242
|
+
fallback_cmd = _extract_primary_command(segment)
|
|
243
|
+
if fallback_cmd:
|
|
244
|
+
logger.debug(
|
|
245
|
+
"shlex fallback used: segment=%r -> command=%r",
|
|
246
|
+
segment,
|
|
247
|
+
fallback_cmd,
|
|
248
|
+
)
|
|
249
|
+
commands.append(fallback_cmd)
|
|
250
|
+
else:
|
|
251
|
+
logger.debug(
|
|
252
|
+
"shlex fallback failed: segment=%r (no command extracted)",
|
|
253
|
+
segment,
|
|
254
|
+
)
|
|
255
|
+
continue
|
|
256
|
+
|
|
257
|
+
if not tokens:
|
|
258
|
+
continue
|
|
259
|
+
|
|
260
|
+
# Track when we expect a command vs arguments
|
|
261
|
+
expect_command = True
|
|
262
|
+
|
|
263
|
+
for token in tokens:
|
|
264
|
+
# Shell operators indicate a new command follows
|
|
265
|
+
if token in ("|", "||", "&&", "&"):
|
|
266
|
+
expect_command = True
|
|
267
|
+
continue
|
|
268
|
+
|
|
269
|
+
# Skip shell keywords that precede commands
|
|
270
|
+
if token in (
|
|
271
|
+
"if",
|
|
272
|
+
"then",
|
|
273
|
+
"else",
|
|
274
|
+
"elif",
|
|
275
|
+
"fi",
|
|
276
|
+
"for",
|
|
277
|
+
"while",
|
|
278
|
+
"until",
|
|
279
|
+
"do",
|
|
280
|
+
"done",
|
|
281
|
+
"case",
|
|
282
|
+
"esac",
|
|
283
|
+
"in",
|
|
284
|
+
"!",
|
|
285
|
+
"{",
|
|
286
|
+
"}",
|
|
287
|
+
):
|
|
288
|
+
continue
|
|
289
|
+
|
|
290
|
+
# Skip flags/options
|
|
291
|
+
if token.startswith("-"):
|
|
292
|
+
continue
|
|
293
|
+
|
|
294
|
+
# Skip variable assignments (VAR=value)
|
|
295
|
+
if "=" in token and not token.startswith("="):
|
|
296
|
+
continue
|
|
297
|
+
|
|
298
|
+
if expect_command:
|
|
299
|
+
# Extract the base command name (handle paths like /usr/bin/python)
|
|
300
|
+
cmd = os.path.basename(token)
|
|
301
|
+
commands.append(cmd)
|
|
302
|
+
expect_command = False
|
|
303
|
+
|
|
304
|
+
return commands
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
# Default pkill process names (hardcoded baseline, always available)
|
|
308
|
+
DEFAULT_PKILL_PROCESSES = {
|
|
309
|
+
"node",
|
|
310
|
+
"npm",
|
|
311
|
+
"npx",
|
|
312
|
+
"vite",
|
|
313
|
+
"next",
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def validate_pkill_command(
|
|
318
|
+
command_string: str,
|
|
319
|
+
extra_processes: Optional[set[str]] = None
|
|
320
|
+
) -> tuple[bool, str]:
|
|
321
|
+
"""
|
|
322
|
+
Validate pkill commands - only allow killing dev-related processes.
|
|
323
|
+
|
|
324
|
+
Uses shlex to parse the command, avoiding regex bypass vulnerabilities.
|
|
325
|
+
|
|
326
|
+
Args:
|
|
327
|
+
command_string: The pkill command to validate
|
|
328
|
+
extra_processes: Optional set of additional process names to allow
|
|
329
|
+
(from org/project config pkill_processes)
|
|
330
|
+
|
|
331
|
+
Returns:
|
|
332
|
+
Tuple of (is_allowed, reason_if_blocked)
|
|
333
|
+
"""
|
|
334
|
+
# Merge default processes with any extra configured processes
|
|
335
|
+
allowed_process_names = DEFAULT_PKILL_PROCESSES.copy()
|
|
336
|
+
if extra_processes:
|
|
337
|
+
allowed_process_names |= extra_processes
|
|
338
|
+
|
|
339
|
+
try:
|
|
340
|
+
tokens = shlex.split(command_string)
|
|
341
|
+
except ValueError:
|
|
342
|
+
return False, "Could not parse pkill command"
|
|
343
|
+
|
|
344
|
+
if not tokens:
|
|
345
|
+
return False, "Empty pkill command"
|
|
346
|
+
|
|
347
|
+
# Separate flags from arguments
|
|
348
|
+
args = []
|
|
349
|
+
for token in tokens[1:]:
|
|
350
|
+
if not token.startswith("-"):
|
|
351
|
+
args.append(token)
|
|
352
|
+
|
|
353
|
+
if not args:
|
|
354
|
+
return False, "pkill requires a process name"
|
|
355
|
+
|
|
356
|
+
# Validate every non-flag argument (pkill accepts multiple patterns on BSD)
|
|
357
|
+
# This defensively ensures no disallowed process can be targeted
|
|
358
|
+
targets = []
|
|
359
|
+
for arg in args:
|
|
360
|
+
# For -f flag (full command line match), take the first word as process name
|
|
361
|
+
# e.g., "pkill -f 'node server.js'" -> target is "node server.js", process is "node"
|
|
362
|
+
t = arg.split()[0] if " " in arg else arg
|
|
363
|
+
targets.append(t)
|
|
364
|
+
|
|
365
|
+
disallowed = [t for t in targets if t not in allowed_process_names]
|
|
366
|
+
if not disallowed:
|
|
367
|
+
return True, ""
|
|
368
|
+
return False, f"pkill only allowed for processes: {sorted(allowed_process_names)}"
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def validate_chmod_command(command_string: str) -> tuple[bool, str]:
|
|
372
|
+
"""
|
|
373
|
+
Validate chmod commands - only allow making files executable with +x.
|
|
374
|
+
|
|
375
|
+
Returns:
|
|
376
|
+
Tuple of (is_allowed, reason_if_blocked)
|
|
377
|
+
"""
|
|
378
|
+
try:
|
|
379
|
+
tokens = shlex.split(command_string)
|
|
380
|
+
except ValueError:
|
|
381
|
+
return False, "Could not parse chmod command"
|
|
382
|
+
|
|
383
|
+
if not tokens or tokens[0] != "chmod":
|
|
384
|
+
return False, "Not a chmod command"
|
|
385
|
+
|
|
386
|
+
# Look for the mode argument
|
|
387
|
+
# Valid modes: +x, u+x, a+x, etc. (anything ending with +x for execute permission)
|
|
388
|
+
mode = None
|
|
389
|
+
files = []
|
|
390
|
+
|
|
391
|
+
for token in tokens[1:]:
|
|
392
|
+
if token.startswith("-"):
|
|
393
|
+
# Skip flags like -R (we don't allow recursive chmod anyway)
|
|
394
|
+
return False, "chmod flags are not allowed"
|
|
395
|
+
elif mode is None:
|
|
396
|
+
mode = token
|
|
397
|
+
else:
|
|
398
|
+
files.append(token)
|
|
399
|
+
|
|
400
|
+
if mode is None:
|
|
401
|
+
return False, "chmod requires a mode"
|
|
402
|
+
|
|
403
|
+
if not files:
|
|
404
|
+
return False, "chmod requires at least one file"
|
|
405
|
+
|
|
406
|
+
# Only allow +x variants (making files executable)
|
|
407
|
+
# This matches: +x, u+x, g+x, o+x, a+x, ug+x, etc.
|
|
408
|
+
import re
|
|
409
|
+
|
|
410
|
+
if not re.match(r"^[ugoa]*\+x$", mode):
|
|
411
|
+
return False, f"chmod only allowed with +x mode, got: {mode}"
|
|
412
|
+
|
|
413
|
+
return True, ""
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def validate_init_script(command_string: str) -> tuple[bool, str]:
|
|
417
|
+
"""
|
|
418
|
+
Validate init.sh script execution - only allow ./init.sh.
|
|
419
|
+
|
|
420
|
+
Returns:
|
|
421
|
+
Tuple of (is_allowed, reason_if_blocked)
|
|
422
|
+
"""
|
|
423
|
+
try:
|
|
424
|
+
tokens = shlex.split(command_string)
|
|
425
|
+
except ValueError:
|
|
426
|
+
return False, "Could not parse init script command"
|
|
427
|
+
|
|
428
|
+
if not tokens:
|
|
429
|
+
return False, "Empty command"
|
|
430
|
+
|
|
431
|
+
# The command should be exactly ./init.sh (possibly with arguments)
|
|
432
|
+
script = tokens[0]
|
|
433
|
+
|
|
434
|
+
# Allow ./init.sh or paths ending in /init.sh
|
|
435
|
+
if script == "./init.sh" or script.endswith("/init.sh"):
|
|
436
|
+
return True, ""
|
|
437
|
+
|
|
438
|
+
return False, f"Only ./init.sh is allowed, got: {script}"
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def matches_pattern(command: str, pattern: str) -> bool:
|
|
442
|
+
"""
|
|
443
|
+
Check if a command matches a pattern.
|
|
444
|
+
|
|
445
|
+
Supports:
|
|
446
|
+
- Exact match: "swift"
|
|
447
|
+
- Prefix wildcard: "swift*" matches "swift", "swiftc", "swiftformat"
|
|
448
|
+
- Local script paths: "./scripts/build.sh" or "scripts/test.sh"
|
|
449
|
+
|
|
450
|
+
Args:
|
|
451
|
+
command: The command to check
|
|
452
|
+
pattern: The pattern to match against
|
|
453
|
+
|
|
454
|
+
Returns:
|
|
455
|
+
True if command matches pattern
|
|
456
|
+
"""
|
|
457
|
+
# Reject bare wildcards - security measure to prevent matching everything
|
|
458
|
+
if pattern == "*":
|
|
459
|
+
return False
|
|
460
|
+
|
|
461
|
+
# Exact match
|
|
462
|
+
if command == pattern:
|
|
463
|
+
return True
|
|
464
|
+
|
|
465
|
+
# Prefix wildcard (e.g., "swift*" matches "swiftc", "swiftlint")
|
|
466
|
+
if pattern.endswith("*"):
|
|
467
|
+
prefix = pattern[:-1]
|
|
468
|
+
# Also reject if prefix is empty (would be bare "*")
|
|
469
|
+
if not prefix:
|
|
470
|
+
return False
|
|
471
|
+
return command.startswith(prefix)
|
|
472
|
+
|
|
473
|
+
# Path patterns (./scripts/build.sh, scripts/test.sh, etc.)
|
|
474
|
+
if "/" in pattern:
|
|
475
|
+
# Extract the script name from the pattern
|
|
476
|
+
pattern_name = os.path.basename(pattern)
|
|
477
|
+
return command == pattern or command == pattern_name or command.endswith("/" + pattern_name)
|
|
478
|
+
|
|
479
|
+
return False
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def _validate_command_list(commands: list, config_path: Path, field_name: str) -> bool:
|
|
483
|
+
"""
|
|
484
|
+
Validate a list of command entries from a YAML config.
|
|
485
|
+
|
|
486
|
+
Each entry must be a dict with a non-empty string 'name' field.
|
|
487
|
+
Used by both load_org_config() and load_project_commands() to avoid
|
|
488
|
+
duplicating the same validation logic.
|
|
489
|
+
|
|
490
|
+
Args:
|
|
491
|
+
commands: List of command entries to validate
|
|
492
|
+
config_path: Path to the config file (for log messages)
|
|
493
|
+
field_name: Name of the YAML field being validated (e.g., 'allowed_commands', 'commands')
|
|
494
|
+
|
|
495
|
+
Returns:
|
|
496
|
+
True if all entries are valid, False otherwise
|
|
497
|
+
"""
|
|
498
|
+
if not isinstance(commands, list):
|
|
499
|
+
logger.warning(f"Config at {config_path}: '{field_name}' must be a list")
|
|
500
|
+
return False
|
|
501
|
+
for i, cmd in enumerate(commands):
|
|
502
|
+
if not isinstance(cmd, dict):
|
|
503
|
+
logger.warning(f"Config at {config_path}: {field_name}[{i}] must be a dict")
|
|
504
|
+
return False
|
|
505
|
+
if "name" not in cmd:
|
|
506
|
+
logger.warning(f"Config at {config_path}: {field_name}[{i}] missing 'name'")
|
|
507
|
+
return False
|
|
508
|
+
if not isinstance(cmd["name"], str) or cmd["name"].strip() == "":
|
|
509
|
+
logger.warning(f"Config at {config_path}: {field_name}[{i}] has invalid 'name'")
|
|
510
|
+
return False
|
|
511
|
+
return True
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
def _validate_pkill_processes(config: dict, config_path: Path) -> Optional[list[str]]:
|
|
515
|
+
"""
|
|
516
|
+
Validate and normalize pkill_processes from a YAML config.
|
|
517
|
+
|
|
518
|
+
Each entry must be a non-empty string matching VALID_PROCESS_NAME_PATTERN
|
|
519
|
+
(alphanumeric, dots, underscores, hyphens only -- no regex metacharacters).
|
|
520
|
+
Used by both load_org_config() and load_project_commands().
|
|
521
|
+
|
|
522
|
+
Args:
|
|
523
|
+
config: Parsed YAML config dict that may contain 'pkill_processes'
|
|
524
|
+
config_path: Path to the config file (for log messages)
|
|
525
|
+
|
|
526
|
+
Returns:
|
|
527
|
+
Normalized list of process names, or None if validation fails.
|
|
528
|
+
Returns an empty list if 'pkill_processes' is not present.
|
|
529
|
+
"""
|
|
530
|
+
if "pkill_processes" not in config:
|
|
531
|
+
return []
|
|
532
|
+
|
|
533
|
+
processes = config["pkill_processes"]
|
|
534
|
+
if not isinstance(processes, list):
|
|
535
|
+
logger.warning(f"Config at {config_path}: 'pkill_processes' must be a list")
|
|
536
|
+
return None
|
|
537
|
+
|
|
538
|
+
normalized = []
|
|
539
|
+
for i, proc in enumerate(processes):
|
|
540
|
+
if not isinstance(proc, str):
|
|
541
|
+
logger.warning(f"Config at {config_path}: pkill_processes[{i}] must be a string")
|
|
542
|
+
return None
|
|
543
|
+
proc = proc.strip()
|
|
544
|
+
if not proc or not VALID_PROCESS_NAME_PATTERN.fullmatch(proc):
|
|
545
|
+
logger.warning(f"Config at {config_path}: pkill_processes[{i}] has invalid value '{proc}'")
|
|
546
|
+
return None
|
|
547
|
+
normalized.append(proc)
|
|
548
|
+
return normalized
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
def get_org_config_path() -> Path:
|
|
552
|
+
"""
|
|
553
|
+
Get the organization-level config file path.
|
|
554
|
+
|
|
555
|
+
Returns:
|
|
556
|
+
Path to ~/.autoforge/config.yaml (falls back to ~/.autocoder/config.yaml)
|
|
557
|
+
"""
|
|
558
|
+
new_path = Path.home() / ".autoforge" / "config.yaml"
|
|
559
|
+
if new_path.exists():
|
|
560
|
+
return new_path
|
|
561
|
+
# Backward compatibility: check old location
|
|
562
|
+
old_path = Path.home() / ".autocoder" / "config.yaml"
|
|
563
|
+
if old_path.exists():
|
|
564
|
+
return old_path
|
|
565
|
+
return new_path
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
def load_org_config() -> Optional[dict]:
|
|
569
|
+
"""
|
|
570
|
+
Load organization-level config from ~/.autoforge/config.yaml.
|
|
571
|
+
|
|
572
|
+
Falls back to ~/.autocoder/config.yaml for backward compatibility.
|
|
573
|
+
|
|
574
|
+
Returns:
|
|
575
|
+
Dict with parsed org config, or None if file doesn't exist or is invalid
|
|
576
|
+
"""
|
|
577
|
+
config_path = get_org_config_path()
|
|
578
|
+
|
|
579
|
+
if not config_path.exists():
|
|
580
|
+
return None
|
|
581
|
+
|
|
582
|
+
try:
|
|
583
|
+
with open(config_path, "r", encoding="utf-8") as f:
|
|
584
|
+
config = yaml.safe_load(f)
|
|
585
|
+
|
|
586
|
+
if not config:
|
|
587
|
+
logger.warning(f"Org config at {config_path} is empty")
|
|
588
|
+
return None
|
|
589
|
+
|
|
590
|
+
# Validate structure
|
|
591
|
+
if not isinstance(config, dict):
|
|
592
|
+
logger.warning(f"Org config at {config_path} must be a YAML dictionary")
|
|
593
|
+
return None
|
|
594
|
+
|
|
595
|
+
if "version" not in config:
|
|
596
|
+
logger.warning(f"Org config at {config_path} missing required 'version' field")
|
|
597
|
+
return None
|
|
598
|
+
|
|
599
|
+
# Validate allowed_commands if present
|
|
600
|
+
if "allowed_commands" in config:
|
|
601
|
+
if not _validate_command_list(config["allowed_commands"], config_path, "allowed_commands"):
|
|
602
|
+
return None
|
|
603
|
+
|
|
604
|
+
# Validate blocked_commands if present
|
|
605
|
+
if "blocked_commands" in config:
|
|
606
|
+
blocked = config["blocked_commands"]
|
|
607
|
+
if not isinstance(blocked, list):
|
|
608
|
+
logger.warning(f"Org config at {config_path}: 'blocked_commands' must be a list")
|
|
609
|
+
return None
|
|
610
|
+
for i, cmd in enumerate(blocked):
|
|
611
|
+
if not isinstance(cmd, str):
|
|
612
|
+
logger.warning(f"Org config at {config_path}: blocked_commands[{i}] must be a string")
|
|
613
|
+
return None
|
|
614
|
+
|
|
615
|
+
# Validate pkill_processes if present
|
|
616
|
+
normalized = _validate_pkill_processes(config, config_path)
|
|
617
|
+
if normalized is None:
|
|
618
|
+
return None
|
|
619
|
+
if normalized:
|
|
620
|
+
config["pkill_processes"] = normalized
|
|
621
|
+
|
|
622
|
+
return config
|
|
623
|
+
|
|
624
|
+
except yaml.YAMLError as e:
|
|
625
|
+
logger.warning(f"Failed to parse org config at {config_path}: {e}")
|
|
626
|
+
return None
|
|
627
|
+
except (IOError, OSError) as e:
|
|
628
|
+
logger.warning(f"Failed to read org config at {config_path}: {e}")
|
|
629
|
+
return None
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
def load_project_commands(project_dir: Path) -> Optional[dict]:
|
|
633
|
+
"""
|
|
634
|
+
Load allowed commands from project-specific YAML config.
|
|
635
|
+
|
|
636
|
+
Args:
|
|
637
|
+
project_dir: Path to the project directory
|
|
638
|
+
|
|
639
|
+
Returns:
|
|
640
|
+
Dict with parsed YAML config, or None if file doesn't exist or is invalid
|
|
641
|
+
"""
|
|
642
|
+
# Check new location first, fall back to old for backward compatibility
|
|
643
|
+
config_path = project_dir.resolve() / ".autoforge" / "allowed_commands.yaml"
|
|
644
|
+
if not config_path.exists():
|
|
645
|
+
config_path = project_dir.resolve() / ".autocoder" / "allowed_commands.yaml"
|
|
646
|
+
|
|
647
|
+
if not config_path.exists():
|
|
648
|
+
return None
|
|
649
|
+
|
|
650
|
+
try:
|
|
651
|
+
with open(config_path, "r", encoding="utf-8") as f:
|
|
652
|
+
config = yaml.safe_load(f)
|
|
653
|
+
|
|
654
|
+
if not config:
|
|
655
|
+
logger.warning(f"Project config at {config_path} is empty")
|
|
656
|
+
return None
|
|
657
|
+
|
|
658
|
+
# Validate structure
|
|
659
|
+
if not isinstance(config, dict):
|
|
660
|
+
logger.warning(f"Project config at {config_path} must be a YAML dictionary")
|
|
661
|
+
return None
|
|
662
|
+
|
|
663
|
+
if "version" not in config:
|
|
664
|
+
logger.warning(f"Project config at {config_path} missing required 'version' field")
|
|
665
|
+
return None
|
|
666
|
+
|
|
667
|
+
commands = config.get("commands", [])
|
|
668
|
+
|
|
669
|
+
# Enforce 100 command limit
|
|
670
|
+
if isinstance(commands, list) and len(commands) > 100:
|
|
671
|
+
logger.warning(f"Project config at {config_path} exceeds 100 command limit ({len(commands)} commands)")
|
|
672
|
+
return None
|
|
673
|
+
|
|
674
|
+
# Validate each command entry using shared helper
|
|
675
|
+
if not _validate_command_list(commands, config_path, "commands"):
|
|
676
|
+
return None
|
|
677
|
+
|
|
678
|
+
# Validate pkill_processes if present
|
|
679
|
+
normalized = _validate_pkill_processes(config, config_path)
|
|
680
|
+
if normalized is None:
|
|
681
|
+
return None
|
|
682
|
+
if normalized:
|
|
683
|
+
config["pkill_processes"] = normalized
|
|
684
|
+
|
|
685
|
+
return config
|
|
686
|
+
|
|
687
|
+
except yaml.YAMLError as e:
|
|
688
|
+
logger.warning(f"Failed to parse project config at {config_path}: {e}")
|
|
689
|
+
return None
|
|
690
|
+
except (IOError, OSError) as e:
|
|
691
|
+
logger.warning(f"Failed to read project config at {config_path}: {e}")
|
|
692
|
+
return None
|
|
693
|
+
|
|
694
|
+
|
|
695
|
+
def validate_project_command(cmd_config: dict) -> tuple[bool, str]:
|
|
696
|
+
"""
|
|
697
|
+
Validate a single command entry from project config.
|
|
698
|
+
|
|
699
|
+
Checks that the command has a valid name and is not in any blocklist.
|
|
700
|
+
Called during hierarchy resolution to gate each project command before
|
|
701
|
+
it is added to the effective allowed set.
|
|
702
|
+
|
|
703
|
+
Args:
|
|
704
|
+
cmd_config: Dict with command configuration (name, description)
|
|
705
|
+
|
|
706
|
+
Returns:
|
|
707
|
+
Tuple of (is_valid, error_message)
|
|
708
|
+
"""
|
|
709
|
+
if not isinstance(cmd_config, dict):
|
|
710
|
+
return False, "Command must be a dict"
|
|
711
|
+
|
|
712
|
+
if "name" not in cmd_config:
|
|
713
|
+
return False, "Command must have 'name' field"
|
|
714
|
+
|
|
715
|
+
name = cmd_config["name"]
|
|
716
|
+
if not isinstance(name, str) or not name:
|
|
717
|
+
return False, "Command name must be a non-empty string"
|
|
718
|
+
|
|
719
|
+
# Reject bare wildcard - security measure to prevent matching all commands
|
|
720
|
+
if name == "*":
|
|
721
|
+
return False, "Bare wildcard '*' is not allowed (security risk: matches all commands)"
|
|
722
|
+
|
|
723
|
+
# Check if command is in the blocklist or dangerous commands
|
|
724
|
+
base_cmd = os.path.basename(name.rstrip("*"))
|
|
725
|
+
if base_cmd in BLOCKED_COMMANDS:
|
|
726
|
+
return False, f"Command '{name}' is in the blocklist and cannot be allowed"
|
|
727
|
+
if base_cmd in DANGEROUS_COMMANDS:
|
|
728
|
+
return False, f"Command '{name}' is in the blocklist and cannot be allowed"
|
|
729
|
+
|
|
730
|
+
# Description is optional
|
|
731
|
+
if "description" in cmd_config and not isinstance(cmd_config["description"], str):
|
|
732
|
+
return False, "Description must be a string"
|
|
733
|
+
|
|
734
|
+
return True, ""
|
|
735
|
+
|
|
736
|
+
|
|
737
|
+
def get_effective_commands(project_dir: Optional[Path]) -> tuple[set[str], set[str]]:
|
|
738
|
+
"""
|
|
739
|
+
Get effective allowed and blocked commands after hierarchy resolution.
|
|
740
|
+
|
|
741
|
+
Hierarchy (highest to lowest priority):
|
|
742
|
+
1. BLOCKED_COMMANDS (hardcoded) - always blocked
|
|
743
|
+
2. Org blocked_commands - cannot be unblocked
|
|
744
|
+
3. Org allowed_commands - adds to global
|
|
745
|
+
4. Project allowed_commands - adds to global + org
|
|
746
|
+
|
|
747
|
+
Args:
|
|
748
|
+
project_dir: Path to the project directory, or None
|
|
749
|
+
|
|
750
|
+
Returns:
|
|
751
|
+
Tuple of (allowed_commands, blocked_commands)
|
|
752
|
+
"""
|
|
753
|
+
# Start with global allowed commands
|
|
754
|
+
allowed = ALLOWED_COMMANDS.copy()
|
|
755
|
+
blocked = BLOCKED_COMMANDS.copy()
|
|
756
|
+
|
|
757
|
+
# Add dangerous commands to blocked (Phase 3 will add approval flow)
|
|
758
|
+
blocked |= DANGEROUS_COMMANDS
|
|
759
|
+
|
|
760
|
+
# Load org config and apply
|
|
761
|
+
org_config = load_org_config()
|
|
762
|
+
if org_config:
|
|
763
|
+
# Add org-level blocked commands (cannot be overridden)
|
|
764
|
+
org_blocked = org_config.get("blocked_commands", [])
|
|
765
|
+
blocked |= set(org_blocked)
|
|
766
|
+
|
|
767
|
+
# Add org-level allowed commands
|
|
768
|
+
for cmd_config in org_config.get("allowed_commands", []):
|
|
769
|
+
if isinstance(cmd_config, dict) and "name" in cmd_config:
|
|
770
|
+
allowed.add(cmd_config["name"])
|
|
771
|
+
|
|
772
|
+
# Load project config and apply
|
|
773
|
+
if project_dir:
|
|
774
|
+
project_config = load_project_commands(project_dir)
|
|
775
|
+
if project_config:
|
|
776
|
+
# Add project-specific commands
|
|
777
|
+
for cmd_config in project_config.get("commands", []):
|
|
778
|
+
valid, error = validate_project_command(cmd_config)
|
|
779
|
+
if valid:
|
|
780
|
+
allowed.add(cmd_config["name"])
|
|
781
|
+
|
|
782
|
+
# Remove blocked commands from allowed (blocklist takes precedence)
|
|
783
|
+
allowed -= blocked
|
|
784
|
+
|
|
785
|
+
return allowed, blocked
|
|
786
|
+
|
|
787
|
+
|
|
788
|
+
def get_project_allowed_commands(project_dir: Optional[Path]) -> set[str]:
|
|
789
|
+
"""
|
|
790
|
+
Get the set of allowed commands for a project.
|
|
791
|
+
|
|
792
|
+
Uses hierarchy resolution from get_effective_commands().
|
|
793
|
+
|
|
794
|
+
Args:
|
|
795
|
+
project_dir: Path to the project directory, or None
|
|
796
|
+
|
|
797
|
+
Returns:
|
|
798
|
+
Set of allowed command names (including patterns)
|
|
799
|
+
"""
|
|
800
|
+
allowed, blocked = get_effective_commands(project_dir)
|
|
801
|
+
return allowed
|
|
802
|
+
|
|
803
|
+
|
|
804
|
+
def get_effective_pkill_processes(project_dir: Optional[Path]) -> set[str]:
|
|
805
|
+
"""
|
|
806
|
+
Get effective pkill process names after hierarchy resolution.
|
|
807
|
+
|
|
808
|
+
Merges processes from:
|
|
809
|
+
1. DEFAULT_PKILL_PROCESSES (hardcoded baseline)
|
|
810
|
+
2. Org config pkill_processes
|
|
811
|
+
3. Project config pkill_processes
|
|
812
|
+
|
|
813
|
+
Args:
|
|
814
|
+
project_dir: Path to the project directory, or None
|
|
815
|
+
|
|
816
|
+
Returns:
|
|
817
|
+
Set of allowed process names for pkill
|
|
818
|
+
"""
|
|
819
|
+
# Start with default processes
|
|
820
|
+
processes = DEFAULT_PKILL_PROCESSES.copy()
|
|
821
|
+
|
|
822
|
+
# Add org-level pkill_processes
|
|
823
|
+
org_config = load_org_config()
|
|
824
|
+
if org_config:
|
|
825
|
+
org_processes = org_config.get("pkill_processes", [])
|
|
826
|
+
if isinstance(org_processes, list):
|
|
827
|
+
processes |= {p for p in org_processes if isinstance(p, str) and p.strip()}
|
|
828
|
+
|
|
829
|
+
# Add project-level pkill_processes
|
|
830
|
+
if project_dir:
|
|
831
|
+
project_config = load_project_commands(project_dir)
|
|
832
|
+
if project_config:
|
|
833
|
+
proj_processes = project_config.get("pkill_processes", [])
|
|
834
|
+
if isinstance(proj_processes, list):
|
|
835
|
+
processes |= {p for p in proj_processes if isinstance(p, str) and p.strip()}
|
|
836
|
+
|
|
837
|
+
return processes
|
|
838
|
+
|
|
839
|
+
|
|
840
|
+
def is_command_allowed(command: str, allowed_commands: set[str]) -> bool:
|
|
841
|
+
"""
|
|
842
|
+
Check if a command is allowed (supports patterns).
|
|
843
|
+
|
|
844
|
+
Args:
|
|
845
|
+
command: The command to check
|
|
846
|
+
allowed_commands: Set of allowed commands (may include patterns)
|
|
847
|
+
|
|
848
|
+
Returns:
|
|
849
|
+
True if command is allowed
|
|
850
|
+
"""
|
|
851
|
+
# Check exact match first
|
|
852
|
+
if command in allowed_commands:
|
|
853
|
+
return True
|
|
854
|
+
|
|
855
|
+
# Check pattern matches
|
|
856
|
+
for pattern in allowed_commands:
|
|
857
|
+
if matches_pattern(command, pattern):
|
|
858
|
+
return True
|
|
859
|
+
|
|
860
|
+
return False
|
|
861
|
+
|
|
862
|
+
|
|
863
|
+
async def bash_security_hook(input_data, tool_use_id=None, context=None):
|
|
864
|
+
"""
|
|
865
|
+
Pre-tool-use hook that validates bash commands using an allowlist.
|
|
866
|
+
|
|
867
|
+
Only commands in ALLOWED_COMMANDS and project-specific commands are permitted.
|
|
868
|
+
|
|
869
|
+
Args:
|
|
870
|
+
input_data: Dict containing tool_name and tool_input
|
|
871
|
+
tool_use_id: Optional tool use ID
|
|
872
|
+
context: Optional context dict with 'project_dir' key
|
|
873
|
+
|
|
874
|
+
Returns:
|
|
875
|
+
Empty dict to allow, or {"decision": "block", "reason": "..."} to block
|
|
876
|
+
"""
|
|
877
|
+
if input_data.get("tool_name") != "Bash":
|
|
878
|
+
return {}
|
|
879
|
+
|
|
880
|
+
command = input_data.get("tool_input", {}).get("command", "")
|
|
881
|
+
if not command:
|
|
882
|
+
return {}
|
|
883
|
+
|
|
884
|
+
# Extract all commands from the command string
|
|
885
|
+
commands = extract_commands(command)
|
|
886
|
+
|
|
887
|
+
if not commands:
|
|
888
|
+
# Could not parse - fail safe by blocking
|
|
889
|
+
return {
|
|
890
|
+
"decision": "block",
|
|
891
|
+
"reason": f"Could not parse command for security validation: {command}",
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
# Get project directory from context
|
|
895
|
+
project_dir = None
|
|
896
|
+
if context and isinstance(context, dict):
|
|
897
|
+
project_dir_str = context.get("project_dir")
|
|
898
|
+
if project_dir_str:
|
|
899
|
+
project_dir = Path(project_dir_str)
|
|
900
|
+
|
|
901
|
+
# Get effective commands using hierarchy resolution
|
|
902
|
+
allowed_commands, blocked_commands = get_effective_commands(project_dir)
|
|
903
|
+
|
|
904
|
+
# Get effective pkill processes (includes org/project config)
|
|
905
|
+
pkill_processes = get_effective_pkill_processes(project_dir)
|
|
906
|
+
|
|
907
|
+
# Split into segments for per-command validation
|
|
908
|
+
segments = split_command_segments(command)
|
|
909
|
+
|
|
910
|
+
# Check each command against the blocklist and allowlist
|
|
911
|
+
for cmd in commands:
|
|
912
|
+
# Check blocklist first (highest priority)
|
|
913
|
+
if cmd in blocked_commands:
|
|
914
|
+
return {
|
|
915
|
+
"decision": "block",
|
|
916
|
+
"reason": f"Command '{cmd}' is blocked at organization level and cannot be approved.",
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
# Check allowlist (with pattern matching)
|
|
920
|
+
if not is_command_allowed(cmd, allowed_commands):
|
|
921
|
+
# Provide helpful error message with config hint
|
|
922
|
+
error_msg = f"Command '{cmd}' is not allowed.\n"
|
|
923
|
+
error_msg += "To allow this command:\n"
|
|
924
|
+
error_msg += " 1. Add to .autoforge/allowed_commands.yaml for this project, OR\n"
|
|
925
|
+
error_msg += " 2. Request mid-session approval (the agent can ask)\n"
|
|
926
|
+
error_msg += "Note: Some commands are blocked at org-level and cannot be overridden."
|
|
927
|
+
return {
|
|
928
|
+
"decision": "block",
|
|
929
|
+
"reason": error_msg,
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
# Additional validation for sensitive commands
|
|
933
|
+
if cmd in COMMANDS_NEEDING_EXTRA_VALIDATION:
|
|
934
|
+
# Find the specific segment containing this command by searching
|
|
935
|
+
# each segment's extracted commands for a match
|
|
936
|
+
cmd_segment = ""
|
|
937
|
+
for segment in segments:
|
|
938
|
+
if cmd in extract_commands(segment):
|
|
939
|
+
cmd_segment = segment
|
|
940
|
+
break
|
|
941
|
+
if not cmd_segment:
|
|
942
|
+
cmd_segment = command # Fallback to full command
|
|
943
|
+
|
|
944
|
+
if cmd == "pkill":
|
|
945
|
+
# Pass configured extra processes (beyond defaults)
|
|
946
|
+
extra_procs = pkill_processes - DEFAULT_PKILL_PROCESSES
|
|
947
|
+
allowed, reason = validate_pkill_command(cmd_segment, extra_procs if extra_procs else None)
|
|
948
|
+
if not allowed:
|
|
949
|
+
return {"decision": "block", "reason": reason}
|
|
950
|
+
elif cmd == "chmod":
|
|
951
|
+
allowed, reason = validate_chmod_command(cmd_segment)
|
|
952
|
+
if not allowed:
|
|
953
|
+
return {"decision": "block", "reason": reason}
|
|
954
|
+
elif cmd == "init.sh":
|
|
955
|
+
allowed, reason = validate_init_script(cmd_segment)
|
|
956
|
+
if not allowed:
|
|
957
|
+
return {"decision": "block", "reason": reason}
|
|
958
|
+
|
|
959
|
+
return {}
|