Codex 权限 profiles 实验:只让报告目录可写

把需要读的输入、唯一可写的产物和应拒绝的目录逐项列出来。

1. 先把“只生成报告”落到目录上

一项只需要生成报告的任务,往往会同时读源码、输入数据和另一个目录里的规范。直接给整个工作区写权限,任务当然更容易跑通,但输入文件和验收材料也一起变成了可修改对象。这个练习从一张路径清单开始:哪些文件必须读,产物放在哪里,哪些目录应当连内容都看不到。

目标是一份小型目录报告:读取 app/input/catalog.json 与 shared/spec.txt,把结果写到 app/reports。app/private 和 outside 只放人工生成的标记文本,用来检查拒读边界。没有安装依赖、调用模型、访问真实秘密或上传文件的步骤。

本文提供三份完整候选:audit 用于只读核对,report 增加一个精确报告目录的写入,report-net 再增加一个明确的公共域名。audit 只做只读核对;report 已足够生成报告。第三份只为说明网络权限怎样核查,可以停在纸面评审。

合成目录分为只读输入、报告输出、拒读标记和宿主控制文件四组。
图 1:三个候选使用同一套文件,逐步比较权限变化。所有允许与拒绝都还是待观察的预期。

2. 本稿依据哪版规则

资料核对日期为 2026 年 10 月 7 日。权限 profiles 在官方权限文档中仍标为 beta。代码核对固定在 openai/codex 提交 1fbe15c962cc3d8eabec36d67987c83cfc4eeec9,包括 配置结构与继承、权限编译、配置选择和 配置 Schema。这个提交是源码基线,不是本文安装并验证过的 CLI 发行版本。

正文中的 Python 检查在本地离线执行;三份生成后的 TOML 还通过该固定提交的 Schema 校验。没有启动真实 Codex 会话,也没有验证操作系统沙箱、代理、账户权限或模型输出。首次真实试验时,应把实际 CLI 版本填进观察记录;不能拿源码提交号代替运行版本。

权限文档与固定源码在混用旧配置时存在差异:文档提示已加载的 sandbox_mode 会选择旧机制;这里核对的源码会按配置层处理选择,SessionFlags 中的 default_permissions 也有专门分支。因此本练习不依赖混用时谁覆盖谁。候选中完全不出现 sandbox_mode 或 sandbox_workspace_write,启动时也不追加 --sandbox。出现旧设置时,先让配置维护者核对并整理,保留现有受管要求。

3. 先区分四件容易混在一起的事

命名权限 profile 由 default_permissions 选择,定义在 permissions 下。普通配置 profile 是另一种配置组织方式;--profile 不能仅凭同名就被理解成“选择权限 profile”。本文只交付独立 TOML 候选,不生成旧式 profiles 表,也不提供自动覆盖用户配置的安装脚本。

approval_policy 仍是独立设置,本例显式使用 on-request。沙箱决定命令原本可以做什么;审批负责判断额外请求。遇到拒绝时,本练习记录结果并结束该项,不批准提升权限,也不改规则后继续把这次拒绝计为通过。组织要求、规则和平台能力可能使一个看似允许的动作仍被阻止。

文档同时提醒,受管 allowed_permission_profiles 会强制采用权限 profiles,并有客户端版本要求。团队部署还要单独核对 requirements.toml。本例没有生成或修改受管配置,不声称能放宽组织策略。源码中的显式 sandbox_mode 与 default_permissions 覆盖值也有冲突检查,不能把旧参数和新候选拼接成一条通用启动命令。

配置选择、本地边界、动作审批及独立能力分别核对。
图 2:这里的检查对象是本地沙箱命令。审批同意、Schema 通过或工具成功返回,都不能单独证明完整边界成立。

4. 生成一次性、没有秘密的练习目录

需要 Python 3.11 或更新版本。下面的命令只运行随文附带的原创标准库程序,创建新的临时目录并检查候选,不会调用 Codex。请从配套包根目录执行。每次生成都会得到新的随机路径,不复用已有工作目录。

