devcouncil 0.1.0 → 0.2.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 (190) hide show
  1. package/LICENSE +201 -201
  2. package/README.md +197 -494
  3. package/package.json +9 -2
  4. package/pyproject.toml +62 -27
  5. package/src/devcouncil/__main__.py +4 -4
  6. package/src/devcouncil/app/__init__.py +28 -28
  7. package/src/devcouncil/app/config.py +297 -108
  8. package/src/devcouncil/app/errors.py +23 -23
  9. package/src/devcouncil/app/events.py +44 -44
  10. package/src/devcouncil/app/orchestrator.py +67 -67
  11. package/src/devcouncil/app/project_status.py +29 -0
  12. package/src/devcouncil/app/run_context.py +39 -39
  13. package/src/devcouncil/app/state_machine.py +108 -108
  14. package/src/devcouncil/artifacts/__init__.py +1 -1
  15. package/src/devcouncil/artifacts/coverage.py +96 -96
  16. package/src/devcouncil/artifacts/graph.py +163 -143
  17. package/src/devcouncil/artifacts/migrations.py +20 -20
  18. package/src/devcouncil/artifacts/schemas.py +23 -23
  19. package/src/devcouncil/artifacts/serializer.py +21 -21
  20. package/src/devcouncil/artifacts/validators.py +27 -27
  21. package/src/devcouncil/assets/__init__.py +1 -0
  22. package/src/devcouncil/assets/devcouncil-logo.svg +60 -0
  23. package/src/devcouncil/assets/devcouncil_logo_premium.png +0 -0
  24. package/src/devcouncil/cli/commands/agents.py +292 -0
  25. package/src/devcouncil/cli/commands/artifacts.py +54 -48
  26. package/src/devcouncil/cli/commands/ast.py +22 -0
  27. package/src/devcouncil/cli/commands/baseline.py +35 -32
  28. package/src/devcouncil/cli/commands/check.py +209 -0
  29. package/src/devcouncil/cli/commands/config.py +115 -54
  30. package/src/devcouncil/cli/commands/cost.py +57 -0
  31. package/src/devcouncil/cli/commands/dashboard.py +31 -0
  32. package/src/devcouncil/cli/commands/doctor.py +291 -47
  33. package/src/devcouncil/cli/commands/evidence.py +48 -0
  34. package/src/devcouncil/cli/commands/go.py +656 -0
  35. package/src/devcouncil/cli/commands/handoff.py +69 -0
  36. package/src/devcouncil/cli/commands/hook.py +209 -33
  37. package/src/devcouncil/cli/commands/init.py +204 -57
  38. package/src/devcouncil/cli/commands/integrate.py +1171 -76
  39. package/src/devcouncil/cli/commands/lsp.py +20 -0
  40. package/src/devcouncil/cli/commands/map.py +96 -22
  41. package/src/devcouncil/cli/commands/plan.py +422 -210
  42. package/src/devcouncil/cli/commands/prompt.py +48 -34
  43. package/src/devcouncil/cli/commands/repair.py +89 -69
  44. package/src/devcouncil/cli/commands/report.py +120 -54
  45. package/src/devcouncil/cli/commands/reset_demo_state.py +33 -28
  46. package/src/devcouncil/cli/commands/rollback.py +55 -54
  47. package/src/devcouncil/cli/commands/run.py +285 -220
  48. package/src/devcouncil/cli/commands/runs.py +223 -0
  49. package/src/devcouncil/cli/commands/scaffold.py +32 -0
  50. package/src/devcouncil/cli/commands/semantic.py +47 -0
  51. package/src/devcouncil/cli/commands/setup.py +300 -20
  52. package/src/devcouncil/cli/commands/shell.py +73 -0
  53. package/src/devcouncil/cli/commands/show.py +76 -57
  54. package/src/devcouncil/cli/commands/skills.py +88 -0
  55. package/src/devcouncil/cli/commands/status.py +141 -105
  56. package/src/devcouncil/cli/commands/tasks.py +55 -41
  57. package/src/devcouncil/cli/commands/trace.py +49 -4
  58. package/src/devcouncil/cli/commands/verify.py +293 -128
  59. package/src/devcouncil/cli/commands/version.py +20 -20
  60. package/src/devcouncil/cli/commands/watch.py +574 -0
  61. package/src/devcouncil/cli/commands/watch_fs.py +40 -0
  62. package/src/devcouncil/cli/main.py +92 -25
  63. package/src/devcouncil/council/prompts/arbiter.md +19 -19
  64. package/src/devcouncil/council/prompts/critic_a.md +10 -10
  65. package/src/devcouncil/council/prompts/critic_b.md +10 -10
  66. package/src/devcouncil/council/prompts/implementation_reviewer.md +16 -16
  67. package/src/devcouncil/council/prompts/planner_a.md +16 -16
  68. package/src/devcouncil/council/prompts/planner_b.md +16 -16
  69. package/src/devcouncil/council/prompts/rebuttal.md +10 -10
  70. package/src/devcouncil/council/prompts/spec_writer.md +12 -12
  71. package/src/devcouncil/domain/assumption.py +17 -17
  72. package/src/devcouncil/domain/critique.py +32 -32
  73. package/src/devcouncil/domain/evidence.py +47 -27
  74. package/src/devcouncil/domain/gap.py +52 -26
  75. package/src/devcouncil/domain/requirement.py +22 -22
  76. package/src/devcouncil/domain/task.py +55 -26
  77. package/src/devcouncil/execution/__init__.py +1 -1
  78. package/src/devcouncil/execution/checkpoints.py +246 -0
  79. package/src/devcouncil/execution/context_builder.py +54 -54
  80. package/src/devcouncil/execution/executor.py +15 -15
  81. package/src/devcouncil/execution/fs_watcher.py +180 -0
  82. package/src/devcouncil/execution/handoff.py +102 -0
  83. package/src/devcouncil/execution/hook_policy.py +186 -77
  84. package/src/devcouncil/execution/patch.py +77 -28
  85. package/src/devcouncil/execution/permissions.py +52 -59
  86. package/src/devcouncil/execution/policy_engine.py +343 -0
  87. package/src/devcouncil/execution/prompt_builder.py +650 -38
  88. package/src/devcouncil/execution/shell_session.py +225 -0
  89. package/src/devcouncil/execution/task_runner.py +68 -64
  90. package/src/devcouncil/executors/__init__.py +1 -1
  91. package/src/devcouncil/executors/agent_registry.py +575 -0
  92. package/src/devcouncil/executors/coding_cli.py +736 -0
  93. package/src/devcouncil/executors/mini_swe.py +63 -63
  94. package/src/devcouncil/executors/native/agent.py +186 -85
  95. package/src/devcouncil/executors/openhands.py +56 -56
  96. package/src/devcouncil/gating/__init__.py +1 -1
  97. package/src/devcouncil/gating/checks/clean_git.py +52 -45
  98. package/src/devcouncil/gating/checks/planned_files_check.py +32 -32
  99. package/src/devcouncil/gating/checks/requirement_coverage.py +26 -26
  100. package/src/devcouncil/gating/checks/secret_scan_check.py +53 -34
  101. package/src/devcouncil/gating/policy.py +315 -167
  102. package/src/devcouncil/hardware.py +184 -0
  103. package/src/devcouncil/indexing/__init__.py +1 -1
  104. package/src/devcouncil/indexing/ast_matcher.py +168 -0
  105. package/src/devcouncil/indexing/graph_index.py +48 -48
  106. package/src/devcouncil/indexing/lsp.py +161 -0
  107. package/src/devcouncil/indexing/repo_mapper.py +1455 -204
  108. package/src/devcouncil/indexing/semantic_index.py +205 -0
  109. package/src/devcouncil/integrations/actions.py +146 -0
  110. package/src/devcouncil/integrations/check.py +423 -0
  111. package/src/devcouncil/integrations/github.py +35 -35
  112. package/src/devcouncil/integrations/github_intent.py +142 -0
  113. package/src/devcouncil/integrations/gitnexus.py +62 -27
  114. package/src/devcouncil/integrations/graphify.py +34 -34
  115. package/src/devcouncil/integrations/mcp/server.py +2072 -96
  116. package/src/devcouncil/integrations/opencode_devcouncil_plugin.mjs +24 -0
  117. package/src/devcouncil/integrations/pr_comments.py +62 -0
  118. package/src/devcouncil/live/__init__.py +2 -0
  119. package/src/devcouncil/live/cards.py +349 -0
  120. package/src/devcouncil/live/models.py +63 -0
  121. package/src/devcouncil/live/repair_prompt.py +83 -0
  122. package/src/devcouncil/live/reviewer.py +70 -0
  123. package/src/devcouncil/live/signals.py +135 -0
  124. package/src/devcouncil/live/summary.py +34 -0
  125. package/src/devcouncil/live/tasks.py +18 -0
  126. package/src/devcouncil/live/transcripts.py +141 -0
  127. package/src/devcouncil/llm/__init__.py +1 -1
  128. package/src/devcouncil/llm/cache.py +42 -38
  129. package/src/devcouncil/llm/model_defaults.yaml +44 -0
  130. package/src/devcouncil/llm/provider.py +627 -125
  131. package/src/devcouncil/llm/router.py +303 -118
  132. package/src/devcouncil/optimization/__init__.py +1 -0
  133. package/src/devcouncil/optimization/gepa_agent.py +318 -0
  134. package/src/devcouncil/planning/__init__.py +1 -1
  135. package/src/devcouncil/planning/arbiter_service.py +57 -57
  136. package/src/devcouncil/planning/correction_manifest.py +303 -0
  137. package/src/devcouncil/planning/critique_service.py +71 -66
  138. package/src/devcouncil/planning/plan_service.py +60 -46
  139. package/src/devcouncil/planning/prompt_enhancer_service.py +167 -0
  140. package/src/devcouncil/planning/repair_service.py +39 -39
  141. package/src/devcouncil/planning/spec_service.py +70 -44
  142. package/src/devcouncil/repo/ci_scaffold.py +157 -0
  143. package/src/devcouncil/repo/gitignore.py +123 -0
  144. package/src/devcouncil/repo/sca.py +374 -0
  145. package/src/devcouncil/reporting/github_check.py +32 -32
  146. package/src/devcouncil/reporting/json_report.py +30 -17
  147. package/src/devcouncil/reporting/markdown_report.py +83 -46
  148. package/src/devcouncil/reporting/report_builder.py +14 -14
  149. package/src/devcouncil/skills/__init__.py +19 -0
  150. package/src/devcouncil/skills/library/README.md +46 -0
  151. package/src/devcouncil/skills/library/ai-training.md +50 -0
  152. package/src/devcouncil/skills/library/android.md +50 -0
  153. package/src/devcouncil/skills/library/backend.md +52 -0
  154. package/src/devcouncil/skills/library/core-engineering.md +95 -0
  155. package/src/devcouncil/skills/library/data-engineering.md +47 -0
  156. package/src/devcouncil/skills/library/desktop.md +46 -0
  157. package/src/devcouncil/skills/library/devops.md +48 -0
  158. package/src/devcouncil/skills/library/game-dev.md +46 -0
  159. package/src/devcouncil/skills/library/ios.md +48 -0
  160. package/src/devcouncil/skills/library/mobile-cross-platform.md +46 -0
  161. package/src/devcouncil/skills/library/security.md +48 -0
  162. package/src/devcouncil/skills/library/systems.md +48 -0
  163. package/src/devcouncil/skills/library/web.md +47 -0
  164. package/src/devcouncil/skills/library/windows.md +47 -0
  165. package/src/devcouncil/skills/registry.py +330 -0
  166. package/src/devcouncil/storage/db.py +147 -66
  167. package/src/devcouncil/storage/models.py +204 -83
  168. package/src/devcouncil/storage/native.py +557 -0
  169. package/src/devcouncil/storage/repositories.py +388 -249
  170. package/src/devcouncil/telemetry/cost.py +140 -34
  171. package/src/devcouncil/telemetry/model_pricing.yaml +48 -0
  172. package/src/devcouncil/telemetry/pricing.py +28 -0
  173. package/src/devcouncil/telemetry/traces.py +62 -7
  174. package/src/devcouncil/telemetry/tracker.py +52 -49
  175. package/src/devcouncil/ui/__init__.py +1 -0
  176. package/src/devcouncil/ui/dashboard.py +423 -0
  177. package/src/devcouncil/utils/__init__.py +1 -1
  178. package/src/devcouncil/utils/redaction.py +147 -141
  179. package/src/devcouncil/utils/subprocess_env.py +69 -0
  180. package/src/devcouncil/verification/__init__.py +1 -1
  181. package/src/devcouncil/verification/acceptance_compiler.py +125 -0
  182. package/src/devcouncil/verification/ad_hoc_check.py +129 -0
  183. package/src/devcouncil/verification/diff_coverage.py +353 -0
  184. package/src/devcouncil/verification/implementation_reviewer.py +55 -55
  185. package/src/devcouncil/verification/next_actions.py +189 -0
  186. package/src/devcouncil/verification/sandbox.py +178 -0
  187. package/src/devcouncil/verification/test_resolver.py +91 -0
  188. package/src/devcouncil/verification/verifier.py +1342 -307
  189. package/uv.lock +205 -64
  190. package/src/devcouncil/indexing/symbol_index.py +0 -0
