git-ai-control 0.4.12 → 0.4.14
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 +17 -0
- package/README.zh-CN.md +9 -2
- package/bin/git-ai-control.js +13 -3
- package/package.json +2 -1
- package/plugins/upload-filter/plugin_filter_runtime.py +172 -0
- package/policy.example.json +6 -1
- package/scripts/install.mjs +41 -6
- package/server.py +38 -0
- package/static/assets/index-oco9luAC.js +50 -0
- package/static/index.html +1 -1
- package/static/assets/index-BcO5FeRx.js +0 -50
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,23 @@
|
|
|
2
2
|
|
|
3
3
|
本项目的所有重要变更都会记录在此文件中。
|
|
4
4
|
|
|
5
|
+
## [0.4.14] - 2026-09-09
|
|
6
|
+
|
|
7
|
+
### 修复
|
|
8
|
+
|
|
9
|
+
- `npx git-ai-control` 发现正在运行的配置中心版本低于当前 npm 包时,自动安装新版并重启本机服务;旧版没有版本标记时也会自动升级。
|
|
10
|
+
|
|
11
|
+
### 变更
|
|
12
|
+
|
|
13
|
+
- npm 包补充作者元数据为 `yingyanzhitong`,保留 GitHub Actions OIDC Trusted Publishing 的供应链签名。
|
|
14
|
+
|
|
15
|
+
## [0.4.13] - 2026-09-09
|
|
16
|
+
|
|
17
|
+
### 新增
|
|
18
|
+
|
|
19
|
+
- Skill 过滤新增“新目录观察期”:目录创建未满 7 天时,首次 Skill 上报默认拦截 24 小时,超时未处理自动放行;观察期状态在本机持久化,服务重启不会重新计时。
|
|
20
|
+
- 管理后台新增观察期白名单和 Skill 黑名单:白名单可立即放行指定 Skill,黑名单持续阻断指定 Skill 上报。
|
|
21
|
+
|
|
5
22
|
## [0.4.12] - 2026-08-12
|
|
6
23
|
|
|
7
24
|
### 变更
|
package/README.zh-CN.md
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
- 可删除仓库信息、项目路径和分支名称等敏感字段;
|
|
18
18
|
- 可将允许上传的 Skill 项目目录替换为固定目录;
|
|
19
19
|
- 可按关键词或正则拦截指定 Skill;
|
|
20
|
+
- 新建目录 7 天内的 Skill 上报默认进入 24 小时观察期,可在后台通过白名单放行或黑名单持续拦截;
|
|
20
21
|
- 仓库插件可按不同主机添加多个,并支持全局兜底、仓库、目录、分支与优先级;
|
|
21
22
|
- 敏感内容脱敏插件可识别 API Key、私钥、凭据,并按规则脱敏或拦截;
|
|
22
23
|
- Agent / 模型治理插件支持允许名单、阻止正则及审计/拦截模式;
|
|
@@ -50,7 +51,7 @@ Windows: %USERPROFILE%\.git-ai\bin\git-ai.exe
|
|
|
50
51
|
npx git-ai-control
|
|
51
52
|
```
|
|
52
53
|
|
|
53
|
-
首次运行时,该命令会自动下载最新版本、安装本机服务,并打开 Git AI
|
|
54
|
+
首次运行时,该命令会自动下载最新版本、安装本机服务,并打开 Git AI 配置中心;之后若检测到正在运行的旧版本,会自动更新文件并重启本机服务,版本相同才复用现有进程。若浏览器没有自动打开,可手动访问:
|
|
54
55
|
|
|
55
56
|
```text
|
|
56
57
|
http://127.0.0.1:38742
|
|
@@ -90,7 +91,13 @@ http://127.0.0.1:38742
|
|
|
90
91
|
3. 每行填写一个关键词或正则;
|
|
91
92
|
4. 保存配置。
|
|
92
93
|
|
|
93
|
-
### 5.
|
|
94
|
+
### 5. 新目录 Skill 观察期
|
|
95
|
+
|
|
96
|
+
“Skill 过滤插件”默认启用新目录观察期。上报事件中的项目目录创建未满 7 天时,首次 Skill 上报会被拦截 24 小时;期间可将 Skill 加入观察期白名单立即放行,或加入 Skill 黑名单持续阻断。24 小时没有处理则自动恢复上报,且过滤服务重启不会重新计时。
|
|
97
|
+
|
|
98
|
+
文件系统未提供目录创建时间时,观察期不会基于不可靠的修改时间推断目录年龄。
|
|
99
|
+
|
|
100
|
+
### 6. 验证配置
|
|
94
101
|
|
|
95
102
|
点击页面中的“验证配置”,或在终端检查两个本机服务:
|
|
96
103
|
|
package/bin/git-ai-control.js
CHANGED
|
@@ -1,15 +1,19 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
import {spawnSync} from "node:child_process"
|
|
4
|
+
import fs from "node:fs"
|
|
4
5
|
import path from "node:path"
|
|
5
6
|
import {fileURLToPath} from "node:url"
|
|
6
7
|
|
|
7
8
|
import {browserCommand} from "../scripts/platform-services.mjs"
|
|
8
|
-
import {
|
|
9
|
+
import {controlPanelNeedsUpdate, controlPanelStatus} from "../scripts/install.mjs"
|
|
9
10
|
|
|
10
11
|
const command = process.argv[2]
|
|
11
12
|
const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..")
|
|
12
13
|
const controlPanelUrl = "http://127.0.0.1:38742"
|
|
14
|
+
const packageVersion = JSON.parse(
|
|
15
|
+
fs.readFileSync(path.join(packageRoot, "package.json"), "utf8"),
|
|
16
|
+
).version
|
|
13
17
|
|
|
14
18
|
function printHelp() {
|
|
15
19
|
console.log(`Git AI Control Panel
|
|
@@ -37,8 +41,14 @@ try {
|
|
|
37
41
|
await uninstall()
|
|
38
42
|
process.exit(0)
|
|
39
43
|
}
|
|
40
|
-
const
|
|
41
|
-
if (!
|
|
44
|
+
const status = await controlPanelStatus()
|
|
45
|
+
if (!status) {
|
|
46
|
+
const {install} = await import("../scripts/install.mjs")
|
|
47
|
+
await install()
|
|
48
|
+
} else if (controlPanelNeedsUpdate(status.controlPanelVersion, packageVersion)) {
|
|
49
|
+
console.log(
|
|
50
|
+
`检测到新版本 ${packageVersion},正在从 ${status.controlPanelVersion || "旧版"} 更新并重启服务`,
|
|
51
|
+
)
|
|
42
52
|
const {install} = await import("../scripts/install.mjs")
|
|
43
53
|
await install()
|
|
44
54
|
} else {
|
package/package.json
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
from __future__ import annotations
|
|
5
5
|
|
|
6
|
+
import hashlib
|
|
6
7
|
import json
|
|
7
8
|
import os
|
|
8
9
|
import re
|
|
@@ -39,9 +40,14 @@ GIT_AI_ROOT = Path(os.environ.get("GIT_AI_ROOT", Path.home() / ".git-ai")).resol
|
|
|
39
40
|
POLICY_CONFIG_PATH = GIT_AI_ROOT / "filter_plugins.json"
|
|
40
41
|
UPSTREAM_CONFIG_PATH = GIT_AI_ROOT / "upstream_metrics.json"
|
|
41
42
|
FILTER_AUDIT_PATH = GIT_AI_ROOT / "filter_audit.jsonl"
|
|
43
|
+
RECENT_SKILL_HOLD_PATH = GIT_AI_ROOT / "recent_skill_holds.json"
|
|
42
44
|
FILTER_AUDIT_RETENTION = timedelta(hours=24)
|
|
43
45
|
FILTER_AUDIT_MAX_EVENTS = 2_000
|
|
44
46
|
FILTER_AUDIT_LOCK = threading.Lock()
|
|
47
|
+
RECENT_SKILL_HOLD_LOCK = threading.Lock()
|
|
48
|
+
RECENT_DIRECTORY_MAX_AGE = timedelta(days=7)
|
|
49
|
+
RECENT_SKILL_HOLD_DURATION = timedelta(hours=24)
|
|
50
|
+
RECENT_SKILL_HOLD_RETENTION = timedelta(days=7)
|
|
45
51
|
OWNER_REPOSITORY_PATTERN = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+(?:\.git)?$")
|
|
46
52
|
SKILL_KEYS = {"skillName", "skill", "name"}
|
|
47
53
|
PATH_KEYS = {
|
|
@@ -137,6 +143,11 @@ DEFAULT_POLICY_CONFIG = {
|
|
|
137
143
|
"cloudflare",
|
|
138
144
|
"cloud-flare",
|
|
139
145
|
],
|
|
146
|
+
"recent_directory_guard": {
|
|
147
|
+
"enabled": True,
|
|
148
|
+
"allowlist_patterns": [],
|
|
149
|
+
"blocklist_patterns": [],
|
|
150
|
+
},
|
|
140
151
|
},
|
|
141
152
|
"plugins": [
|
|
142
153
|
{
|
|
@@ -275,6 +286,15 @@ def iter_skill_text(value):
|
|
|
275
286
|
yield from iter_skill_text(child)
|
|
276
287
|
|
|
277
288
|
|
|
289
|
+
def skill_names(payload) -> list[str]:
|
|
290
|
+
names = set()
|
|
291
|
+
for value in iter_skill_text(payload):
|
|
292
|
+
name = audit_text(value, 160)
|
|
293
|
+
if name:
|
|
294
|
+
names.add(name)
|
|
295
|
+
return sorted(names)
|
|
296
|
+
|
|
297
|
+
|
|
278
298
|
def audit_timestamp() -> str:
|
|
279
299
|
return datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")
|
|
280
300
|
|
|
@@ -660,6 +680,148 @@ def matches_allowlist(value: str, patterns) -> bool:
|
|
|
660
680
|
return any(selector_matches(pattern, [value]) for pattern in patterns)
|
|
661
681
|
|
|
662
682
|
|
|
683
|
+
def compile_skill_patterns(patterns) -> list[re.Pattern]:
|
|
684
|
+
compiled = []
|
|
685
|
+
for pattern in patterns:
|
|
686
|
+
try:
|
|
687
|
+
compiled.append(re.compile(str(pattern), re.IGNORECASE))
|
|
688
|
+
except re.error:
|
|
689
|
+
continue
|
|
690
|
+
return compiled
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
def directory_created_at(path: Path) -> datetime | None:
|
|
694
|
+
try:
|
|
695
|
+
metadata = path.stat()
|
|
696
|
+
except OSError:
|
|
697
|
+
return None
|
|
698
|
+
|
|
699
|
+
timestamp = getattr(metadata, "st_birthtime", None)
|
|
700
|
+
if timestamp is None and os.name == "nt":
|
|
701
|
+
timestamp = metadata.st_ctime
|
|
702
|
+
if timestamp is None:
|
|
703
|
+
return None
|
|
704
|
+
return datetime.fromtimestamp(timestamp, timezone.utc)
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
def recent_payload_directories(payload, now: datetime) -> list[str]:
|
|
708
|
+
directories = set()
|
|
709
|
+
for value in iter_path_text(payload):
|
|
710
|
+
if not isinstance(value, str) or not value.strip():
|
|
711
|
+
continue
|
|
712
|
+
try:
|
|
713
|
+
path = Path(value).expanduser().resolve(strict=True)
|
|
714
|
+
except OSError:
|
|
715
|
+
continue
|
|
716
|
+
if not path.is_dir():
|
|
717
|
+
path = path.parent
|
|
718
|
+
created_at = directory_created_at(path)
|
|
719
|
+
if created_at is not None and now - created_at < RECENT_DIRECTORY_MAX_AGE:
|
|
720
|
+
directories.add(str(path))
|
|
721
|
+
return sorted(directories)
|
|
722
|
+
|
|
723
|
+
|
|
724
|
+
def read_recent_skill_holds(path: Path = RECENT_SKILL_HOLD_PATH) -> dict[str, str]:
|
|
725
|
+
try:
|
|
726
|
+
value = json.loads(path.read_text(encoding="utf-8"))
|
|
727
|
+
except (OSError, json.JSONDecodeError):
|
|
728
|
+
return {}
|
|
729
|
+
holds = value.get("holds", {}) if isinstance(value, dict) else {}
|
|
730
|
+
if not isinstance(holds, dict):
|
|
731
|
+
return {}
|
|
732
|
+
return {
|
|
733
|
+
key: timestamp
|
|
734
|
+
for key, timestamp in holds.items()
|
|
735
|
+
if isinstance(key, str) and parse_audit_timestamp(timestamp) is not None
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
|
|
739
|
+
def write_recent_skill_holds(
|
|
740
|
+
holds: dict[str, str], path: Path = RECENT_SKILL_HOLD_PATH
|
|
741
|
+
) -> None:
|
|
742
|
+
temporary = None
|
|
743
|
+
try:
|
|
744
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
745
|
+
temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp")
|
|
746
|
+
temporary.write_text(
|
|
747
|
+
json.dumps({"version": 1, "holds": holds}, ensure_ascii=False, indent=2)
|
|
748
|
+
+ "\n",
|
|
749
|
+
encoding="utf-8",
|
|
750
|
+
)
|
|
751
|
+
if os.name != "nt":
|
|
752
|
+
os.chmod(temporary, 0o600)
|
|
753
|
+
os.replace(temporary, path)
|
|
754
|
+
except OSError:
|
|
755
|
+
if temporary:
|
|
756
|
+
try:
|
|
757
|
+
temporary.unlink(missing_ok=True)
|
|
758
|
+
except OSError:
|
|
759
|
+
pass
|
|
760
|
+
|
|
761
|
+
|
|
762
|
+
def recent_skill_hold_key(directory: str, skill: str) -> str:
|
|
763
|
+
return hashlib.sha256(f"{directory}\0{skill}".encode("utf-8")).hexdigest()
|
|
764
|
+
|
|
765
|
+
|
|
766
|
+
def recent_directory_skill_guard(
|
|
767
|
+
payload,
|
|
768
|
+
skill_policy: dict,
|
|
769
|
+
*,
|
|
770
|
+
now: datetime | None = None,
|
|
771
|
+
state_path: Path = RECENT_SKILL_HOLD_PATH,
|
|
772
|
+
) -> str | None:
|
|
773
|
+
if not skill_policy.get("installed", True) or not skill_policy.get("enabled", True):
|
|
774
|
+
return None
|
|
775
|
+
guard = skill_policy.get("recent_directory_guard", {})
|
|
776
|
+
if not isinstance(guard, dict):
|
|
777
|
+
return None
|
|
778
|
+
skills = skill_names(payload) or ["[unknown]"]
|
|
779
|
+
blocklist = compile_skill_patterns(guard.get("blocklist_patterns", []))
|
|
780
|
+
if any(pattern.search(skill) for skill in skills for pattern in blocklist):
|
|
781
|
+
return "recent_directory_skill_blocklist"
|
|
782
|
+
if not guard.get("enabled", True):
|
|
783
|
+
return None
|
|
784
|
+
allowlist = compile_skill_patterns(guard.get("allowlist_patterns", []))
|
|
785
|
+
if skills and all(any(pattern.search(skill) for pattern in allowlist) for skill in skills):
|
|
786
|
+
return None
|
|
787
|
+
|
|
788
|
+
now = now or datetime.now(timezone.utc)
|
|
789
|
+
if now.tzinfo is None:
|
|
790
|
+
now = now.replace(tzinfo=timezone.utc)
|
|
791
|
+
now = now.astimezone(timezone.utc)
|
|
792
|
+
directories = recent_payload_directories(payload, now)
|
|
793
|
+
if not directories:
|
|
794
|
+
return None
|
|
795
|
+
|
|
796
|
+
with RECENT_SKILL_HOLD_LOCK:
|
|
797
|
+
holds = read_recent_skill_holds(state_path)
|
|
798
|
+
holds = {
|
|
799
|
+
key: timestamp
|
|
800
|
+
for key, timestamp in holds.items()
|
|
801
|
+
if now - parse_audit_timestamp(timestamp) < RECENT_SKILL_HOLD_RETENTION
|
|
802
|
+
}
|
|
803
|
+
should_hold = False
|
|
804
|
+
for directory in directories:
|
|
805
|
+
for skill in skills:
|
|
806
|
+
key = recent_skill_hold_key(directory, skill)
|
|
807
|
+
first_seen = parse_audit_timestamp(holds.get(key))
|
|
808
|
+
if first_seen is None:
|
|
809
|
+
first_seen = now
|
|
810
|
+
holds[key] = audit_timestamp_from(now)
|
|
811
|
+
if now - first_seen < RECENT_SKILL_HOLD_DURATION:
|
|
812
|
+
should_hold = True
|
|
813
|
+
write_recent_skill_holds(holds, state_path)
|
|
814
|
+
return "recent_directory_skill_hold" if should_hold else None
|
|
815
|
+
|
|
816
|
+
|
|
817
|
+
def audit_timestamp_from(value: datetime) -> str:
|
|
818
|
+
return (
|
|
819
|
+
value.astimezone(timezone.utc)
|
|
820
|
+
.isoformat(timespec="seconds")
|
|
821
|
+
.replace("+00:00", "Z")
|
|
822
|
+
)
|
|
823
|
+
|
|
824
|
+
|
|
663
825
|
def governance_findings(payload, policy: dict) -> list[str]:
|
|
664
826
|
if not policy.get("installed", False) or not policy.get("enabled", False):
|
|
665
827
|
return []
|
|
@@ -717,6 +879,16 @@ def evaluate_request(
|
|
|
717
879
|
"plugin": plugin_id,
|
|
718
880
|
"config_error": config_error,
|
|
719
881
|
}
|
|
882
|
+
if event_type == "skill" and (
|
|
883
|
+
reason := recent_directory_skill_guard(payload, skill_policy)
|
|
884
|
+
):
|
|
885
|
+
return {
|
|
886
|
+
"blocked": True,
|
|
887
|
+
"reason": reason,
|
|
888
|
+
"payload": payload,
|
|
889
|
+
"plugin": plugin_id,
|
|
890
|
+
"config_error": config_error,
|
|
891
|
+
}
|
|
720
892
|
|
|
721
893
|
if plugin:
|
|
722
894
|
allowed = bool(plugin.get("allow", {}).get(event_type, False))
|
package/policy.example.json
CHANGED
package/scripts/install.mjs
CHANGED
|
@@ -42,6 +42,14 @@ const ROUTE_KEYS = {
|
|
|
42
42
|
}
|
|
43
43
|
const CONTROL_PANEL_STATUS_URL = "http://127.0.0.1:38742/api/status"
|
|
44
44
|
|
|
45
|
+
function packageVersion() {
|
|
46
|
+
const metadata = readJson(path.join(PROJECT_ROOT, "package.json"), {})
|
|
47
|
+
if (typeof metadata.version !== "string" || !metadata.version.trim()) {
|
|
48
|
+
throw new Error("无法识别当前 git-ai-control 版本")
|
|
49
|
+
}
|
|
50
|
+
return metadata.version.trim()
|
|
51
|
+
}
|
|
52
|
+
|
|
45
53
|
function run(command, args, options = {}) {
|
|
46
54
|
const result = spawnSync(command, args, {
|
|
47
55
|
encoding: "utf8",
|
|
@@ -207,7 +215,7 @@ function configureCustomMetrics(gitAiRoot) {
|
|
|
207
215
|
writeJsonAtomic(metricsPath, {...config, ...LOCAL_ENDPOINTS})
|
|
208
216
|
}
|
|
209
217
|
|
|
210
|
-
function copyRuntime(gitAiRoot) {
|
|
218
|
+
function copyRuntime(gitAiRoot, version) {
|
|
211
219
|
const controlPanelDir = path.join(gitAiRoot, "control-panel")
|
|
212
220
|
const filterDir = path.join(gitAiRoot, "filters")
|
|
213
221
|
fs.mkdirSync(controlPanelDir, {recursive: true})
|
|
@@ -224,6 +232,7 @@ function copyRuntime(gitAiRoot) {
|
|
|
224
232
|
fs.cpSync(path.join(PROJECT_ROOT, "static"), path.join(controlPanelDir, "static"), {
|
|
225
233
|
recursive: true,
|
|
226
234
|
})
|
|
235
|
+
writeJsonAtomic(path.join(controlPanelDir, "runtime.json"), {version})
|
|
227
236
|
if (process.platform !== "win32") {
|
|
228
237
|
fs.chmodSync(path.join(controlPanelDir, "server.py"), 0o700)
|
|
229
238
|
fs.chmodSync(path.join(filterDir, "plugin_filter_runtime.py"), 0o700)
|
|
@@ -387,13 +396,13 @@ async function waitForHttp(url, label) {
|
|
|
387
396
|
throw new Error(`${label}启动失败:${url}`)
|
|
388
397
|
}
|
|
389
398
|
|
|
390
|
-
export async function
|
|
399
|
+
export async function controlPanelStatus(url = CONTROL_PANEL_STATUS_URL) {
|
|
391
400
|
const controller = new AbortController()
|
|
392
401
|
const timeout = setTimeout(() => controller.abort(), 1_500)
|
|
393
402
|
try {
|
|
394
403
|
const response = await fetch(url, {signal: controller.signal})
|
|
395
404
|
if (!response.ok) {
|
|
396
|
-
return
|
|
405
|
+
return null
|
|
397
406
|
}
|
|
398
407
|
const status = await response.json()
|
|
399
408
|
return (
|
|
@@ -401,14 +410,39 @@ export async function controlPanelIsRunning(url = CONTROL_PANEL_STATUS_URL) {
|
|
|
401
410
|
typeof status === "object" &&
|
|
402
411
|
typeof status.distribution === "string" &&
|
|
403
412
|
typeof status.gitAiStatus === "string"
|
|
404
|
-
)
|
|
413
|
+
) ? status : null
|
|
405
414
|
} catch {
|
|
406
|
-
return
|
|
415
|
+
return null
|
|
407
416
|
} finally {
|
|
408
417
|
clearTimeout(timeout)
|
|
409
418
|
}
|
|
410
419
|
}
|
|
411
420
|
|
|
421
|
+
export async function controlPanelIsRunning(url = CONTROL_PANEL_STATUS_URL) {
|
|
422
|
+
return Boolean(await controlPanelStatus(url))
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
export function controlPanelNeedsUpdate(installedVersion, packageVersion) {
|
|
426
|
+
if (typeof installedVersion !== "string" || !installedVersion.trim()) {
|
|
427
|
+
return true
|
|
428
|
+
}
|
|
429
|
+
const parseVersion = (value) => {
|
|
430
|
+
const match = value.trim().match(/^(\d+)\.(\d+)\.(\d+)$/)
|
|
431
|
+
return match ? match.slice(1).map(Number) : null
|
|
432
|
+
}
|
|
433
|
+
const installed = parseVersion(installedVersion)
|
|
434
|
+
const available = parseVersion(packageVersion)
|
|
435
|
+
if (!installed || !available) {
|
|
436
|
+
return false
|
|
437
|
+
}
|
|
438
|
+
for (let index = 0; index < available.length; index += 1) {
|
|
439
|
+
if (available[index] !== installed[index]) {
|
|
440
|
+
return available[index] > installed[index]
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
return false
|
|
444
|
+
}
|
|
445
|
+
|
|
412
446
|
export async function install(options = {}) {
|
|
413
447
|
const platform = options.platform ?? process.platform
|
|
414
448
|
if (!["darwin", "linux", "win32"].includes(platform)) {
|
|
@@ -418,6 +452,7 @@ export async function install(options = {}) {
|
|
|
418
452
|
options.gitAiRoot ?? process.env.GIT_AI_ROOT ?? path.join(os.homedir(), ".git-ai"),
|
|
419
453
|
)
|
|
420
454
|
const pythonCommand = options.pythonCommand ?? findPython(platform)
|
|
455
|
+
const version = packageVersion()
|
|
421
456
|
const binary = gitAiBinaryCandidates(gitAiRoot, platform).find(fs.existsSync)
|
|
422
457
|
if (!binary) {
|
|
423
458
|
throw new Error(
|
|
@@ -459,7 +494,7 @@ export async function install(options = {}) {
|
|
|
459
494
|
console.log("检测到官方上游版:保留原生配置管理,不修改无效的 custom_metrics.json")
|
|
460
495
|
}
|
|
461
496
|
|
|
462
|
-
copyRuntime(gitAiRoot)
|
|
497
|
+
copyRuntime(gitAiRoot, version)
|
|
463
498
|
run(pythonCommand[0], [
|
|
464
499
|
...pythonCommand.slice(1),
|
|
465
500
|
"-m",
|
package/server.py
CHANGED
|
@@ -35,6 +35,7 @@ FILTER_HEALTH_URL = "http://127.0.0.1:38741/health"
|
|
|
35
35
|
FILTER_MAC_LABEL = "com.git-ai.skill-usage-filter"
|
|
36
36
|
FILTER_LINUX_UNIT = "git-ai-filter.service"
|
|
37
37
|
FILTER_WINDOWS_TASK = "GitAIFilter"
|
|
38
|
+
CONTROL_PANEL_RUNTIME_PATH = APP_ROOT / "runtime.json"
|
|
38
39
|
|
|
39
40
|
NATIVE_FIELDS = {
|
|
40
41
|
"git_path",
|
|
@@ -229,6 +230,31 @@ def validate_policy_config(value) -> dict:
|
|
|
229
230
|
re.compile(pattern)
|
|
230
231
|
except re.error as error:
|
|
231
232
|
raise ConfigError(f"无效的 Skill 正则:{pattern}({error})") from error
|
|
233
|
+
recent_directory_guard = skill_policy.get("recent_directory_guard", {})
|
|
234
|
+
if not isinstance(recent_directory_guard, dict):
|
|
235
|
+
raise ConfigError("新目录观察期配置必须是对象")
|
|
236
|
+
recent_directory_guard_enabled = recent_directory_guard.get("enabled", True)
|
|
237
|
+
if not isinstance(recent_directory_guard_enabled, bool):
|
|
238
|
+
raise ConfigError("新目录观察期开关必须是布尔值")
|
|
239
|
+
recent_directory_allowlist = validate_string_list(
|
|
240
|
+
recent_directory_guard.get("allowlist_patterns", []),
|
|
241
|
+
"新目录观察期白名单",
|
|
242
|
+
maximum=256,
|
|
243
|
+
)
|
|
244
|
+
recent_directory_blocklist = validate_string_list(
|
|
245
|
+
recent_directory_guard.get("blocklist_patterns", []),
|
|
246
|
+
"新目录观察期黑名单",
|
|
247
|
+
maximum=256,
|
|
248
|
+
)
|
|
249
|
+
for label, patterns in (
|
|
250
|
+
("新目录观察期白名单", recent_directory_allowlist),
|
|
251
|
+
("新目录观察期黑名单", recent_directory_blocklist),
|
|
252
|
+
):
|
|
253
|
+
for pattern in patterns:
|
|
254
|
+
try:
|
|
255
|
+
re.compile(pattern)
|
|
256
|
+
except re.error as error:
|
|
257
|
+
raise ConfigError(f"无效的{label}正则:{pattern}({error})") from error
|
|
232
258
|
|
|
233
259
|
plugins = value.get("plugins")
|
|
234
260
|
if not isinstance(plugins, list) or len(plugins) > 32:
|
|
@@ -392,6 +418,11 @@ def validate_policy_config(value) -> dict:
|
|
|
392
418
|
"installed": skill_installed,
|
|
393
419
|
"enabled": skill_enabled,
|
|
394
420
|
"blocked_patterns": blocked_patterns,
|
|
421
|
+
"recent_directory_guard": {
|
|
422
|
+
"enabled": recent_directory_guard_enabled,
|
|
423
|
+
"allowlist_patterns": recent_directory_allowlist,
|
|
424
|
+
"blocklist_patterns": recent_directory_blocklist,
|
|
425
|
+
},
|
|
395
426
|
},
|
|
396
427
|
"plugins": normalized_plugins,
|
|
397
428
|
"plugin_order": plugin_order,
|
|
@@ -534,6 +565,12 @@ def git_ai_updated_at() -> str:
|
|
|
534
565
|
return ""
|
|
535
566
|
|
|
536
567
|
|
|
568
|
+
def control_panel_version() -> str:
|
|
569
|
+
metadata = read_json(CONTROL_PANEL_RUNTIME_PATH, {})
|
|
570
|
+
version = metadata.get("version") if isinstance(metadata, dict) else ""
|
|
571
|
+
return version if isinstance(version, str) else ""
|
|
572
|
+
|
|
573
|
+
|
|
537
574
|
@lru_cache(maxsize=1)
|
|
538
575
|
def custom_metrics_supported() -> bool:
|
|
539
576
|
binary = git_ai_binary_path()
|
|
@@ -597,6 +634,7 @@ def runtime_status() -> dict:
|
|
|
597
634
|
"platform": platform.system().lower(),
|
|
598
635
|
"gitAiVersion": git_ai_version(),
|
|
599
636
|
"gitAiUpdatedAt": git_ai_updated_at(),
|
|
637
|
+
"controlPanelVersion": control_panel_version(),
|
|
600
638
|
"paths": {
|
|
601
639
|
"nativeConfig": str(NATIVE_CONFIG_PATH),
|
|
602
640
|
"policyConfig": str(POLICY_CONFIG_PATH),
|