离线准备

python3 make_fixture.py
# 将上一行输出记作 LAB;例如 /tmp/permission-lab-某个随机后缀
# 手动把实际路径代入以下参数,不要照抄示意后缀。
python3 check_candidate.py /实际的/LAB audit
python3 check_candidate.py /实际的/LAB report
python3 check_candidate.py /实际的/LAB report-net
PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s tests -v

临时目录的完整绝对路径不能含 * ? [ ]。例如父目录叫 lab[demo] 时,TOML 里的引号只解决字符串语法,不会把权限路径变成字面量。生成器在创建 fixture 前检查已解析的父目录;遇到这些字符就报错,不生成候选,也不自动改名或转到别处。请另行选择不含这些字符的临时父目录,再重新准备。

生成后的 app 是未来会话的唯一预定运行工作区;shared 由候选额外加入根集合。不要把 LAB 整体作为工作区,也不要顺手加上 control 或用户主目录。control 保存候选和空观察记录,由宿主阅读。模板中的 __APP_REPORTS__、__SHARED_ROOT__、__OUTSIDE_ROOT__ 会替换为本次临时目录里的绝对路径;它们不是 Codex 内建变量。

真实运行还可能带入环境原有的工作区根。workspace_roots 是补充集合,不是清空并替换运行时根集合。确认有效根只有本练习需要的范围后,才继续。目录里不放符号链接,也不把外部仓库或真实个人文件复制进来。

目录结构

LAB/
  app/
    src/label.txt
    input/catalog.json
    reports/README.txt
    private/demo.txt
  shared/spec.txt
  outside/marker.txt
  control/
    audit.toml
    report.toml
    report-net.toml
    observation.json

5. 三份完整候选:每次只选择一份

先阅读模板,再阅读生成后的绝对路径版本。候选不是可以直接复制进现有配置的补丁。生成的 audit.toml 等文件名只用于审阅;仅执行 --profile audit 不会自动加载它们。配置维护者应按所用 CLI 的配置方式在独立试验环境中加载选定候选,保留必要组织控制,并在会话开始时检查实际选择、配置来源和工作区根。如果无法确认这些信息,就停在静态检查阶段。

这里从空白自定义配置写起,是为了只给 minimal 运行时读取、工作区读取和一个精确报告目录的写入。与直接继承可写工作区相比,本例不需要再逐个收回输入、共享规范和控制目录的写权限。minimal 会包含工具运行所需的例外路径,其实际展开随平台而定,所以不能把此候选宣传为“只能读文中六个文件”。

configs/audit.toml

# Inert candidate. Never copy over a live config without reviewing all layers.
# Source snapshot: openai/codex 1fbe15c962cc3d8eabec36d67987c83cfc4eeec9
# Replace the path tokens with generated absolute paths before use.
approval_policy = "on-request"
default_permissions = "audit"

[permissions.audit]
description = "Synthetic catalog lab: audit candidate"

[permissions.audit.workspace_roots]
"__SHARED_ROOT__" = true

[permissions.audit.filesystem]
":minimal" = "read"
"__OUTSIDE_ROOT__" = "deny"

[permissions.audit.filesystem.":workspace_roots"]
"." = "read"
"private" = "deny"

[permissions.audit.network]
enabled = false

configs/report.toml

# Inert candidate. Never copy over a live config without reviewing all layers.
# Source snapshot: openai/codex 1fbe15c962cc3d8eabec36d67987c83cfc4eeec9
# Replace the path tokens with generated absolute paths before use.
approval_policy = "on-request"
default_permissions = "report"

[permissions.report]
description = "Synthetic catalog lab: report candidate"

[permissions.report.workspace_roots]
"__SHARED_ROOT__" = true

[permissions.report.filesystem]
":minimal" = "read"
"__OUTSIDE_ROOT__" = "deny"
"__APP_REPORTS__" = "write"

[permissions.report.filesystem.":workspace_roots"]
"." = "read"
"private" = "deny"

[permissions.report.network]
enabled = false

configs/report-net.toml

