OpenProgram Docs

权限系统设计(Permission System Design)#

本文是 OpenProgram 权限系统的实现级设计文档:一个不了解代码的人读完,就知道权限系统是什么、有哪些权限、后端怎么写、前端怎么写、代码写在哪些文件。每个数据结构给字段定义,每个关键函数给签名,每个 WS 帧给字段,每个前端组件给结构。所有引用带 file:line

阅读顺序:① 概览(是什么、怎么运作)→ ② 有哪些权限(模式与规则的定义)→ ③ 后端怎么实现(判定、匹配、存储)→ ④ 前端怎么实现(审批卡片、模式选择、规则管理)→ ⑤ 值守(一个正交机制)→ ⑥ 关键约束与代码地图⑦ 不做的边界


1. 概览#

1.1 权限系统解决什么#

模型想调用一个工具(bash / write / …)时,只有三种可能的处置:直接执行先问用户直接拒绝。权限系统就是决定每一次工具调用走哪条路的机制。它不是安全沙箱(不做进程/文件隔离),是决策与知情层——让用户能控制"什么自动、什么要点头、什么绝不允许",并在需要点头时看清批的是什么。

1.2 四部分怎么协作#

权限判定由四个部分串成一条决策链,从硬到软:

部分 管什么 能否被 bypass 关掉 位置
gate(硬拦截) 策略层的绝对禁止(proactive policy 的 deny/ask) 否,永远生效 openprogram/events/tool_gate.py
规则层 用户配的 allow / deny / ask 规则(per-tool + per-pattern,项目级为主,多来源分层) deny/ask 否;allow 是 openprogram/agent/internals/_approval.py:50-68_match_rule)+ openprogram/functions/permission_rule.py
权限模式(会话级) 会话档位:ask / acceptEdits / auto / bypass / plan(对齐 Claude Code 官方名,5 档) 档位本身就是这个开关 _gated_executeinternals/_approval.py:150-197
审批流 需要点头时的前后端交互(弹卡片、阻塞等答、写回项目规则) 否(弹出即阻塞) await_user_approvalinternals/_approval.py:245-)+ 前端 approval mode

关键安全约束贯穿全文:决策优先级是 deny > ask > allow,且 deny/ask 判定早于 bypass 短路。 因为 web 入口默认就是 bypass(webui/_execute/__init__.py:552-553),如果把 deny 规则匹配放在 bypass 之后,用户设的"禁止 rm -rf"会在默认下被静默忽略——那是安全缺陷。第 3 节的判定伪代码严格保证这一点。

1.3 数据流#

LLM 发起工具调用
        │
        ▼
┌──────────────────────────────────────────────────────────────┐
│ agent_loop.py:695  构造 tool.before 事件                        │
│ agent_loop.py:701  decide_tool_gate(before_ev)  ← gate 硬拦截    │
│   deny → 抛 ToolGateDenied → error tool result 回给模型(终止)  │
└──────────────────────────────────────────────────────────────┘
        │ 放行
        ▼
┌──────────────────────────────────────────────────────────────┐
│ _gated_execute (internals/_approval.py:150-197)                │
│                                                                │
│  ① 规则层 deny/ask(bypass 之前)                                │
│     _match_rule → "deny" → 返回 [denied](任何模式含 bypass)    │
│     _match_rule → "ask"  → 强制 await_user_approval(含 bypass) │
│  ② force_ask 工具(exit_plan_mode)→ 强制审批                    │
│  ③ permission_mode == "bypass" → 直接执行                       │
│  ④ 规则层 allow(bypass 之后)                                   │
│     _match_rule → "allow" → 直接执行                            │
│  ⑤ 只读安全工具(SAFE_AUTO_ALLOWLIST)→ 全模式直接执行            │
│  ⑥ permission_mode == "acceptEdits" 且工具写安全 → 直接执行      │
│  ⑦ permission_mode == "auto" → 危险工具直接 [denied];           │
│     其余调 haiku 分类器判定                                      │
│  ⑧ 其余 → await_user_approval 弹卡片阻塞                        │
└──────────────────────────────────────────────────────────────┘
        │
        ▼   需审批时
┌──────────────────────────────────────────────────────────────┐
│ await_user_approval → open_question(kind="approval")           │
│   → emit_question_asked → 事件层 → WS question.asked 帧          │
│   → 前端 approval mode 渲染审批卡片                              │
│   → 用户点 允许一次 / 总是允许 / 拒绝                             │
│   → question_reply/question_reject → _resolve_question          │
│   → threading.Event 唤醒 → consume_or_timeout → (approved,      │
│      reason, scope)                                             │
│   scope=="always" → _persist_always_allow_rule → 写回项目规则   │
└──────────────────────────────────────────────────────────────┘
        │
        ▼
   approved → orig_execute   |   denied/timeout → [denied] error result

2. 有哪些权限(定义)#

这一节只讲"是什么"——权限模式和规则的定义。怎么实现在第 3、4 节。

2.1 权限模式(5 档,Claude Code 官方名)#

权限模式是会话级的:存 SessionRunConfig.permission_mode,前端在聊天页顶栏的权限徽章里选(§4.5),按会话隔离。定义在 openprogram/agent/dispatcher/types.py:19,合法值集在 openprogram/agent/session_config.py:27(对齐 Claude Code 官方 5 档):