@@ -1,28 +1,77 @@
1
- import subprocess
2
- from pathlib import Path
3
- from devcouncil.app.errors import ExecutionError
4
-
5
- class PatchEngine:
6
- """Handles applying unified diff patches to the codebase."""
7
-
8
- def __init__(self, project_root: Path):
9
- self.project_root = project_root
10
-
11
- def apply_patch(self, patch_content: str) -> bool:
12
- """Applies a git-style patch to the repository."""
13
- patch_file = self.project_root / ".devcouncil" / "temp.patch"
14
- patch_file.parent.mkdir(parents=True, exist_ok=True)
15
- patch_file.write_text(patch_content, encoding="utf-8")
16
-
17
- try:
18
- # Using git apply for robust patch application
19
- subprocess.check_call(
20
- ["git", "apply", "--ignore-whitespace", str(patch_file)],
21
- cwd=self.project_root
22
- )
23
- return True
24
- except subprocess.CalledProcessError as e:
25
- raise ExecutionError(f"Failed to apply patch: {e}")
26
- finally:
27
- if patch_file.exists():
28
- patch_file.unlink()
1
+ import re
2
+ import subprocess
3
+ from pathlib import Path
4
+ from devcouncil.app.errors import ExecutionError
5
+
6
+
7
+ class PatchEngine:
8
+ """Handles applying unified diff patches to the codebase."""
9
+
10
+ # Progressively more tolerant `git apply` invocations. The first that succeeds
11
+ # wins; we only escalate tolerance (whitespace, 3-way merge) rather than ever
12
+ # falling back to `--reject` (which would leave a half-applied tree + .rej files).
13
+ _APPLY_LADDER: tuple[tuple[str, ...], ...] = (
14
+ (), # strict: exact context match
15
+ ("--ignore-whitespace",), # tolerate whitespace-only context drift
16
+ ("--3way",), # reconstruct via blob ancestry when context moved
17
+ ("--3way", "--ignore-whitespace"), # both
18
+ )
19
+
20
+ def __init__(self, project_root: Path):
21
+ self.project_root = project_root
22
+
23
+ def _validate_paths(self, patch_content: str) -> None:
24
+ """Defense-in-depth: reject a diff that touches anything outside the repo root.
25
+
26
+ Callers that go through ``TaskRunner.apply_patch`` already gate against the
27
+ task's planned files, but the engine is directly callable, so it validates the
28
+ coarse containment boundary (within ``project_root``) itself."""
29
+ root = self.project_root.resolve()
30
+ for match in re.finditer(r"^(?:\+\+\+|---)\s+(?:[ab]/)?(\S+)", patch_content, re.MULTILINE):
31
+ raw = match.group(1)
32
+ if raw == "/dev/null": # new/deleted file sentinel
33
+ continue
34
+ try:
35
+ resolved = (root / raw).resolve()
36
+ except (OSError, ValueError) as exc:
37
+ raise ExecutionError(f"Patch references an invalid path: {raw!r} ({exc})")
38
+ if resolved != root and root not in resolved.parents:
39
+ raise ExecutionError(
40
+ f"Patch attempts to modify a path outside the project root: {raw!r}."
41
+ )
42
+
43
+ def apply_patch(self, patch_content: str) -> bool:
44
+ """Applies a git-style patch to the repository.
45
+
46
+ Tries an escalating ladder of ``git apply`` tolerances so a patch whose context
47
+ drifted slightly (common when an agent edits the file after producing the diff)
48
+ still applies cleanly instead of hard-failing on the strict first pass. If every
49
+ rung fails, the error names the failing hunks from git's own report rather than a
50
+ bare exit code."""
51
+ self._validate_paths(patch_content)
52
+
53
+ patch_file = self.project_root / ".devcouncil" / "temp.patch"
54
+ patch_file.parent.mkdir(parents=True, exist_ok=True)
55
+ patch_file.write_text(patch_content, encoding="utf-8")
56
+
57
+ last_stderr = ""
58
+ try:
59
+ for extra_args in self._APPLY_LADDER:
60
+ proc = subprocess.run(
61
+ ["git", "apply", *extra_args, str(patch_file)],
62
+ cwd=self.project_root,
63
+ capture_output=True,
64
+ text=True,
65
+ encoding="utf-8",
66
+ errors="replace",
67
+ )
68
+ if proc.returncode == 0:
69
+ return True
70
+ last_stderr = (proc.stderr or "").strip()
71
+ raise ExecutionError(
72
+ "Failed to apply patch even with whitespace/3-way fallbacks. "
73
+ f"git reported: {last_stderr or '(no detail)'}"
74
+ )
75
+ finally:
76
+ if patch_file.exists():
77
+ patch_file.unlink()
@@ -1,57 +1,55 @@
1
- import fnmatch
2
- from pathlib import Path
1
+ import fnmatch
2
+ from pathlib import Path
3
3
  from typing import List, Literal, Optional
