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.
- package/CHANGELOG.md +41 -0
- package/COMPATIBILITY.md +66 -0
- package/README.md +180 -0
- package/bin/git-ai-control.js +57 -0
- package/docs/images/git-ai-settings.jpg +0 -0
- package/docs/images/plugin-management.jpg +0 -0
- package/launchagents/com.git-ai.control-panel.plist.in +24 -0
- package/launchagents/com.git-ai.skill-usage-filter.plist.in +22 -0
- package/package.json +45 -0
- package/plugins/upload-filter/plugin_filter_runtime.py +560 -0
- package/policy.example.json +53 -0
- package/scripts/install.sh +241 -0
- package/scripts/uninstall.sh +91 -0
- package/server.py +534 -0
- package/static/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
- package/static/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
- package/static/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
- package/static/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
- package/static/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
- package/static/assets/index-DWeFCLPQ.js +50 -0
- package/static/assets/index-WWn7U1pB.css +2 -0
- package/static/index.html +14 -0
|
@@ -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
|
+
}
|