# openprogram/agent/dispatcher/types.py:19
PermissionMode = Literal["ask", "acceptEdits", "plan", "auto", "bypass"]

# openprogram/agent/session_config.py:27
VALID_PERMISSION = {"ask", "acceptEdits", "plan", "auto", "bypass"}
_PERMISSION_BY_LOWER = {m.lower(): m for m in VALID_PERMISSION}  # 大小写不敏感规范化

档位内部值用官方英文原名,前端标签对齐 Claude Code 官方中英文(use-permission-mode.tsMODE_LABELS):

模式(内部值) 前端标签(EN / 中文) 行为
ask Ask permissions / 逐次确认 每个工具调用都弹审批卡片阻塞等答(除非规则 allow、只读安全工具、或 per-tool 声明不需审批)。逐次问。
acceptEdits Accept edits / 接受编辑 写类且路径安全的工具(read/write/edit/glob/grep/list,且目标在工作目录内、非危险文件)自动放行;bash/exec/shell 等命令类仍走完整审批
auto Auto mode / 自动判定 LLM 分类器档。危险工具(RISKY_AUTO_DENYLIST:bash/exec/shell/execute_code/process)直接 [denied];其余拿不准的调一次 haiku 判定安全与否(internals/_auto_classifier.py)。
bypass Bypass permissions / 绕过权限 全部直接放行,不弹审批。例外exit_plan_mode 强制审批(_FORCE_APPROVAL_TOOLSinternals/_approval.py:34);规则层 deny/ask 仍生效。
plan Plan mode / 计划模式 计划态。写类工具在此模式对模型不可见(apply_tool_policy(source="plan"))——纯可见性控制,与批准强度正交(见 §3.7)。

大小写规范化:acceptEdits 是驼峰。VALID_PERMISSION 存的是驼峰规范值,_PERMISSION_BY_LOWER 建一张 小写 → 规范值 表;_normalize_permissionsession_config.py:289-293)用 _PERMISSION_BY_LOWER.get(value.lower()) 做大小写不敏感匹配,所以前端传 "acceptedits" 也能规回 "acceptEdits",非法值返回 None

2.2 规则(allow / deny / ask 三平行)#

规则是用户配的覆盖,与档位正交,主要载体是项目(见 §2.3)。三个平行 list,规则的 behavior 由它住在哪个 list 决定,不由字符串自带字段:

# openprogram/agent/session_config.py:35-45
@dataclass
class PermissionRules:
    allow: list[str] = field(default_factory=list)
    deny:  list[str] = field(default_factory=list)
    ask:   list[str] = field(default_factory=list)

    def is_empty(self) -> bool:
        return not (self.allow or self.deny or self.ask)

规则字符串语法

