devcouncil 0.1.1 → 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 (129) hide show
  1. package/README.md +190 -6
  2. package/package.json +9 -2
  3. package/pyproject.toml +34 -2
  4. package/src/devcouncil/app/config.py +167 -5
  5. package/src/devcouncil/artifacts/graph.py +23 -3
  6. package/src/devcouncil/assets/__init__.py +1 -0
  7. package/src/devcouncil/assets/devcouncil-logo.svg +60 -0
  8. package/src/devcouncil/assets/devcouncil_logo_premium.png +0 -0
  9. package/src/devcouncil/cli/commands/agents.py +292 -0
  10. package/src/devcouncil/cli/commands/artifacts.py +6 -3
  11. package/src/devcouncil/cli/commands/check.py +209 -0
  12. package/src/devcouncil/cli/commands/config.py +43 -4
  13. package/src/devcouncil/cli/commands/cost.py +57 -0
  14. package/src/devcouncil/cli/commands/dashboard.py +6 -1
  15. package/src/devcouncil/cli/commands/doctor.py +221 -21
  16. package/src/devcouncil/cli/commands/evidence.py +48 -0
  17. package/src/devcouncil/cli/commands/go.py +452 -33
  18. package/src/devcouncil/cli/commands/handoff.py +69 -0
  19. package/src/devcouncil/cli/commands/hook.py +124 -15
  20. package/src/devcouncil/cli/commands/init.py +154 -18
  21. package/src/devcouncil/cli/commands/integrate.py +894 -105
  22. package/src/devcouncil/cli/commands/map.py +80 -10
  23. package/src/devcouncil/cli/commands/plan.py +212 -51
  24. package/src/devcouncil/cli/commands/prompt.py +18 -7
  25. package/src/devcouncil/cli/commands/repair.py +40 -23
  26. package/src/devcouncil/cli/commands/report.py +8 -0
  27. package/src/devcouncil/cli/commands/reset_demo_state.py +4 -2
  28. package/src/devcouncil/cli/commands/rollback.py +27 -28
  29. package/src/devcouncil/cli/commands/run.py +69 -49
  30. package/src/devcouncil/cli/commands/runs.py +223 -0
  31. package/src/devcouncil/cli/commands/scaffold.py +32 -0
  32. package/src/devcouncil/cli/commands/semantic.py +47 -0
  33. package/src/devcouncil/cli/commands/setup.py +145 -6
  34. package/src/devcouncil/cli/commands/shell.py +73 -0
  35. package/src/devcouncil/cli/commands/skills.py +88 -0
  36. package/src/devcouncil/cli/commands/status.py +25 -1
  37. package/src/devcouncil/cli/commands/trace.py +47 -3
  38. package/src/devcouncil/cli/commands/verify.py +138 -3
  39. package/src/devcouncil/cli/commands/watch.py +9 -9
  40. package/src/devcouncil/cli/commands/watch_fs.py +40 -0
  41. package/src/devcouncil/cli/main.py +56 -7
  42. package/src/devcouncil/domain/evidence.py +22 -2
  43. package/src/devcouncil/domain/gap.py +27 -1
  44. package/src/devcouncil/domain/task.py +31 -2
  45. package/src/devcouncil/execution/checkpoints.py +246 -0
  46. package/src/devcouncil/execution/context_builder.py +1 -1
  47. package/src/devcouncil/execution/fs_watcher.py +180 -0
  48. package/src/devcouncil/execution/handoff.py +102 -0
  49. package/src/devcouncil/execution/hook_policy.py +162 -74
  50. package/src/devcouncil/execution/patch.py +59 -10
  51. package/src/devcouncil/execution/permissions.py +17 -24
  52. package/src/devcouncil/execution/policy_engine.py +343 -0
  53. package/src/devcouncil/execution/prompt_builder.py +633 -21
  54. package/src/devcouncil/execution/shell_session.py +225 -0
  55. package/src/devcouncil/execution/task_runner.py +6 -2
  56. package/src/devcouncil/executors/agent_registry.py +575 -0
  57. package/src/devcouncil/executors/coding_cli.py +663 -39
  58. package/src/devcouncil/executors/native/agent.py +121 -20
  59. package/src/devcouncil/gating/checks/clean_git.py +3 -1
  60. package/src/devcouncil/gating/checks/secret_scan_check.py +40 -21
  61. package/src/devcouncil/gating/policy.py +158 -10
  62. package/src/devcouncil/hardware.py +184 -0
  63. package/src/devcouncil/indexing/ast_matcher.py +1 -1
  64. package/src/devcouncil/indexing/lsp.py +45 -4
  65. package/src/devcouncil/indexing/repo_mapper.py +1256 -9
  66. package/src/devcouncil/indexing/semantic_index.py +205 -0
  67. package/src/devcouncil/integrations/actions.py +146 -0
  68. package/src/devcouncil/integrations/check.py +423 -0
  69. package/src/devcouncil/integrations/github_intent.py +142 -0
  70. package/src/devcouncil/integrations/gitnexus.py +35 -0
  71. package/src/devcouncil/integrations/mcp/server.py +1552 -29
  72. package/src/devcouncil/integrations/opencode_devcouncil_plugin.mjs +24 -0
  73. package/src/devcouncil/live/cards.py +161 -19
  74. package/src/devcouncil/live/signals.py +2 -2
  75. package/src/devcouncil/live/transcripts.py +9 -6
  76. package/src/devcouncil/llm/cache.py +10 -6
  77. package/src/devcouncil/llm/model_defaults.yaml +44 -0
  78. package/src/devcouncil/llm/provider.py +515 -34
  79. package/src/devcouncil/llm/router.py +231 -46
  80. package/src/devcouncil/optimization/__init__.py +1 -0
  81. package/src/devcouncil/optimization/gepa_agent.py +318 -0
  82. package/src/devcouncil/planning/correction_manifest.py +303 -0
  83. package/src/devcouncil/planning/critique_service.py +7 -2
  84. package/src/devcouncil/planning/plan_service.py +17 -3
  85. package/src/devcouncil/planning/prompt_enhancer_service.py +82 -1
  86. package/src/devcouncil/planning/spec_service.py +27 -1
  87. package/src/devcouncil/repo/ci_scaffold.py +157 -0
  88. package/src/devcouncil/repo/gitignore.py +123 -0
  89. package/src/devcouncil/repo/sca.py +374 -0
  90. package/src/devcouncil/reporting/json_report.py +11 -1
  91. package/src/devcouncil/reporting/markdown_report.py +15 -0
  92. package/src/devcouncil/skills/__init__.py +19 -0
  93. package/src/devcouncil/skills/library/README.md +46 -0
  94. package/src/devcouncil/skills/library/ai-training.md +50 -0
  95. package/src/devcouncil/skills/library/android.md +50 -0
  96. package/src/devcouncil/skills/library/backend.md +52 -0
  97. package/src/devcouncil/skills/library/core-engineering.md +95 -0
  98. package/src/devcouncil/skills/library/data-engineering.md +47 -0
  99. package/src/devcouncil/skills/library/desktop.md +46 -0
  100. package/src/devcouncil/skills/library/devops.md +48 -0
  101. package/src/devcouncil/skills/library/game-dev.md +46 -0
  102. package/src/devcouncil/skills/library/ios.md +48 -0
  103. package/src/devcouncil/skills/library/mobile-cross-platform.md +46 -0
  104. package/src/devcouncil/skills/library/security.md +48 -0
  105. package/src/devcouncil/skills/library/systems.md +48 -0
  106. package/src/devcouncil/skills/library/web.md +47 -0
  107. package/src/devcouncil/skills/library/windows.md +47 -0
  108. package/src/devcouncil/skills/registry.py +330 -0
  109. package/src/devcouncil/storage/db.py +83 -2
  110. package/src/devcouncil/storage/models.py +121 -0
  111. package/src/devcouncil/storage/native.py +557 -0
  112. package/src/devcouncil/storage/repositories.py +137 -75
  113. package/src/devcouncil/telemetry/cost.py +123 -17
  114. package/src/devcouncil/telemetry/model_pricing.yaml +48 -0
  115. package/src/devcouncil/telemetry/pricing.py +28 -0
  116. package/src/devcouncil/telemetry/traces.py +62 -7
  117. package/src/devcouncil/telemetry/tracker.py +12 -9
  118. package/src/devcouncil/ui/dashboard.py +324 -23
  119. package/src/devcouncil/utils/redaction.py +9 -3
  120. package/src/devcouncil/utils/subprocess_env.py +69 -0
  121. package/src/devcouncil/verification/acceptance_compiler.py +125 -0
  122. package/src/devcouncil/verification/ad_hoc_check.py +129 -0
  123. package/src/devcouncil/verification/diff_coverage.py +353 -0
  124. package/src/devcouncil/verification/next_actions.py +189 -0
  125. package/src/devcouncil/verification/sandbox.py +178 -0
  126. package/src/devcouncil/verification/test_resolver.py +91 -0
  127. package/src/devcouncil/verification/verifier.py +1065 -47
  128. package/uv.lock +205 -64
  129. package/src/devcouncil/indexing/symbol_index.py +0 -0
