devcouncil 0.2.0 → 0.2.1

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 (205) hide show
  1. package/README.md +191 -253
  2. package/bin/devcouncil.js +130 -32
  3. package/package.json +6 -17
  4. package/pyproject.toml +0 -66
  5. package/src/devcouncil/__init__.py +0 -0
  6. package/src/devcouncil/__main__.py +0 -4
  7. package/src/devcouncil/app/__init__.py +0 -28
  8. package/src/devcouncil/app/config.py +0 -320
  9. package/src/devcouncil/app/errors.py +0 -23
  10. package/src/devcouncil/app/events.py +0 -44
  11. package/src/devcouncil/app/orchestrator.py +0 -92
  12. package/src/devcouncil/app/project_status.py +0 -29
  13. package/src/devcouncil/app/run_context.py +0 -39
  14. package/src/devcouncil/app/state_machine.py +0 -108
  15. package/src/devcouncil/artifacts/__init__.py +0 -1
  16. package/src/devcouncil/artifacts/coverage.py +0 -96
  17. package/src/devcouncil/artifacts/graph.py +0 -163
  18. package/src/devcouncil/artifacts/migrations.py +0 -20
  19. package/src/devcouncil/artifacts/schemas.py +0 -23
  20. package/src/devcouncil/artifacts/serializer.py +0 -21
  21. package/src/devcouncil/artifacts/validators.py +0 -27
  22. package/src/devcouncil/assets/__init__.py +0 -1
  23. package/src/devcouncil/assets/devcouncil-logo.svg +0 -60
  24. package/src/devcouncil/assets/devcouncil_logo_premium.png +0 -0
  25. package/src/devcouncil/cli/__init__.py +0 -0
  26. package/src/devcouncil/cli/commands/__init__.py +0 -0
  27. package/src/devcouncil/cli/commands/agents.py +0 -292
  28. package/src/devcouncil/cli/commands/artifacts.py +0 -54
  29. package/src/devcouncil/cli/commands/ast.py +0 -22
  30. package/src/devcouncil/cli/commands/baseline.py +0 -35
  31. package/src/devcouncil/cli/commands/check.py +0 -209
  32. package/src/devcouncil/cli/commands/config.py +0 -115
  33. package/src/devcouncil/cli/commands/cost.py +0 -57
  34. package/src/devcouncil/cli/commands/dashboard.py +0 -31
  35. package/src/devcouncil/cli/commands/doctor.py +0 -340
  36. package/src/devcouncil/cli/commands/evidence.py +0 -48
  37. package/src/devcouncil/cli/commands/go.py +0 -656
  38. package/src/devcouncil/cli/commands/handoff.py +0 -69
  39. package/src/devcouncil/cli/commands/hook.py +0 -237
  40. package/src/devcouncil/cli/commands/init.py +0 -289
  41. package/src/devcouncil/cli/commands/integrate.py +0 -1515
  42. package/src/devcouncil/cli/commands/lsp.py +0 -20
  43. package/src/devcouncil/cli/commands/map.py +0 -112
  44. package/src/devcouncil/cli/commands/mcp_server.py +0 -18
  45. package/src/devcouncil/cli/commands/plan.py +0 -488
  46. package/src/devcouncil/cli/commands/prompt.py +0 -61
  47. package/src/devcouncil/cli/commands/repair.py +0 -89
  48. package/src/devcouncil/cli/commands/report.py +0 -137
  49. package/src/devcouncil/cli/commands/reset_demo_state.py +0 -33
  50. package/src/devcouncil/cli/commands/rollback.py +0 -59
  51. package/src/devcouncil/cli/commands/run.py +0 -289
  52. package/src/devcouncil/cli/commands/runs.py +0 -223
  53. package/src/devcouncil/cli/commands/scaffold.py +0 -32
  54. package/src/devcouncil/cli/commands/semantic.py +0 -47
  55. package/src/devcouncil/cli/commands/setup.py +0 -362
  56. package/src/devcouncil/cli/commands/shell.py +0 -73
  57. package/src/devcouncil/cli/commands/show.py +0 -76
  58. package/src/devcouncil/cli/commands/skills.py +0 -88
  59. package/src/devcouncil/cli/commands/status.py +0 -141
  60. package/src/devcouncil/cli/commands/tasks.py +0 -55
  61. package/src/devcouncil/cli/commands/trace.py +0 -88
  62. package/src/devcouncil/cli/commands/verify.py +0 -328
  63. package/src/devcouncil/cli/commands/version.py +0 -20
  64. package/src/devcouncil/cli/commands/watch.py +0 -574
  65. package/src/devcouncil/cli/commands/watch_fs.py +0 -40
  66. package/src/devcouncil/cli/main.py +0 -137
  67. package/src/devcouncil/council/__init__.py +0 -0
  68. package/src/devcouncil/council/prompts/__init__.py +0 -0
  69. package/src/devcouncil/council/prompts/arbiter.md +0 -19
  70. package/src/devcouncil/council/prompts/critic_a.md +0 -10
  71. package/src/devcouncil/council/prompts/critic_b.md +0 -10
  72. package/src/devcouncil/council/prompts/implementation_reviewer.md +0 -16
  73. package/src/devcouncil/council/prompts/planner_a.md +0 -16
  74. package/src/devcouncil/council/prompts/planner_b.md +0 -16
  75. package/src/devcouncil/council/prompts/rebuttal.md +0 -10
  76. package/src/devcouncil/council/prompts/spec_writer.md +0 -12
  77. package/src/devcouncil/domain/__init__.py +0 -0
  78. package/src/devcouncil/domain/assumption.py +0 -17
  79. package/src/devcouncil/domain/critique.py +0 -32
  80. package/src/devcouncil/domain/evidence.py +0 -47
  81. package/src/devcouncil/domain/gap.py +0 -52
  82. package/src/devcouncil/domain/requirement.py +0 -22
  83. package/src/devcouncil/domain/task.py +0 -55
  84. package/src/devcouncil/execution/__init__.py +0 -1
  85. package/src/devcouncil/execution/checkpoints.py +0 -246
  86. package/src/devcouncil/execution/context_builder.py +0 -60
  87. package/src/devcouncil/execution/executor.py +0 -15
  88. package/src/devcouncil/execution/fs_watcher.py +0 -180
  89. package/src/devcouncil/execution/handoff.py +0 -102
  90. package/src/devcouncil/execution/hook_policy.py +0 -253
  91. package/src/devcouncil/execution/patch.py +0 -77
  92. package/src/devcouncil/execution/paths.py +0 -14
  93. package/src/devcouncil/execution/permissions.py +0 -85
  94. package/src/devcouncil/execution/policy_engine.py +0 -343
  95. package/src/devcouncil/execution/prompt_builder.py +0 -671
  96. package/src/devcouncil/execution/shell_session.py +0 -225
  97. package/src/devcouncil/execution/task_runner.py +0 -170
  98. package/src/devcouncil/executors/__init__.py +0 -1
  99. package/src/devcouncil/executors/agent_registry.py +0 -575
  100. package/src/devcouncil/executors/coding_cli.py +0 -736
  101. package/src/devcouncil/executors/mini_swe.py +0 -73
  102. package/src/devcouncil/executors/native/__init__.py +0 -0
  103. package/src/devcouncil/executors/native/agent.py +0 -208
  104. package/src/devcouncil/executors/openhands.py +0 -71
  105. package/src/devcouncil/gating/__init__.py +0 -1
  106. package/src/devcouncil/gating/checks/__init__.py +0 -0
  107. package/src/devcouncil/gating/checks/clean_git.py +0 -52
  108. package/src/devcouncil/gating/checks/planned_files_check.py +0 -32
  109. package/src/devcouncil/gating/checks/requirement_coverage.py +0 -26
  110. package/src/devcouncil/gating/checks/secret_scan_check.py +0 -53
  111. package/src/devcouncil/gating/policy.py +0 -338
  112. package/src/devcouncil/hardware.py +0 -184
  113. package/src/devcouncil/indexing/__init__.py +0 -1
  114. package/src/devcouncil/indexing/ast_matcher.py +0 -168
  115. package/src/devcouncil/indexing/graph_index.py +0 -48
  116. package/src/devcouncil/indexing/lsp.py +0 -161
  117. package/src/devcouncil/indexing/repo_mapper.py +0 -1455
  118. package/src/devcouncil/indexing/semantic_index.py +0 -205
  119. package/src/devcouncil/integrations/actions.py +0 -146
  120. package/src/devcouncil/integrations/check.py +0 -423
  121. package/src/devcouncil/integrations/code_review_graph.py +0 -163
  122. package/src/devcouncil/integrations/github.py +0 -39
  123. package/src/devcouncil/integrations/github_intent.py +0 -142
  124. package/src/devcouncil/integrations/gitnexus.py +0 -62
  125. package/src/devcouncil/integrations/graphify.py +0 -34
  126. package/src/devcouncil/integrations/mcp/__init__.py +0 -0
  127. package/src/devcouncil/integrations/mcp/server.py +0 -2122
  128. package/src/devcouncil/integrations/opencode_devcouncil_plugin.mjs +0 -24
  129. package/src/devcouncil/integrations/pr_comments.py +0 -62
  130. package/src/devcouncil/live/__init__.py +0 -2
  131. package/src/devcouncil/live/cards.py +0 -349
  132. package/src/devcouncil/live/models.py +0 -63
  133. package/src/devcouncil/live/repair_prompt.py +0 -83
  134. package/src/devcouncil/live/reviewer.py +0 -70
  135. package/src/devcouncil/live/signals.py +0 -135
  136. package/src/devcouncil/live/summary.py +0 -34
  137. package/src/devcouncil/live/tasks.py +0 -18
  138. package/src/devcouncil/live/transcripts.py +0 -141
  139. package/src/devcouncil/llm/__init__.py +0 -1
  140. package/src/devcouncil/llm/cache.py +0 -42
  141. package/src/devcouncil/llm/model_defaults.yaml +0 -44
  142. package/src/devcouncil/llm/provider.py +0 -627
  143. package/src/devcouncil/llm/router.py +0 -310
  144. package/src/devcouncil/optimization/__init__.py +0 -1
  145. package/src/devcouncil/optimization/gepa_agent.py +0 -318
  146. package/src/devcouncil/planning/__init__.py +0 -1
  147. package/src/devcouncil/planning/arbiter_service.py +0 -57
  148. package/src/devcouncil/planning/correction_manifest.py +0 -303
  149. package/src/devcouncil/planning/critique_service.py +0 -71
  150. package/src/devcouncil/planning/plan_service.py +0 -60
  151. package/src/devcouncil/planning/prompt_enhancer_service.py +0 -167
  152. package/src/devcouncil/planning/repair_service.py +0 -39
  153. package/src/devcouncil/planning/spec_service.py +0 -70
  154. package/src/devcouncil/repo/__init__.py +0 -0
  155. package/src/devcouncil/repo/ci_scaffold.py +0 -157
  156. package/src/devcouncil/repo/gitignore.py +0 -123
  157. package/src/devcouncil/repo/sca.py +0 -374
  158. package/src/devcouncil/reporting/__init__.py +0 -0
  159. package/src/devcouncil/reporting/github_check.py +0 -32
  160. package/src/devcouncil/reporting/json_report.py +0 -30
  161. package/src/devcouncil/reporting/markdown_report.py +0 -83
  162. package/src/devcouncil/reporting/report_builder.py +0 -14
  163. package/src/devcouncil/skills/__init__.py +0 -19
  164. package/src/devcouncil/skills/library/README.md +0 -46
  165. package/src/devcouncil/skills/library/ai-training.md +0 -50
  166. package/src/devcouncil/skills/library/android.md +0 -50
  167. package/src/devcouncil/skills/library/backend.md +0 -52
  168. package/src/devcouncil/skills/library/core-engineering.md +0 -95
  169. package/src/devcouncil/skills/library/data-engineering.md +0 -47
  170. package/src/devcouncil/skills/library/desktop.md +0 -46
  171. package/src/devcouncil/skills/library/devops.md +0 -48
  172. package/src/devcouncil/skills/library/game-dev.md +0 -46
  173. package/src/devcouncil/skills/library/ios.md +0 -48
  174. package/src/devcouncil/skills/library/mobile-cross-platform.md +0 -46
  175. package/src/devcouncil/skills/library/security.md +0 -48
  176. package/src/devcouncil/skills/library/systems.md +0 -48
  177. package/src/devcouncil/skills/library/web.md +0 -47
  178. package/src/devcouncil/skills/library/windows.md +0 -47
  179. package/src/devcouncil/skills/registry.py +0 -330
  180. package/src/devcouncil/storage/__init__.py +0 -0
  181. package/src/devcouncil/storage/db.py +0 -147
  182. package/src/devcouncil/storage/models.py +0 -204
  183. package/src/devcouncil/storage/native.py +0 -557
  184. package/src/devcouncil/storage/repositories.py +0 -485
  185. package/src/devcouncil/telemetry/__init__.py +0 -0
  186. package/src/devcouncil/telemetry/cost.py +0 -140
  187. package/src/devcouncil/telemetry/model_pricing.yaml +0 -48
  188. package/src/devcouncil/telemetry/pricing.py +0 -28
  189. package/src/devcouncil/telemetry/traces.py +0 -146
  190. package/src/devcouncil/telemetry/tracker.py +0 -52
  191. package/src/devcouncil/ui/__init__.py +0 -1
  192. package/src/devcouncil/ui/dashboard.py +0 -423
  193. package/src/devcouncil/utils/__init__.py +0 -1
  194. package/src/devcouncil/utils/redaction.py +0 -147
  195. package/src/devcouncil/utils/subprocess_env.py +0 -69
  196. package/src/devcouncil/verification/__init__.py +0 -1
  197. package/src/devcouncil/verification/acceptance_compiler.py +0 -125
  198. package/src/devcouncil/verification/ad_hoc_check.py +0 -129
  199. package/src/devcouncil/verification/diff_coverage.py +0 -353
  200. package/src/devcouncil/verification/implementation_reviewer.py +0 -55
  201. package/src/devcouncil/verification/next_actions.py +0 -189
  202. package/src/devcouncil/verification/sandbox.py +0 -178
  203. package/src/devcouncil/verification/test_resolver.py +0 -91
  204. package/src/devcouncil/verification/verifier.py +0 -1548
  205. package/uv.lock +0 -1226