ToolName                 整工具,per-tool。例:Bash / write_file / read_file
ToolName(content)        命令级,per-pattern。例:bash(git:*) / read_file(/etc/**)
  • bash{tool_name="bash"}(整个 bash 工具)
  • bash(git status){tool_name="bash", pattern="git status"}(精确命令)
  • bash(git:*){tool_name="bash", pattern="git:*"}(前缀通配:git 开头的命令)
  • read_file(/etc/**){tool_name="read_file", pattern="/etc/**"}(路径 glob)
  • 转义:pattern 内的 ( ) \ 需转义(\( \) \\),它们是语法定界符。序列化/反序列化对偶。
# openprogram/functions/permission_rule.py:19-22
@dataclass(frozen=True)
class PermissionRuleValue:
    tool_name: str
    pattern: str | None = None   # None = per-tool;非 None = per-pattern

2.3 规则来源(3 层,项目是主要载体)#

规则可来自多个来源,低优先级在前、高优先级在后,后进覆盖前进。合并在 load_merged_rules(session_id)openprogram/functions/permission_rule.py:100-146)。项目层是规则的主要载体——规则跟项目走,切会话仍在、"总是允许"能长期记住。只映射真实存在的载体(无 local/cliArg/企业策略后台,见第 7 节边界):

优先级 载体 可写
global(全局配置) 最低 全局 config 的 tools.permission_ruleswebui._setup._read_config()
project(主要载体) <project>/.openprogram/settings.jsonpermission_rules;默认项目落 <state>/projects/default-settings.json。经 project_for_session(session_id) 反查项目
session(本会话,一次性覆盖) 最高 SessionRunConfig.permission_rules,随会话落 session meta(schemaless)
  • 读写项目层:openprogram/store/project/project_store.pyload_project_settings / save_project_settings:565-);载体路径由 _settings_path_for:559-)决定——非默认项目落 <project>/.openprogram/settings.json,默认项目落 <state>/projects/default-settings.json(绝不往家目录塞配置)。
  • 合并只是拼接三 list:deny/ask/allow 的总序由 _match_rule 保证(命中即返回,deny > ask > allow),来源顺序只影响同一 behavior 内的先后。
  • "总是允许"(scope=always)写回项目 settings(_persist_always_allow_ruleinternals/_approval.py:90-106),不再是 session meta。

2.4 审批帧的数据载体 PendingQuestion#

审批合流进统一的 QuestionRegistry——审批就是 kind="approval" 的问题,和 runtime.ask 走同一条链路、同一个前端承接点。

# openprogram/agent/questions.py:34-54
@dataclass
class PendingQuestion:
    id: str                    # UUID hex[:12]
    session_id: str            # webui 会话 id,可空
    kind: str                  # "ask"|"confirm"|"approval"|"form"|"ask_many"
    prompt: str
    options: list[str] = field(default_factory=list)
    multi: bool = False
    allow_custom: bool = True
    detail: str = ""           # approval 用:工具名+参数摘要
    schema: dict = field(default_factory=dict)
    questions: list = field(default_factory=list)
    created_at: float = 0.0
    expires_at: float = 0.0

_Resolution = tuple[str, object],registry 的 outcome 只有两态 {"answered", "declined"}questions.py:57-58)。"timeout" 不是 registry 状态,而是 consume_or_timeoutquestions.py:256-)等不到结果时合成的返回值:return res if res is not None else ("timeout", None)


3. 后端怎么实现#

一次工具调用完整走两道关卡:gate(agent 主循环里)、审批包装(工具协程内)。规则匹配、各模式分支、存储、危险检测都在这一层。

3.1 gate(关卡 A,同步硬拦截)#

agent_loop.py 里每次执行工具之前:agent_loop.py:695 构造 tool.before 事件 → agent_loop.py:701decide_tool_gate(before_ev) 问一圈已注册的 gate → 有 deny 则 agent_loop.py:708ToolGateDenied,deny 理由作为 error tool result 回给模型。

# openprogram/events/tool_gate.py
ToolGate = Callable[[Event], "str | None"]   # 返回 None 放行 / 字符串 deny 理由

def decide_tool_gate(event: Event) -> str | None:
    """询问所有 gate,取最严:任一 deny 即拦(理由 "; " 合并)。
    gate 抛异常 → fail-open(stderr 打印),继续问下一个。"""

关键性质:gate 在权限审批包装之外,bypass 关不掉它events/tool_gate.py:15),对 subagent 同样生效。gate 是策略层(proactive policy 的 Gate allow/deny/ask)的硬拦截点,必须快(同步热路径,不许 LLM / 慢 IO)。

3.2 审批包装(关卡 B)与决策伪代码#

工具进入 dispatcher 时被逐个包一层审批(dispatcher/__init__.py:802tools = [_wrap_with_approval(t, req, on_event) for t in tools],函数真名是 wrap_with_approval,dispatcher 处 import 时改了别名)。包在工具协程内部,因为 agent_loop 急切调度 tool.execute,从外面拦有竞态(internals/_approval.py:121-126)。_gated_execute 是被替换进去的 execute(internals/_approval.py:150-197),完整判定顺序(8 分支):

# openprogram/agent/internals/_approval.py:34, 150-197
_FORCE_APPROVAL_TOOLS = {"exit_plan_mode"}  # :34

async def _gated_execute(call_id, args, cancel, on_update):
    mode = req.permission_mode
    force_ask = name in _FORCE_APPROVAL_TOOLS

    # ① 规则层 deny/ask —— bypass 之前,最高安全优先级
    verdict = _match_rule(getattr(req, "permission_rules", None), name, args)  # 3.4
    if verdict == "deny":
        return _denied(f"[denied] blocked by deny rule: {name}")
    if verdict == "ask":
        return await _approve_then_run(call_id, args, cancel, on_update)  # 即使 bypass 也弹

    # ② force_ask(exit_plan_mode),bypass 也不能跳
    if force_ask:
        return await _approve_then_run(call_id, args, cancel, on_update)

    # ③ bypass 短路(deny/ask/force 之后)
    if mode == "bypass":
        return await orig_execute(call_id, args, cancel, on_update)

    # ④ 规则层 allow —— bypass 之后
    if verdict == "allow":
        return await orig_execute(call_id, args, cancel, on_update)

    # ⑤ 只读安全工具全模式放行(ask / acceptEdits / plan 下 read/grep/glob
    #    这类只读调用不弹卡;复用 auto 分类器白名单)
    if name in SAFE_AUTO_ALLOWLIST:
        return await orig_execute(call_id, args, cancel, on_update)

    # ⑥ acceptEdits:写安全工具自动放行;命令类落审批
    if mode == "acceptEdits" and getattr(agent_tool, "_accept_edits_safe", False) \
            and _path_is_safe(name, args, req):        # 3.3 / 3.5
        return await orig_execute(call_id, args, cancel, on_update)

    # ⑦ auto:明显危险直接拒,拿不准问一次 haiku
    if mode == "auto":
        if name in RISKY_AUTO_DENYLIST:
            return _denied(f"[denied] auto mode: risky tool blocked: {name}")
        should_block, reason = await auto_classify_tool(name, args)
        if should_block:
            return _denied(f"[denied] auto classifier: {reason}")
        return await orig_execute(call_id, args, cancel, on_update)

    # ⑧ 弹卡片阻塞等答(ask / plan / acceptEdits 的命令类都落这里)
    return await _approve_then_run(call_id, args, cancel, on_update)

# internals/_approval.py:139-148
async def _approve_then_run(call_id, args, cancel, on_update):
    approved, reason, scope = await await_user_approval(
        req=req, tool_name=name, args=args, on_event=on_event)
    if not approved:
        return _denied(reason_or_default(reason, name))
    if scope == "always":
        _persist_always_allow_rule(req.session_id, name)  # 写回项目规则,见 §4.4
    return await orig_execute(call_id, args, cancel, on_update)

deny 早于 bypass 的安全约束(全设计最关键,务必保留):deny/ask 规则匹配(① ②)必须在 bypass 短路(③)之前。反例:若把规则整块插在 bypass 之后,则 web 默认 bypass(_execute/__init__.py:552-553)下,用户配的 deny: ["bash(rm -rf:*)"] 永远不被查到——rm -rf 被静默执行。所以 deny/ask 查在 bypass 之前、allow 查在 bypass 之后。这个先后是安全性质,改动 _gated_execute 时不可打乱。

3.3 各权限模式的分支实现#

对应 3.2 伪代码编号:

  • acceptEdits(⑥):三部分——① @functionaccept_edits_safe: bool = False 参数(functions/_runtime.py:767),落到工具对象的 _accept_edits_safe:1079);read/write/edit/glob/grep/list 各自的 @functionTrue(如 functions/tools/write/write.py:24edit/edit.py:25read/read.py:28grep/grep.py:101list/list.py:30glob/glob.py:43),bash/exec/execute_code 不标(默认 False);② _path_is_safeinternals/_approval.py:72-87)复用 3.5 的 check_path_safety(路径在工作目录集内、非危险文件/目录、无 Windows 绕过);③ 命令类工具即使有宽 allow 也 fall-through 到 ⑧ 强制审批。
  • plan(可见性控制)apply_tool_policy(tools, source="plan")dispatcher/__init__.py:798)滤掉写类工具,根本不进模型工具列表。plan 状态存布尔集(agent/plan_mode.py_active),不切批准强度——与批准档正交(详见 §3.7)。_gated_execute 无 plan 专属分支(写类已被滤掉,只读工具按当前档常规走)。
  • auto(⑦):LLM 分类器档,三级过滤省调用(internals/_auto_classifier.py):明显安全的只读工具在 ⑤ 已放行;RISKY_AUTO_DENYLIST(bash/exec/shell/execute_code/process)直接 [denied];其余拿不准的调一次 auto_classify_tool 问 haiku。规则层 deny/ask(①)仍在其前生效,allow(④)不受影响。
  • ask:不命中 allow、不在只读白名单、per-tool 不免审的工具全部落 ⑧。
  • bypass(③):deny/ask/force 之后全部直接执行。

3.4 规则匹配 _match_rule#

# openprogram/agent/internals/_approval.py:50-68
def _match_rule(rules, tool_name: str, args: dict) -> "str | None":
    """返回 "deny" | "ask" | "allow" | None(未命中)。
    优先级固定 deny > ask > allow:先扫 deny 命中即返回,再 ask,再 allow。
    每档内部:先试 per-tool(rule.pattern is None 且 tool_name 相等),
    再试 per-pattern(rule.pattern 对 parse_command(tool_name, args) 前缀/glob 匹配)。"""
    if rules is None:
        return None
    from openprogram.functions.permission_rule import parse_rule, parse_command, pattern_matches
    cmd = None  # 惰性求值:只在遇到 per-pattern 规则时才解析命令
    for behavior, ruleset in (("deny", rules.deny), ("ask", rules.ask), ("allow", rules.allow)):
        for raw in ruleset:
            rv = parse_rule(raw)
            if rv.tool_name != tool_name:
                continue
            if rv.pattern is None:                      # per-tool
                return behavior
            if cmd is None:
                cmd = parse_command(tool_name, args)    # 见下
            if cmd is not None and pattern_matches(rv.pattern, cmd):
                return behavior
    return None
  • per-toolrv.pattern is None):rv.tool_name == tool_name 命中整工具。例 deny: ["bash"] 拦所有 bash。
  • per-patternrv.pattern 非空):先取可比命令串 cmd = parse_command(...),再 pattern_matchespermission_rule.py:149-)::* 结尾→前缀匹配(git:* 匹配 git status、不匹配 github);含 glob(*?[)→fnmatch/etc/** 匹配 /etc/passwd);否则精确相等。

命令解析器 + 规则解析(openprogram/functions/permission_rule.py):

# permission_rule.py:43, 77, 83-98
def parse_rule(s: str) -> PermissionRuleValue: ...        # "bash(git:*)" → (bash, "git:*")
def rule_to_string(v: PermissionRuleValue) -> str: ...    # 与 parse_rule 对偶
def parse_command(tool_name: str, args: dict) -> str | None:
    """把工具参数归约成可比字符串(per-pattern 匹配用)。
    bash/exec/shell/execute_code/process → args["command"];
    read*/write*/edit*/apply_patch/list → args["path"] 或 args["file_path"];
    其余无可比字段 → None(per-pattern 对其不生效,只 per-tool 可拦)。"""

各层规则合并(load_merged_rules(session_id)permission_rule.py:100-146)——按优先级 global < project < session 拼接三 list,供 _gated_execute 用(真正跑判定时 req.permission_rules 由构造 TurnRequest 时填入):

# openprogram/functions/permission_rule.py:100-146
def load_merged_rules(session_id: str) -> PermissionRules:
    """合并三层真实载体:全局配置 < 项目(主要载体)< 会话(一次性覆盖)。
    项目层经 project_for_session(session_id) 反查 → load_project_settings。
    合并只是拼接三 list;deny/ask/allow 的总序由 _match_rule 保证(命中即返回),
    来源顺序只影响同一 behavior 内的先后。"""

与 per-tool requires_approval 的关系:两层并存互补。@function(requires_approval=...)functions/_runtime.py)是工具作者写死的声明(True/False/None/callable(**args)->bool|str),dispatcher 经 tool_requires_approvalfunctions/_runtime.py:1099)读。规则层是用户运行时覆盖,跑在 per-tool 之前(① ⑤ 在 ⑦ 之前)。

3.5 危险检测与路径安全#

RiskLevel + 卡片高亮internals/_approval.py:218-242):

# openprogram/agent/internals/_approval.py:218-230
def _risk_level(tool_name: str, args: dict) -> str:
    """审批卡片的危险分级 "low"|"medium"|"high",驱动前端高亮。
    high:命令类工具(_RISKY_TOOLS)且命令含 rm -rf / sudo / mkfs /
          fork bomb / 管道到 shell / curl / wget。
    medium:其余命令类工具;写/编辑/删除类工具。 low:只读工具。"""

_approval_detailinternals/_approval.py:232-242,生成"工具名 + 参数全文,超长首尾截断")给审批卡片一段可读摘要(第一版不做危险 token 高亮)。_on_askedawait_user_approval 内)的 question.asked 帧带上 tool/args/risk_level,前端据此上色(§4.2)。

路径安全openprogram/functions/tools/file_safety.py):

# file_safety.py:20-40, 63
DANGEROUS_FILES = {".bashrc", ".bash_profile", ".bash_login", ".profile",
                   ".zshrc", ".zprofile", ".zshenv", ".gitconfig", ".gitmodules",
                   ".git-credentials", ".npmrc", ".pypirc", ".netrc",
                   ".mcp.json", ".claude.json", ".env"}
DANGEROUS_DIRECTORIES = {".git", ".hg", ".svn", ".vscode", ".idea",
                         ".openprogram", ".claude", ".ssh", ".gnupg"}
DANGEROUS_BASH_PATTERNS = {"python","python3","node","deno","bun","ruby","perl",
                           "php","sh","bash","zsh","eval","exec","source",
                           "sudo","ssh","npx"}

def check_path_safety(path: str, working_dirs=None) -> dict:
    """返回 {"safe": bool, "message": str}。不安全:命中 DANGEROUS_FILES(按
    basename)/ 段命中 DANGEROUS_DIRECTORIES / 目标在 working_dirs 之外 /
    Windows 绕过(NTFS 流 ::$DATA、8.3 短名 ~1、UNC \\、尾部点空格、
    DOS 设备名 CON/PRN、三连点 .../)。working_dirs 缺省 = [cwd]。"""

check_path_safety 目前只被 acceptEdits 分支的 _path_is_safeinternals/_approval.py:72-87)消费:路径不安全 → acceptEdits 不自动放行、fall-through 到 ⑦ 审批。

额外工作目录SessionRunConfig.additional_working_dirs(§3.6)扩展路径安全的工作目录集。_path_is_safe 组装 work_dirs = [current_worktree_path() or os.getcwd(), *req.additional_working_dirs]:85-86)传给 check_path_safety——围栏基准与 system prompt 的 cwd 同源(dispatcher 每 turn 把真实 worktree/项目路径绑进 current_worktree_path,进程 getcwd 只是回落)。该字段从 session meta 经 TurnRequest.additional_working_dirsdispatcher/types.py:112)流下,填充点在 webui/_execute/chat.py:259channels/_conversation.py:243。用户可加"这个目录也算安全区";缺它则只认 cwd。

未启用的能力is_dangerous_allow_rule(tool_name, pattern)file_safety.py:94-100,用 DANGEROUS_BASH_PATTERNS 判一条 allow 规则在 acceptEdits 下会不会放过危险命令)有实现但无调用方——"进 acceptEdits 时临时剥离危险 allow 规则"不启用。系统也不提供 bypass 免疫的 safetyCheck 强制审批(tool_requires_approval(bool, reason) 二元组,不带 classifier_approvable):路径安全只在 acceptEdits 分支起作用,bypass 下写危险文件不会被强制拦。补充方案见 §7 末尾。

3.6 存储:session meta schemaless + SessionRunConfig#

存储分两处,各管一半:权限模式在会话(session meta),权限规则在项目(settings.json)

会话层(模式)是 schemaless 的——这是权限模式持久化不需要 DB migration 的原因。SessionDB.update_session(session_id, **fields)store/session/session_store.py:651-)把 head_id 特殊路由到 idx.set_head(),其余任意字段(permission_mode / additional_working_dirs,以及一次性覆盖用的 permission_rules)全部经 idx.set_meta(**clean) 落进 session meta。所以加会话级权限字段只改 session_config.py 的 load/save,旧会话读回不报错。

# openprogram/store/session/session_store.py:651-
def update_session(self, session_id, **fields):
    """head_id → set_head();其余字段 → set_meta(**clean)。schemaless。"""
# openprogram/agent/session_config.py:47-61
@dataclass
class SessionRunConfig:
    tools_enabled: Optional[bool] = None
    tools_override: ToolsOverride = None
    web_search: Optional[bool] = None
    toolset: Optional[str] = None
    thinking_effort: Optional[str] = None
    permission_mode: Optional[str] = None
    # ── 权限规则(会话层作最高优先的一次性覆盖;主要载体是项目,见 §2.3)──
    permission_rules: Optional[PermissionRules] = None          # §2.2
    additional_working_dirs: list[str] = field(default_factory=list)  # §3.5 路径安全

# session_config.py:192-193
def permission_from_config(cfg, *, default: str) -> str:
    return _normalize_permission(cfg.permission_mode) or default

**项目层(规则)**落在 <project>/.openprogram/settings.json(默认项目落 <state>/projects/default-settings.json)的 permission_rules 键,读写经 project_store.load_project_settings / save_project_settingsstore/project/project_store.py:565-)。这是规则的主要载体,跟项目走。会话层 permission_rules 仅作最高优先的一次性覆盖。合并见 load_merged_rules(§3.4)。

默认值三处permission_from_config 的 default 决定 session 未设置时落哪;web/channels 两条路先查项目默认 project_defaults(session_id),缺才用下表兜底):

入口 默认 位置
TurnRequest 数据类字段 ask dispatcher/types.py:53
Web 执行路径 项目默认,缺则 bypass webui/_execute/__init__.py:552-553
渠道(channels) 项目默认,缺则 ask channels/_conversation.py:240-241

子 agent 固定 bypasssub_agent_run.py:89):子 agent 的 lane 上没有 UI 订阅审批事件,ask 会让每工具超时 [denied];且"派生子 agent"本身已是用户显式动作。

3.7 plan 与 permission_mode 的关系(不做 prePlanMode)#

plan 是可见性控制(藏写工具,agent/plan_mode.py 布尔集),不切 permission_mode——两者正交。所以不像 Claude Code 那样需要"进 plan 记住旧档、退出恢复"(CC 的 plan 是权限档,占用档位槽才需要 prePlanMode)。进/退 plan 只翻 plan_mode._active 的开关,当前的 permission_mode(ask/acceptEdits/auto/bypass)始终不变、退出即原样生效——不记录、不恢复。代码里没有 pre_plan_permission_mode 字段,没有 permission_context.py


4. 前端怎么实现#

前端三件事:审批卡片(收 question.asked、渲染三选一)、权限模式选择(顶栏权限徽章,会话级)、规则管理面板(Projects 页,项目级)。

4.1 审批卡片入口#

审批合流进统一的问题渲染(approval 是 kind="approval" 的问题,和 runtime.ask 走同一条链路)。入口组件 QuestionModeweb/components/chat/composer/modes/question/question-mode.tsx)按 kind 分支:approval 分支(:82-83, :309-334)把帧的 prompt/detail/risk_level 归一成一个 approval step 后渲染卡片。

question.asked 帧字段(后端 emit_question_asked 发,internals/_approval.py:274-282):

{
  "type": "question.asked",
  "data": {
    "id": "<uuid hex[:12]>", "session_id": "<可空>", "kind": "approval",
    "prompt": "允许执行 <tool_name>?",
    "options": ["允许", "拒绝"], "multi": false, "allow_custom": false,
    "detail": "<tool_name>\n<args_json 超长截断>", "expires_at": 1735689600.0,
    "tool": "<tool_name>", "args": { "...": "工具参数 dict" },
    "risk_level": "high"
  }
}

tool/args/risk_level 是 approval 专属(:281),给前端画危险摘要、驱动高亮。

4.2 审批卡片三选一 + 危险高亮#

approval 分支渲染三颗按钮(允许一次 / 总是允许 / 拒绝)+ 危险高亮(question-mode.tsx:309-334):

// question-mode.tsx:309-334
if (step.kind === "approval") {
  const pick = (answer as { pick: "once" | "always" | "deny" | null }).pick;
  const risk = step.risk ?? "low";   // "low" | "medium" | "high"
  const label = { once: "允许一次", always: "总是允许", deny: "拒绝" } as const;
  return (
    <>
      <div className={styles.prompt}>{withColon(step.prompt)}</div>
      {step.detail ? (
        <pre className={approvalStyles.summary + " " + (approvalStyles["risk_" + risk] ?? "")}>
          {step.detail}
        </pre>
      ) : null}
      <div className={styles.options}>
        {(["once", "always", "deny"] as const).map((p) => (
          <button className={styles.opt + (pick === p ? " " + styles.optPicked : "")}
            onClick={() => onChange({ pick: pick === p ? null : p })}>
            {pick === p ? "✓ " : ""}{label[p]}
          </button>
        ))}
      </div>
    </>
  );
}

Answer 类型 approval 分支是 {pick:"once"|"always"|"deny"|null}question-mode.tsx:74)。危险高亮由 approval-mode.module.css.risk_high/.risk_medium/.risk_low 套在 .summary 上;按钮用 .opt/.optPicked(选中态加 )。

4.3 回传 WS payload#

前端 submit() 按 pick 发(question-mode.tsx:162-166):

wsSend({ action: "question_reply", id: q.id, answer: "允许", scope: "once" })   // 允许一次
wsSend({ action: "question_reply", id: q.id, answer: "允许", scope: "always" }) // 总是允许
wsSend({ action: "question_reject", id: q.id })                                 // 拒绝

后端处理(webui/ws_actions/session.py:693-712)——scope 存在时把 {answer, scope} 打包成 value,await_user_approval 消费时拆出 scope:

# webui/ws_actions/session.py:693-712
async def handle_question_reply(ws, cmd):
    qid = cmd.get("id") or ""; answer = cmd.get("answer"); scope = cmd.get("scope")
    if qid:
        value = {"answer": answer, "scope": scope} if scope else answer
        _resolve_question(qid, "answered", value)

async def handle_question_reject(ws, cmd):
    qid = cmd.get("id") or ""; reason = cmd.get("reason")
    if qid:
        _resolve_question(qid, "declined", reason)

_resolve_questionsession.py:686-690)薄封装 resolve_question_and_broadcastquestions.py)——WS/REST/channel /answer 的共享 claim-once 路径:resolve registry + 广播收回别处 UI。

后端 await_user_approval 返回 (approved, reason, scope)internals/_approval.py:235-305),scope ∈ {"once","always"}。流程:open_question(kind="approval",...)await asyncio.to_thread(ev.wait, timeout)(不阻塞 asyncio loop,默认 300s)→ consume_or_timeout:answered 时拆出 answer/scopeanswer ∈ {"允许","approve","yes","y","true","ok","是"}(True, None, scope);declined → (False, reason, "once");timeout → retract_question 收回卡片 → (False, None, "once")

4.4 allow-always 写回项目规则#

_approve_then_run 拿到 approved=True and scope=="always" → 写回一条 per-tool allow 规则到项目层:

# openprogram/agent/internals/_approval.py:90-106
def _persist_always_allow_rule(session_id: str, tool_name: str) -> None:
    """把 tool_name 作为一条 per-tool allow 规则,落到项目层
    (<project>/.openprogram/settings.json 的 permission_rules.allow)。
    经 project_for_session(session_id) 反查项目,缺则用 get_default_project()。
    规则跟项目走——切会话仍生效、长期记住。"""

写完下次同工具 _match_rule 命中 allow → 不再弹。撤回误点的"总是允许":在 Projects 页规则面板(§4.6)逐条删除。

4.5 权限模式选择(top-bar 权限菜单,会话级)#

选择器不在 composer 的 plus-menu 里,而是聊天页顶栏的权限徽章 PermissionBadgeweb/components/chat/top-bar/permission-menu.tsx:68)。徽章由 usePermissionMode hook 驱动(web/components/chat/composer/controls/use-permission-mode.ts), hook 返回 {mode, options, set}

5 档标签取 Claude Code 官方名(use-permission-mode.ts:28-34MODE_LABELS,带 1-5 数字快捷键):

内部值 EN 中文
ask Ask permissions 逐次确认
acceptEdits Accept edits 接受编辑
plan Plan mode 计划模式
auto Auto mode 自动判定
bypass Bypass permissions 绕过权限

存储:按会话隔离,无全局值。 读的是 useBoundComposerSettings().permission_modeuse-permission-mode.ts:48)——绑定当前会话,切会话即换值。

聊天帧不承载 permission_mode 构帧的 composer/legacy-send.ts:136-238sendChatMessage)不含 permission_mode 字段—— 帧只带 text / thinking / tools / web_search / service_tier / attachments 等。 后端 webui/ws_actions/chat.py:312 仍读 cmd.get("permission_mode"),但没有任何前端填它, 这一路恒为 None,属未清理的无效代码,不是生效机制。

生效的写入方只有两条,都不经聊天帧:

  • 会话设置:改会话的 SessionRunConfig.permission_modesession_config.py),落到 session meta。
  • 项目配置:项目级默认档,由项目 settings 提供。

下游 dispatcher 照旧读 run config:effective_permission = permission_from_config(run_cfg, default="bypass")_execute/__init__.py:557)塞进 TurnRequest。

4.6 规则管理面板(Projects 页,项目级)#

规则管理 UI 在 Projects 页web/components/projects/projects-page.tsx:146-148):点开一个项目展开其规则面板。面板组件 PermissionsSectionweb/components/projects/permissions-section.tsx)按 projectId 工作:

  • 列出该项目的 deny / ask / allow 三组规则,每组可手动新增、逐条删除。
  • 拉取/刷新走 WS:list_permission_rules / add_permission_rule / remove_permission_rule,请求都带 project_id;后端广播 permission_rules 帧(session.py:742-748)刷新面板。
  • 规则字符串语法 ToolNameToolName(pattern)(如 bash(git:*)),见 §2.2。

后端 WS handler(webui/ws_actions/session.py:751-783)都是项目级_resolve_project_id:718-730)支持请求直接带 project_id(Projects 页知道项目),或只带 session_id 时经 project_for_session 反查项目(composer 路径);_mutate_project_rule 增删后 save_project_settings + 广播。

规则只在 Projects 页管理:settings 没有 Permissions tab,chat composer 也没有 "Manage rules…" 入口。规则统一落项目层。


5. 值守(attended)——一个正交机制#

值守与权限是两套独立机制,管的不是一回事:

机制 管什么 谁触发
权限模式 模型调工具时是否要用户批准 模型发起工具调用
值守(attended) 模型是否有权主动向用户提问 模型想调 ask_user_question

值守在 openprogram/agent/attended.py。核心(attended.py:1-23):长跑要么"有人看着能回答"(attended),要么"人走开了别问"(unattended)。控制手段——unattended 时不把提问工具给模型。状态:进程级默认 _default = False:33,默认 unattended)+ 按 session 覆盖 _by_session:34)。落地:denied_ask_tools:64-68)在 unattended 时把 ask_user_question 折进工具解析 deny 集,运行时侧 runtime.py:1516 引用。设置入口 set_attended(value, session_id):38-46),web 经 ws_actions/runtime.py:503-504handle_set_attended(per-session)。

配合:权限模式管"工具执行要不要批准",值守管"模型能不能开口问"。unattended + bypass = 既不停下问、工具全直接执行(无人值守自动跑);attended + ask = 可提问、每工具要点头(盯着干)。二者正交,任意组合。


6. 关键约束与代码地图#

6.1 改动权限代码时必须守住的性质#

  • deny/ask 早于 bypass_gated_executeinternals/_approval.py:151-188)里规则层 deny/ask(① ②)必须在 bypass 短路(③)之前。web 默认就是 bypass,把 deny/ask 挪到 bypass 之后会让"禁止 rm -rf"被静默忽略——安全缺陷。
  • exit_plan_mode 强制审批_FORCE_APPROVAL_TOOLS:34)在 bypass 下也弹卡片;提交计划要用户签字。
  • 模式 vs 规则的作用域:权限模式是会话级(session meta),权限规则是项目级为主(<project>/.openprogram/settings.json)。别把两者的存储混起来。
  • 驼峰规范化acceptEdits 是驼峰规范值,一切比较走 _normalize_permission 的大小写不敏感表(session_config.py:289-293),不要直接 .lower() 后当规范值用。
  • acceptEdits 只放路径安全的写工具:命令类(bash/exec/execute_code)无论如何 fall-through 到审批(⑥ 只对 _accept_edits_safe=True_path_is_safe 的工具放行)。

6.2 代码地图#

关注点 代码位置
判定链 _gated_execute / _match_rule / await_user_approval / _persist_always_allow_rule / _risk_level openprogram/agent/internals/_approval.py
规则字符串解析、匹配、多层合并 openprogram/functions/permission_rule.pyparse_rule / parse_command / pattern_matches / load_merged_rules
路径安全 / 危险文件目录 / Windows 绕过 openprogram/functions/tools/file_safety.py
gate 硬拦截 openprogram/events/tool_gate.py
权限模式合法值 + 规范化 + SessionRunConfig 字段 openprogram/agent/session_config.py
PermissionMode 类型 + TurnRequest 字段/默认 openprogram/agent/dispatcher/types.py
会话 meta schemaless 存储 openprogram/store/session/session_store.py
项目级 settings 读写 + project_for_session openprogram/store/project/project_store.py
accept_edits_safe 声明 + per-tool requires_approval openprogram/functions/_runtime.py;工具标记在 openprogram/functions/tools/{read,write,edit,glob,grep,list}/
web 默认 bypass + effective_permission openprogram/webui/_execute/__init__.pyadditional_working_dirs 填充在 _execute/chat.pychannels/_conversation.py
WS:审批应答 + 项目规则 list/add/remove openprogram/webui/ws_actions/session.pychat.py
值守(正交机制) openprogram/agent/attended.pyopenprogram/webui/ws_actions/runtime.py
前端审批卡片(approval mode) web/components/chat/composer/modes/question/question-mode.tsx + ../approval/approval-mode.module.css
前端权限模式选择(会话级 hook) web/components/chat/composer/controls/use-permission-mode.ts + composer/index.tsx
前端规则面板(项目级) web/components/projects/projects-page.tsx + web/components/projects/permissions-section.tsx

7. 不做的边界#

只列真没有物理载体、或加了必然冲突无法消解的。不是"为省事不做"。

  • 企业策略层(policy/flag 来源):无特性开关、无企业 MDM/策略下发后台。§2.3 的三层来源全部落地,唯独企业层无载体承接。未来接企业部署可补。
  • local / cliArg 规则层:无 .openprogram/settings.local.json、无 --allow-tool 之类的 CLI 标志。等价能力由项目层 + 会话层覆盖。
  • 外部审批委托(permissionPromptTool):把审批决策委托给外部 MCP 工具。统一走 QuestionRegistry + 前端卡片,无此机制。未来接自定义审批后端可补。
  • 沙箱隔离(安全边界):权限系统是决策与知情层,不是安全边界,不做进程/文件隔离。真正隔离是独立 sandbox 工作线。
  • bypass 免疫的 safetyCheck 强制审批tool_requires_approval 仍是 (bool, reason),无 classifier_approvable;路径安全只在 acceptEdits 分支起作用,bypass 下写危险文件不会被强制拦。is_dangerous_allow_rulefile_safety.py:94-100)已实现但未接线。若要补 bypass 免疫,需把 tool_requires_approval 扩成三元组、在 _gated_execute ① 处把"路径不安全"视为 ask。
Last updated · 2026-08-13