@@ -2,10 +2,29 @@ import fnmatch
2
2
  import re
3
3
  from dataclasses import dataclass
4
4
  from pathlib import Path
5
- from pathlib import PurePosixPath
6
5
  from typing import Any, Optional
7
6
 
8
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
+ )
9
28
 
10
29
 
11
30
  @dataclass(frozen=True)
@@ -23,30 +42,30 @@ class HookPolicy:
23
42
  """Policy-backed hook checks for coding CLI tool-use events."""
24
43
 
25
44
  def __init__(self, project_root: Path | None = None):
26
- self.project_root = project_root.resolve() if project_root else None
27
-
28
- secret_path_patterns = (
29
- ".env",
30
- ".env.*",
31
- "**/.env",
32
- "**/.env.*",
33
- "**/credentials/**",
34
- "**/secrets/**",
35
- "**/*.pem",
36
- "**/*.key",
37
- )
38
- protected_path_patterns = (
39
- "package.json",
40
- "pyproject.toml",
41
- "uv.lock",
42
- "Dockerfile",
43
- "docker-compose.yml",
44
- ".github/workflows/*.yml",
45
- ".github/workflows/*.yaml",
46
- "schema.prisma",
47
- "wrangler.toml",
48
- "index.html",
49
- )
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
50
69
  write_tools = {
51
70
  "apply_patch",
52
71
  "edit",
@@ -57,6 +76,9 @@ class HookPolicy:
57
76
  "Edit",
58
77
  "MultiEdit",
59
78
  "Write",
79
+ "create_file",
80
+ "str_replace",
81
+ "search_replace",
60
82
  }
