codebee 0.1.101 → 0.1.103

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.
Files changed (134) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/LICENSE +21 -21
  3. package/README.md +3 -6
  4. package/app/.playwright-cli/console-2026-09-23T08-26-03-635Z.log +88 -0
  5. package/app/.playwright-cli/page-2026-09-23T08-26-03-845Z.yml +126 -0
  6. package/app/.playwright-cli/page-2026-09-23T08-29-44-203Z.png +0 -0
  7. package/app/.playwright-cli/page-2026-09-23T08-35-42-046Z.yml +210 -0
  8. package/app/.playwright-cli/page-2026-09-23T08-36-52-858Z.png +0 -0
  9. package/app/.playwright-cli/page-2026-09-23T08-37-33-938Z.png +0 -0
  10. package/app/.playwright-cli/run-detail-after.png +0 -0
  11. package/app/core/acceptance.py +100 -100
  12. package/app/core/acp_client.py +457 -426
  13. package/app/core/aiflavor.py +197 -197
  14. package/app/core/analytics.py +38 -38
  15. package/app/core/artifacts.py +148 -148
  16. package/app/core/assets.py +110 -110
  17. package/app/core/attachments.py +623 -623
  18. package/app/core/audit.py +30 -30
  19. package/app/core/backend_profiles.py +188 -188
  20. package/app/core/backup.py +470 -470
  21. package/app/core/beekeeper.py +183 -183
  22. package/app/core/benchstore.py +102 -102
  23. package/app/core/board.py +21 -17
  24. package/app/core/bookmeta.py +1036 -1036
  25. package/app/core/bookmeta_catalog.py +180 -180
  26. package/app/core/branching.py +137 -137
  27. package/app/core/builtin_agent.py +13 -4
  28. package/app/core/capability.py +144 -144
  29. package/app/core/catalog.py +28 -6
  30. package/app/core/chaptersafe.py +32 -32
  31. package/app/core/checkpoints.py +117 -31
  32. package/app/core/cleanup.py +303 -303
  33. package/app/core/command_auth.py +302 -302
  34. package/app/core/compaction.py +201 -201
  35. package/app/core/contracts.py +255 -255
  36. package/app/core/covergen.py +356 -356
  37. package/app/core/defectretro.py +43 -43
  38. package/app/core/diagnostics.py +114 -114
  39. package/app/core/dispatch.py +1 -1
  40. package/app/core/dispatch_log.py +128 -128
  41. package/app/core/env_scrub.py +85 -85
  42. package/app/core/errorlog.py +137 -137
  43. package/app/core/eval_matrix.py +91 -82
  44. package/app/core/evaluation.py +174 -174
  45. package/app/core/failure_notify.py +290 -290
  46. package/app/core/flow_graph.py +33 -33
  47. package/app/core/goal_service.py +167 -167
  48. package/app/core/health.py +389 -389
  49. package/app/core/history.py +38 -38
  50. package/app/core/hooks.py +162 -162
  51. package/app/core/knowledge_pipeline.py +86 -86
  52. package/app/core/manager.py +2385 -2385
  53. package/app/core/mcp_client.py +7 -2
  54. package/app/core/mocks.py +64 -64
  55. package/app/core/notify.py +201 -201
  56. package/app/core/novel_quality.py +181 -181
  57. package/app/core/openapi.py +217 -209
  58. package/app/core/operations.py +228 -228
  59. package/app/core/paihang.py +145 -145
  60. package/app/core/pipeline.py +6 -3
  61. package/app/core/policy.py +9 -0
  62. package/app/core/portguard.py +111 -111
  63. package/app/core/presence.py +77 -77
  64. package/app/core/preview.py +293 -293
  65. package/app/core/project_memory.py +206 -206
  66. package/app/core/prompt_guard.py +78 -78
  67. package/app/core/publish/__init__.py +7 -7
  68. package/app/core/publish/auto.py +659 -562
  69. package/app/core/publish/chaprows.py +138 -138
  70. package/app/core/publish/fanqie.py +16 -10
  71. package/app/core/publish/flow.py +29 -1
  72. package/app/core/publish/flows-fanqie-calibrated.json +442 -442
  73. package/app/core/publish/manager.py +1185 -1177
  74. package/app/core/publish/qimao.py +193 -193
  75. package/app/core/publish/volumes.py +108 -108
  76. package/app/core/publish/ws.py +139 -139
  77. package/app/core/quality_gate.py +201 -201
  78. package/app/core/redact.py +38 -38
  79. package/app/core/repeat_guard.py +129 -129
  80. package/app/core/retrieval.py +137 -137
  81. package/app/core/sandbox_runtime.py +95 -0
  82. package/app/core/selfupdate.py +384 -384
  83. package/app/core/session_log.py +161 -161
  84. package/app/core/sessions.py +312 -312
  85. package/app/core/settings.py +215 -215
  86. package/app/core/settings_schema.py +282 -282
  87. package/app/core/share_page.py +114 -114
  88. package/app/core/skill_scan.py +139 -139
  89. package/app/core/skillpacks/market/character-bible.md +66 -66
  90. package/app/core/skillpacks/market/code-risk-checklist.md +58 -58
  91. package/app/core/skillpacks/market/git-workflow.md +57 -57
  92. package/app/core/skillpacks/market/release-notes.md +72 -72
  93. package/app/core/skillpacks/market/weekly-report.md +71 -71
  94. package/app/core/skillpacks/market/worldview-consistency.md +70 -70
  95. package/app/core/step_runner.py +132 -132
  96. package/app/core/store.py +13 -3
  97. package/app/core/story_tracking.py +275 -275
  98. package/app/core/task_compile.py +199 -199
  99. package/app/core/telemetry.py +292 -292
  100. package/app/core/tlsctx.py +64 -64
  101. package/app/core/token_meter.py +147 -147
  102. package/app/core/tool_outputs.py +107 -107
  103. package/app/core/tracing.py +207 -207
  104. package/app/core/upstream.py +41 -41
  105. package/app/core/usage.py +1038 -1038
  106. package/app/core/volumes.py +389 -389
  107. package/app/core/webhooks.py +89 -89
  108. package/app/core/wxdigest.py +865 -865
  109. package/app/core/zentao.py +2692 -2692
  110. package/app/main.py +1 -0
  111. package/app/mcp_server.py +244 -244
  112. package/app/output/_createbook_body.json +1 -0
  113. package/app/output/_probe_fq_click.py +50 -0
  114. package/app/output/_probe_fq_form.py +42 -0
  115. package/app/output/_probe_fq_modal.py +55 -0
  116. package/app/output/_probe_fq_points.py +71 -0
  117. package/app/output/_probe_fq_submit.py +34 -0
  118. package/app/output/_probe_fq_watch.py +62 -0
  119. package/app/output/workbench-clean-preview.jpg +0 -0
  120. package/app/output/workbench-reference-render.png +0 -0
  121. package/app/output/workbench-shell.png +0 -0
  122. package/app/pick_dialog.py +110 -110
  123. package/app/ui/app.js +165 -29
  124. package/app/ui/artdiff.js +74 -74
  125. package/app/ui/hive3d.js +491 -223
  126. package/app/ui/i18n.js +18 -1
  127. package/app/ui/icons/bee.svg +79 -79
  128. package/app/ui/index.html +24 -4
  129. package/app/ui/manifest.json +16 -16
  130. package/app/ui/qrcode.js +2297 -2297
  131. package/app/ui/style.css +548 -18
  132. package/bin/tutti.js +147 -147
  133. package/package.json +1 -1
  134. package/tools/wx_reader.py +261 -261