# Inert candidate. Never copy over a live config without reviewing all layers.
# Source snapshot: openai/codex 1fbe15c962cc3d8eabec36d67987c83cfc4eeec9
# Replace the path tokens with generated absolute paths before use.
approval_policy = "on-request"
default_permissions = "report-net"

[features]
network_proxy = true

[permissions.report-net]
description = "Synthetic catalog lab: report-net candidate"

[permissions.report-net.workspace_roots]
"__SHARED_ROOT__" = true

[permissions.report-net.filesystem]
":minimal" = "read"
"__OUTSIDE_ROOT__" = "deny"
"__APP_REPORTS__" = "write"

[permissions.report-net.filesystem.":workspace_roots"]
"." = "read"
"private" = "deny"

[permissions.report-net.network]
enabled = true

[permissions.report-net.network.domains]
"example.com" = "allow"

三个候选都把每个有效根的 private 子目录设为 deny。outside 使用精确绝对目录的 deny;report 和 report-net 只对 app/reports 的精确绝对目录设为 write,因此 shared 即使出现同名 reports 目录,也不会自动变成可写。

read 允许读文件和列目录,write 也包含读取,并可在操作系统允许时创建、修改、改名或删除。deny 同时拒绝读写。规则按路径具体程度处理,同路径冲突再比较访问等级;不要将“deny 永远压过任何更具体规则”当成通用结论。本练习没有更具体的重新开放规则,也不用 glob;生成器和检查器会拒绝含 glob 元字符的完整路径,避免把目录名字误当成模式。若将来改用 glob,还要重新研究平台的匹配和启动前展开边界。

没有任何写规则指向脚本、输入、共享规范或配置目录。虽然报告目录可写,本轮任务仍只允许报告产物;文件系统允许写入某目录,并不等于用户批准在其中放可执行脚本。

6. 先写预期,再给每项留下实际观察

“允许”表示这份候选规则允许进入下一步,前提是候选已选中、平台能执行该限制,且没有更高优先级限制或其他审批拦截。它不保证命令一定成功。“拒绝”也必须确认发生在预期层:Python 不存在、文件不存在或启动失败,不能冒充路径拒绝。

编号操作auditreportreport-net核对依据
R1读取 app/input/catalog.json允许允许允许输入目录 read
R2读取 shared/spec.txt允许允许允许额外根 + 工作区 read
W1新建 app/reports/result.json拒绝允许允许仅 report 类候选有精确 write
W2新建 app/input/denied-probe.txt拒绝拒绝拒绝只读输入不应创建文件
W3新建 shared/denied-probe.txt拒绝拒绝拒绝额外根没有获得 write
D1读取 app/private/demo.txt拒绝拒绝拒绝private 子目录 deny
D2读取 outside/marker.txt拒绝拒绝拒绝精确目录 deny
N1无请求体访问 example.com拒绝拒绝域名允许*网络开启且代理已生效
N2访问未列入的公开主机拒绝拒绝域名拒绝*未在 allow 表内;仅纸面核对
N3浏览器或连接器请求另行控制另行控制另行控制不属于本地命令域名表

* 网络一栏只描述域名政策。DNS、TLS、代理启动和远端响应仍需分别观察。N2 只评审域名表,不需要为了证明拒绝而选择并请求陌生真实网站。所有实际观察保持空白。生成器创建的是 control/observation.json,其中 cases 初始为空;本文不依赖另一个 CSV 文件。

  1. 先由宿主检查合成文件存在,记录哈希或内容长度,确认没有符号链接;记录实际 CLI 版本、系统、沙箱能力、所选候选和所有有效根。
  2. 在明确的独立试验会话中先选 audit。一次只请求一个 R 或 D 项,用实际绝对路径,保留原始工具命令、退出码和必要错误片段。只读项不要求把合成标记内容抄入报告。
  3. 对 W 项只尝试创建表中的新文件,不覆盖已有文件、不删除目录。一次操作完成或被拒绝后,由宿主检查该精确路径是否出现,再记录实际结果。
  4. 切到 report 前创建新的 fixture,重新核对候选与根集合,再按同一顺序检查。不要沿用前一次的 result.json;文件已存在导致的错误与沙箱拒绝无关。
  5. 网络候选可以只做静态审阅。如另行安排真实 N1,先获得联网许可并核实代理已生效,只做无请求体的公共页面读取,不发送目录内容、标记、参数或凭据,不跟随到新域名。
  6. 一旦预期拒绝的动作成功,停止本轮,将候选、配置来源和路径记录交给配置维护者。不要再读真实敏感目录“看看是否也能访问”。