61
83
  shell_tools = {
62
84
  "bash",
@@ -65,9 +87,11 @@ class HookPolicy:
65
87
  "local_shell",
66
88
  "run_command",
67
89
  "run_shell_command",
90
+ "run_terminal_cmd",
68
91
  "shell",
69
92
  "shell_command",
70
93
  "Bash",
94
+ "Shell",
71
95
  }
72
96
 
73
97
  def evaluate(self, call_data: dict[str, Any], active_task: Optional[Task]) -> HookDecision:
@@ -78,57 +102,131 @@ class HookPolicy:
78
102
 
79
103
  if tool_name in self.shell_tools:
80
104
  command = self._extract_command(arguments)
81
- return self.evaluate_command(command)
105
+ return self.evaluate_command(command, active_task)
82
106
 
83
107
  if tool_name in self.write_tools:
84
108
  target = self._extract_path(arguments)
85
- return self.evaluate_file_write(target, active_task)
109
+ content = self._extract_content(arguments)
110
+ return self.evaluate_file_write(target, active_task, content=content)
86
111
 
87
112
  return HookDecision("allow", "Tool is outside DevCouncil hook policy.")
88
113
 
89
- def evaluate_command(self, command: str) -> HookDecision:
90
- normalized = " ".join(command.split())
91
- lowered = normalized.lower()
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()
92
160
  if not normalized:
93
- return HookDecision("allow", "No command detected.")
94
-
95
- if "--no-verify" in lowered or "--no-gpg-sign" in lowered:
96
- return HookDecision("deny", "Verification bypass flags are not allowed.", normalized)
97
-
98
- if re.search(r"\bgit\s+reset\s+--hard\s+(origin/)?(main|master)\b", lowered):
99
- return HookDecision("deny", "Protected branch hard resets are not allowed.", normalized)
100
-
101
- if re.search(r"\bgit\s+push\b.*(\s--force(?:-with-lease)?\b|\s-f\b)", lowered):
102
- return HookDecision("deny", "Force pushes are not allowed.", normalized)
103
-
104
- if re.search(r"\bgit\s+push\s+\S+\s+((head:)?(main|master)|(main|master):\S+)\b", lowered):
105
- return HookDecision("warn", "Direct pushes to protected branches should go through verification gates.", normalized)
106
-
107
- return HookDecision("allow", "Command is allowed.", normalized)
108
-
109
- def evaluate_file_write(self, raw_path: Optional[str], active_task: Optional[Task]) -> HookDecision:
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:
110
187
  if not raw_path:
111
188
  return HookDecision("allow", "No file path detected.")