@@ -1,302 +1,302 @@
1
- # -*- coding: utf-8 -*-
2
- """Command execution policy with live approval requests and redacted audit."""
3
- from __future__ import annotations
4
-
5
- import hashlib
6
- import json
7
- import os
8
- import re
9
- import threading
10
- import time
11
- import uuid
12
- from pathlib import Path
13
-
14
- from .redact import scrub_text
15
-
16
- _LOCK = threading.RLock()
17
- _FILE = None
18
- _PENDING = {}
19
- _WAIT_SECONDS = 300
20
- _MAX_AUDIT = 500
21
- _MODES = {"legacy", "restricted"}
22
- _SCOPES = {"once", "run", "project", "global"}
23
- _MATCHES = {"exact", "prefix", "any"}
24
- _SHELL_META = re.compile(r"[;&|<>`$\r\n]")
25
-
26
-
27
- def init(data_dir=None):
28
- global _FILE, _PENDING
29
- with _LOCK:
30
- if data_dir is None:
31
- from . import paths
32
- base = paths.DATA_DIR
33
- else:
34
- base = Path(data_dir)
35
- base.mkdir(parents=True, exist_ok=True)
36
- _FILE = base / "command-permissions.json"
37
- _PENDING = {}
38
-
39
-
40
- def _file():
41
- global _FILE
42
- if _FILE is None:
43
- init()
44
- return _FILE
45
-
46
-
47
- def _default_state():
48
- return {"mode": "legacy", "rules": [], "audit": []}
49
-
50
-
51
- def _load():
52
- path = _file()
53
- try:
54
- data = json.loads(path.read_text(encoding="utf-8"))
55
- except FileNotFoundError:
56
- return _default_state()
57
- except (OSError, ValueError):
58
- return {"mode": "restricted", "rules": [], "audit": []}
59
- if not isinstance(data, dict) or data.get("mode") not in _MODES:
60
- return {"mode": "restricted", "rules": [], "audit": []}
61
- return {"mode": data["mode"],
62
- "rules": data.get("rules") if isinstance(data.get("rules"), list) else [],
63
- "audit": data.get("audit") if isinstance(data.get("audit"), list) else []}
64
-
65
-
66
- def _persist(state):
67
- path = _file()
68
- tmp = path.with_name(path.name + ".tmp")
69
- tmp.write_text(json.dumps(state, ensure_ascii=False, indent=2), encoding="utf-8")
70
- try:
71
- os.chmod(str(tmp), 0o600)
72
- except OSError:
73
- pass
74
- os.replace(str(tmp), str(path))
75
-
76
-
77
- def _hash(value):
78
- return hashlib.sha256(str(value or "").encode("utf-8")).hexdigest()
79
-
80
-
81
- def _path_key(workdir):
82
- if not workdir:
83
- return ""
84
- return _hash(os.path.normcase(os.path.realpath(os.path.expanduser(str(workdir)))))
85
-
86
-
87
- def _scope_key(scope, run_id, workdir):
88
- if scope == "global":
89
- return "*"
90
- if scope == "run":
91
- return str(run_id or "")
92
- if scope == "project":
93
- return _path_key(workdir)
94
- return ""
95
-
96
-
97
- def _prefix_tokens(value):
98
- return tuple(str(value or "").strip().split())
99
-
100
-
101
- def _rule_matches(rule, command, run_id, workdir):
102
- scope = rule.get("scope")
103
- if scope not in ("run", "project", "global"):
104
- return False
105
- if _scope_key(scope, run_id, workdir) != rule.get("scope_key"):
106
- return False
107
- match = rule.get("match")
108
- if match == "any":
109
- return True
110
- if match == "exact":
111
- return _hash(command) == rule.get("command_hash")
112
- if match == "prefix":
113
- if _SHELL_META.search(command):
114
- return False
115
- tokens = _prefix_tokens(command)
116
- prefix_hashes = rule.get("prefix_hashes") or []
117
- return (bool(prefix_hashes) and len(tokens) >= len(prefix_hashes) and
118
- [_hash(x) for x in tokens[:len(prefix_hashes)]] == prefix_hashes)
119
- return False
120
-
121
-
122
- def _audit(state, action, command, request_id="", scope="", match=""):
123
- entry = {"id": request_id or uuid.uuid4().hex[:16], "action": action,
124
- "command": scrub_text(command, limit=240), "command_sha256": _hash(command),
125
- "scope": scope or "", "match": match or "", "at": time.time()}
126
- state["audit"] = (state.get("audit") or [])[-(_MAX_AUDIT - 1):] + [entry]
127
-
128
-
129
- def _bump_state():
130
- try:
131
- from . import store
132
- store.bump_state()
133
- except Exception:
134
- pass
135
-
136
-
137
- def mode():
138
- with _LOCK:
139
- return _load()["mode"]
140
-
141
-
142
- def set_mode(value):
143
- if value not in _MODES:
144
- raise ValueError("mode 必须是 legacy/restricted")
145
- with _LOCK:
146
- state = _load()
147
- changed = state["mode"] != value
148
- state["mode"] = value
149
- _persist(state)
150
- if changed:
151
- for request in _PENDING.values():
152
- request["decision"] = {"allowed": False, "reason": "policy_changed"}
153
- request["event"].set()
154
- if changed:
155
- _bump_state()
156
- return value
157
-
158
-
159
- def _make_rule(command, scope, match, run_id, workdir, prefix=None):
160
- if scope not in ("run", "project", "global"):
161
- raise ValueError("持久规则 scope 必须是 run/project/global")
162
- if match not in _MATCHES:
163
- raise ValueError("match 必须是 exact/prefix/any")
164
- if scope == "run" and not run_id:
165
- raise ValueError("当前运行授权缺少 run_id")
166
- if scope == "project" and not workdir:
167
- raise ValueError("项目授权缺少工作目录")
168
- rule = {"id": uuid.uuid4().hex[:16], "scope": scope,
169
- "scope_key": _scope_key(scope, run_id, workdir), "match": match,
170
- "command_preview": scrub_text(command if match != "any" else "*", 180),
171
- "created_at": time.time()}
172
- if match == "exact":
173
- rule["command_hash"] = _hash(command)
174
- elif match == "prefix":
175
- raw_prefix = str(prefix or command or "").strip()
176
- if not raw_prefix or _SHELL_META.search(raw_prefix) or _SHELL_META.search(command):
177
- raise ValueError("前缀授权只支持不含 shell 控制符的单条命令")
178
- tokens = _prefix_tokens(raw_prefix)
179
- if not tokens or len(tokens) > len(_prefix_tokens(command)):
180
- raise ValueError("授权前缀必须是本次请求命令的前缀")
181
- if _prefix_tokens(command)[:len(tokens)] != tokens:
182
- raise ValueError("授权前缀必须与本次请求命令开头一致")
183
- rule["prefix_hashes"] = [_hash(token) for token in tokens]
184
- rule["command_preview"] = scrub_text(raw_prefix, 180)
185
- return rule
186
-
187
-
188
- def authorize(command, *, run_id="", workdir="", timeout_s=None,
189
- deadline=None, cancel_event=None, source="run_command"):
190
- command = str(command or "").strip()
191
- if not command:
192
- return {"allowed": False, "reason": "empty_command"}
193
- with _LOCK:
194
- state = _load()
195
- if state["mode"] == "legacy":
196
- return {"allowed": True, "reason": "legacy"}
197
- matched = next((rule for rule in reversed(state["rules"])
198
- if _rule_matches(rule, command, run_id, workdir)), None)
199
- if matched:
200
- _audit(state, "allowed_by_rule", command,
201
- scope=matched.get("scope"), match=matched.get("match"))
202
- _persist(state)
203
- return {"allowed": True, "reason": "rule", "rule_id": matched.get("id")}
204
- wait_s = _WAIT_SECONDS if timeout_s is None else max(0.0, float(timeout_s))
205
- if deadline is not None:
206
- wait_s = min(wait_s, max(0.0, float(deadline) - time.monotonic()))
207
- if wait_s <= 0 or (cancel_event is not None and cancel_event.is_set()):
208
- reason = "cancelled" if cancel_event is not None and cancel_event.is_set() else "timeout"
209
- _audit(state, reason, command)
210
- _persist(state)
211
- return {"allowed": False, "reason": reason}
212
- request_id = uuid.uuid4().hex[:16]
213
- request = {"id": request_id, "command": command, "run_id": str(run_id or ""),
214
- "workdir": str(workdir or ""), "source": str(source or "")[:40],
215
- "created_at": time.time(), "event": threading.Event(), "decision": None}
216
- _PENDING[request_id] = request
217
- _audit(state, "requested", command, request_id=request_id)
218
- _persist(state)
219
- _bump_state()
220
- expires = time.monotonic() + wait_s
221
- decision = None
222
- while time.monotonic() < expires:
223
- if cancel_event is not None and cancel_event.is_set():
224
- decision = {"allowed": False, "reason": "cancelled"}
225
- break
226
- if request["event"].wait(min(0.2, max(0.0, expires - time.monotonic()))):
227
- decision = request.get("decision")
228
- break
229
- if decision is None:
230
- decision = {"allowed": False, "reason": "timeout"}
231
- with _LOCK:
232
- _PENDING.pop(request_id, None)
233
- state = _load()
234
- _audit(state, decision.get("reason") or "denied", command,
235
- request_id=request_id, scope=decision.get("scope", ""),
236
- match=decision.get("match", ""))
237
- _persist(state)
238
- _bump_state()
239
- return decision
240
-
241
-
242
- def pending_requests():
243
- with _LOCK:
244
- rows = []
245
- for request in _PENDING.values():
246
- rows.append({"id": request["id"], "command": scrub_text(request["command"], 500),
247
- "run_id": request["run_id"], "source": request["source"],
248
- "created_at": request["created_at"]})
249
- return sorted(rows, key=lambda row: row["created_at"])
250
-
251
-
252
- def decide(request_id, *, approve, scope="once", match="exact", prefix=None):
253
- if not isinstance(approve, bool):
254
- raise ValueError("approve 必須是布尔值")
255
- with _LOCK:
256
- request = _PENDING.get(str(request_id or ""))
257
- if request is None:
258
- raise KeyError("命令授权请求不存在或已结束")
259
- state = _load()
260
- if approve:
261
- if scope not in _SCOPES:
262
- raise ValueError("scope 必须是 once/run/project/global")
263
- if scope == "once":
264
- rule = None
265
- else:
266
- rule = _make_rule(request["command"], scope, match,
267
- request["run_id"], request["workdir"], prefix=prefix)
268
- state["rules"].append(rule)
269
- request["decision"] = {"allowed": True, "reason": "approved",
270
- "rule_id": rule.get("id") if rule else "",
271
- "scope": scope, "match": match}
272
- _audit(state, "approved", request["command"], request_id=request["id"],
273
- scope=scope, match=match)
274
- else:
275
- request["decision"] = {"allowed": False, "reason": "denied"}
276
- _audit(state, "denied_by_user", request["command"],
277
- request_id=request["id"])
278
- _persist(state)
279
- request["event"].set()
280
- _bump_state()
281
- return {"ok": True}
282
-
283
-
284
- def revoke(rule_id):
285
- with _LOCK:
286
- state = _load()
287
- remaining = [rule for rule in state["rules"] if rule.get("id") != str(rule_id or "")]
288
- if len(remaining) == len(state["rules"]):
289
- raise KeyError("授权规则不存在")
290
- state["rules"] = remaining
291
- _audit(state, "revoked", "", request_id=str(rule_id or ""))
292
- _persist(state)
293
- _bump_state()
294
- return True
295
-
296
-
297
- def view():
298
- with _LOCK:
299
- state = _load()
300
- return {"mode": state["mode"], "rules": [dict(rule) for rule in state["rules"]],
301
- "pending": pending_requests(),
302
- "audit": [dict(row) for row in state["audit"][-100:]]}
1
+ # -*- coding: utf-8 -*-
2
+ """Command execution policy with live approval requests and redacted audit."""
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import os
8
+ import re
9
+ import threading
10
+ import time
11
+ import uuid
12
+ from pathlib import Path
13
+
14
+ from .redact import scrub_text
15
+
16
+ _LOCK = threading.RLock()
17
+ _FILE = None
18
+ _PENDING = {}
19
+ _WAIT_SECONDS = 300
20
+ _MAX_AUDIT = 500
21
+ _MODES = {"legacy", "restricted"}
22
+ _SCOPES = {"once", "run", "project", "global"}
23
+ _MATCHES = {"exact", "prefix", "any"}
24
+ _SHELL_META = re.compile(r"[;&|<>`$\r\n]")
25
+
26
+
27
+ def init(data_dir=None):
28
+ global _FILE, _PENDING
29
+ with _LOCK:
30
+ if data_dir is None:
31
+ from . import paths
32
+ base = paths.DATA_DIR
33
+ else:
34
+ base = Path(data_dir)
35
+ base.mkdir(parents=True, exist_ok=True)
36
+ _FILE = base / "command-permissions.json"
37
+ _PENDING = {}
38
+
39
+
40
+ def _file():
41
+ global _FILE
42
+ if _FILE is None:
43
+ init()
44
+ return _FILE
45
+
46
+
47
+ def _default_state():
48
+ return {"mode": "legacy", "rules": [], "audit": []}
49
+
50
+
51
+ def _load():
52
+ path = _file()
53
+ try:
54
+ data = json.loads(path.read_text(encoding="utf-8"))
55
+ except FileNotFoundError:
56
+ return _default_state()
57
+ except (OSError, ValueError):
58
+ return {"mode": "restricted", "rules": [], "audit": []}
59
+ if not isinstance(data, dict) or data.get("mode") not in _MODES:
60
+ return {"mode": "restricted", "rules": [], "audit": []}
61
+ return {"mode": data["mode"],
62
+ "rules": data.get("rules") if isinstance(data.get("rules"), list) else [],
63
+ "audit": data.get("audit") if isinstance(data.get("audit"), list) else []}
64
+
65
+
66
+ def _persist(state):
67
+ path = _file()
68
+ tmp = path.with_name(path.name + ".tmp")
69
+ tmp.write_text(json.dumps(state, ensure_ascii=False, indent=2), encoding="utf-8")
70
+ try:
71
+ os.chmod(str(tmp), 0o600)
72
+ except OSError:
73
+ pass
74
+ os.replace(str(tmp), str(path))
75
+
76
+
77
+ def _hash(value):
78
+ return hashlib.sha256(str(value or "").encode("utf-8")).hexdigest()
79
+
80
+
81
+ def _path_key(workdir):
82
+ if not workdir:
83
+ return ""
84
+ return _hash(os.path.normcase(os.path.realpath(os.path.expanduser(str(workdir)))))
85
+
86
+
87
+ def _scope_key(scope, run_id, workdir):
88
+ if scope == "global":
89
+ return "*"
90
+ if scope == "run":
91
+ return str(run_id or "")
92
+ if scope == "project":
93
+ return _path_key(workdir)
94
+ return ""
95
+
96
+
97
+ def _prefix_tokens(value):
98
+ return tuple(str(value or "").strip().split())
99
+
100
+
101
+ def _rule_matches(rule, command, run_id, workdir):
102
+ scope = rule.get("scope")
103
+ if scope not in ("run", "project", "global"):
104
+ return False
105
+ if _scope_key(scope, run_id, workdir) != rule.get("scope_key"):
106
+ return False
107
+ match = rule.get("match")
108
+ if match == "any":
109
+ return True
110
+ if match == "exact":
111
+ return _hash(command) == rule.get("command_hash")
112
+ if match == "prefix":
113
+ if _SHELL_META.search(command):
114
+ return False
115
+ tokens = _prefix_tokens(command)
116
+ prefix_hashes = rule.get("prefix_hashes") or []
117
+ return (bool(prefix_hashes) and len(tokens) >= len(prefix_hashes) and
118
+ [_hash(x) for x in tokens[:len(prefix_hashes)]] == prefix_hashes)
119
+ return False
120
+
121
+
122
+ def _audit(state, action, command, request_id="", scope="", match=""):
123
+ entry = {"id": request_id or uuid.uuid4().hex[:16], "action": action,
124
+ "command": scrub_text(command, limit=240), "command_sha256": _hash(command),
125
+ "scope": scope or "", "match": match or "", "at": time.time()}
126
+ state["audit"] = (state.get("audit") or [])[-(_MAX_AUDIT - 1):] + [entry]
127
+
128
+
129
+ def _bump_state():
130
+ try:
131
+ from . import store
132
+ store.bump_state()
133
+ except Exception:
134
+ pass
135
+
136
+
137
+ def mode():
138
+ with _LOCK:
139
+ return _load()["mode"]
140
+
141
+
142
+ def set_mode(value):
143
+ if value not in _MODES:
144
+ raise ValueError("mode 必须是 legacy/restricted")
145
+ with _LOCK:
146
+ state = _load()
147
+ changed = state["mode"] != value
148
+ state["mode"] = value
149
+ _persist(state)
150
+ if changed:
151
+ for request in _PENDING.values():
152
+ request["decision"] = {"allowed": False, "reason": "policy_changed"}
153
+ request["event"].set()
154
+ if changed:
155
+ _bump_state()
156
+ return value
157
+
158
+
159
+ def _make_rule(command, scope, match, run_id, workdir, prefix=None):
160
+ if scope not in ("run", "project", "global"):
161
+ raise ValueError("持久规则 scope 必须是 run/project/global")
162
+ if match not in _MATCHES:
163
+ raise ValueError("match 必须是 exact/prefix/any")
164
+ if scope == "run" and not run_id:
165
+ raise ValueError("当前运行授权缺少 run_id")
166
+ if scope == "project" and not workdir:
167
+ raise ValueError("项目授权缺少工作目录")
168
+ rule = {"id": uuid.uuid4().hex[:16], "scope": scope,
169
+ "scope_key": _scope_key(scope, run_id, workdir), "match": match,
170
+ "command_preview": scrub_text(command if match != "any" else "*", 180),
171
+ "created_at": time.time()}
172
+ if match == "exact":
173
+ rule["command_hash"] = _hash(command)
174
+ elif match == "prefix":
175
+ raw_prefix = str(prefix or command or "").strip()
176
+ if not raw_prefix or _SHELL_META.search(raw_prefix) or _SHELL_META.search(command):
177
+ raise ValueError("前缀授权只支持不含 shell 控制符的单条命令")
178
+ tokens = _prefix_tokens(raw_prefix)
179
+ if not tokens or len(tokens) > len(_prefix_tokens(command)):
180
+ raise ValueError("授权前缀必须是本次请求命令的前缀")
181
+ if _prefix_tokens(command)[:len(tokens)] != tokens:
182
+ raise ValueError("授权前缀必须与本次请求命令开头一致")
183
+ rule["prefix_hashes"] = [_hash(token) for token in tokens]
184
+ rule["command_preview"] = scrub_text(raw_prefix, 180)
185
+ return rule
186
+
187
+
188
+ def authorize(command, *, run_id="", workdir="", timeout_s=None,
189
+ deadline=None, cancel_event=None, source="run_command"):
190
+ command = str(command or "").strip()
191
+ if not command:
192
+ return {"allowed": False, "reason": "empty_command"}
193
+ with _LOCK:
194
+ state = _load()
195
+ if state["mode"] == "legacy":
196
+ return {"allowed": True, "reason": "legacy"}
197
+ matched = next((rule for rule in reversed(state["rules"])
198
+ if _rule_matches(rule, command, run_id, workdir)), None)
199
+ if matched:
200
+ _audit(state, "allowed_by_rule", command,
201
+ scope=matched.get("scope"), match=matched.get("match"))
202
+ _persist(state)
203
+ return {"allowed": True, "reason": "rule", "rule_id": matched.get("id")}
204
+ wait_s = _WAIT_SECONDS if timeout_s is None else max(0.0, float(timeout_s))
205
+ if deadline is not None:
206
+ wait_s = min(wait_s, max(0.0, float(deadline) - time.monotonic()))
207
+ if wait_s <= 0 or (cancel_event is not None and cancel_event.is_set()):
208
+ reason = "cancelled" if cancel_event is not None and cancel_event.is_set() else "timeout"
209
+ _audit(state, reason, command)
210
+ _persist(state)
211
+ return {"allowed": False, "reason": reason}
212
+ request_id = uuid.uuid4().hex[:16]
213
+ request = {"id": request_id, "command": command, "run_id": str(run_id or ""),
214
+ "workdir": str(workdir or ""), "source": str(source or "")[:40],
215
+ "created_at": time.time(), "event": threading.Event(), "decision": None}
216
+ _PENDING[request_id] = request
217
+ _audit(state, "requested", command, request_id=request_id)
218
+ _persist(state)
219
+ _bump_state()
220
+ expires = time.monotonic() + wait_s
221
+ decision = None
222
+ while time.monotonic() < expires:
223
+ if cancel_event is not None and cancel_event.is_set():
224
+ decision = {"allowed": False, "reason": "cancelled"}
225
+ break
226
+ if request["event"].wait(min(0.2, max(0.0, expires - time.monotonic()))):
227
+ decision = request.get("decision")
228
+ break
229
+ if decision is None:
230
+ decision = {"allowed": False, "reason": "timeout"}
231
+ with _LOCK:
232
+ _PENDING.pop(request_id, None)
233
+ state = _load()
234
+ _audit(state, decision.get("reason") or "denied", command,
235
+ request_id=request_id, scope=decision.get("scope", ""),
236
+ match=decision.get("match", ""))
237
+ _persist(state)
238
+ _bump_state()
239
+ return decision
240
+
241
+
242
+ def pending_requests():
243
+ with _LOCK:
244
+ rows = []
245
+ for request in _PENDING.values():
246
+ rows.append({"id": request["id"], "command": scrub_text(request["command"], 500),
247
+ "run_id": request["run_id"], "source": request["source"],
248
+ "created_at": request["created_at"]})
249
+ return sorted(rows, key=lambda row: row["created_at"])
250
+
251
+
252
+ def decide(request_id, *, approve, scope="once", match="exact", prefix=None):
253
+ if not isinstance(approve, bool):
254
+ raise ValueError("approve 必須是布尔值")
255
+ with _LOCK:
256
+ request = _PENDING.get(str(request_id or ""))
257
+ if request is None:
258
+ raise KeyError("命令授权请求不存在或已结束")
259
+ state = _load()
260
+ if approve:
261
+ if scope not in _SCOPES:
262
+ raise ValueError("scope 必须是 once/run/project/global")
263
+ if scope == "once":
264
+ rule = None
265
+ else:
266
+ rule = _make_rule(request["command"], scope, match,
267
+ request["run_id"], request["workdir"], prefix=prefix)
268
+ state["rules"].append(rule)
269
+ request["decision"] = {"allowed": True, "reason": "approved",
270
+ "rule_id": rule.get("id") if rule else "",
271
+ "scope": scope, "match": match}
272
+ _audit(state, "approved", request["command"], request_id=request["id"],
273
+ scope=scope, match=match)
274
+ else:
275
+ request["decision"] = {"allowed": False, "reason": "denied"}
276
+ _audit(state, "denied_by_user", request["command"],
277
+ request_id=request["id"])
278
+ _persist(state)
279
+ request["event"].set()
280
+ _bump_state()
281
+ return {"ok": True}
282
+
283
+
284
+ def revoke(rule_id):
285
+ with _LOCK:
286
+ state = _load()
287
+ remaining = [rule for rule in state["rules"] if rule.get("id") != str(rule_id or "")]
288
+ if len(remaining) == len(state["rules"]):
289
+ raise KeyError("授权规则不存在")
290
+ state["rules"] = remaining
291
+ _audit(state, "revoked", "", request_id=str(rule_id or ""))
292
+ _persist(state)
293
+ _bump_state()
294
+ return True
295
+
296
+
297
+ def view():
298
+ with _LOCK:
299
+ state = _load()
300
+ return {"mode": state["mode"], "rules": [dict(rule) for rule in state["rules"]],
301
+ "pending": pending_requests(),
302
+ "audit": [dict(row) for row in state["audit"][-100:]]}