本练习没有提供自动探测全部边界的运行脚本,避免一条命令把未核准的运行和网络动作串在一起。观察中区分 allowed、denied、blocked_before_probe 和 not_run,并另记判断依据。不要直接把模型的一句“已拒绝”当作操作系统证据。

需要开始记录时,将下面数组复制到生成文件的 cases 字段,保留其余顶层字段。每份文件只记录一个候选和一次新 fixture;candidate 填 audit、report 或 report-net,expected 按上表填写,它不是实测结果。未执行项保持 observed="not_run",命令、退出码和证据保持 null。实际执行后才填 allowed、denied 或 blocked_before_probe,并记录具体依据;host_check 写宿主对精确路径是否出现、输入是否保持原样的核查。N2、N3 在本实验中只作纸面评审,仍保持 not_run。

[
  {
    "id": "R1",
    "operation": "读取 app/input/catalog.json",
    "candidate": null,
    "expected": null,
    "observed": "not_run",
    "command": null,
    "exit_code": null,
    "evidence_excerpt_redacted": null,
    "host_check": null
  },
  {
    "id": "R2",
    "operation": "读取 shared/spec.txt",
    "candidate": null,
    "expected": null,
    "observed": "not_run",
    "command": null,
    "exit_code": null,
    "evidence_excerpt_redacted": null,
    "host_check": null
  },
  {
    "id": "W1",
    "operation": "新建 app/reports/result.json",
    "candidate": null,
    "expected": null,
    "observed": "not_run",
    "command": null,
    "exit_code": null,
    "evidence_excerpt_redacted": null,
    "host_check": null
  },
  {
    "id": "W2",
    "operation": "新建 app/input/denied-probe.txt",
    "candidate": null,
    "expected": null,
    "observed": "not_run",
    "command": null,
    "exit_code": null,
    "evidence_excerpt_redacted": null,
    "host_check": null
  },
  {
    "id": "W3",
    "operation": "新建 shared/denied-probe.txt",
    "candidate": null,
    "expected": null,
    "observed": "not_run",
    "command": null,
    "exit_code": null,
    "evidence_excerpt_redacted": null,
    "host_check": null
  },
  {
    "id": "D1",
    "operation": "读取 app/private/demo.txt",
    "candidate": null,
    "expected": null,
    "observed": "not_run",
    "command": null,
    "exit_code": null,
    "evidence_excerpt_redacted": null,
    "host_check": null
  },
  {
    "id": "D2",
    "operation": "读取 outside/marker.txt",
    "candidate": null,
    "expected": null,
    "observed": "not_run",
    "command": null,
    "exit_code": null,
    "evidence_excerpt_redacted": null,
    "host_check": null
  },
  {
    "id": "N1",
    "operation": "无请求体访问 example.com",
    "candidate": null,
    "expected": null,
    "observed": "not_run",
    "command": null,
    "exit_code": null,
    "evidence_excerpt_redacted": null,
    "host_check": null
  },
  {
    "id": "N2",
    "operation": "只在纸面核对未列入的公开主机",
    "candidate": null,
    "expected": null,
    "observed": "not_run",
    "command": null,
    "exit_code": null,
    "evidence_excerpt_redacted": null,
    "host_check": null
  },
  {
    "id": "N3",
    "operation": "浏览器或连接器请求的独立控制",
    "candidate": null,
    "expected": null,
    "observed": "not_run",
    "command": null,
    "exit_code": null,
    "evidence_excerpt_redacted": null,
    "host_check": null
  }
]