112
-
113
- path = self._normalize_path(raw_path)
114
- if self._matches_any(path, self.secret_path_patterns):
115
- return HookDecision("deny", "Secret and credential paths are never writable through hooks.", path)
116
-
117
- if active_task is None:
118
- return HookDecision("deny", "No running DevCouncil task authorizes this file write.", path)
119
-
120
- if active_task and not self._is_planned_file(path, active_task):
121
- return HookDecision("deny", f"Task {active_task.id} does not authorize changes to {path}.", path)
122
-
123
- if self._matches_any(path, self.protected_path_patterns):
124
- return HookDecision("warn", f"{path} is a protected high-impact file; verification gates must approve it.", path)
125
-
126
- return HookDecision("allow", "File write is allowed.", path)
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
127
215
 
128
216
  def _extract_command(self, arguments: dict[str, Any]) -> str:
129
217
  value = arguments.get("command") or arguments.get("cmd") or arguments.get("script") or ""
130
218
  return str(value)
131
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
+
132
230
  def _extract_path(self, arguments: dict[str, Any]) -> Optional[str]:
133
231
  value = (
134
232
  arguments.get("path")
@@ -141,18 +239,8 @@ class HookPolicy:
141
239
  return str(value) if value else None
142
240
 
143
241
  def _normalize_path(self, raw_path: str) -> str:
144
- path = raw_path.strip().strip('"').replace("\\", "/")
145
- if self.project_root:
146
- try:
147
- candidate = Path(path)
148
- resolved = candidate.resolve() if candidate.is_absolute() else (self.project_root / path).resolve()
149
- return resolved.relative_to(self.project_root).as_posix()
150
- except (OSError, ValueError):
151
- pass
152
- if re.match(r"^[A-Za-z]:/", path):
153
- parts = PurePosixPath(path).parts
154
- path = "/".join(parts[1:])
155
- return path[2:] if path.startswith("./") else path
242
+ # Shared normalizer — single source of truth with TaskPolicyEngine.
243
+ return normalize_repo_path(self.project_root, raw_path)[0]
156
244
 
157
245
  def _is_planned_file(self, path: str, task: Task) -> bool:
158
246
  for planned in task.planned_files:
@@ -1,28 +1,77 @@
1
+ import re
1
2
  import subprocess
2
3
  from pathlib import Path
3
4
  from devcouncil.app.errors import ExecutionError
4
5
 
6
+
5
7
  class PatchEngine:
6
8
  """Handles applying unified diff patches to the codebase."""
7
-
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
+
8
20
  def __init__(self, project_root: Path):
9
21
  self.project_root = project_root
10
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
+
11
43
  def apply_patch(self, patch_content: str) -> bool:
12
- """Applies a git-style patch to the repository."""
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
+
13
53
  patch_file = self.project_root / ".devcouncil" / "temp.patch"
14
54
  patch_file.parent.mkdir(parents=True, exist_ok=True)
15
55
  patch_file.write_text(patch_content, encoding="utf-8")
16
-
56
+
57
+ last_stderr = ""
17
58
  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
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)'}"
22
74
  )
23
- return True
24
- except subprocess.CalledProcessError as e:
25
- raise ExecutionError(f"Failed to apply patch: {e}")
26
75
  finally:
27
76
  if patch_file.exists():
28
77
  patch_file.unlink()
@@ -4,6 +4,7 @@ from typing import List, Literal, Optional
4
4
  from pydantic import BaseModel, Field
5
5
  from devcouncil.domain.task import PlannedFile, Task
6
6
  from devcouncil.app.errors import GatingError
7
+ from devcouncil.execution.policy_engine import TaskPolicyEngine
7
8
 
8
9
  class PermissionPolicy(BaseModel):
9
10
  """Defines the security boundaries for task execution."""
@@ -17,6 +18,10 @@ class PermissionManager:
17
18
  self.policy = policy
18
19
  self.project_root = project_root
19
20
  self.dynamic_ignores = self._load_devcouncilignore()
21
+ self.policy_engine = TaskPolicyEngine(
22
+ project_root,
23
+ global_allowed_commands=policy.allowed_shell_commands,
24
+ )
20
25
 
21
26
  def _load_devcouncilignore(self) -> List[str]:
22
27
  """Load additional restricted paths from .devcouncilignore."""
@@ -34,24 +39,17 @@ class PermissionManager:
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("\\", "/")
@@ -63,15 +61,8 @@ class PermissionManager:
63
61
 
64
62
  def is_command_allowed(self, command: str, task: Task) -> bool:
65
63
  """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
64
+ decision = self.policy_engine.evaluate_command(command, task)
65
+ return decision.action == "allow"
75
66
 
76
67
  def validate_action(
77
68
  self,
@@ -79,10 +70,12 @@ class PermissionManager:
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."