4
- from pydantic import BaseModel, Field
4
+ from pydantic import BaseModel, Field
5
5
  from devcouncil.domain.task import PlannedFile, Task
6
- from devcouncil.app.errors import GatingError
7
-
8
- class PermissionPolicy(BaseModel):
9
- """Defines the security boundaries for task execution."""
10
- allow_file_create: bool = False
11
- allow_file_delete: bool = False
12
- allowed_shell_commands: List[str] = Field(default_factory=list)
13
- restricted_paths: List[str] = Field(default_factory=lambda: [".git/*", ".devcouncil/*", ".env*"])
14
-
6
+ from devcouncil.app.errors import GatingError
7
+ from devcouncil.execution.policy_engine import TaskPolicyEngine
8
+
9
+ class PermissionPolicy(BaseModel):
10
+ """Defines the security boundaries for task execution."""
11
+ allow_file_create: bool = False
12
+ allow_file_delete: bool = False
13
+ allowed_shell_commands: List[str] = Field(default_factory=list)
14
+ restricted_paths: List[str] = Field(default_factory=lambda: [".git/*", ".devcouncil/*", ".env*"])
15
+
15
16
  class PermissionManager:
16
- def __init__(self, policy: PermissionPolicy, project_root: Path = Path(".")):
17
- self.policy = policy
18
- self.project_root = project_root
19
- self.dynamic_ignores = self._load_devcouncilignore()
20
-
21
- def _load_devcouncilignore(self) -> List[str]:
22
- """Load additional restricted paths from .devcouncilignore."""
23
- ignore_file = self.project_root / ".devcouncilignore"
24
- if ignore_file.exists():
25
- try:
26
- lines = ignore_file.read_text().splitlines()
27
- return [line.strip() for line in lines if line.strip() and not line.startswith("#")]
28
- except Exception:
29
- pass
30
- return []
31
-
17
+ def __init__(self, policy: PermissionPolicy, project_root: Path = Path(".")):
18
+ self.policy = policy
19
+ self.project_root = project_root
20
+ self.dynamic_ignores = self._load_devcouncilignore()
21
+ self.policy_engine = TaskPolicyEngine(
22
+ project_root,
23
+ global_allowed_commands=policy.allowed_shell_commands,
24
+ )
25
+
26
+ def _load_devcouncilignore(self) -> List[str]:
27
+ """Load additional restricted paths from .devcouncilignore."""
28
+ ignore_file = self.project_root / ".devcouncilignore"
29
+ if ignore_file.exists():
30
+ try:
31
+ lines = ignore_file.read_text().splitlines()
32
+ return [line.strip() for line in lines if line.strip() and not line.startswith("#")]
33
+ except Exception:
34
+ pass
35
+ return []
36
+
32
37
  def is_file_change_allowed(
33
38
  self,
34
39
  path: str,
35
40
  task: Task,
36
41
  operation: Literal["create", "modify", "delete", "write"] = "write",
42
+ *,
43
+ internal: bool = False,
37
44
  ) -> bool:
