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.
- package/CHANGELOG.md +14 -0
- package/LICENSE +21 -21
- package/README.md +3 -6
- package/app/.playwright-cli/console-2026-09-23T08-26-03-635Z.log +88 -0
- package/app/.playwright-cli/page-2026-09-23T08-26-03-845Z.yml +126 -0
- package/app/.playwright-cli/page-2026-09-23T08-29-44-203Z.png +0 -0
- package/app/.playwright-cli/page-2026-09-23T08-35-42-046Z.yml +210 -0
- package/app/.playwright-cli/page-2026-09-23T08-36-52-858Z.png +0 -0
- package/app/.playwright-cli/page-2026-09-23T08-37-33-938Z.png +0 -0
- package/app/.playwright-cli/run-detail-after.png +0 -0
- package/app/core/acceptance.py +100 -100
- package/app/core/acp_client.py +457 -426
- package/app/core/aiflavor.py +197 -197
- package/app/core/analytics.py +38 -38
- package/app/core/artifacts.py +148 -148
- package/app/core/assets.py +110 -110
- package/app/core/attachments.py +623 -623
- package/app/core/audit.py +30 -30
- package/app/core/backend_profiles.py +188 -188
- package/app/core/backup.py +470 -470
- package/app/core/beekeeper.py +183 -183
- package/app/core/benchstore.py +102 -102
- package/app/core/board.py +21 -17
- package/app/core/bookmeta.py +1036 -1036
- package/app/core/bookmeta_catalog.py +180 -180
- package/app/core/branching.py +137 -137
- package/app/core/builtin_agent.py +13 -4
- package/app/core/capability.py +144 -144
- package/app/core/catalog.py +28 -6
- package/app/core/chaptersafe.py +32 -32
- package/app/core/checkpoints.py +117 -31
- package/app/core/cleanup.py +303 -303
- package/app/core/command_auth.py +302 -302
- package/app/core/compaction.py +201 -201
- package/app/core/contracts.py +255 -255
- package/app/core/covergen.py +356 -356
- package/app/core/defectretro.py +43 -43
- package/app/core/diagnostics.py +114 -114
- package/app/core/dispatch.py +1 -1
- package/app/core/dispatch_log.py +128 -128
- package/app/core/env_scrub.py +85 -85
- package/app/core/errorlog.py +137 -137
- package/app/core/eval_matrix.py +91 -82
- package/app/core/evaluation.py +174 -174
- package/app/core/failure_notify.py +290 -290
- package/app/core/flow_graph.py +33 -33
- package/app/core/goal_service.py +167 -167
- package/app/core/health.py +389 -389
- package/app/core/history.py +38 -38
- package/app/core/hooks.py +162 -162
- package/app/core/knowledge_pipeline.py +86 -86
- package/app/core/manager.py +2385 -2385
- package/app/core/mcp_client.py +7 -2
- package/app/core/mocks.py +64 -64
- package/app/core/notify.py +201 -201
- package/app/core/novel_quality.py +181 -181
- package/app/core/openapi.py +217 -209
- package/app/core/operations.py +228 -228
- package/app/core/paihang.py +145 -145
- package/app/core/pipeline.py +6 -3
- package/app/core/policy.py +9 -0
- package/app/core/portguard.py +111 -111
- package/app/core/presence.py +77 -77
- package/app/core/preview.py +293 -293
- package/app/core/project_memory.py +206 -206
- package/app/core/prompt_guard.py +78 -78
- package/app/core/publish/__init__.py +7 -7
- package/app/core/publish/auto.py +659 -562
- package/app/core/publish/chaprows.py +138 -138
- package/app/core/publish/fanqie.py +16 -10
- package/app/core/publish/flow.py +29 -1
- package/app/core/publish/flows-fanqie-calibrated.json +442 -442
- package/app/core/publish/manager.py +1185 -1177
- package/app/core/publish/qimao.py +193 -193
- package/app/core/publish/volumes.py +108 -108
- package/app/core/publish/ws.py +139 -139
- package/app/core/quality_gate.py +201 -201
- package/app/core/redact.py +38 -38
- package/app/core/repeat_guard.py +129 -129
- package/app/core/retrieval.py +137 -137
- package/app/core/sandbox_runtime.py +95 -0
- package/app/core/selfupdate.py +384 -384
- package/app/core/session_log.py +161 -161
- package/app/core/sessions.py +312 -312
- package/app/core/settings.py +215 -215
- package/app/core/settings_schema.py +282 -282
- package/app/core/share_page.py +114 -114
- package/app/core/skill_scan.py +139 -139
- package/app/core/skillpacks/market/character-bible.md +66 -66
- package/app/core/skillpacks/market/code-risk-checklist.md +58 -58
- package/app/core/skillpacks/market/git-workflow.md +57 -57
- package/app/core/skillpacks/market/release-notes.md +72 -72
- package/app/core/skillpacks/market/weekly-report.md +71 -71
- package/app/core/skillpacks/market/worldview-consistency.md +70 -70
- package/app/core/step_runner.py +132 -132
- package/app/core/store.py +13 -3
- package/app/core/story_tracking.py +275 -275
- package/app/core/task_compile.py +199 -199
- package/app/core/telemetry.py +292 -292
- package/app/core/tlsctx.py +64 -64
- package/app/core/token_meter.py +147 -147
- package/app/core/tool_outputs.py +107 -107
- package/app/core/tracing.py +207 -207
- package/app/core/upstream.py +41 -41
- package/app/core/usage.py +1038 -1038
- package/app/core/volumes.py +389 -389
- package/app/core/webhooks.py +89 -89
- package/app/core/wxdigest.py +865 -865
- package/app/core/zentao.py +2692 -2692
- package/app/main.py +1 -0
- package/app/mcp_server.py +244 -244
- package/app/output/_createbook_body.json +1 -0
- package/app/output/_probe_fq_click.py +50 -0
- package/app/output/_probe_fq_form.py +42 -0
- package/app/output/_probe_fq_modal.py +55 -0
- package/app/output/_probe_fq_points.py +71 -0
- package/app/output/_probe_fq_submit.py +34 -0
- package/app/output/_probe_fq_watch.py +62 -0
- package/app/output/workbench-clean-preview.jpg +0 -0
- package/app/output/workbench-reference-render.png +0 -0
- package/app/output/workbench-shell.png +0 -0
- package/app/pick_dialog.py +110 -110
- package/app/ui/app.js +165 -29
- package/app/ui/artdiff.js +74 -74
- package/app/ui/hive3d.js +491 -223
- package/app/ui/i18n.js +18 -1
- package/app/ui/icons/bee.svg +79 -79
- package/app/ui/index.html +24 -4
- package/app/ui/manifest.json +16 -16
- package/app/ui/qrcode.js +2297 -2297
- package/app/ui/style.css +548 -18
- package/bin/tutti.js +147 -147
- package/package.json +1 -1
- package/tools/wx_reader.py +261 -261
package/app/core/command_auth.py
CHANGED
|
@@ -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:]]}
|