P0 security batch: true global admin, quota gates on all spending paths, default quota for quota-less users, fix-review author trust + docker/owner attribution, GitHub App setup issuer binding, real cancel kill, wider shell env exclusion

- CODEX_GLOBAL_ADMINS replaces maintainer-derived global admin;
  admin endpoints (quotas, sweep, backup, push dispatch) fail-closed
  403 when unset, with audit on allow and deny.
- continue/followups/messages/retry check both caller and original
  creator quotas (USD + unpriced token cap), 402 on either overrun.
- Quota-less users now get the #86 default quota instead of unlimited;
  init_db backfills and OAuth create/bind/existing-login branches
  INSERT OR IGNORE a default row; explicit NULL row stays unlimited.
  user_quotas added to migration tables.
- /fix-review: untrusted authors rejected before dedup claim, no task;
  trusted tasks force executor=docker, created_by=project owner, owner
  quota gate, prompt is a fixed template plus a 2000-char excerpt.
- GitHub App setup state only issued by global admins, state rows bind
  issued_by (ALTER-compatible for old DBs), callback rejects issuers
  no longer in the admin list without consuming the state.
- Cancel: popen poll checks DB status every second, Docker path runs a
  cancel watchdog that kills by container name, write-back is CAS-guarded
  so late results never overwrite a new status.
- SHELL_ENV_EXCLUDE adds *_TOKEN, *_AUTH*, *_CREDENTIAL*, *API_KEY*;
  comment states the residual risk: same-UID /proc environ remains
  readable, true isolation needs UID separation or hidepid.
- Tests: new cloud/tests/test_p0_security.py (17 cases); old tests
  updated to the new semantics (admin env, default quota, trusted
  fix-review author) without weakening any gate.
This commit is contained in:
host committed 2026-10-27 15:35:23 +00:00
1 parent d0ca1f7f60
commit 7091a2d0d2
13 files changed
+1017 -146

No files matched your search