38
45
  """Check if a file change is authorized by the task or policy."""
39
- # 1. Check restricted paths (e.g. .git) and dynamic ignores
40
- all_restricted = self.policy.restricted_paths + self.dynamic_ignores
41
- for restricted in all_restricted:
46
+ for restricted in self.dynamic_ignores:
42
47
  if fnmatch.fnmatch(path, restricted) or path.startswith(restricted.strip("*")):
43
48
  return False
44
-
45
- # 2. Check if path is in task's planned files with a compatible operation.
46
- planned = self._planned_file_for(path, task)
47
- if not planned:
48
- return False
49
-
50
- if planned.allowed_change == "read_only":
51
- return False
52
- if operation == "write":
53
- return planned.allowed_change in {"create", "modify"}
54
- return planned.allowed_change == operation
49
+ decision = self.policy_engine.evaluate_file_change(
50
+ path, task, operation, internal=internal
51
+ )
52
+ return decision.action in {"allow", "warn"}
55
53
 
56
54
  def _planned_file_for(self, path: str, task: Task) -> Optional[PlannedFile]:
57
55
  normalized = path.replace("\\", "/")
@@ -60,33 +58,28 @@ class PermissionManager:
60
58
  if normalized == planned_path or fnmatch.fnmatch(normalized, planned_path):