只有完成了对应真实观察,才更新顶层 actual_cli_version、os_and_sandbox、effective_workspace_roots 与审阅标记。一次文件探针不能证明网络代理有效;没有代理观察证据时 proxy_active_evidence 仍为 null。离线检查通过时 real_sandbox_run 继续保持 false。

7. 网络开关与代理必须一起核对

report-net 的网络声明是有意分成两处的:network.enabled 允许本地命令联网;features.network_proxy 负责启用执行域名规则的代理。仅写 example.com 的 allow 条目不能证明直连会被过滤。若最终配置把代理关掉,这份域名表就失去预期的筛选作用,应停止联网阶段。

网络关闭、网络开启代理关闭、网络开启代理生效三种情况有不同边界。
图 3:这是配置状态比较,不是网络实测。包中只提供带代理的网络候选,不提供无过滤的运行候选。

example.com 是说明域名表的单一公共示例,不是报告任务所需的服务,也不是对该站点的可信背书。改成团队服务时,需要重新核对收件方、请求内容和访问目的。域名允许表不会限制向已允许的地址发送什么数据,也不能替代保密和动作授权。

这里的代理不统一接管网页搜索、连接器、MCP、浏览器、Computer Use 或模型认证流量。不要因为沙箱里的网络被禁用,就据此宣布这些工具全部离线。需要限制其他能力时,应查对应功能的配置与受管要求。

8. 本地检查到底证明了什么

check_candidate.py 只检查这篇文章定义的窄配方:选中的名字、精确路径、只读与拒绝项、代理和单一域名,同时严格区分布尔值与整数。它故意拒绝其他配置,即使那些配置在 Codex 中可能合法。检查成功的含义是“候选与本文约定相符”,没有模拟路径匹配、继承、配置合并或平台沙箱。

随文 18 个 unittest 用例覆盖三份正常候选、合成 fixture、空观察记录,以及混入旧配置、选择错名、添加根、放开输入写入、移除拒读、父路径穿越、关闭代理、扩大域名等变化;还包含非 BMP Unicode 临时路径、整数冒充布尔值、glob 父目录在创建前被拒绝,以及三份候选都拒绝 glob 根路径的子用例。修改后可以重跑这些检查,但它们没有执行真实被拒绝的文件访问,也没有发送请求。