+229 -95
View File
@@ -146,6 +146,7 @@ from cloud.github_app import (
)
from cloud.webhooks import (
WebhookDeliveryStore,
is_trusted_fix_review_author,
parse_github_check_run,
parse_github_pr_comment,
parse_slack_command,
@@ -979,11 +980,12 @@ def _default_quota_usd_micros():
def _apply_default_quota(conn, user_id):
"""给新用户写默认配额行(已存在则不动,幂等)。
"""给用户写默认配额行(已存在则不动,幂等)。
只在 OAuth 建号(created=True)时调:INSERT OR IGNORE
保证重复回调不覆盖管理员后来改过的额度。返回 True 表示
本次写了新行(调用方据此记 quota_default 审计)。
INSERT OR IGNORE 保证重复回调不覆盖管理员后来改过的额度。
返回 True 表示本次写了新行(调用方据此记 quota_default
审计)。P0 安全批后:OAuth 新建、绑定、已存在用户登录
三条分支都调它补行,不再只给新建用户补。
"""
_ensure_quotas_table(conn)
cur = conn.execute(
@@ -997,6 +999,43 @@ def _apply_default_quota(conn, user_id):
return cur.rowcount == 1
def _global_admin_ids():
"""真全局管理员清单:环境变量 CODEX_GLOBAL_ADMINS。
逗号分隔的用户 id 列表,空白项忽略。未配置或为空时返回
空集——此时 admin 端点一律 fail-closed,绝不回落到项目
角色推导(P0 安全批前“任一项目 maintainer 即全局管理”
的推导已被废掉,见 _is_global_admin)。
"""
raw = os.environ.get("CODEX_GLOBAL_ADMINS") or ""
return {part.strip() for part in raw.split(",") if part.strip()}
def _backfill_default_quotas(conn):
"""给已有无行用户回填默认配额行(init_db 时调,幂等)。
只补“users 里有、user_quotas 里无”的用户,已有行(含
管理员显式设为 NULL 的不限额行)一律不动。返回补写行数。
"""
_ensure_quotas_table(conn)
try:
cur = conn.execute(
"INSERT OR IGNORE INTO user_quotas(user_id,"
" quota_usd_micros, updated_at)"
" SELECT u.id, ?, ? FROM users u"
" LEFT JOIN user_quotas q ON q.user_id = u.id"
" WHERE q.user_id IS NULL",
(_default_quota_usd_micros(), time.time()))
conn.commit()
return cur.rowcount or 0
except Exception:
try:
conn.rollback()
except Exception:
pass
return 0
_ENV_BRANCH_RE = re.compile(r"^[A-Za-z0-9_.\-/]+$")
@@ -1058,6 +1097,7 @@ def init_db(db_path):
_ensure_followups_table(conn)
_ensure_environments_table(conn)
_ensure_quotas_table(conn)
_backfill_default_quotas(conn)
_ensure_gateway_keys(conn)
_ensure_uploads_tables(conn)
conn.commit()
@@ -1091,6 +1131,7 @@ def init_db(db_path):
_ensure_followups_table(conn)
_ensure_environments_table(conn)
_ensure_quotas_table(conn)
_backfill_default_quotas(conn)
_ensure_uploads_tables(conn)
# worker 每轮 poll 都按 status 取任务(见 load_tasks 的
# WHERE status IN (...)),无索引时是全表扫描;任务表随
@@ -3005,6 +3046,8 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
user_id, task["project_id"], "create_task"):
self._send_json(403, {"error": "forbidden"})
return
if self._reject_if_over_quota_for_task(user_id, task):
return
text = body.get("body")
if not text or not isinstance(text, str) or not text.strip():
self._send_json(400, {"error": "body required"})
@@ -3143,6 +3186,8 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
user_id, sender["project_id"], "read"):
self._send_json(403, {"error": "forbidden"})
return
if self._reject_if_over_quota_for_task(user_id, target):
return
text = body.get("body")
if not text or not isinstance(text, str) or not text.strip():
self._send_json(400, {"error": "body required"})
@@ -3680,6 +3725,8 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
user_id, task["project_id"], "create_task"):
self._send_json(403, {"error": "forbidden"})
return
if self._reject_if_over_quota_for_task(user_id, task):
return
if task["status"] in ("queued", "running"):
self._send_json(409, {
"error": "进行中任务请用后续消息继续",
@@ -3812,8 +3859,12 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
interrupted / merge-failed(其它状态一律 409:running
本来就在跑,queued 本来就在等,completed/merged 已有
结果,重跑应是新任务而不是覆盖结果)。
配额按任务创建者查(费用记在创建者名下):维护者替
他人重试时,操作人超限不应拦、创建者超限才应拦。
配额按双人门查(P0 安全批,见
_reject_if_over_quota_for_task):触发人与原任务创建
人任一超限即拦。费用仍记在创建者名下,所以只查创建
者不够——触发人用自己的额度过门、费用记他人名下,
仍是绕限;只查触发人也不够——原创建者已超限时,
替他人重试仍会把费用记到已超限的账上。
成功后状态回 queued,上一轮的 result_text/error_text/
actual_model 清空(否则新一轮的结果会和旧结果混在一起
无法分辨);prompt、模型、优先级、依赖原样保留,依赖
@@ -3832,8 +3883,7 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
user_id, task["project_id"], "approve_merge"):
self._send_json(403, {"error": "forbidden"})
return
if self._reject_if_over_quota(
user_id, quota_user_id=task.get("created_by") or user_id):
if self._reject_if_over_quota_for_task(user_id, task):
return
if task["status"] not in ("failed", "interrupted", "merge-failed"):
self._send_json(409, {
@@ -3882,9 +3932,11 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
def _quota_status(self, user_id):
"""返回 (quota_usd_micros|None, used_usd_micros, month)。
quota 为 None 表示不限额:老用户与未设配额的用户默认
不限额,行为与过去一致,不会因为上了配额功能就突然
被挡住。"""
P0 安全批后:无配额行不再等于不限额——缺行时按默认
配额处理(与 _default_quota_usd_micros() 同值,只在
返回值里合成,不在这里写行;写行由 _apply_default_quota
/ _backfill_default_quotas 负责)。只有管理员显式把
行写成 NULL 时,quota 才为 None(不限额)。"""
month = self._quota_month()
conn = self._db()
try:
@@ -3902,7 +3954,10 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
"SELECT quota_usd_micros FROM user_quotas"
" WHERE user_id = ?",
(user_id,)).fetchone()
quota = row[0] if row is not None else None
if row is None:
quota = _default_quota_usd_micros()
else:
quota = row[0]
used = conn.execute(
"SELECT COALESCE(SUM(c.usd_micros), 0)"
" FROM cost_records c JOIN tasks t ON c.task_id = t.id"
@@ -3959,22 +4014,10 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
def _check_unpriced_token_cap(self, user_id):
"""无价 token 上限准入:超限返回 (cap, used, month),否则 None。
只有有配额行的用户才受限(无配额行 = 不限额,与 USD
口径一致);USD 配额本身不计量无价模型,所以这道是
补 USD 口径的洞,不是第二套计费。"""
P0 安全批后:无配额行用户同样受限(无价 token 上限与
配额行存在与否无关);USD 配额本身不计量无价模型,所
以这道是补 USD 口径的洞,不是第二套计费。"""
month = self._quota_month()
conn = self._db()
try:
_ensure_quotas_table(conn)
conn.commit()
row = conn.execute(
"SELECT quota_usd_micros FROM user_quotas"
" WHERE user_id = ?",
(user_id,)).fetchone()
if row is None:
return None
finally:
conn.close()
used = self._quota_unpriced_tokens(user_id, month)
cap = unpriced_token_cap()
if used >= cap:
@@ -3987,9 +4030,12 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
太快,所以不用 429。
准入点:建任务 / 分支 / 重试 / 续跑,均为权限校验
通过后、写库之前。后续消息(followup)创建不查:
排队本身不花钱,执行时轮次边界的配额门照常生效,
不要顺手把 followup 也改成准入校验。
通过后、写库之前。P0 安全批后,后续消息与任务间
消息创建时同样查双人配额门(见
_reject_if_over_quota_for_task):排队本身不花钱,
但续跑/复活必然花钱,只靠执行时轮次边界门拦不住
“超限用户靠他人任务续命”,所以创建时先拦一道,
执行时轮次边界门仍照常生效。
跑中掐断(与旧 docstring 不同,旧说法已过时):
Local popen 路径的 worker 轮询每 30 秒查一次配额,
@@ -4030,19 +4076,43 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
return True
return False
def _is_global_maintainer(self, user_id):
"""全局管理判定:沿用 _admin_backup 的口径——在任一
项目里是 maintainer/owner 即算(没有单设的全局管理
员表;没有任何项目角色的用户无权改任何人的配额)。"""
conn = self._db()
def _reject_if_over_quota_for_task(self, caller_id, task):
"""花钱路径的双人配额门:触发人 + 原任务创建人。
任一超限即 402(USD 配额与无价 token cap 都查)。
触发人等于创建人时只查一次。调用方须在权限校验后、
写状态前调它;返回 True 表示已回 402,调用方直接
return。"""
if self._reject_if_over_quota(caller_id):
return True
creator = (task or {}).get("created_by")
if creator and creator != caller_id:
if self._reject_if_over_quota(creator):
return True
return False
def _audit_admin(self, user_id, action, detail):
"""admin 调用审计(成功与被拒都记,失败绝不挡主流程)。"""
try:
row = conn.execute(
"SELECT COUNT(*) FROM project_members"
" WHERE user_id = ? AND role IN ('maintainer', 'owner')",
(user_id,)).fetchone()
return (row[0] if row is not None else 0) > 0
finally:
conn.close()
with _auth_store(self.server.db_path) as store:
store.audit(user_id, "", action, detail)
except Exception:
pass
def _is_global_admin(self, user_id):
"""真全局管理员:只认 CODEX_GLOBAL_ADMINS。
未配置时返回 False(fail-closed)。P0 安全批前“任一
项目 maintainer 即全局管理”的推导已被废掉:项目角色
只管项目内权限,不管全系统管理权。部署时运维必须先
把管理员用户 id 写进 CODEX_GLOBAL_ADMINS 再重启,
否则 admin 端点对所有人都是 403(数据不丢,改 env
重启即恢复)。"""
return user_id is not None and user_id in _global_admin_ids()
def _is_global_maintainer(self, user_id):
"""旧名兼容:等价于 _is_global_admin(推导口径已废)。"""
return self._is_global_admin(user_id)
def _user_exists(self, target):
conn = self._db()
@@ -4061,7 +4131,9 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
self._quota_unpriced_tokens(user_id)))
def _admin_quota_get(self, user_id, query):
if not self._is_global_maintainer(user_id):
if not self._is_global_admin(user_id):
self._audit_admin(user_id, "admin_quota_denied",
"get quotas")
self._send_json(403, {"error": "forbidden"})
return
target = (query.get("user_id") or [None])[0]
@@ -4078,7 +4150,9 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
self._quota_unpriced_tokens(target)))
def _admin_quota_set(self, user_id):
if not self._is_global_maintainer(user_id):
if not self._is_global_admin(user_id):
self._audit_admin(user_id, "admin_quota_denied",
"set quotas")
self._send_json(403, {"error": "forbidden"})
return
body, err = self._read_json()
@@ -4323,14 +4397,14 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
def _admin_sweep(self, user_id):
"""POST /admin/sweep:把超阈值的陈旧 running 任务标 interrupted。
权限:与 _admin_backup 同口径——调用方须在任一项目里
是 maintainer 以上,否则 403。过去这里没有任何权限检查,
任意持 key 用户都能跨用户改任务状态。threshold_seconds
下限钳到 60 秒:0 会把所有 running 任务一口气扫掉,
只能是管理员的显式紧急操作才需要那么小的值,而 60 秒
以下的“陈旧”本就没有意义。扫完记 admin_sweep 审计。
权限:只认 CODEX_GLOBAL_ADMINS(见 _is_global_admin),
未配置时一律 403。threshold_seconds 下限钳到 60 秒:
0 会把所有 running 任务一口气扫掉,只能是管理员的
显式紧急操作才需要那么小的值,而 60 秒以下的“陈旧”
本就没有意义。扫完记 admin_sweep 审计,被拒也记审计。
"""
if not self._is_global_maintainer(user_id):
if not self._is_global_admin(user_id):
self._audit_admin(user_id, "admin_sweep_denied", "sweep")
self._send_json(403, {"error": "forbidden"})
return
body, err = self._read_json()
@@ -4354,32 +4428,21 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
def _admin_backup(self, user_id):
"""POST /admin/backup:备份当前后端数据库,返回路径与规模信息。
权限:调用方须在至少一个项目里是 maintainer 以上(全局
管理端点没有单个项目上下文,按“任一项目 maintainer+”
判定;无此角色返回 403)。返回只含 backend、备份文件
路径、文件大小与任务行数,绝不返回数据库密码、备份文件
内容或任何凭据。out_dir 可由 body 指定,否则走环境变量
CLOUD_BACKUP_DIR,未设则用 cloud-backups。
权限:只认 CODEX_GLOBAL_ADMINS(见 _is_global_admin),
未配置时一律 403。返回只含 backend、备份文件路径、
文件大小与任务行数,绝不返回数据库密码、备份文件内容
或任何凭据。out_dir 可由 body 指定,否则走环境变量
CLOUD_BACKUP_DIR,未设则用 cloud-backups。成功与被拒
都记审计(审计里只记路径与规模,不记库内容)。
"""
if not self._is_global_admin(user_id):
self._audit_admin(user_id, "admin_backup_denied", "backup")
self._send_json(403, {"error": "forbidden"})
return
body, err = self._read_json()
if err:
self._send_json(400, {"error": err})
return
conn = self._db()
try:
member_rows = conn.execute(
"SELECT role FROM project_members WHERE user_id = ?",
(user_id,),
).fetchall()
finally:
conn.close()
role_levels = {"viewer": 0, "contributor": 1,
"maintainer": 2, "owner": 3}
is_maintainer = any(
role_levels.get(row[0], 0) >= 2 for row in member_rows)
if not is_maintainer:
self._send_json(403, {"error": "forbidden"})
return
out_dir = body.get("out_dir") or os.environ.get(
"CLOUD_BACKUP_DIR") or "cloud-backups"
if not isinstance(out_dir, str) or not out_dir:
@@ -4403,6 +4466,10 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
except Exception as exc:
self._send_json(500, {"error": "backup failed: %s" % (exc,)})
return
self._audit_admin(
user_id, "admin_backup",
"backend=%s path=%s size=%d tasks=%d"
% (backend, dest, size_bytes, task_count))
self._send_json(200, {
"backend": backend, "path": dest,
"size_bytes": size_bytes, "task_count": task_count,
@@ -4571,6 +4638,10 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
auth, provider, identity, bind_user_id)
auth.audit(bind_user_id, "", "bind_external",
"%s:%s" % (provider, identity.get("external_id")))
if _apply_default_quota(auth.conn, bind_user_id):
auth.audit(bind_user_id, "", "quota_default",
"quota_usd_micros=%d"
% _default_quota_usd_micros())
identity["already_bound"] = binding["already"]
return (bind_user_id, False, None, False, identity, True)
bound_user_id = find_bound_user(auth, provider, identity)
@@ -4580,6 +4651,10 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
auth.audit(bound_user_id, "", "oauth_login",
"%s:%s" % (provider, identity.get("external_id")))
auth.audit(bound_user_id, "", "api_key_issued", provider)
if _apply_default_quota(auth.conn, bound_user_id):
auth.audit(bound_user_id, "", "quota_default",
"quota_usd_micros=%d"
% _default_quota_usd_micros())
return (bound_user_id, False, api_key_plain, False, identity,
False)
local_id, created, api_key_plain, rotated = \
@@ -4587,7 +4662,7 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
auth, provider, identity)
auth.audit(local_id, "", "oauth_login",
"%s:%s" % (provider, identity.get("external_id")))
if created and _apply_default_quota(auth.conn, local_id):
if _apply_default_quota(auth.conn, local_id):
auth.audit(local_id, "", "quota_default",
"quota_usd_micros=%d"
% _default_quota_usd_micros())
@@ -4864,17 +4939,25 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
html.escape(str(message))))
def _github_app_setup_state(self):
"""签发 setup state:须持本站 key(只有操作方能发起建 App)。
"""签发 setup state:只限全局管理员(见 _is_global_admin)。
要求环境变量 GITHUB_APP_PUBLIC_BASE(公网域名,如
P0 安全批前任何持 key 用户都能签发并回调覆盖全局
App 配置;现在非管理员直接 403 并记审计。要求环境
变量 GITHUB_APP_PUBLIC_BASE(公网域名,如
https://api.diante.me):manifest 里的 hook / 回调地址
都由它拼出。返回 {state, setup_url},setup_url 是用户
在浏览器里打开的表单页;state 一次性、1 小时过期。
在浏览器里打开的表单页;state 一次性、1 小时过期,
且绑定签发人。
"""
user_id = self._authenticate()
if user_id is None:
self._send_json(401, {"error": "unauthorized"})
return
if not self._is_global_admin(user_id):
self._audit_admin(user_id, "github_app_setup_denied",
"setup state")
self._send_json(403, {"error": "forbidden"})
return
base = self._github_app_public_base()
if not base:
self._send_json(400, {
@@ -4886,7 +4969,7 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
org = (body or {}).get("org")
store = SetupStateStore(self.server.db_path)
try:
state = store.create_state()
state = store.create_state(issued_by=user_id)
finally:
try:
store.conn.close()
@@ -4968,6 +5051,11 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
报错误页,不存任何东西。成功页只显示 App 身份(slug、
id、主页)与安装链接,绝不显示 webhook_secret 与 pem
(两者只在库里,server 进程内用)。
P0 安全批后:回调要求签发人仍在 CODEX_GLOBAL_ADMINS
里,否则 state 不消耗、直接报 invalid_state;覆盖全局
配置时记审计。诚实边界:回调是浏览器无认证重定向,
这里核对的是“签发人仍是管理员”,不是“回调者就是
签发人”(见 SetupStateStore.consume_state 文档)。
"""
query = parse_qs(urlparse(self.path).query)
code = (query.get("code") or [None])[0]
@@ -4978,13 +5066,18 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
return
store = SetupStateStore(self.server.db_path)
try:
ok = store.consume_state(state)
issuer = store.state_issuer(state)
ok = store.consume_state(
state, allowed_issuers=_global_admin_ids())
finally:
try:
store.conn.close()
except Exception:
pass
if not ok:
self._audit_admin(
issuer or "unknown", "github_app_callback_denied",
"state issuer not a global admin")
self._send_html(400, self._github_app_error_page(
"invalid_state", "setup state invalid or expired"))
return
@@ -5001,6 +5094,10 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
save_app_config(conn, config)
finally:
conn.close()
self._audit_admin(
issuer or "unknown", "github_app_config_replaced",
"slug=%s app_id=%s" % (config.get("slug"),
config.get("app_id")))
slug = config.get("slug") or ""
page = (
"<!doctype html><html><head><meta charset='utf-8'>"
@@ -5130,17 +5227,22 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
if not event_type or not project_id:
self._send_json(400, {"error": "event_type and project_id required"})
return
# 分发者须是该项目的成员(至少可读),否则不能往别人项目里推。
with _auth_store(self.server.db_path) as auth:
if not auth.check_permission(user_id, project_id, "read"):
self._send_json(403, {"error": "forbidden"})
return
# P0 安全批后:/admin/push/dispatch 是管理端点,只认
# CODEX_GLOBAL_ADMINS,不再只凭项目 read 就能让服务端
# 向订阅目标(可能含内网地址)真 POST。被拒与成功都记审计。
if not self._is_global_admin(user_id):
self._audit_admin(user_id, "admin_push_denied",
"dispatch %s %s" % (event_type, project_id))
self._send_json(403, {"error": "forbidden"})
return
# 分发:sender=None 时用 push 的内置真实发送端
# (webhook/slack 真 POST,webpush/apns/fcm 仍报缺凭证);
# 显式注入的 sender 优先于内置端(见 push.dispatch_event)。
result = _push_dispatch(
self.server.db_path, event_type, project_id,
body.get("payload") or {}, sender=None)
self._audit_admin(user_id, "admin_push_dispatch",
"dispatch %s %s" % (event_type, project_id))
self._send_json(200, result)
# -- webhooks(签名认证,不走 X-API-Key)---------------------------
@@ -5255,12 +5357,14 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
GITHUB_WEBHOOK_SECRET(手动 webhook,过渡期共存;App
装好、手动 webhook 删掉后自然不再被命中)。
/fix-review 解析成功后在控制面建一个 queued 任务(prompt
含 repo、PR 号、评论原文、作者),由 worker 认领执行——
webhook 本身不跑模型。任务归属:按项目名 == repo
full_name 匹配项目;没有匹配项目时不凭空建项目、不建
任务,返回 task_id 为 None 并说明原因(worker 的 repo
仍是配置里的 repo,不是任意 GitHub 仓库的自动克隆,
见 README)。check_run / check_suite 只做通知解析,
是固定模板 + 截断后的评论摘录,不是评论原文原样),
由 worker 认领执行——webhook 本身不跑模型。任务归属:
按项目名 == repo full_name 匹配项目,created_by 归属
项目 owner 并走 owner 的配额门,executor 强制 docker;
没有匹配项目时不凭空建项目、不建任务,返回 task_id
为 None 并说明原因(worker 的 repo 仍是配置里的 repo,
不是任意 GitHub 仓库的自动克隆,见 README)。作者
不可信时同样不建任务(见 is_trusted_fix_review_author)。check_run / check_suite 只做通知解析,
返回解析结果而不建任务、不改云端任务状态(CI 状态回填
由 pr-checks 主动拉取)。installation / installation_repositories
事件记安装记录(created/added 增行、removed 减行、deleted
@@ -5368,12 +5472,16 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
self._send_json(200, {"result": None})
return
# /fix-review 执行:建 queued 任务(不跑模型,worker 认领)。
# P0 安全批后:先验作者、再查配额、最后才建任务。
# 作者校验用 GitHub 事件自带的 author_association 与
# PR/issue 作者比对(见 is_trusted_fix_review_author):
# 不可信一律不建任务、不占去重位,fail-closed。
conn = self._db()
try:
project_row = None
if repo_name:
project_row = conn.execute(
"SELECT id FROM projects WHERE name = ?",
"SELECT id, owner_id FROM projects WHERE name = ?",
(repo_name,),
).fetchone()
if project_row is None:
@@ -5382,6 +5490,28 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
error="no matching project for repo"))
return
project_id = project_row[0]
project_owner = project_row[1]
if not is_trusted_fix_review_author(payload):
with _auth_store(self.server.db_path) as auth:
auth.audit("system", project_id,
"webhook_fix_review_rejected",
"untrusted author %s"
% (result.get("author"),))
self._send_json(200, dict(
result, task_id=None, project_id=project_id,
error="untrusted fix-review author"))
return
if not project_owner:
self._send_json(200, dict(
result, task_id=None, project_id=project_id,
error="project has no owner for quota attribution"))
return
if self._reject_if_over_quota(project_owner):
with _auth_store(self.server.db_path) as auth:
auth.audit("system", project_id,
"webhook_fix_review_quota_blocked",
project_owner)
return
# 内容去重:手动 webhook 与 App webhook 共存时同一评论
# 送两遍(delivery_id 不同),只建一次任务;重复送达
# 回读当初的 task_id,不重复记审计。
@@ -5395,20 +5525,24 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
return
task_id = uuid.uuid4().hex
now = time.time()
# prompt 不再原样贴评论全文:固定模板 + 截断后的
# 评论摘录(上限 2000 字),防超长评论撑爆 prompt
# 与指令注入面无限放大。
prompt = (
"[webhook /fix-review] repo=%s pr=%s author=%s\n%s"
% (result.get("repo"), result.get("pr_number"),
result.get("author"), result.get("comment")))
result.get("author"),
(result.get("comment") or "")[:2000]))
try:
conn.execute(
"INSERT INTO tasks(id, project_id, prompt, status,"
" idempotency_key, depends_on, priority, created_by,"
" created_at, updated_at, last_heartbeat)"
" VALUES(?, ?, ?, 'queued', ?, '[]', 0, 'system',"
" ?, ?, NULL)",
" created_at, updated_at, last_heartbeat, executor)"
" VALUES(?, ?, ?, 'queued', ?, '[]', 0, ?,"
" ?, ?, NULL, 'docker')",
(task_id, project_id, prompt,
"webhook:%s" % delivery_id if delivery_id else None,
now, now),
project_owner, now, now),
)
conn.commit()
except (sqlite3.IntegrityError, _PgIntegrityError):