61
59
  return planned
62
60
  return None
63
-
64
- def is_command_allowed(self, command: str, task: Task) -> bool:
65
- """Check if a shell command is authorized by the task or global allowlist."""
66
- # 1. Check task-specific allowlist
67
- if any(fnmatch.fnmatch(command, allowed) for allowed in task.allowed_commands):
68
- return True
69
-
70
- # 2. Check global policy allowlist
71
- if any(fnmatch.fnmatch(command, allowed) for allowed in self.policy.allowed_shell_commands):
72
- return True
73
-
74
- return False
75
-
61
+
62
+ def is_command_allowed(self, command: str, task: Task) -> bool:
63
+ """Check if a shell command is authorized by the task or global allowlist."""
64
+ decision = self.policy_engine.evaluate_command(command, task)
65
+ return decision.action == "allow"
66
+
76
67
  def validate_action(
77
68
  self,
78
69
  action_type: str,
79
70
  target: str,
80
71
  task: Task,
81
72
  operation: Literal["create", "modify", "delete", "write"] = "write",
73
+ *,
74
+ internal: bool = False,
82
75
  ):
83
76
  """Raise GatingError if an execution action violates permissions."""
84
77
  if action_type == "file_write":
85
- if not self.is_file_change_allowed(target, task, operation):
78
+ if not self.is_file_change_allowed(target, task, operation, internal=internal):
86
79
  raise GatingError(
87
80
  f"Unauthorized file {operation}: {target}. "
88
81
  "File and operation must match task planned_files."
89
82
  )
