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 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
 
@@ -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 {controlPanelIsRunning} from "../scripts/install.mjs"
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 reused = await controlPanelIsRunning()
41
- if (!reused) {
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
@@ -1,7 +1,8 @@
1
1
  {
2
2
  "name": "git-ai-control",
3
- "version": "0.4.12",
3
+ "version": "0.4.14",
4
4
  "description": "Git AI 本地配置与上传隐私控制面板",
5
+ "author": "yingyanzhitong",
5
6
  "type": "module",
6
7
  "bin": {
7
8
  "git-ai-control": "bin/git-ai-control.js"
@@ -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))
@@ -22,7 +22,12 @@
22
22
  "git-hub",
23
23
  "cloudflare",
24
24
  "cloud-flare"
25
- ]
25
+ ],
26
+ "recent_directory_guard": {
27
+ "enabled": true,
28
+ "allowlist_patterns": [],
29
+ "blocklist_patterns": []
30
+ }
26
31
  },
27
32
  "plugins": [
28
33
  {
@@ -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 controlPanelIsRunning(url = CONTROL_PANEL_STATUS_URL) {
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 false
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 false
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),