@hunyed15/codecgc 0.1.7 → 0.1.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/hooks/route-edit.ps1 +58 -57
- package/INSTALLATION.md +122 -484
- package/README.md +124 -149
- package/bin/cgc-external-status.js +4 -0
- package/bin/cgc-start.js +4 -0
- package/bin/codecgc.js +141 -20
- package/codecgc/compound/codecgc-capability-matrix.md +37 -0
- package/codecgc/reference/README.md +69 -0
- package/codecgc/reference/execution-model.md +3 -1
- package/codecgc/reference/maintainer-guide.md +93 -0
- package/codecgc/reference/mcp-tool-surface.md +112 -0
- package/codecgc/reference/onboarding.md +69 -0
- package/codecgc/reference/operation-guide.md +29 -23
- package/codecgc/reference/path-contract.md +53 -0
- package/codecgc/reference/policy-routing.md +58 -0
- package/codecgc/reference/project-structure.md +87 -0
- package/codecgc/reference/quickstart.md +110 -0
- package/codecgc/reference/real-workflow-loop.md +123 -0
- package/codecgc/reference/recovery-loop.md +109 -0
- package/codecgc/reference/release-maintenance-playbook.md +4 -1
- package/codecgc/reference/troubleshooting.md +114 -0
- package/codecgc/roadmap/codecgc-release-maintenance/delivery-plan.md +49 -0
- package/codecgc/roadmap/codecgc-release-maintenance/overview.md +41 -0
- package/codecgc/roadmap/codecgc-release-maintenance/phases.md +84 -0
- package/codecgc/templates/claude/settings.local.json +27 -0
- package/codecgc/templates/codex/codecgcrc.json +22 -0
- package/codecgc/templates/gemini/codecgc-policy.toml +47 -0
- package/codecgcmcp/README.md +57 -11
- package/codecgcmcp/src/codecgcmcp/server.py +164 -26
- package/codexmcp/src/codexmcp/server.py +45 -0
- package/geminimcp/src/geminimcp/server.py +106 -24
- package/model-routing.yaml +31 -6
- package/package.json +12 -4
- package/scripts/audit_codecgc_external_capabilities.py +83 -4
- package/scripts/audit_codecgc_historical_audits.py +42 -2
- package/scripts/audit_codecgc_package_runtime.py +76 -4
- package/scripts/audit_codecgc_release_readiness.py +55 -3
- package/scripts/audit_codecgc_workflow_history.py +8 -5
- package/scripts/build_codecgc_task.py +69 -45
- package/scripts/codecgc_artifact_roots.py +8 -40
- package/scripts/codecgc_console_io.py +3 -45
- package/scripts/codecgc_executor_registry.py +4 -54
- package/scripts/codecgc_path_contract.py +7 -0
- package/scripts/codecgc_policy.py +447 -0
- package/scripts/codecgc_routing_paths.py +3 -16
- package/scripts/codecgc_routing_template.py +11 -135
- package/scripts/codecgc_runtime/__init__.py +1 -0
- package/scripts/codecgc_runtime/artifacts.py +42 -0
- package/scripts/codecgc_runtime/console.py +45 -0
- package/scripts/codecgc_runtime/executor_registry.py +55 -0
- package/scripts/codecgc_runtime/mcp_config.py +72 -0
- package/scripts/codecgc_runtime/path_contract.py +123 -0
- package/scripts/codecgc_runtime/paths.py +22 -0
- package/scripts/codecgc_runtime/routing_paths.py +16 -0
- package/scripts/codecgc_runtime/routing_template.py +171 -0
- package/scripts/codecgc_runtime/workflow_runtime.py +72 -0
- package/scripts/codecgc_runtime_paths.py +3 -22
- package/scripts/codecgc_step_control.py +3 -2
- package/scripts/codecgc_workflow_runtime.py +3 -71
- package/scripts/entry_codecgc_workflow.py +4 -0
- package/scripts/install_codecgc.py +560 -32
- package/scripts/normalize_codecgc_audits.py +5 -3
- package/scripts/postinstall_codecgc.js +6 -56
- package/scripts/review_codecgc_workflow.py +6 -3
- package/scripts/route_codecgc_workflow.py +67 -36
- package/scripts/run_codecgc_build.py +28 -0
- package/scripts/run_codecgc_fix.py +28 -0
- package/scripts/run_codecgc_task.py +5 -2
- package/scripts/run_codecgc_test.py +28 -0
- package/scripts/sync_codecgc_mcp_config.py +4 -54
- package/scripts/write_codecgc_review.py +7 -3
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
from codecgc_runtime.path_contract import is_project_relative_path
|
|
2
|
+
from codecgc_runtime.path_contract import normalize_audit_path_fields
|
|
3
|
+
from codecgc_runtime.path_contract import normalize_path_text
|
|
4
|
+
from codecgc_runtime.path_contract import normalize_persisted_project_path
|
|
5
|
+
from codecgc_runtime.path_contract import normalize_source_contract
|
|
6
|
+
from codecgc_runtime.path_contract import resolve_project_path
|
|
7
|
+
from codecgc_runtime.path_contract import to_project_relative_path
|
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
import shlex
|
|
7
|
+
import sys
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from fnmatch import fnmatch
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
import yaml
|
|
14
|
+
|
|
15
|
+
from codecgc_console_io import configure_utf8_stdio
|
|
16
|
+
from codecgc_console_io import print_json
|
|
17
|
+
from codecgc_path_contract import to_project_relative_path
|
|
18
|
+
from codecgc_routing_paths import resolve_active_routing_file
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
OWNERS = {"orchestration", "docs", "frontend", "backend", "frontend-test", "backend-test", "shared", "unknown"}
|
|
22
|
+
POLICY_PROJECT_ROOT_KEY = "_codecgc_project_root"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class PathDecision:
|
|
27
|
+
path: str
|
|
28
|
+
owner: str
|
|
29
|
+
allowed: bool
|
|
30
|
+
reason: str
|
|
31
|
+
recommended_action: str
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class HookRequest:
|
|
36
|
+
tool_name: str
|
|
37
|
+
file_path: str
|
|
38
|
+
command: str
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
EDIT_TOOL_NAMES = {"Edit", "Write", "MultiEdit"}
|
|
42
|
+
SHELL_TOOL_NAMES = {"Bash", "PowerShell"}
|
|
43
|
+
SHELL_DENIED_COMMAND_PATTERNS = (
|
|
44
|
+
r"\brm\s+-[^\n\r]*[rf]",
|
|
45
|
+
r"\bdel\s+",
|
|
46
|
+
r"\brmdir\s+",
|
|
47
|
+
r"\bremove-item\b",
|
|
48
|
+
r"\bset-content\b",
|
|
49
|
+
r"\badd-content\b",
|
|
50
|
+
r"\bout-file\b",
|
|
51
|
+
r"\bnew-item\b",
|
|
52
|
+
r"\bcopy-item\b",
|
|
53
|
+
r"\bmove-item\b",
|
|
54
|
+
r"\bgit\s+reset\s+--hard\b",
|
|
55
|
+
r"\bgit\s+clean\b",
|
|
56
|
+
r"\bpython\s+-c\b",
|
|
57
|
+
r"\bnode\s+-e\b",
|
|
58
|
+
r"\bpowershell(\.exe)?\s+-(command|encodedcommand)\b",
|
|
59
|
+
r"\bcmd(\.exe)?\s+/c\b",
|
|
60
|
+
)
|
|
61
|
+
SHELL_ALLOWED_COMMAND_PREFIXES = (
|
|
62
|
+
"git status",
|
|
63
|
+
"git diff",
|
|
64
|
+
"git log",
|
|
65
|
+
"git show",
|
|
66
|
+
"git branch",
|
|
67
|
+
"git rev-parse",
|
|
68
|
+
"git ls-files",
|
|
69
|
+
"git remote",
|
|
70
|
+
"rg",
|
|
71
|
+
"ls",
|
|
72
|
+
"dir",
|
|
73
|
+
"pwd",
|
|
74
|
+
"get-content",
|
|
75
|
+
"get-childitem",
|
|
76
|
+
"select-string",
|
|
77
|
+
"test-path",
|
|
78
|
+
"resolve-path",
|
|
79
|
+
"pytest",
|
|
80
|
+
"python -m pytest",
|
|
81
|
+
"python -m compileall",
|
|
82
|
+
"npm test",
|
|
83
|
+
"npm run test",
|
|
84
|
+
"npm run lint",
|
|
85
|
+
"npm run typecheck",
|
|
86
|
+
"npm run check",
|
|
87
|
+
"pnpm test",
|
|
88
|
+
"pnpm run test",
|
|
89
|
+
"pnpm run lint",
|
|
90
|
+
"pnpm run typecheck",
|
|
91
|
+
"yarn test",
|
|
92
|
+
"yarn lint",
|
|
93
|
+
"codex --help",
|
|
94
|
+
"gemini --help",
|
|
95
|
+
"gemini --version",
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def normalize_path_text(path_text: str) -> str:
|
|
100
|
+
normalized = str(path_text or "").replace("\\", "/").strip()
|
|
101
|
+
while normalized.startswith("./"):
|
|
102
|
+
normalized = normalized[2:]
|
|
103
|
+
return normalized
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _as_string_list(value: Any) -> list[str]:
|
|
107
|
+
if value is None:
|
|
108
|
+
return []
|
|
109
|
+
if isinstance(value, list):
|
|
110
|
+
return [str(item).strip() for item in value if str(item).strip()]
|
|
111
|
+
return [str(value).strip()] if str(value).strip() else []
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _load_yaml(path: Path) -> dict[str, Any]:
|
|
115
|
+
if not path.exists():
|
|
116
|
+
raise FileNotFoundError(f"Routing policy file not found: {path}")
|
|
117
|
+
data = yaml.safe_load(path.read_text(encoding="utf-8"))
|
|
118
|
+
if not isinstance(data, dict):
|
|
119
|
+
raise ValueError(f"Routing policy must be a mapping: {path}")
|
|
120
|
+
return data
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def load_policy(path: Path | None = None) -> dict[str, Any]:
|
|
124
|
+
policy_path = path or resolve_active_routing_file()
|
|
125
|
+
policy = _load_yaml(policy_path)
|
|
126
|
+
if int(policy.get("version", 0) or 0) != 2:
|
|
127
|
+
raise ValueError("model-routing.yaml must use version: 2.")
|
|
128
|
+
validate_policy(policy)
|
|
129
|
+
policy[POLICY_PROJECT_ROOT_KEY] = str(policy_path.parent.resolve())
|
|
130
|
+
return policy
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def validate_policy(policy: dict[str, Any]) -> None:
|
|
134
|
+
required_lists = [
|
|
135
|
+
"orchestration_paths",
|
|
136
|
+
"docs_paths",
|
|
137
|
+
"frontend_paths",
|
|
138
|
+
"backend_paths",
|
|
139
|
+
"shared_paths",
|
|
140
|
+
]
|
|
141
|
+
missing = [name for name in required_lists if not isinstance(policy.get(name), list)]
|
|
142
|
+
test_paths = policy.get("test_paths")
|
|
143
|
+
rules = policy.get("rules")
|
|
144
|
+
if not isinstance(test_paths, dict):
|
|
145
|
+
missing.append("test_paths")
|
|
146
|
+
if not isinstance(rules, dict):
|
|
147
|
+
missing.append("rules")
|
|
148
|
+
if missing:
|
|
149
|
+
raise ValueError(f"model-routing.yaml is missing required policy sections: {', '.join(missing)}")
|
|
150
|
+
|
|
151
|
+
for name in ("frontend", "backend"):
|
|
152
|
+
if not isinstance(test_paths.get(name), list):
|
|
153
|
+
raise ValueError(f"model-routing.yaml test_paths.{name} must be a list.")
|
|
154
|
+
|
|
155
|
+
allowed = set(_as_string_list(rules.get("claude_allowed_owners")))
|
|
156
|
+
invalid_allowed = sorted(allowed - OWNERS)
|
|
157
|
+
if invalid_allowed:
|
|
158
|
+
raise ValueError(f"Invalid claude_allowed_owners entries: {', '.join(invalid_allowed)}")
|
|
159
|
+
|
|
160
|
+
if str(rules.get("shared_policy", "")).strip() != "split-first":
|
|
161
|
+
raise ValueError("Only shared_policy: split-first is supported.")
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _matches_any(path_text: str, patterns: list[str]) -> bool:
|
|
165
|
+
return any(fnmatch(path_text, pattern) for pattern in patterns)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def normalize_policy_path(path_text: str, policy: dict[str, Any]) -> str:
|
|
169
|
+
project_root = str(policy.get(POLICY_PROJECT_ROOT_KEY, "")).strip()
|
|
170
|
+
if project_root:
|
|
171
|
+
return normalize_path_text(
|
|
172
|
+
to_project_relative_path(path_text, Path(project_root), allow_legacy_suffix=False)
|
|
173
|
+
)
|
|
174
|
+
return normalize_path_text(path_text)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def classify_path(path_text: str, policy: dict[str, Any]) -> str:
|
|
178
|
+
normalized = normalize_policy_path(path_text, policy)
|
|
179
|
+
test_paths = policy.get("test_paths", {})
|
|
180
|
+
|
|
181
|
+
ordered_groups: list[tuple[str, list[str]]] = [
|
|
182
|
+
("shared", _as_string_list(policy.get("shared_paths"))),
|
|
183
|
+
("orchestration", _as_string_list(policy.get("orchestration_paths"))),
|
|
184
|
+
("docs", _as_string_list(policy.get("docs_paths"))),
|
|
185
|
+
("frontend-test", _as_string_list(test_paths.get("frontend"))),
|
|
186
|
+
("backend-test", _as_string_list(test_paths.get("backend"))),
|
|
187
|
+
("frontend", _as_string_list(policy.get("frontend_paths"))),
|
|
188
|
+
("backend", _as_string_list(policy.get("backend_paths"))),
|
|
189
|
+
]
|
|
190
|
+
for owner, patterns in ordered_groups:
|
|
191
|
+
if _matches_any(normalized, patterns):
|
|
192
|
+
return owner
|
|
193
|
+
return "unknown"
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def allowed_owners_for_actor(actor: str, policy: dict[str, Any]) -> set[str]:
|
|
197
|
+
normalized_actor = str(actor or "").strip().lower()
|
|
198
|
+
rules = policy.get("rules", {})
|
|
199
|
+
if normalized_actor == "claude":
|
|
200
|
+
return set(_as_string_list(rules.get("claude_allowed_owners")))
|
|
201
|
+
if normalized_actor in {"codex", "codexmcp", "backend"}:
|
|
202
|
+
return {"backend", "backend-test"}
|
|
203
|
+
if normalized_actor in {"gemini", "geminimcp", "frontend"}:
|
|
204
|
+
return {"frontend", "frontend-test"}
|
|
205
|
+
return set()
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def recommended_action_for(owner: str, actor: str) -> str:
|
|
209
|
+
if owner == "backend":
|
|
210
|
+
return "route through cgc-build or cgc-fix with a backend step"
|
|
211
|
+
if owner == "backend-test":
|
|
212
|
+
return "route through cgc-test with a backend test step"
|
|
213
|
+
if owner == "frontend":
|
|
214
|
+
return "route through cgc-build or cgc-fix with a frontend step"
|
|
215
|
+
if owner == "frontend-test":
|
|
216
|
+
return "route through cgc-test with a frontend test step"
|
|
217
|
+
if owner == "shared":
|
|
218
|
+
return "split the shared change before execution"
|
|
219
|
+
if owner == "unknown":
|
|
220
|
+
return "add the path to model-routing.yaml or narrow the target path"
|
|
221
|
+
return f"request a policy route for actor {actor}"
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def decide_path(path_text: str, actor: str, operation: str, policy: dict[str, Any]) -> PathDecision:
|
|
225
|
+
owner = classify_path(path_text, policy)
|
|
226
|
+
allowed_owners = allowed_owners_for_actor(actor, policy)
|
|
227
|
+
normalized_actor = str(actor or "").strip().lower()
|
|
228
|
+
normalized_operation = str(operation or "").strip().lower() or "write"
|
|
229
|
+
|
|
230
|
+
allowed = normalized_operation in {"read", "inspect"} or owner in allowed_owners
|
|
231
|
+
if owner == "unknown":
|
|
232
|
+
allowed = False
|
|
233
|
+
if owner == "shared" and normalized_operation not in {"read", "inspect"}:
|
|
234
|
+
allowed = False
|
|
235
|
+
|
|
236
|
+
if allowed:
|
|
237
|
+
reason = f"{normalized_actor} may {normalized_operation} {owner} paths"
|
|
238
|
+
recommended = ""
|
|
239
|
+
elif owner == "shared":
|
|
240
|
+
reason = "shared paths require split-first routing"
|
|
241
|
+
recommended = recommended_action_for(owner, normalized_actor)
|
|
242
|
+
elif owner == "unknown":
|
|
243
|
+
reason = "path is not covered by model-routing.yaml"
|
|
244
|
+
recommended = recommended_action_for(owner, normalized_actor)
|
|
245
|
+
else:
|
|
246
|
+
reason = f"{owner} paths are not owned by {normalized_actor}"
|
|
247
|
+
recommended = recommended_action_for(owner, normalized_actor)
|
|
248
|
+
|
|
249
|
+
return PathDecision(
|
|
250
|
+
path=normalize_policy_path(path_text, policy),
|
|
251
|
+
owner=owner,
|
|
252
|
+
allowed=allowed,
|
|
253
|
+
reason=reason,
|
|
254
|
+
recommended_action=recommended,
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def evaluate_paths(paths: list[str], actor: str, operation: str, policy: dict[str, Any]) -> dict[str, Any]:
|
|
259
|
+
decisions = [decide_path(path, actor, operation, policy) for path in paths]
|
|
260
|
+
return {
|
|
261
|
+
"success": all(decision.allowed for decision in decisions),
|
|
262
|
+
"allowed": all(decision.allowed for decision in decisions),
|
|
263
|
+
"actor": str(actor or "").strip().lower(),
|
|
264
|
+
"operation": str(operation or "").strip().lower() or "write",
|
|
265
|
+
"decisions": [decision.__dict__ for decision in decisions],
|
|
266
|
+
"owners": sorted({decision.owner for decision in decisions}),
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def validate_executor_target(kind: str, target_paths: list[str], policy: dict[str, Any]) -> dict[str, Any]:
|
|
271
|
+
actor = "codex" if str(kind).strip().lower() == "backend" else "gemini" if str(kind).strip().lower() == "frontend" else str(kind)
|
|
272
|
+
result = evaluate_paths(target_paths, actor=actor, operation="write", policy=policy)
|
|
273
|
+
result["kind"] = str(kind).strip().lower()
|
|
274
|
+
return result
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def parse_hook_request(text: str) -> HookRequest:
|
|
278
|
+
if not text.strip():
|
|
279
|
+
return HookRequest(tool_name="", file_path="", command="")
|
|
280
|
+
payload = json.loads(text)
|
|
281
|
+
if not isinstance(payload, dict):
|
|
282
|
+
return HookRequest(tool_name="", file_path="", command="")
|
|
283
|
+
tool_name = str(payload.get("tool_name", "")).strip()
|
|
284
|
+
tool_input = payload.get("tool_input", {})
|
|
285
|
+
if not isinstance(tool_input, dict):
|
|
286
|
+
return HookRequest(tool_name=tool_name, file_path="", command="")
|
|
287
|
+
file_path = str(tool_input.get("file_path") or tool_input.get("path") or "").strip()
|
|
288
|
+
command = str(tool_input.get("command") or tool_input.get("script") or "").strip()
|
|
289
|
+
return HookRequest(tool_name=tool_name, file_path=file_path, command=command)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def parse_hook_payload(text: str) -> tuple[str, str]:
|
|
293
|
+
request = parse_hook_request(text)
|
|
294
|
+
return request.tool_name, request.file_path
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def has_unquoted_shell_control_operator(command: str) -> bool:
|
|
298
|
+
in_single = False
|
|
299
|
+
in_double = False
|
|
300
|
+
escaped = False
|
|
301
|
+
for index, char in enumerate(command):
|
|
302
|
+
if escaped:
|
|
303
|
+
escaped = False
|
|
304
|
+
continue
|
|
305
|
+
if char == "\\":
|
|
306
|
+
escaped = True
|
|
307
|
+
continue
|
|
308
|
+
if char == "'" and not in_double:
|
|
309
|
+
in_single = not in_single
|
|
310
|
+
continue
|
|
311
|
+
if char == '"' and not in_single:
|
|
312
|
+
in_double = not in_double
|
|
313
|
+
continue
|
|
314
|
+
if in_single or in_double:
|
|
315
|
+
continue
|
|
316
|
+
if char in {"|", ";", ">", "<", "`", "&"}:
|
|
317
|
+
return True
|
|
318
|
+
if char == "$" and index + 1 < len(command) and command[index + 1] == "(":
|
|
319
|
+
return True
|
|
320
|
+
return False
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def normalize_shell_command_for_prefix(command: str) -> str:
|
|
324
|
+
try:
|
|
325
|
+
parts = shlex.split(command, posix=False)
|
|
326
|
+
except ValueError:
|
|
327
|
+
parts = command.split()
|
|
328
|
+
if not parts:
|
|
329
|
+
return ""
|
|
330
|
+
|
|
331
|
+
first = parts[0].strip().strip("'\"")
|
|
332
|
+
first_name = Path(first.replace("\\", "/")).name.lower()
|
|
333
|
+
for suffix in (".cmd", ".ps1", ".exe", ".bat"):
|
|
334
|
+
if first_name.endswith(suffix):
|
|
335
|
+
first_name = first_name[: -len(suffix)]
|
|
336
|
+
break
|
|
337
|
+
rest = " ".join(str(item).strip().strip("'\"").lower() for item in parts[1:])
|
|
338
|
+
return f"{first_name} {rest}".strip()
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def shell_prefix_matches(command: str, prefix: str) -> bool:
|
|
342
|
+
return command == prefix or command.startswith(f"{prefix} ")
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def is_codecgc_cli_shell_command(normalized_command: str) -> bool:
|
|
346
|
+
first = normalized_command.split(" ", 1)[0]
|
|
347
|
+
return first == "cgc" or first == "codecgc" or first.startswith("cgc-")
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def evaluate_shell_command(command: str, tool_name: str = "Bash") -> dict[str, Any]:
|
|
351
|
+
normalized_tool_name = str(tool_name or "").strip()
|
|
352
|
+
if not str(command or "").strip():
|
|
353
|
+
return {"allowed": True, "reason": "", "recommended_action": ""}
|
|
354
|
+
|
|
355
|
+
if has_unquoted_shell_control_operator(command):
|
|
356
|
+
return {
|
|
357
|
+
"allowed": False,
|
|
358
|
+
"reason": f"{normalized_tool_name} commands with shell control operators are not allowed for Claude",
|
|
359
|
+
"recommended_action": "route write work through /cgc or run a single read-only check",
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
lowered = command.lower()
|
|
363
|
+
if any(re.search(pattern, lowered) for pattern in SHELL_DENIED_COMMAND_PATTERNS):
|
|
364
|
+
return {
|
|
365
|
+
"allowed": False,
|
|
366
|
+
"reason": f"{normalized_tool_name} command looks like a direct write or destructive shell operation",
|
|
367
|
+
"recommended_action": "route implementation through /cgc so CodeCGC can dispatch Codex or Gemini",
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
normalized_command = normalize_shell_command_for_prefix(command)
|
|
371
|
+
if is_codecgc_cli_shell_command(normalized_command):
|
|
372
|
+
return {"allowed": True, "reason": "", "recommended_action": ""}
|
|
373
|
+
|
|
374
|
+
if any(shell_prefix_matches(normalized_command, prefix) for prefix in SHELL_ALLOWED_COMMAND_PREFIXES):
|
|
375
|
+
return {"allowed": True, "reason": "", "recommended_action": ""}
|
|
376
|
+
|
|
377
|
+
return {
|
|
378
|
+
"allowed": False,
|
|
379
|
+
"reason": f"{normalized_tool_name} command is outside the CodeCGC Claude shell allowlist",
|
|
380
|
+
"recommended_action": "use /cgc for write work, or use an allowed read-only/test command",
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def build_hook_response(allowed: bool, reason: str) -> dict[str, Any]:
|
|
385
|
+
if allowed:
|
|
386
|
+
return {"decision": "approve"}
|
|
387
|
+
return {"decision": "deny", "reason": reason}
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
391
|
+
parser = argparse.ArgumentParser(description="Evaluate CodeCGC routing policy decisions.")
|
|
392
|
+
parser.add_argument("--routing-file", default="", help="Optional explicit model-routing.yaml path.")
|
|
393
|
+
parser.add_argument("--actor", default="claude", help="Actor requesting the operation.")
|
|
394
|
+
parser.add_argument("--operation", default="write", help="Operation to evaluate.")
|
|
395
|
+
parser.add_argument("--path", action="append", default=[], help="Path to evaluate. Repeatable.")
|
|
396
|
+
parser.add_argument("--hook-check", action="store_true", help="Read Claude hook JSON from stdin and return hook JSON.")
|
|
397
|
+
return parser
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def main() -> int:
|
|
401
|
+
configure_utf8_stdio()
|
|
402
|
+
parser = build_parser()
|
|
403
|
+
args = parser.parse_args()
|
|
404
|
+
|
|
405
|
+
try:
|
|
406
|
+
policy_path = Path(args.routing_file).resolve() if args.routing_file else resolve_active_routing_file()
|
|
407
|
+
policy = load_policy(policy_path)
|
|
408
|
+
|
|
409
|
+
if args.hook_check:
|
|
410
|
+
request = parse_hook_request(sys.stdin.read())
|
|
411
|
+
if request.tool_name in SHELL_TOOL_NAMES:
|
|
412
|
+
result = evaluate_shell_command(request.command, request.tool_name)
|
|
413
|
+
if result["allowed"]:
|
|
414
|
+
print_json(build_hook_response(True, ""))
|
|
415
|
+
return 0
|
|
416
|
+
hook_reason = f"CodeCGC: {result['reason']}."
|
|
417
|
+
recommended = str(result.get("recommended_action", "")).strip()
|
|
418
|
+
if recommended:
|
|
419
|
+
hook_reason += f" {recommended}."
|
|
420
|
+
print_json(build_hook_response(False, hook_reason))
|
|
421
|
+
return 0
|
|
422
|
+
|
|
423
|
+
if request.tool_name not in EDIT_TOOL_NAMES or not request.file_path:
|
|
424
|
+
print_json(build_hook_response(True, ""))
|
|
425
|
+
return 0
|
|
426
|
+
result = evaluate_paths([request.file_path], actor=args.actor, operation=args.operation, policy=policy)
|
|
427
|
+
decision = result["decisions"][0]
|
|
428
|
+
reason = decision.get("reason", "")
|
|
429
|
+
recommended = decision.get("recommended_action", "")
|
|
430
|
+
hook_reason = f"CodeCGC: {reason}."
|
|
431
|
+
if recommended:
|
|
432
|
+
hook_reason += f" {recommended}."
|
|
433
|
+
print_json(build_hook_response(bool(result["allowed"]), hook_reason))
|
|
434
|
+
return 0
|
|
435
|
+
|
|
436
|
+
if not args.path:
|
|
437
|
+
raise ValueError("At least one --path is required unless --hook-check is used.")
|
|
438
|
+
|
|
439
|
+
print_json(evaluate_paths(args.path, actor=args.actor, operation=args.operation, policy=policy))
|
|
440
|
+
return 0
|
|
441
|
+
except Exception as error:
|
|
442
|
+
print_json({"success": False, "allowed": False, "error": str(error)}, file=sys.stderr)
|
|
443
|
+
return 1
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
if __name__ == "__main__":
|
|
447
|
+
raise SystemExit(main())
|
|
@@ -1,16 +1,3 @@
|
|
|
1
|
-
from
|
|
2
|
-
|
|
3
|
-
from
|
|
4
|
-
|
|
5
|
-
from codecgc_runtime_paths import PACKAGE_ROOT
|
|
6
|
-
from codecgc_runtime_paths import PROJECT_ROOT
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
PACKAGE_ROUTING_FILE = PACKAGE_ROOT / "model-routing.yaml"
|
|
10
|
-
PROJECT_ROUTING_FILE = PROJECT_ROOT / "model-routing.yaml"
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
def resolve_active_routing_file() -> Path:
|
|
14
|
-
if PROJECT_ROUTING_FILE.exists():
|
|
15
|
-
return PROJECT_ROUTING_FILE
|
|
16
|
-
return PACKAGE_ROUTING_FILE
|
|
1
|
+
from codecgc_runtime.routing_paths import PACKAGE_ROUTING_FILE
|
|
2
|
+
from codecgc_runtime.routing_paths import PROJECT_ROUTING_FILE
|
|
3
|
+
from codecgc_runtime.routing_paths import resolve_active_routing_file
|
|
@@ -1,135 +1,11 @@
|
|
|
1
|
-
from
|
|
2
|
-
|
|
3
|
-
from
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
"web/**",
|
|
13
|
-
"frontend/**",
|
|
14
|
-
]
|
|
15
|
-
|
|
16
|
-
DEFAULT_BACKEND_PATHS = [
|
|
17
|
-
"apps/api/**",
|
|
18
|
-
"server/**",
|
|
19
|
-
"src/server/**",
|
|
20
|
-
"src/services/**",
|
|
21
|
-
"src/repositories/**",
|
|
22
|
-
"backend/**",
|
|
23
|
-
]
|
|
24
|
-
|
|
25
|
-
DEFAULT_SHARED_PATHS = [
|
|
26
|
-
"packages/shared/**",
|
|
27
|
-
"src/shared/**",
|
|
28
|
-
"src/lib/**",
|
|
29
|
-
"src/types/**",
|
|
30
|
-
]
|
|
31
|
-
|
|
32
|
-
DEFAULT_RULES = {
|
|
33
|
-
"frontend_executor": "geminimcp",
|
|
34
|
-
"backend_executor": "codexmcp",
|
|
35
|
-
"shared_policy": "split-first",
|
|
36
|
-
"claude_role": "plan-review-accept-only",
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
def _render_list_block(name: str, items: list[str]) -> list[str]:
|
|
41
|
-
lines = [f"{name}:"]
|
|
42
|
-
for item in items:
|
|
43
|
-
lines.append(f' - "{item}"')
|
|
44
|
-
return lines
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
def _render_rules_block() -> list[str]:
|
|
48
|
-
lines = ["rules:"]
|
|
49
|
-
for key, value in DEFAULT_RULES.items():
|
|
50
|
-
lines.append(f' {key}: "{value}"')
|
|
51
|
-
return lines
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
def render_default_routing_yaml() -> str:
|
|
55
|
-
lines: list[str] = [
|
|
56
|
-
"version: 1",
|
|
57
|
-
"",
|
|
58
|
-
*_render_list_block("frontend_paths", DEFAULT_FRONTEND_PATHS),
|
|
59
|
-
"",
|
|
60
|
-
*_render_list_block("custom_frontend_paths", []),
|
|
61
|
-
"",
|
|
62
|
-
*_render_list_block("backend_paths", DEFAULT_BACKEND_PATHS),
|
|
63
|
-
"",
|
|
64
|
-
*_render_list_block("custom_backend_paths", []),
|
|
65
|
-
"",
|
|
66
|
-
*_render_list_block("shared_paths", DEFAULT_SHARED_PATHS),
|
|
67
|
-
"",
|
|
68
|
-
*_render_rules_block(),
|
|
69
|
-
"",
|
|
70
|
-
]
|
|
71
|
-
return "\n".join(lines)
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
def _normalize_line_endings(text: str) -> str:
|
|
75
|
-
return text.replace("\r\n", "\n").replace("\r", "\n")
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
def _extract_list_block(lines: list[str], block_name: str) -> list[str]:
|
|
79
|
-
items: list[str] = []
|
|
80
|
-
inside = False
|
|
81
|
-
for line in lines:
|
|
82
|
-
stripped = line.strip()
|
|
83
|
-
if not inside:
|
|
84
|
-
if stripped == f"{block_name}:":
|
|
85
|
-
inside = True
|
|
86
|
-
continue
|
|
87
|
-
|
|
88
|
-
if line and not line.startswith(" "):
|
|
89
|
-
break
|
|
90
|
-
if stripped.startswith("- "):
|
|
91
|
-
value = stripped[2:].strip()
|
|
92
|
-
if len(value) >= 2 and value[0] == value[-1] and value[0] in {'"', "'"}:
|
|
93
|
-
value = value[1:-1]
|
|
94
|
-
if value:
|
|
95
|
-
items.append(value)
|
|
96
|
-
return items
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
def merge_routing_template(existing_text: str) -> str:
|
|
100
|
-
if not existing_text.strip():
|
|
101
|
-
return render_default_routing_yaml()
|
|
102
|
-
|
|
103
|
-
lines = _normalize_line_endings(existing_text).split("\n")
|
|
104
|
-
custom_frontend = _extract_list_block(lines, "custom_frontend_paths")
|
|
105
|
-
custom_backend = _extract_list_block(lines, "custom_backend_paths")
|
|
106
|
-
|
|
107
|
-
merged = render_default_routing_yaml().split("\n")
|
|
108
|
-
output: list[str] = []
|
|
109
|
-
current_block = ""
|
|
110
|
-
|
|
111
|
-
for line in merged:
|
|
112
|
-
stripped = line.strip()
|
|
113
|
-
output.append(line)
|
|
114
|
-
if stripped == "custom_frontend_paths:":
|
|
115
|
-
current_block = "custom_frontend_paths"
|
|
116
|
-
for item in custom_frontend:
|
|
117
|
-
output.append(f' - "{item}"')
|
|
118
|
-
continue
|
|
119
|
-
if stripped == "custom_backend_paths:":
|
|
120
|
-
current_block = "custom_backend_paths"
|
|
121
|
-
for item in custom_backend:
|
|
122
|
-
output.append(f' - "{item}"')
|
|
123
|
-
continue
|
|
124
|
-
if stripped.endswith(":") and stripped not in {"custom_frontend_paths:", "custom_backend_paths:"}:
|
|
125
|
-
current_block = stripped[:-1]
|
|
126
|
-
|
|
127
|
-
return "\n".join(output).rstrip() + "\n"
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
def sync_workspace_routing_file(target_path: Path) -> Path:
|
|
131
|
-
existing_text = target_path.read_text(encoding="utf-8") if target_path.exists() else ""
|
|
132
|
-
merged_text = merge_routing_template(existing_text)
|
|
133
|
-
target_path.parent.mkdir(parents=True, exist_ok=True)
|
|
134
|
-
target_path.write_text(merged_text, encoding="utf-8")
|
|
135
|
-
return target_path
|
|
1
|
+
from codecgc_runtime.routing_template import DEFAULT_BACKEND_PATHS
|
|
2
|
+
from codecgc_runtime.routing_template import DEFAULT_BACKEND_TEST_PATHS
|
|
3
|
+
from codecgc_runtime.routing_template import DEFAULT_DOCS_PATHS
|
|
4
|
+
from codecgc_runtime.routing_template import DEFAULT_FRONTEND_PATHS
|
|
5
|
+
from codecgc_runtime.routing_template import DEFAULT_FRONTEND_TEST_PATHS
|
|
6
|
+
from codecgc_runtime.routing_template import DEFAULT_ORCHESTRATION_PATHS
|
|
7
|
+
from codecgc_runtime.routing_template import DEFAULT_RULES
|
|
8
|
+
from codecgc_runtime.routing_template import DEFAULT_SHARED_PATHS
|
|
9
|
+
from codecgc_runtime.routing_template import merge_routing_template
|
|
10
|
+
from codecgc_runtime.routing_template import render_default_routing_yaml
|
|
11
|
+
from codecgc_runtime.routing_template import sync_workspace_routing_file
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Shared CodeCGC runtime helpers."""
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from .paths import PROJECT_ROOT
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
CODECGC_ROOT = PROJECT_ROOT / "codecgc"
|
|
9
|
+
PRODUCT_ROOT = CODECGC_ROOT
|
|
10
|
+
FIXTURE_ROOT = CODECGC_ROOT / "fixtures"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def normalize_artifact_class(value: str | None) -> str:
|
|
14
|
+
cleaned = str(value or "product").strip().lower()
|
|
15
|
+
return "fixture" if cleaned == "fixture" else "product"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def flow_root(flow: str, artifact_class: str) -> Path:
|
|
19
|
+
base = FIXTURE_ROOT if normalize_artifact_class(artifact_class) == "fixture" else PRODUCT_ROOT
|
|
20
|
+
return base / ("features" if flow == "feature" else "issues")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def execution_root(artifact_class: str) -> Path:
|
|
24
|
+
base = FIXTURE_ROOT if normalize_artifact_class(artifact_class) == "fixture" else PRODUCT_ROOT
|
|
25
|
+
return base / "execution"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def discover_flow_directory(flow: str, slug: str, artifact_class: str = "auto") -> tuple[str, Path] | None:
|
|
29
|
+
classes = (
|
|
30
|
+
[normalize_artifact_class(artifact_class)]
|
|
31
|
+
if artifact_class in {"product", "fixture"}
|
|
32
|
+
else ["product", "fixture"]
|
|
33
|
+
)
|
|
34
|
+
for candidate_class in classes:
|
|
35
|
+
directory = flow_root(flow, candidate_class) / slug
|
|
36
|
+
if directory.exists():
|
|
37
|
+
return candidate_class, directory
|
|
38
|
+
return None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def artifact_output_root(artifact_class: str) -> Path:
|
|
42
|
+
return FIXTURE_ROOT if normalize_artifact_class(artifact_class) == "fixture" else PRODUCT_ROOT
|