90
- elif action_type == "shell":
91
- if not self.is_command_allowed(target, task):
92
- raise GatingError(f"Unauthorized shell command: {target}. Command must be in task's allowed_commands.")
83
+ elif action_type == "shell":
84
+ if not self.is_command_allowed(target, task):
85
+ raise GatingError(f"Unauthorized shell command: {target}. Command must be in task's allowed_commands.")
@@ -0,0 +1,343 @@
1
+ """Shared task policy engine for shell commands and file changes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import fnmatch
6
+ import re
7
+ from pathlib import Path
8
+ from typing import Literal
9
+
10
+ from pydantic import BaseModel
11
+
12
+ from devcouncil.domain.task import PlannedFile, Task
13
+
14
+
15
+ class PolicyDecision(BaseModel):
16
+ action: Literal["allow", "warn", "deny"]
17
+ reason: str
18
+ target: str
19
+ task_id: str | None = None
20
+
21
+
22
+ def normalize_repo_path(project_root: Path, raw_path: str) -> tuple[str, bool]:
23
+ """Resolve ``raw_path`` against ``project_root`` and report containment.
24
+
25
+ Returns ``(normalized_posix_path, is_outside_root)``. This is the single source of
26
+ truth used by both the task policy engine and the coding-CLI hook policy, so a path
27
+ is normalized identically wherever it is checked — closing the bypass where a path
28
+ that failed to resolve was returned raw and enforced differently than it was
29
+ checked. ``is_outside_root`` is True for anything that escapes the project (absolute
30
+ elsewhere, ``..`` traversal) or cannot be resolved, so callers fail closed."""
31
+ cleaned = raw_path.strip().strip('"').replace("\\", "/")
32
+ root = project_root.resolve()
33
+ try:
34
+ candidate = Path(cleaned)
35
+ resolved = candidate.resolve() if candidate.is_absolute() else (root / cleaned).resolve()
36
+ except OSError:
37
+ fallback = cleaned[2:] if cleaned.startswith("./") else cleaned
38
+ return fallback, True
39
+ try:
40
+ return resolved.relative_to(root).as_posix(), False
41
+ except ValueError:
42
+ return resolved.as_posix(), True
43
+
44
+
45
+ _NO_TASK_ALLOWED_COMMANDS = (
46
+ "dev status",
47
+ "dev tasks",
48
+ "git status",
49
+ "git diff",
50
+ "git diff *",
51
+ )
52
+
53
+ # Shared with HookPolicy — keep these as the single source of truth for
54
+ # protected/secret path patterns.
55
+ PROTECTED_WRITE_PATTERNS = (
56
+ "package.json",
57
+ "pyproject.toml",
58
+ "uv.lock",
59
+ "package-lock.json",
60
+ "yarn.lock",
61
+ "pnpm-lock.yaml",
62
+ "Dockerfile",
63
+ "docker-compose.yml",
64
+ ".github/workflows/*.yml",
65
+ ".github/workflows/*.yaml",
66
+ "schema.prisma",
67
+ "wrangler.toml",
68
+ "index.html",
69
+ )
70
+
71
+ SECRET_PATH_PATTERNS = (
72
+ ".env",
73
+ ".env.*",
74
+ "**/.env",
75
+ "**/.env.*",
76
+ "**/credentials/**",
77
+ "**/secrets/**",
78
+ "**/*.pem",
79
+ "**/*.key",
80
+ # Private SSH keys and well-known credential/token files. These are never
81
+ # writable through the gate so an agent cannot plant or overwrite credentials.
82
+ "**/id_rsa",
83
+ "**/id_dsa",
84
+ "**/id_ecdsa",
85
+ "**/id_ed25519",
86
+ "id_rsa",
87
+ "id_ed25519",
88
+ ".npmrc",
89
+ "**/.npmrc",
90
+ ".pypirc",
91
+ "**/.pypirc",
92
+ ".netrc",
93
+ "**/.netrc",
94
+ "**/.aws/credentials",
95
+ ".aws/credentials",
96
+ "**/*.pfx",
97
+ "**/*.p12",
98
+ "*.pfx",
99
+ "*.p12",
100
+ ".git-credentials",
101
+ "**/.git-credentials",
102
+ "**/kube/config",
103
+ "**/.kube/config",
104
+ )
105
+
106
+ _RESTRICTED_PATH_PATTERNS = (
107
+ ".git/*",
108
+ ".devcouncil/*",
109
+ # Protect the client hook/agent configs themselves: an agent must not be able to
110
+ # disarm or rewire the pre-action gate by editing its own client integration files.
111
+ ".claude/*",
112
+ ".claude/**",
113
+ ".codex/*",
114
+ ".codex/**",
115
+ ".cursor/*",
116
+ ".cursor/**",
117
+ ".gemini/*",
118
+ ".gemini/**",
119
+ ".opencode/*",
120
+ ".opencode/**",
121
+ ".agents/*",
122
+ ".agents/**",
123
+ "opencode.json",
124
+ )
125
+
126
+
127
+ class TaskPolicyEngine:
128
+ def __init__(
129
+ self,
130
+ project_root: Path,
131
+ global_allowed_commands: list[str] | None = None,
132
+ ):
133
+ self.project_root = project_root.resolve()
134
+ self.global_allowed_commands = global_allowed_commands or []
135
+
136
+ def evaluate_command(self, command: str, task: Task | None) -> PolicyDecision:
137
+ normalized = " ".join(command.split())
138
+ if not normalized:
139
+ return PolicyDecision(
140
+ action="deny",
141
+ reason="Empty command is not allowed.",
142
+ target=command,
143
+ task_id=task.id if task else None,
144
+ )
145
+
146
+ if task is None:
147
+ if any(fnmatch.fnmatch(normalized, allowed) for allowed in _NO_TASK_ALLOWED_COMMANDS):
148
+ return PolicyDecision(
149
+ action="allow",
150
+ reason="Read-only command allowed without active task.",
151
+ target=normalized,
152
+ )
153
+ return PolicyDecision(
154
+ action="deny",
155
+ reason="Shell commands require an active task lease.",
156
+ target=normalized,
157
+ )
158
+
159
+ if any(fnmatch.fnmatch(normalized, allowed) for allowed in task.allowed_commands):
160
+ return PolicyDecision(
161
+ action="allow",
162
+ reason="Command matches task allowed_commands.",
163
+ target=normalized,
164
+ task_id=task.id,
165
+ )
166
+ if any(fnmatch.fnmatch(normalized, allowed) for allowed in self.global_allowed_commands):
167
+ return PolicyDecision(
168
+ action="allow",
169
+ reason="Command matches global allowed commands.",
170
+ target=normalized,
171
+ task_id=task.id,
172
+ )
173
+ return PolicyDecision(
174
+ action="deny",
175
+ reason="Command is not in task or global allowlists.",
176
+ target=normalized,
177
+ task_id=task.id,
178
+ )
179
+
180
+ def evaluate_file_change(
181
+ self,
182
+ path: str,
183
+ task: Task | None,
184
+ operation: Literal["create", "modify", "delete", "write"] = "write",
185
+ *,
186
+ internal: bool = False,
187
+ ) -> PolicyDecision:
188
+ normalized, outside_root = normalize_repo_path(self.project_root, path)
189
+ task_id = task.id if task else None
190
+
191
+ if outside_root:
192
+ return PolicyDecision(
193
+ action="deny",
194
+ reason="Path is outside the project root.",
195
+ target=normalized,
196
+ task_id=task_id,
197
+ )
198
+
199
+ if self._matches_any(normalized, SECRET_PATH_PATTERNS):
200
+ return PolicyDecision(
201
+ action="deny",
202
+ reason="Secret and credential paths are never writable.",
203
+ target=normalized,
204
+ task_id=task_id,
205
+ )
206
+
207
+ if not internal and self._matches_restricted(normalized):
208
+ return PolicyDecision(
209
+ action="deny",
210
+ reason="Protected repository paths cannot be modified.",
211
+ target=normalized,
212
+ task_id=task_id,
213
+ )
214
+
215
+ if task is None:
216
+ return PolicyDecision(
217
+ action="deny",
218
+ reason="No running DevCouncil task authorizes this file write.",
219
+ target=normalized,
220
+ )
221
+
222
+ if self._matches_forbidden(normalized, task):
223
+ return PolicyDecision(
224
+ action="deny",
225
+ reason="Path is listed in forbidden_changes.",
226
+ target=normalized,
227
+ task_id=task.id,
228
+ )
229
+
230
+ planned = self._planned_file_for(normalized, task)
231
+ if planned is None:
232
+ return PolicyDecision(
233
+ action="deny",
234
+ reason=f"Task {task.id} does not authorize changes to {normalized}.",
235
+ target=normalized,
236
+ task_id=task.id,
237
+ )
238
+
239
+ if planned.allowed_change == "read_only":
240
+ return PolicyDecision(
241
+ action="deny",
242
+ reason="Planned file is read-only.",
243
+ target=normalized,
244
+ task_id=task.id,
245
+ )
246
+ if operation == "write":
247
+ if planned.allowed_change not in {"create", "modify"}:
248
+ return PolicyDecision(
249
+ action="deny",
250
+ reason=f"Operation {operation} not allowed for planned file.",
251
+ target=normalized,
252
+ task_id=task.id,
253
+ )
254
+ elif planned.allowed_change != operation:
255
+ return PolicyDecision(
256
+ action="deny",
257
+ reason=f"Operation {operation} not allowed for planned file.",
258
+ target=normalized,
259
+ task_id=task.id,
260
+ )
261
+
262
+ if self._matches_any(normalized, PROTECTED_WRITE_PATTERNS):
263
+ return PolicyDecision(
264
+ action="warn",
265
+ reason=f"{normalized} is a protected high-impact file; verification gates must approve it.",
266
+ target=normalized,
267
+ task_id=task.id,
268
+ )
269
+
270
+ return PolicyDecision(
271
+ action="allow",
272
+ reason="File change is allowed.",
273
+ target=normalized,
274
+ task_id=task.id,
275
+ )
276
+
277
+ def evaluate_hook_command(self, command: str) -> PolicyDecision:
278
+ """Git safety checks for hook/shell tool paths."""
279
+ normalized = " ".join(command.split())
280
+ lowered = normalized.lower()
281
+ if not normalized:
282
+ return PolicyDecision(action="allow", reason="No command detected.", target=normalized)
283
+
284
+ if "--no-verify" in lowered or "--no-gpg-sign" in lowered:
285
+ return PolicyDecision(
286
+ action="deny",
287
+ reason="Verification bypass flags are not allowed.",
288
+ target=normalized,
289
+ )
290
+
291
+ if re.search(r"\bgit\s+reset\s+--hard\s+(origin/)?(main|master)\b", lowered):
292
+ return PolicyDecision(
293
+ action="deny",
294
+ reason="Protected branch hard resets are not allowed.",
295
+ target=normalized,
296
+ )
297
+
298
+ if re.search(r"\bgit\s+push\b.*(\s--force(?:-with-lease)?\b|\s-f\b)", lowered) or re.search(
299
+ r"\bgit\s+push\s+\S+\s+\+\S", lowered
300
+ ):
301
+ # The second pattern catches the leading-plus refspec form
302
+ # (`git push origin +HEAD:master`), which forces a non-fast-forward update
303
+ # without the --force flag.
304
+ return PolicyDecision(
305
+ action="deny",
306
+ reason="Force pushes are not allowed.",
307
+ target=normalized,
308
+ )
309
+
310
+ if re.search(r"\bgit\s+push\s+\S+\s+((head:)?(main|master)|(main|master):\S+)\b", lowered):
311
+ return PolicyDecision(
312
+ action="warn",
313
+ reason="Direct pushes to protected branches should go through verification gates.",
314
+ target=normalized,
315
+ )
316
+
317
+ return PolicyDecision(action="allow", reason="Command is allowed.", target=normalized)
318
+
319
+ def _normalize_path(self, raw_path: str) -> str:
320
+ return normalize_repo_path(self.project_root, raw_path)[0]
321
+
322
+ def _planned_file_for(self, path: str, task: Task) -> PlannedFile | None:
323
+ for planned in task.planned_files:
324
+ planned_path = planned.path.replace("\\", "/")
325
+ if path == planned_path or fnmatch.fnmatch(path, planned_path):
326
+ return planned
327
+ return None
328
+
329
+ def _matches_forbidden(self, path: str, task: Task) -> bool:
330
+ for forbidden in task.forbidden_changes:
331
+ pattern = forbidden.replace("\\", "/")
332
+ if path == pattern or fnmatch.fnmatch(path, pattern):
333
+ return True
334
+ return False
335
+
336
+ def _matches_restricted(self, path: str) -> bool:
337
+ for pattern in _RESTRICTED_PATH_PATTERNS:
338
+ if fnmatch.fnmatch(path, pattern) or path.startswith(pattern.strip("*")):
339
+ return True
340
+ return False
341
+
342
+ def _matches_any(self, path: str, patterns: tuple[str, ...]) -> bool:
343
+ return any(fnmatch.fnmatch(path, pattern) for pattern in patterns)