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.
Files changed (84) hide show
  1. package/.claude/commands/check-code.md +32 -0
  2. package/.claude/commands/checkpoint.md +40 -0
  3. package/.claude/commands/create-spec.md +613 -0
  4. package/.claude/commands/expand-project.md +234 -0
  5. package/.claude/commands/gsd-to-autoforge-spec.md +10 -0
  6. package/.claude/commands/review-pr.md +75 -0
  7. package/.claude/templates/app_spec.template.txt +331 -0
  8. package/.claude/templates/coding_prompt.template.md +265 -0
  9. package/.claude/templates/initializer_prompt.template.md +354 -0
  10. package/.claude/templates/testing_prompt.template.md +146 -0
  11. package/.env.example +64 -0
  12. package/LICENSE.md +676 -0
  13. package/README.md +423 -0
  14. package/agent.py +444 -0
  15. package/api/__init__.py +10 -0
  16. package/api/database.py +536 -0
  17. package/api/dependency_resolver.py +449 -0
  18. package/api/migration.py +156 -0
  19. package/auth.py +83 -0
  20. package/autoforge_paths.py +315 -0
  21. package/autonomous_agent_demo.py +293 -0
  22. package/bin/autoforge.js +3 -0
  23. package/client.py +607 -0
  24. package/env_constants.py +27 -0
  25. package/examples/OPTIMIZE_CONFIG.md +230 -0
  26. package/examples/README.md +531 -0
  27. package/examples/org_config.yaml +172 -0
  28. package/examples/project_allowed_commands.yaml +139 -0
  29. package/lib/cli.js +791 -0
  30. package/mcp_server/__init__.py +1 -0
  31. package/mcp_server/feature_mcp.py +988 -0
  32. package/package.json +53 -0
  33. package/parallel_orchestrator.py +1800 -0
  34. package/progress.py +247 -0
  35. package/prompts.py +427 -0
  36. package/pyproject.toml +17 -0
  37. package/rate_limit_utils.py +132 -0
  38. package/registry.py +614 -0
  39. package/requirements-prod.txt +14 -0
  40. package/security.py +959 -0
  41. package/server/__init__.py +17 -0
  42. package/server/main.py +261 -0
  43. package/server/routers/__init__.py +32 -0
  44. package/server/routers/agent.py +177 -0
  45. package/server/routers/assistant_chat.py +327 -0
  46. package/server/routers/devserver.py +309 -0
  47. package/server/routers/expand_project.py +239 -0
  48. package/server/routers/features.py +746 -0
  49. package/server/routers/filesystem.py +514 -0
  50. package/server/routers/projects.py +524 -0
  51. package/server/routers/schedules.py +356 -0
  52. package/server/routers/settings.py +127 -0
  53. package/server/routers/spec_creation.py +357 -0
  54. package/server/routers/terminal.py +453 -0
  55. package/server/schemas.py +593 -0
  56. package/server/services/__init__.py +36 -0
  57. package/server/services/assistant_chat_session.py +496 -0
  58. package/server/services/assistant_database.py +304 -0
  59. package/server/services/chat_constants.py +57 -0
  60. package/server/services/dev_server_manager.py +557 -0
  61. package/server/services/expand_chat_session.py +399 -0
  62. package/server/services/process_manager.py +657 -0
  63. package/server/services/project_config.py +475 -0
  64. package/server/services/scheduler_service.py +683 -0
  65. package/server/services/spec_chat_session.py +502 -0
  66. package/server/services/terminal_manager.py +756 -0
  67. package/server/utils/__init__.py +1 -0
  68. package/server/utils/process_utils.py +134 -0
  69. package/server/utils/project_helpers.py +32 -0
  70. package/server/utils/validation.py +54 -0
  71. package/server/websocket.py +903 -0
  72. package/start.py +456 -0
  73. package/ui/dist/assets/index-8W_wmZzz.js +168 -0
  74. package/ui/dist/assets/index-B47Ubhox.css +1 -0
  75. package/ui/dist/assets/vendor-flow-CVNK-_lx.js +7 -0
  76. package/ui/dist/assets/vendor-query-BUABzP5o.js +1 -0
  77. package/ui/dist/assets/vendor-radix-DTNNCg2d.js +45 -0
  78. package/ui/dist/assets/vendor-react-qkC6yhPU.js +1 -0
  79. package/ui/dist/assets/vendor-utils-COeKbHgx.js +2 -0
  80. package/ui/dist/assets/vendor-xterm-DP_gxef0.js +16 -0
  81. package/ui/dist/index.html +23 -0
  82. package/ui/dist/ollama.png +0 -0
  83. package/ui/dist/vite.svg +6 -0
  84. 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 {}