git-ai-control 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.
@@ -0,0 +1,560 @@
1
+ #!/usr/bin/env python3
2
+ """Config-driven upload filter for Git AI custom metrics."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ import os
8
+ import re
9
+ import subprocess
10
+ import sys
11
+ import urllib.error
12
+ import urllib.request
13
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
14
+ from pathlib import Path
15
+ from urllib.parse import urlsplit
16
+
17
+
18
+ EVENT_TYPES = {
19
+ "/commit": "commit",
20
+ "/legacy/commit": "commit",
21
+ "/checkpoint": "checkpoint",
22
+ "/legacy/checkpoint": "checkpoint",
23
+ "/token-usage": "token",
24
+ "/token-usage/batch": "token",
25
+ "/token-usage/stats": "token",
26
+ "/legacy/token-usage": "token",
27
+ "/skill-usage": "skill",
28
+ "/agent-usage": "agent",
29
+ "/prompt-duration": "prompt_duration",
30
+ "/prompt-report": "prompt_report",
31
+ }
32
+
33
+ HOST = "127.0.0.1"
34
+ PORT = 38741
35
+ POLICY_CONFIG_PATH = Path.home() / ".git-ai" / "filter_plugins.json"
36
+ UPSTREAM_CONFIG_PATH = Path.home() / ".git-ai" / "upstream_metrics.json"
37
+ OWNER_REPOSITORY_PATTERN = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+(?:\.git)?$")
38
+ SKILL_KEYS = {"skillName", "skill", "name"}
39
+ PATH_KEYS = {
40
+ "cwd",
41
+ "dir",
42
+ "path",
43
+ "project_dir",
44
+ "projectDir",
45
+ "repoDir",
46
+ "repoRoot",
47
+ "repo_work_dir",
48
+ "repoWorkingDir",
49
+ "root",
50
+ "workspace_root",
51
+ "workspaceRoot",
52
+ "workdir",
53
+ "workingDir",
54
+ }
55
+ REPOSITORY_KEYS = {
56
+ "repo",
57
+ "reponame",
58
+ "repoid",
59
+ "repourl",
60
+ "repopath",
61
+ "reporemote",
62
+ "reposlug",
63
+ "repoowner",
64
+ "remote",
65
+ "remoteurl",
66
+ "remoteuri",
67
+ "origin",
68
+ "originurl",
69
+ "originuri",
70
+ "giturl",
71
+ "gitremote",
72
+ "url",
73
+ "uri",
74
+ }
75
+ BRANCH_KEYS = {
76
+ "branch",
77
+ "branchname",
78
+ "branchref",
79
+ "headbranch",
80
+ "basebranch",
81
+ "sourcebranch",
82
+ "targetbranch",
83
+ "ref",
84
+ "headref",
85
+ "baseref",
86
+ }
87
+ NORMALIZED_PATH_KEYS = {re.sub(r"[^a-z0-9]", "", key.lower()) for key in PATH_KEYS}
88
+ DROP = object()
89
+
90
+ DEFAULT_POLICY_CONFIG = {
91
+ "version": 1,
92
+ "default_allow_unmatched": True,
93
+ "skill_policy": {
94
+ "installed": True,
95
+ "enabled": True,
96
+ "blocked_patterns": [
97
+ "godot",
98
+ r"\bgame\b",
99
+ "game-",
100
+ "-game",
101
+ "game_studio",
102
+ "game-studio",
103
+ "phaser",
104
+ "three",
105
+ "webgl",
106
+ "unity",
107
+ "unreal",
108
+ r"battle[-_ ]?brothers",
109
+ "tactics",
110
+ "sprite",
111
+ "github",
112
+ "git-hub",
113
+ "cloudflare",
114
+ "cloud-flare",
115
+ ],
116
+ },
117
+ "plugins": [
118
+ {
119
+ "id": "github",
120
+ "name": "GitHub 仓库",
121
+ "enabled": True,
122
+ "match": {"hosts": ["github.com"]},
123
+ "fixed_project_directory": "",
124
+ "allow": {
125
+ "token": False,
126
+ "skill": False,
127
+ "commit": False,
128
+ "checkpoint": False,
129
+ "agent": False,
130
+ "prompt_duration": False,
131
+ "prompt_report": False,
132
+ },
133
+ "fields": {
134
+ "repository": False,
135
+ "path": False,
136
+ "branch": False,
137
+ },
138
+ }
139
+ ],
140
+ }
141
+
142
+
143
+ def clone_default_policy() -> dict:
144
+ return json.loads(json.dumps(DEFAULT_POLICY_CONFIG))
145
+
146
+
147
+ def load_upstreams() -> tuple[dict[str, str], str]:
148
+ try:
149
+ config = json.loads(UPSTREAM_CONFIG_PATH.read_text(encoding="utf-8"))
150
+ except FileNotFoundError:
151
+ return {}, f"missing upstream config: {UPSTREAM_CONFIG_PATH}"
152
+ except (OSError, json.JSONDecodeError) as error:
153
+ return {}, f"invalid upstream config: {error}"
154
+
155
+ routes = config.get("routes", config)
156
+ if not isinstance(routes, dict):
157
+ return {}, "invalid upstream config: routes must be an object"
158
+
159
+ validated = {}
160
+ for route, url in routes.items():
161
+ if route not in EVENT_TYPES or not isinstance(url, str):
162
+ continue
163
+ parsed = urlsplit(url)
164
+ if parsed.scheme not in {"http", "https"} or not parsed.netloc:
165
+ continue
166
+ if parsed.hostname in {HOST, "localhost", "::1"} and parsed.port == PORT:
167
+ continue
168
+ validated[route] = url
169
+
170
+ if not validated:
171
+ return {}, "invalid upstream config: no usable routes"
172
+ return validated, ""
173
+
174
+
175
+ def normalize_key(key) -> str:
176
+ return re.sub(r"[^a-z0-9]", "", str(key).lower())
177
+
178
+
179
+ def is_path_key(key) -> bool:
180
+ return normalize_key(key) in NORMALIZED_PATH_KEYS
181
+
182
+
183
+ def is_repository_key(key) -> bool:
184
+ normalized = normalize_key(key)
185
+ return normalized in REPOSITORY_KEYS or normalized.startswith("repository")
186
+
187
+
188
+ def is_branch_key(key) -> bool:
189
+ normalized = normalize_key(key)
190
+ return normalized in BRANCH_KEYS or normalized.startswith("branch")
191
+
192
+
193
+ def iter_all_text(value):
194
+ if isinstance(value, dict):
195
+ for child in value.values():
196
+ yield from iter_all_text(child)
197
+ elif isinstance(value, list):
198
+ for child in value:
199
+ yield from iter_all_text(child)
200
+ elif isinstance(value, str):
201
+ yield value
202
+
203
+
204
+ def iter_skill_text(value):
205
+ if isinstance(value, dict):
206
+ for key, child in value.items():
207
+ if key in SKILL_KEYS:
208
+ yield from iter_all_text(child)
209
+ else:
210
+ yield from iter_skill_text(child)
211
+ elif isinstance(value, list):
212
+ for child in value:
213
+ yield from iter_skill_text(child)
214
+
215
+
216
+ def iter_path_text(value):
217
+ if isinstance(value, dict):
218
+ for key, child in value.items():
219
+ if is_path_key(key):
220
+ yield from iter_all_text(child)
221
+ else:
222
+ yield from iter_path_text(child)
223
+ elif isinstance(value, list):
224
+ for child in value:
225
+ yield from iter_path_text(child)
226
+
227
+
228
+ def read_remote_text(path: str) -> str:
229
+ if not isinstance(path, str) or not path:
230
+ return ""
231
+ expanded = os.path.expanduser(path)
232
+ candidates = [expanded]
233
+ if os.path.exists(expanded) and not os.path.isdir(expanded):
234
+ candidates.insert(0, os.path.dirname(expanded))
235
+ for candidate in candidates:
236
+ if not os.path.isdir(candidate):
237
+ continue
238
+ try:
239
+ result = subprocess.run(
240
+ ["git", "-C", candidate, "remote", "-v"],
241
+ check=False,
242
+ capture_output=True,
243
+ text=True,
244
+ timeout=2,
245
+ )
246
+ except Exception:
247
+ continue
248
+ if result.stdout:
249
+ return result.stdout
250
+ return ""
251
+
252
+
253
+ def load_policy_config() -> tuple[dict, str]:
254
+ try:
255
+ with POLICY_CONFIG_PATH.open("r", encoding="utf-8") as handle:
256
+ config = json.load(handle)
257
+ if not isinstance(config, dict) or config.get("version") != 1:
258
+ raise ValueError("unsupported policy version")
259
+ if not isinstance(config.get("plugins"), list):
260
+ raise ValueError("plugins must be a list")
261
+ return config, ""
262
+ except Exception as error:
263
+ return clone_default_policy(), str(error)
264
+
265
+
266
+ def host_needles(plugin: dict) -> list[str]:
267
+ hosts = plugin.get("match", {}).get("hosts", [])
268
+ return [
269
+ str(host).lower().replace("*", "").strip()
270
+ for host in hosts
271
+ if str(host).lower().replace("*", "").strip()
272
+ ]
273
+
274
+
275
+ def plugin_matches(plugin: dict, payload, raw_body: bytes) -> bool:
276
+ needles = host_needles(plugin)
277
+ if not needles:
278
+ return False
279
+ raw_text = raw_body.decode("utf-8", errors="ignore").lower()
280
+ if any(needle in raw_text for needle in needles):
281
+ return True
282
+ if any(needle in text.lower() for text in iter_all_text(payload) for needle in needles):
283
+ return True
284
+ for path in iter_path_text(payload):
285
+ remote_text = read_remote_text(path).lower()
286
+ if any(needle in remote_text for needle in needles):
287
+ return True
288
+ return False
289
+
290
+
291
+ def find_matching_plugin(config: dict, payload, raw_body: bytes) -> dict | None:
292
+ for plugin in config.get("plugins", []):
293
+ if plugin.get("enabled", True) and plugin_matches(plugin, payload, raw_body):
294
+ return plugin
295
+ return None
296
+
297
+
298
+ def skill_is_blocked(payload, skill_policy: dict) -> bool:
299
+ if not skill_policy.get("installed", True) or not skill_policy.get("enabled", True):
300
+ return False
301
+ patterns = []
302
+ for pattern in skill_policy.get("blocked_patterns", []):
303
+ try:
304
+ patterns.append(re.compile(str(pattern), re.IGNORECASE))
305
+ except re.error:
306
+ patterns.append(re.compile(re.escape(str(pattern)), re.IGNORECASE))
307
+ return any(
308
+ pattern.search(text)
309
+ for text in iter_skill_text(payload)
310
+ for pattern in patterns
311
+ )
312
+
313
+
314
+ def replace_skill_project_dirs(value, directory: str):
315
+ if isinstance(value, dict):
316
+ replaced = {}
317
+ for key, child in value.items():
318
+ if is_path_key(key):
319
+ replaced[key] = directory
320
+ else:
321
+ replaced[key] = replace_skill_project_dirs(child, directory)
322
+ return replaced
323
+ if isinstance(value, list):
324
+ return [replace_skill_project_dirs(child, directory) for child in value]
325
+ return value
326
+
327
+
328
+ def sanitize_fields(value, fields: dict, hosts: list[str]):
329
+ if isinstance(value, dict):
330
+ sanitized = {}
331
+ for key, child in value.items():
332
+ if not fields.get("path", True) and is_path_key(key):
333
+ continue
334
+ if not fields.get("repository", True) and is_repository_key(key):
335
+ continue
336
+ if not fields.get("branch", True) and is_branch_key(key):
337
+ continue
338
+ cleaned = sanitize_fields(child, fields, hosts)
339
+ if cleaned is not DROP:
340
+ sanitized[key] = cleaned
341
+ return sanitized
342
+ if isinstance(value, list):
343
+ return [
344
+ cleaned
345
+ for child in value
346
+ if (cleaned := sanitize_fields(child, fields, hosts)) is not DROP
347
+ ]
348
+ if isinstance(value, str) and not fields.get("repository", True):
349
+ lowered = value.lower()
350
+ if any(host in lowered for host in hosts) or OWNER_REPOSITORY_PATTERN.fullmatch(value):
351
+ return DROP
352
+ return value
353
+
354
+
355
+ def evaluate_request(
356
+ endpoint_path: str,
357
+ payload,
358
+ raw_body: bytes | None = None,
359
+ config: dict | None = None,
360
+ ) -> dict:
361
+ if config is None:
362
+ config, config_error = load_policy_config()
363
+ else:
364
+ config_error = ""
365
+ if raw_body is None:
366
+ raw_body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
367
+ event_type = EVENT_TYPES.get(endpoint_path)
368
+ plugin = find_matching_plugin(config, payload, raw_body)
369
+ plugin_id = plugin.get("id") if plugin else None
370
+
371
+ skill_policy = config.get("skill_policy", {})
372
+ if event_type == "skill" and skill_is_blocked(payload, skill_policy):
373
+ return {
374
+ "blocked": True,
375
+ "reason": "skill_pattern",
376
+ "payload": payload,
377
+ "plugin": plugin_id,
378
+ "config_error": config_error,
379
+ }
380
+
381
+ if plugin:
382
+ allowed = bool(plugin.get("allow", {}).get(event_type, False))
383
+ if not allowed:
384
+ return {
385
+ "blocked": True,
386
+ "reason": f"plugin:{plugin_id}:{event_type}",
387
+ "payload": payload,
388
+ "plugin": plugin_id,
389
+ "config_error": config_error,
390
+ }
391
+ elif not config.get("default_allow_unmatched", True):
392
+ return {
393
+ "blocked": True,
394
+ "reason": "default_deny",
395
+ "payload": payload,
396
+ "plugin": None,
397
+ "config_error": config_error,
398
+ }
399
+
400
+ transformed = payload
401
+ if event_type == "skill" and plugin:
402
+ fixed_directory = str(plugin.get("fixed_project_directory", "")).strip()
403
+ if fixed_directory:
404
+ transformed = replace_skill_project_dirs(transformed, fixed_directory)
405
+
406
+ if plugin:
407
+ transformed = sanitize_fields(
408
+ transformed,
409
+ plugin.get("fields", {}),
410
+ host_needles(plugin),
411
+ )
412
+ if transformed is DROP:
413
+ return {
414
+ "blocked": True,
415
+ "reason": f"plugin:{plugin_id}:empty",
416
+ "payload": payload,
417
+ "plugin": plugin_id,
418
+ "config_error": config_error,
419
+ }
420
+
421
+ return {
422
+ "blocked": False,
423
+ "reason": "",
424
+ "payload": transformed,
425
+ "plugin": plugin_id,
426
+ "config_error": config_error,
427
+ }
428
+
429
+
430
+ class Handler(BaseHTTPRequestHandler):
431
+ server_version = "git-ai-plugin-filter/3.0"
432
+
433
+ def log_message(self, fmt, *args):
434
+ sys.stderr.write("%s - %s\n" % (self.log_date_time_string(), fmt % args))
435
+
436
+ def do_OPTIONS(self):
437
+ self.send_response(200)
438
+ self.send_header("Allow", "POST,OPTIONS")
439
+ self.send_header("Access-Control-Allow-Origin", "*")
440
+ self.send_header("Access-Control-Allow-Headers", "*")
441
+ self.end_headers()
442
+
443
+ def do_GET(self):
444
+ if self.path == "/health":
445
+ config, config_error = load_policy_config()
446
+ upstreams, upstream_error = load_upstreams()
447
+ self.send_json(
448
+ 200,
449
+ {
450
+ "ok": not bool(config_error or upstream_error),
451
+ "mode": "plugins",
452
+ "policyVersion": config.get("version"),
453
+ "plugins": [
454
+ plugin.get("id")
455
+ for plugin in config.get("plugins", [])
456
+ if plugin.get("enabled", True)
457
+ ],
458
+ "configError": config_error or None,
459
+ "upstreamConfigError": upstream_error or None,
460
+ "routes": sorted(upstreams),
461
+ },
462
+ )
463
+ return
464
+ self.send_json(404, {"ok": False, "message": "not found"})
465
+
466
+ def do_POST(self):
467
+ endpoint_path = urlsplit(self.path).path
468
+ upstreams, upstream_error = load_upstreams()
469
+ if upstream_error:
470
+ self.send_json(
471
+ 503,
472
+ {"code": -1, "data": None, "message": upstream_error},
473
+ )
474
+ return
475
+ upstream_url = upstreams.get(endpoint_path)
476
+ if not upstream_url:
477
+ self.send_json(404, {"code": -1, "data": None, "message": "unknown endpoint"})
478
+ return
479
+
480
+ length = int(self.headers.get("Content-Length") or "0")
481
+ body = self.rfile.read(length)
482
+ try:
483
+ payload = json.loads(body.decode("utf-8") or "{}")
484
+ except json.JSONDecodeError:
485
+ self.send_filtered(endpoint_path, "invalid_json")
486
+ return
487
+
488
+ decision = evaluate_request(endpoint_path, payload, body)
489
+ if decision["blocked"]:
490
+ self.send_filtered(endpoint_path, decision["reason"], decision.get("plugin"))
491
+ return
492
+
493
+ body = json.dumps(
494
+ decision["payload"],
495
+ ensure_ascii=False,
496
+ separators=(",", ":"),
497
+ ).encode("utf-8")
498
+ request = urllib.request.Request(
499
+ upstream_url,
500
+ data=body,
501
+ method="POST",
502
+ headers={
503
+ "Content-Type": self.headers.get("Content-Type", "application/json"),
504
+ "Accept": self.headers.get("Accept", "application/json"),
505
+ },
506
+ )
507
+ try:
508
+ with urllib.request.urlopen(request, timeout=10) as response:
509
+ response_body = response.read()
510
+ self.send_response(response.status)
511
+ self.send_header(
512
+ "Content-Type",
513
+ response.headers.get("Content-Type", "application/json"),
514
+ )
515
+ self.end_headers()
516
+ self.wfile.write(response_body)
517
+ except urllib.error.HTTPError as error:
518
+ self.send_response(error.code)
519
+ self.send_header(
520
+ "Content-Type",
521
+ error.headers.get("Content-Type", "application/json"),
522
+ )
523
+ self.end_headers()
524
+ self.wfile.write(error.read())
525
+ except Exception as error:
526
+ self.send_json(502, {"code": -1, "data": None, "message": str(error)})
527
+
528
+ def send_filtered(self, endpoint_path: str, reason: str, plugin: str | None = None):
529
+ self.send_json(
530
+ 200,
531
+ {
532
+ "code": 0,
533
+ "data": {
534
+ "filtered": True,
535
+ "reason": reason,
536
+ "plugin": plugin,
537
+ },
538
+ "message": "filtered",
539
+ },
540
+ )
541
+ sys.stderr.write(f"filtered {endpoint_path} reason={reason} plugin={plugin or '-'}\n")
542
+
543
+ def send_json(self, status: int, payload):
544
+ data = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
545
+ self.send_response(status)
546
+ self.send_header("Content-Type", "application/json")
547
+ self.send_header("Content-Length", str(len(data)))
548
+ self.send_header("Access-Control-Allow-Origin", "*")
549
+ self.end_headers()
550
+ self.wfile.write(data)
551
+
552
+
553
+ def main():
554
+ server = ThreadingHTTPServer((HOST, PORT), Handler)
555
+ print(f"git-ai plugin filter listening on http://{HOST}:{PORT}", flush=True)
556
+ server.serve_forever()
557
+
558
+
559
+ if __name__ == "__main__":
560
+ main()
@@ -0,0 +1,53 @@
1
+ {
2
+ "version": 1,
3
+ "default_allow_unmatched": true,
4
+ "skill_policy": {
5
+ "installed": true,
6
+ "enabled": true,
7
+ "blocked_patterns": [
8
+ "godot",
9
+ "\\bgame\\b",
10
+ "game-",
11
+ "-game",
12
+ "game_studio",
13
+ "phaser",
14
+ "three",
15
+ "webgl",
16
+ "unity",
17
+ "unreal",
18
+ "tactics",
19
+ "sprite",
20
+ "github",
21
+ "git-hub",
22
+ "cloudflare",
23
+ "cloud-flare"
24
+ ]
25
+ },
26
+ "plugins": [
27
+ {
28
+ "id": "github",
29
+ "name": "GitHub 仓库",
30
+ "enabled": true,
31
+ "match": {
32
+ "hosts": [
33
+ "github.com"
34
+ ]
35
+ },
36
+ "fixed_project_directory": "",
37
+ "allow": {
38
+ "agent": false,
39
+ "checkpoint": false,
40
+ "commit": false,
41
+ "prompt_duration": false,
42
+ "prompt_report": false,
43
+ "skill": false,
44
+ "token": true
45
+ },
46
+ "fields": {
47
+ "branch": false,
48
+ "path": false,
49
+ "repository": false
50
+ }
51
+ }
52
+ ]
53
+ }