路径检查为何不能只看 TOML 或 Schema:离线回归把 fixture 放进名为 lab[demo] 的合成父目录,旧检查器和固定 Schema 都会放过三份候选。固定源码的 路径语法检查却把 * ? [ ] 识别为 glob 字符;权限编译会把含此类字符的 deny 路径按模式处理,非尾部 /** 的 write glob 也不能当作精确写路径。现在生成器先拒绝这样的父目录,检查器也独立拒绝含这些字符的根路径。新增回归对四种字符逐一检查,避免合法 TOML 造成“精确路径已经核对”的假象。这是教学配方与固定源码的静态一致性修正,不是沙箱执行结果。

另一次作者侧静态核对将生成后的 TOML 转为对象,对照上述固定官方 Schema 做 Draft 7 验证,三份均通过。Schema 仅约束结构与字段类型;它不等于 Codex 的配置解析器,更不验证路径存在、选中层、代理或操作系统执行。官方 Schema 没有放进发布包,配套离线程序只用 Python 标准库。

实际平台无法实施分离读写规则时,可能在命令执行前拒绝启动。此时记录 blocked_before_probe 并调查平台支持,保留原有限制,不改成宽泛权限来获得一个绿色结果。

9. 配套程序与测试完整原文

make_fixture.py

"""Create synthetic files and inert config candidates; never launch Codex."""
import json
import tempfile
from pathlib import Path

TEMPLATE_ROOT = Path(__file__).resolve().parent / 'configs'


def create_fixture(parent=None):
    base = Path(tempfile.gettempdir() if parent is None else parent).resolve()
    if any(char in str(base) for char in '*?[]'):
        raise ValueError('lab parent must not contain glob characters: * ? [ ]')
    root = Path(tempfile.mkdtemp(prefix='permission-lab-', dir=base)).resolve()
    files = {
        'app/src/label.txt': 'A small catalog report exercise.\n',
        'app/input/catalog.json': '[{"name":"blue mug","count":2}]\n',
        'app/reports/README.txt': 'Only synthetic report outputs belong here.\n',
        'app/private/demo.txt': 'SYNTHETIC PRIVATE MARKER; not a credential.\n',
        'shared/spec.txt': 'Report the number of catalog entries.\n',
        'outside/marker.txt': 'SYNTHETIC OUTSIDE MARKER.\n',
    }
    for name, value in files.items():
        path = root / name
        path.parent.mkdir(parents=True, exist_ok=True)
        path.write_text(value, encoding='utf-8')
    (root / 'control').mkdir()
    for source in sorted(TEMPLATE_ROOT.glob('*.toml')):
        # JSON strings are valid TOML basic strings for these generated paths.
        raw = source.read_text(encoding='utf-8')
        for token, target in [('"__APP_REPORTS__"', root / 'app/reports'),
                              ('"__SHARED_ROOT__"', root / 'shared'),
                              ('"__OUTSIDE_ROOT__"', root / 'outside')]:
            raw = raw.replace(token, json.dumps(str(target), ensure_ascii=False))
        (root / 'control' / source.name).write_text(raw, encoding='utf-8')
    (root / 'control/observation.json').write_text(json.dumps({
        'actual_cli_version': None, 'os_and_sandbox': None,
        'effective_workspace_roots': [], 'effective_config_reviewed': False,
        'proxy_active_evidence': None, 'cases': [],
        'real_sandbox_run': False
    }, indent=2) + '\n', encoding='utf-8')
    return root


if __name__ == '__main__':
    print(create_fixture())

check_candidate.py

"""Check this article's narrow candidate recipe. Not a Codex parser or sandbox."""
import argparse
import json
import tomllib
from pathlib import Path


def check(data, root, kind):
    if any(char in str(root) for char in '*?[]'):
        raise ValueError('lab root must not contain glob characters: * ? [ ]')
    if kind not in {'audit', 'report', 'report-net'}:
        raise ValueError('unknown candidate kind')
    expected_top = {'approval_policy', 'default_permissions', 'permissions'}
    if kind == 'report-net':
        expected_top.add('features')
    if set(data) != expected_top:
        raise ValueError('unexpected or mixed top-level settings')
    if data['approval_policy'] != 'on-request':
        raise ValueError('keep the documented approval policy')
    if data['default_permissions'] != kind or set(data['permissions']) != {kind}:
        raise ValueError('profile selection mismatch')
    profile = data['permissions'][kind]
    if set(profile) != {'description', 'workspace_roots', 'filesystem', 'network'}:
        raise ValueError('unexpected profile fields')
    if not isinstance(profile['description'], str) or not profile['description'].strip():
        raise ValueError('missing description')
    roots = profile['workspace_roots']
    if not isinstance(roots, dict) or any(type(v) is not bool for v in roots.values()):
        raise ValueError('workspace root flags must be booleans')
    if profile['workspace_roots'] != {str(root / 'shared'): True}:
        raise ValueError('extra or mismatched workspace roots')
    expected_fs = {':minimal': 'read', str(root / 'outside'): 'deny', ':workspace_roots': {
        '.': 'read', 'private': 'deny'}}
    if kind != 'audit':
        expected_fs[str(root / 'app/reports')] = 'write'
    if profile['filesystem'] != expected_fs:
        raise ValueError('filesystem recipe changed; review every change')
    network = profile['network']
    if not isinstance(network, dict) or type(network.get('enabled')) is not bool:
        raise ValueError('network enabled flag must be a boolean')
    expected_net = {'enabled': False}
    if kind == 'report-net':
        expected_net = {'enabled': True, 'domains': {'example.com': 'allow'}}
        features = data['features']
        if not isinstance(features, dict) or type(features.get('network_proxy')) is not bool:
            raise ValueError('network proxy flag must be a boolean')
        if data['features'] != {'network_proxy': True}:
            raise ValueError('network proxy must be explicitly enabled')
    if profile['network'] != expected_net:
        raise ValueError('network recipe changed')
    return {'status': 'candidate_recipe_matches', 'kind': kind,
            'sandbox_tested': False, 'config_layers_checked': False,
            'proxy_tested': False}


def main():
    parser = argparse.ArgumentParser()
    parser.add_argument('root', type=Path)
    parser.add_argument('kind', choices=['audit', 'report', 'report-net'])
    args = parser.parse_args()
    root = args.root.resolve(strict=True)
    with (root / 'control' / (args.kind + '.toml')).open('rb') as handle:
        data = tomllib.load(handle)
    print(json.dumps(check(data, root, args.kind), indent=2))


if __name__ == '__main__':
    main()

tests/test_candidates.py

import copy
import tempfile
import tomllib
import unittest
from pathlib import Path
from unittest.mock import patch
from check_candidate import check
from make_fixture import create_fixture


class CandidateTests(unittest.TestCase):
    def setUp(self):
        self.tmp = tempfile.TemporaryDirectory(prefix='candidate-test-')
        self.root = create_fixture(self.tmp.name)

    def tearDown(self):
        self.tmp.cleanup()

    def data(self, kind='report'):
        return tomllib.loads((self.root / 'control' / (kind+'.toml')).read_text())

    def rejects(self, mutate, kind='report'):
        data = copy.deepcopy(self.data(kind))
        mutate(data)
        with self.assertRaises(ValueError):
            check(data, self.root, kind)

    def test_three_candidates(self):
        for kind in ['audit', 'report', 'report-net']:
            with self.subTest(kind=kind):
                self.assertFalse(check(self.data(kind), self.root, kind)['sandbox_tested'])
        emoji_parent = Path(self.tmp.name) / '路径-🌱'
        emoji_parent.mkdir()
        emoji_root = create_fixture(emoji_parent)
        for kind in ['audit', 'report', 'report-net']:
            with self.subTest(kind=kind, path='non-BMP'):
                data = tomllib.loads((emoji_root/'control'/(kind+'.toml')).read_text())
                self.assertFalse(check(data, emoji_root, kind)['sandbox_tested'])

    def test_glob_parent_rejected_before_creation(self):
        for char in '*?[]':
            parent = Path(self.tmp.name) / ('parent-' + char)
            with self.subTest(char=char):
                with patch('make_fixture.tempfile.mkdtemp') as create:
                    with self.assertRaises(ValueError):
                        create_fixture(parent)
                    create.assert_not_called()
                self.assertFalse(parent.exists())
        with patch('make_fixture.tempfile.gettempdir',
                   return_value=str(Path(self.tmp.name) / 'default[lab]')):
            with patch('make_fixture.tempfile.mkdtemp') as create:
                with self.assertRaises(ValueError):
                    create_fixture()
                create.assert_not_called()

    def test_glob_root_recipe_rejected(self):
        for char in '*?[]':
            root = Path(self.tmp.name) / ('lab-' + char)
            for kind in ['audit', 'report', 'report-net']:
                with self.subTest(char=char, kind=kind):
                    data = self.data(kind)
                    profile = data['permissions'][kind]
                    profile['workspace_roots'] = {str(root / 'shared'): True}
                    fs = profile['filesystem']
                    fs[str(root / 'outside')] = fs.pop(str(self.root / 'outside'))
                    if kind != 'audit':
                        fs[str(root / 'app/reports')] = fs.pop(str(self.root / 'app/reports'))
                    with self.assertRaises(ValueError):
                        check(data, root, kind)

    def test_fixture_is_synthetic(self):
        self.assertIn('SYNTHETIC', (self.root/'app/private/demo.txt').read_text())

    def test_observation_not_filled(self):
        self.assertIn('"real_sandbox_run": false', (self.root/'control/observation.json').read_text())

    def test_legacy_sandbox_rejected(self):
        self.rejects(lambda d: d.update(sandbox_mode='workspace-write'))

    def test_legacy_workspace_rejected(self):
        self.rejects(lambda d: d.update(sandbox_workspace_write={}))

    def test_other_config_profiles_rejected(self):
        self.rejects(lambda d: d.update(profiles={'x': {}}))

    def test_wrong_selection_rejected(self):
        self.rejects(lambda d: d.update(default_permissions='audit'))

    def test_extra_root_rejected(self):
        self.rejects(lambda d: d['permissions']['report']['workspace_roots'].update({'/extra':True}))
        self.rejects(lambda d: d['permissions']['report']['workspace_roots'].update({str(self.root/'shared'):1}))

    def test_input_write_rejected(self):
        self.rejects(lambda d: d['permissions']['report']['filesystem'][':workspace_roots'].update({'input':'write'}))

    def test_workspace_write_rejected(self):
        self.rejects(lambda d: d['permissions']['report']['filesystem'][':workspace_roots'].update({'.':'write'}))

    def test_private_read_rejected(self):
        self.rejects(lambda d: d['permissions']['report']['filesystem'][':workspace_roots'].update({'private':'read'}))

    def test_parent_traversal_rejected(self):
        self.rejects(lambda d: d['permissions']['report']['filesystem'][':workspace_roots'].update({'../outside':'read'}))

    def test_proxy_off_rejected(self):
        self.rejects(lambda d: d['features'].update(network_proxy=False), 'report-net')
        self.rejects(lambda d: d['features'].update(network_proxy=1), 'report-net')
        self.rejects(lambda d: d['permissions']['report']['network'].update(enabled=0))
        self.rejects(lambda d: d['permissions']['report-net']['network'].update(enabled=1), 'report-net')

    def test_broad_domain_rejected(self):
        self.rejects(lambda d: d['permissions']['report-net']['network']['domains'].update({'*':'allow'}),'report-net')

    def test_unknown_kind_rejected(self):
        with self.assertRaises(ValueError):
            check(self.data(), self.root, 'other')

    def test_never_claims_layers_checked(self):
        self.assertFalse(check(self.data(), self.root, 'report')['config_layers_checked'])


if __name__ == '__main__':
    unittest.main()

下列六份合成文件也单独放在配套包 fixture 目录。生成器输出的内容与它们相同。

fixture/app/input/catalog.json

[{"name":"blue mug","count":2}]

fixture/app/private/demo.txt

SYNTHETIC PRIVATE MARKER; not a credential.

fixture/app/reports/README.txt

Only synthetic report outputs belong here.

fixture/app/src/label.txt

A small catalog report exercise.

fixture/outside/marker.txt

SYNTHETIC OUTSIDE MARKER.

fixture/shared/spec.txt

Report the number of catalog entries.

10. 交接时留下哪些证据

交接一份权限配置时,至少保留候选原文、生成的绝对路径、实际 CLI 版本、配置来源、有效根集合、组织要求是否参与、代理状态、逐项命令和结果。正文中的矩阵可作为起点,空白处留给第一次真实观察,不能提前填上“通过”。

读者完成这个练习后,应当能指出:报告任务到底为什么需要 shared;为什么输入不能写;哪个规则保护 private;为什么只有 app/reports 获得写入;域名过滤依赖什么;一次拒绝停在了哪一层。说清这些细节,再把相同方法用于经过授权的真实项目。

本文提供三份配置模板、fixture 生成器、检查程序、18 项测试、观察表和三张原创 SVG。已完成离线程序测试与固定 Schema 校验,尚未验证真实 Codex 会话、操作系统沙箱及网络代理。插图为原创概念图,没有声称是真实运行截图。

11. 来源与复核边界

代码只作只读核对,没有摘抄其实现或把官方源码再分发进配套包。配置候选、目录案例、检查程序、测试与插图均为本文原创。日期之后的文档、客户端和管理策略可能变化;升级时重新核对,不把本稿当成跨版本不变的产品契约。