@@ -1,253 +0,0 @@
1
- import fnmatch
2
- import re
3
- from dataclasses import dataclass
4
- from pathlib import Path
5
- from typing import Any, Optional
6
-
7
- from devcouncil.domain.task import Task
8
- from devcouncil.execution.policy_engine import (
9
- PROTECTED_WRITE_PATTERNS,
10
- SECRET_PATH_PATTERNS,
11
- TaskPolicyEngine,
12
- normalize_repo_path,
13
- )
14
- from devcouncil.utils.redaction import SECRET_PATTERNS
15
-
16
- # Splits a shell command into the segments a shell would execute independently, on
17
- # the chaining/pipe/sequence operators. We deliberately do NOT try to parse quoting
18
- # perfectly — any operator we miss only makes us evaluate a *larger* segment as a
19
- # single command (which then fails the allowlist), so this errs toward DENY.
20
- _SEGMENT_SPLIT_RE = re.compile(r"\s*(?:\|\||&&|\||;|\n)\s*")
21
-
22
- # Strips a `bash -c "..."` / `sh -c '...'` wrapper so the inner command is what gets
23
- # checked against the allowlist, closing the trivial obfuscation where a denied
24
- # command is smuggled inside a shell wrapper.
25
- _SHELL_WRAPPER_RE = re.compile(
26
- r"""^\s*(?:[A-Za-z0-9_./\\-]*?(?:bash|sh|zsh|dash|ksh))(?:\.exe)?\s+-[A-Za-z]*c\s+(?P<quote>['"])(?P<inner>.*)(?P=quote)\s*$"""
27
- )
28
-
29
-
30
- @dataclass(frozen=True)
31
- class HookDecision:
32
- action: str
33
- reason: str
34
- target: Optional[str] = None
35
-
36
- @property
37
- def allowed(self) -> bool:
38
- return self.action in {"allow", "warn"}
39
-
40
-
41
- class HookPolicy:
42
- """Policy-backed hook checks for coding CLI tool-use events."""
43
-
44
- def __init__(self, project_root: Path | None = None):
45
- self.project_root = (project_root or Path(".")).resolve()
46
- self.policy_engine = TaskPolicyEngine(
47
- self.project_root,
48
- global_allowed_commands=self._load_global_allowed_commands(),
49
- )
50
-
51
- def _load_global_allowed_commands(self) -> list[str]:
52
- """Best-effort load of repo-wide allowed commands from config.
53
-
54
- Never raises — a missing/invalid config must not disable the gate, and the
55
- gate stays fail-closed (empty allowlist) when config can't be read."""
56
- try:
57
- from devcouncil.app.config import load_config
58
-
59
- execution = load_config(self.project_root).execution
60
- configured = getattr(execution, "global_allowed_commands", None)
61
- if isinstance(configured, (list, tuple)):
62
- return [str(item) for item in configured]
63
- except Exception:
64
- pass
65
- return []
66
-
67
- secret_path_patterns = SECRET_PATH_PATTERNS
68
- protected_path_patterns = PROTECTED_WRITE_PATTERNS
69
- write_tools = {
70
- "apply_patch",
71
- "edit",
72
- "edit_file",
73
- "replace",
74
- "write",
75
- "write_file",
76
- "Edit",
77
- "MultiEdit",
78
- "Write",
79
- "create_file",
80
- "str_replace",
81
- "search_replace",
82
- }
83
- shell_tools = {
84
- "bash",
85
- "exec",
86
- "exec_command",
87
- "local_shell",
88
- "run_command",
89
- "run_shell_command",
90
- "run_terminal_cmd",
91
- "shell",
92
- "shell_command",
93
- "Bash",
94
- "Shell",
95
- }
96
-
97
- def evaluate(self, call_data: dict[str, Any], active_task: Optional[Task]) -> HookDecision:
98
- tool_name = str(call_data.get("name") or call_data.get("tool_name") or call_data.get("tool") or "")
99
- arguments = call_data.get("arguments") or call_data.get("input") or call_data.get("tool_input") or {}
100
- if not isinstance(arguments, dict):
101
- arguments = {}
102
-
103
- if tool_name in self.shell_tools:
104
- command = self._extract_command(arguments)
105
- return self.evaluate_command(command, active_task)
106
-
107
- if tool_name in self.write_tools:
108
- target = self._extract_path(arguments)
109
- content = self._extract_content(arguments)
110
- return self.evaluate_file_write(target, active_task, content=content)
111
-
112
- return HookDecision("allow", "Tool is outside DevCouncil hook policy.")
113
-
114
- def evaluate_command(self, command: str, active_task: Optional[Task] = None) -> HookDecision:
115
- """Evaluate a shell command before execution.
116
-
117
- Two gates, deny wins:
118
- 1. Git-safety regexes (force push, --no-verify, protected-branch resets).
119
- 2. The task command allowlist: every segment of a chained command must be
120
- authorized by the active task (or the global allowlist). With no active
121
- task only the read-only no-task allowlist applies, so a Bash-routed
122
- write/destructive command can no longer escape the planned-files gate.
123
- This fails closed: anything we cannot positively authorize is denied."""
124
- if self.policy_engine is None:
125
- return HookDecision("deny", "No project root configured.", command)
126
-
127
- # 1) Git-safety check first — a hard deny here wins regardless of allowlist.
128
- git_decision = self.policy_engine.evaluate_hook_command(command)
129
- if git_decision.action == "deny":
130
- return HookDecision(git_decision.action, git_decision.reason, git_decision.target)
131
-
132
- # 2) Allowlist enforcement over every executed segment.
133
- segments = self._split_command_segments(command)
134
- if not segments:
135
- return HookDecision("deny", "Empty command is not allowed.", command)
136
-
137
- warn: HookDecision | None = None
138
- for segment in segments:
139
- decision = self.policy_engine.evaluate_command(segment, active_task)
140
- if decision.action == "deny":
141
- return HookDecision("deny", decision.reason, decision.target)
142
- if decision.action == "warn" and warn is None:
143
- warn = HookDecision("warn", decision.reason, decision.target)
144
-
145
- # A git-safety warn (e.g. direct push to a protected branch) should surface even
146
- # when every segment is otherwise allowed.
147
- if git_decision.action == "warn":
148
- return HookDecision(git_decision.action, git_decision.reason, git_decision.target)
149
- if warn is not None:
150
- return warn
151
- return HookDecision("allow", "Command authorized by task allowlist.", command)
152
-
153
- def _split_command_segments(self, command: str) -> list[str]:
154
- """Split a shell command on ; && || | and newlines, unwrapping bash -c wrappers.
155
-
156
- Each returned segment is itself unwrapped/re-split so a denied command nested
157
- inside a `bash -c "..."` wrapper is still checked. Errs toward DENY: anything
158
- ambiguous collapses into a larger segment that the allowlist will reject."""
159
- normalized = command.strip()
160
- if not normalized:
161
- return []
162
-
163
- wrapper = _SHELL_WRAPPER_RE.match(normalized)
164
- if wrapper is not None:
165
- # Recurse into the wrapped command so its own chaining is evaluated.
166
- return self._split_command_segments(wrapper.group("inner"))
167
-
168
- segments: list[str] = []
169
- for raw in _SEGMENT_SPLIT_RE.split(normalized):
170
- piece = raw.strip()
171
- if not piece:
172
- continue
173
- nested = _SHELL_WRAPPER_RE.match(piece)
174
- if nested is not None:
175
- segments.extend(self._split_command_segments(nested.group("inner")))
176
- else:
177
- segments.append(piece)
178
- return segments
179
-
180
- def evaluate_file_write(
181
- self,
182
- raw_path: Optional[str],
183
- active_task: Optional[Task],
184
- *,
185
- content: Optional[str] = None,
186
- ) -> HookDecision:
187
- if not raw_path:
188
- return HookDecision("allow", "No file path detected.")
189
- if self.policy_engine is None:
190
- return HookDecision("deny", "No project root configured.", raw_path)
191
-
192
- # Pre-action secret scan: refuse to write content that contains a secret,
193
- # before it ever lands on disk. Reuses the shared secret regexes.
194
- if content is not None:
195
- secret = self._scan_content_for_secret(content)
196
- if secret is not None:
197
- return HookDecision(
198
- "deny",
199
- f"Refusing to write content containing a potential {secret}.",
200
- raw_path,
201
- )
202
-
203
- # Delegate to the engine, which normalizes via the shared normalize_repo_path and
204
- # denies out-of-root targets — so the path that is checked is the path enforced.
205
- decision = self.policy_engine.evaluate_file_change(raw_path, active_task)
206
- return HookDecision(decision.action, decision.reason, decision.target)
207
-
208
- def _scan_content_for_secret(self, content: str) -> Optional[str]:
209
- if not isinstance(content, str) or not content:
210
- return None
211
- for key_type, pattern in SECRET_PATTERNS.items():
212
- if pattern.search(content):
213
- return key_type
214
- return None
215
-
216
- def _extract_command(self, arguments: dict[str, Any]) -> str:
217
- value = arguments.get("command") or arguments.get("cmd") or arguments.get("script") or ""
218
- return str(value)
219
-
220
- def _extract_content(self, arguments: dict[str, Any]) -> Optional[str]:
221
- """Pull the to-be-written content from a write tool's arguments.
222
-
223
- Covers the common shapes across coding CLIs (content/new_str/new_string/text)."""
224
- for key in ("content", "new_str", "new_string", "text", "file_text", "contents"):
225
- value = arguments.get(key)
226
- if isinstance(value, str) and value:
227
- return value
228
- return None
229
-
230
- def _extract_path(self, arguments: dict[str, Any]) -> Optional[str]:
231
- value = (
232
- arguments.get("path")
233
- or arguments.get("file_path")
234
- or arguments.get("filepath")
235
- or arguments.get("filePath")
236
- or arguments.get("target")
237
- or arguments.get("target_file")
238
- )
239
- return str(value) if value else None
240
-
241
- def _normalize_path(self, raw_path: str) -> str:
242
- # Shared normalizer — single source of truth with TaskPolicyEngine.
243
- return normalize_repo_path(self.project_root, raw_path)[0]
244
-
245
- def _is_planned_file(self, path: str, task: Task) -> bool:
246
- for planned in task.planned_files:
247
- planned_path = self._normalize_path(planned.path)
248
- if path == planned_path or fnmatch.fnmatch(path, planned_path):
249
- return True
250
- return False
251
-
252
- def _matches_any(self, path: str, patterns: tuple[str, ...]) -> bool:
253
- return any(fnmatch.fnmatch(path, pattern) for pattern in patterns)
@@ -1,77 +0,0 @@
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,14 +0,0 @@
1
- from pathlib import Path
2
-
3
- from devcouncil.app.errors import ExecutionError
4
-
5
-
6
- def resolve_project_path(project_root: Path, path: str) -> Path:
7
- """Resolve a repository-relative path and reject paths outside the project."""
8
- root_resolved = project_root.resolve()
9
- resolved = (root_resolved / path).resolve()
10
- try:
11
- resolved.relative_to(root_resolved)
12
- except ValueError as exc:
13
- raise ExecutionError(f"Path traversal blocked: {path} resolves outside project root.") from exc
14
- return resolved
@@ -1,85 +0,0 @@
1
- import fnmatch
2
- from pathlib import Path
3
- from typing import List, Literal, Optional
4
- from pydantic import BaseModel, Field
5
- from devcouncil.domain.task import PlannedFile, Task
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
-
16
- class PermissionManager:
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
-
37
- def is_file_change_allowed(
38
- self,
39
- path: str,
40
- task: Task,
41
- operation: Literal["create", "modify", "delete", "write"] = "write",
42
- *,
43
- internal: bool = False,
44
- ) -> bool:
45
- """Check if a file change is authorized by the task or policy."""
46
- for restricted in self.dynamic_ignores:
47
- if fnmatch.fnmatch(path, restricted) or path.startswith(restricted.strip("*")):
48
- return False
49
- decision = self.policy_engine.evaluate_file_change(
50
- path, task, operation, internal=internal
51
- )
52
- return decision.action in {"allow", "warn"}
53
-
54
- def _planned_file_for(self, path: str, task: Task) -> Optional[PlannedFile]:
55
- normalized = path.replace("\\", "/")
56
- for planned in task.planned_files:
57
- planned_path = planned.path.replace("\\", "/")
58
- if normalized == planned_path or fnmatch.fnmatch(normalized, planned_path):
59
- return planned
60
- return None
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
-
67
- def validate_action(
68
- self,
69
- action_type: str,
70
- target: str,
71
- task: Task,
72
- operation: Literal["create", "modify", "delete", "write"] = "write",
73
- *,
74
- internal: bool = False,
75
- ):
76
- """Raise GatingError if an execution action violates permissions."""
77
- if action_type == "file_write":
78
- if not self.is_file_change_allowed(target, task, operation, internal=internal):
79
- raise GatingError(
80
- f"Unauthorized file {operation}: {target}. "
81
- "File and operation must match task planned_files."
82
- )
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.")