From fbedc7ff5fcfb16124da5ef0a87d702d07a3b1cf Mon Sep 17 00:00:00 2001 From: limityan Date: Fri, 31 Jul 2026 13:02:30 +0800 Subject: [PATCH 1/2] feat: enforce OpenCode subagent permission constraints Translate supported OpenCode agent permission rules into independent constraint layers and carry them through the existing tool pipeline. Preserve current source ordering, V1 migration, path semantics, and fail-closed behavior without adding a new runtime owner. --- .../opencode-config-assets-adapter-design.md | 56 +- .../opencode-extension-compatibility.md | 2 +- .../claude-code-adapter/src/agent_source.rs | 1 + .../codex-adapter/src/agent_source.rs | 1 + .../opencode-adapter/src/agent_source.rs | 814 ++++++++++++++++-- .../tests/opencode_subagent_adapter.rs | 604 +++++++++++++ .../agentic/agents/definitions/external.rs | 8 + .../assembly/core/src/agentic/agents/mod.rs | 9 + .../core/src/agentic/agents/registry/query.rs | 3 + .../core/src/agentic/agents/registry/types.rs | 2 + .../src/agentic/execution/execution_engine.rs | 5 + .../src/agentic/execution/round_executor.rs | 33 +- .../core/src/agentic/execution/types.rs | 5 +- .../core/src/agentic/permission_policy.rs | 33 +- .../src/agentic/tools/file_permissions.rs | 39 + .../agentic/tools/pipeline/tool_pipeline.rs | 223 +++-- .../core/src/agentic/tools/pipeline/types.rs | 9 +- .../assembly/core/src/external_subagents.rs | 77 ++ .../tests/subagent_coordinator.rs | 1 + .../product-domains/src/external_subagents.rs | 28 + .../product-domains/src/tool_permissions.rs | 166 +++- .../tests/external_source_contracts.rs | 18 + .../tests/tool_permission_contracts.rs | 103 ++- src/crates/contracts/runtime-ports/src/lib.rs | 6 +- 24 files changed, 2009 insertions(+), 237 deletions(-) diff --git a/docs/architecture/extensions/opencode-config-assets-adapter-design.md b/docs/architecture/extensions/opencode-config-assets-adapter-design.md index ac62e3a609..ab881249b9 100644 --- a/docs/architecture/extensions/opencode-config-assets-adapter-design.md +++ b/docs/architecture/extensions/opencode-config-assets-adapter-design.md @@ -12,8 +12,8 @@ 本文同时记录当前可用切片与后续目标。BitFun 已实现本地用户全局/项目 Prompt Command 的来源发现、 JSON/JSONC/Markdown 解析、参数展开、运行时刷新和冲突选择,也已接入全局/项目 Subagent 声明的安全子集与 -本地 MCP 配置。三类 provider 复用 adapter 内部的本地路径顺序与监听根解析;但尚未实现本文定义的完整 -OpenCode 配置来源序列、全部合并语义和其他资产映射。 +本地 MCP 配置。三类 provider 复用 adapter 内部的路径候选与监听根基础设施,但各自保留与资产语义匹配的 +来源顺序;当前尚未实现本文定义的完整 OpenCode 配置来源序列、全部合并语义和其他资产映射。 ## 1. 目标与边界 @@ -101,13 +101,13 @@ OpenCode 当前版本的真实合并/去重语义,不用 BitFun 常规配置 所有来源合并后再应用 `OPENCODE_PERMISSION`、旧 `tools` 到 permission 的迁移,以及关闭自动压缩/裁剪的环境覆盖。这些属于固定版本的后处理,不是新的配置来源。 -当前 Prompt Command、Subagent 与 MCP 子集只实现上述本地来源:XDG 用户配置根、`OPENCODE_CONFIG`、root-first -项目配置、opened-to-root 的项目 `.opencode` 目录阶段、兼容 `~/.opencode` 与最后应用的 -`OPENCODE_CONFIG_DIR`。Command/Subagent 再从对应目录读取 `command(s)/` 与 `agent(s)/mode(s)/`; -`OPENCODE_DISABLE_PROJECT_CONFIG` 会同时关闭项目配置、项目目录资产和对应监听根。三类 provider 复用同一私有 -路径顺序和 creation-safe 监听根,`OPENCODE_CONFIG_DIR` 不替换 XDG 用户根,并在 BitFun 来源标签中保持 -`WorkspaceLocal`。`ConfigPaths.directories` 按规范化来源身份保留首次出现的位置;显式目录与 XDG、项目 -`.opencode` 或兼容目录指向同一物理路径时,在该首次位置应用显式目录的加载语义和来源标签,不把它移动到末尾。 +当前 Prompt Command、Subagent 与 MCP 子集只实现上述本地来源的一部分,并复用 creation-safe 监听根与路径候选 +基础设施;来源顺序仍由各资产 provider 自己定义,不能视为一套通用顺序。Command 与 MCP 保留各自既有的本地 +加载顺序。Subagent 依次应用用户配置与用户 Markdown、project direct config(root-to-nearest)、project +`.opencode` config/Markdown(root-to-nearest);`OPENCODE_CONFIG_DIR` 与其他来源指向同一物理目录时保留首次 +出现的位置,只更新该位置的加载语义和来源标签,不移动到末尾。`OPENCODE_DISABLE_PROJECT_CONFIG` 会关闭 +项目配置、项目目录资产和对应监听根;显式目录不替换 XDG 用户根,并在 BitFun 来源标签中保持 +`WorkspaceLocal`。 `OPENCODE_CONFIG_CONTENT`、远程、组织、系统管理员与 MDM 来源尚未接入,不能因三类本地候选可运行就把目标来源 序列标记为完整实现。 @@ -199,7 +199,7 @@ OpenCode adapter 在来源发现、解析和审批前不 import module、不读 | 资产 | OpenCode 输入 | BitFun 归属模块 / 适配方式 | 默认行为 | 降级条件 | |---|---|---|---|---| | Rules / Instructions | 项目/全局 `AGENTS.md`、Claude fallback、`instructions` glob、本地文件、远程 URL | Workspace Instructions 归属模块保存有序来源引用 | 当前实现项目根与 `.opencode` 配置中的本地精确文件/glob;全局与远程 URL 仍是目标 | 无效 JSONC 或 glob 只排除对应配置项;文件 I/O 失败时当前构建不缓存并在下一条消息重试。 | -| Agents / Modes | JSON、Markdown、description、mode、prompt、model、variant、temperature、top_p、steps、deprecated `maxSteps`、deprecated `tools`、permission、disable、options、hidden、color | Agent 归属模块创建兼容定义和使用范围视图 | 当前支持 Subagent 安全子集;首次按行为、来源、模型和工具范围确认,fresh single-run 调用 | primary/mode、permission、variant/options、采样、steps 与续接保持诊断或阻断,不影响其他 Agent。 | +| Agents / Modes | V1 `agent/prompt/disable/permission`、当前 `agents/system/disabled/permissions`,以及 Markdown、description、mode、model、variant、temperature、top_p、steps、deprecated `maxSteps`、deprecated `tools`、options、hidden、color | Agent 归属模块创建兼容定义和使用范围视图;OpenCode adapter 只翻译来源语义 | 当前支持 Subagent 安全子集和 Agent-local 权限约束;首次按行为、来源、模型、工具与权限范围确认,fresh single-run 调用 | primary/mode、variant/options、采样、steps 与续接保持诊断或阻断;root ambient 权限和 V1 嵌套 resource map 尚不激活,不影响其他 Agent。 | | Skills | `.opencode/.claude/.agents` 项目与用户根、`SKILL.md`、`skills.paths/urls` | OpenCode adapter 只由 `bitfun-core/external_sources` 组合并投影有序本地配置根;Skill 归属模块负责有界递归、解析、覆盖与按需加载 | 标准根及 V1 `skills.paths`/当前本地字符串数组可用;项目配置限项目根,用户配置限项目根或用户目录;配置根最多 64 个、每根 512 个 Skill、单文件 256 KiB、可选策略 64 KiB,实际加载再次执行有界非链接读取;配置根在同 scope 覆盖标准 OpenCode 根,但不重排更早的 BitFun/Claude/Codex/Cursor 来源 | URL、下载/缓存、脚本与外部依赖不加载;无效根不影响标准 Skill。 | | References | `references` / 旧 `reference`,本地 path 或 Git repository/branch/description/hidden | **基础能力缺失**:先补 Workspace Reference 的异步准备与 `@alias` 消费接口 | 本地引用保留相对来源;Git 拉取按 L2 确认并保留缓存/隐藏语义 | 拉取失败不阻止项目,外部目录仍遵守工具权限。 | | Commands | JSON/JSONC、Markdown、`$ARGUMENTS`、位置参数、`@file`、`!shell`、agent/model/variant/subtask | Prompt Command 专属契约;adapter 提取静态文件引用,Product Assembly 经共享本地文本服务完成有界装配 | prompt-only 与静态 workspace 相对 UTF-8 `@file` 可发送;动态/绝对/越界文件、shell、agent/model/variant/subtask 整体受限 | 任一文件失败则本次调用原子失败;最多 8 文件、单文件 64 KiB、文件总量 128 KiB、最终命令 1 MiB。 | @@ -209,7 +209,7 @@ OpenCode adapter 在来源发现、解析和审批前不 import module、不读 | Themes | builtin/user/project/cwd JSON | **部分已有**:GUI Theme 已有;TUI 主题消费边界在终端阶段补齐 | 保留覆盖顺序和语义角色 | 颜色能力不支持时做可见降级。 | | Keybinds | `tui.json` 的 leader、组合键、禁用和命令标识 | **已有行为、边界未抽取**:从现有 TUI 输入/命令路径提取最小接口 | 保留用户和项目覆盖 | 平台冲突时显示最终绑定与原因。 | | Models / Providers | `model`、`small_model`、`default_agent`、provider options/variants,以及 `enabled_providers` / `disabled_providers` | Model/Provider 与 Agent 归属模块 | 静态选择按 L1 映射;新增 Provider 连接、网络、凭据或动态适配器按 L2/L3 确认 | 动态软件包适配器交给插件运行时,未知 Provider 只禁用对应选择。 | -| Permissions / Policies | 工具、Skill、Agent 等 allow/deny/ask pattern | Permission 归属模块建立 OpenCode 兼容策略层 | 收紧可以自动应用;扩大权限进入确认,激活后保持 OpenCode 决策 | BitFun 用户/组织策略可进一步收紧并明确标记。 | +| Permissions / Policies | 工具、Skill、Agent 等 allow/deny/ask pattern | OpenCode adapter 生成来源无关约束;Permission 归属模块保持最终裁决 | 当前只接入外部 Subagent 的当前有序 `permissions` 与 V1 Agent-local 扁平 `permission`;约束参与行为审批并随 Agent 固定到执行链 | 约束只能收紧,不能覆盖 BitFun 用户/项目/组织策略;root ambient、V1 嵌套 resource map 和其他资产权限保持明确不支持。 | | Plugins / Tools | config plugin 列表、`plugins/`、`tools/` | 只生成执行来源和顺序,交给 OpenCode adapter 与 `PluginRuntimeClient` | 自动发现;首次确认后才准备和 import 当前执行版本 | 不在配置解析线程加载代码。 | ### 5.1 Rules 与 Instructions @@ -227,16 +227,36 @@ OpenCode adapter 在来源发现、解析和审批前不 import module、不读 兼容定义进入现有 Agent 归属模块,而不是新建 OpenCode Agent Runtime。当前已实现范围按是否能保持行为等价划分: -- 可等价映射并激活:名称、description、prompt、`subagent|all`、隐藏/停用状态、可精确解析的 model,以及能映射到 - 当前有效 Tool route 的明确工具选择;缺省工具使用 BitFun 保守 Subagent 默认集并展示在确认摘要中。 -- 可识别但不激活:`primary`/legacy mode、`permission` pattern、variant/options、temperature/top_p、steps/ - deprecated maxSteps,以及不能精确解析的模型或工具。当前不能把这些字段静默忽略后宣称兼容。 +- 可等价映射并激活:名称、description、V1 `prompt/disable` 与当前 `system/disabled`、`subagent|all`、隐藏状态、 + 可精确解析的 model,以及能映射到当前有效 Tool route 的明确工具选择;缺省工具使用 BitFun 保守 Subagent + 默认集并展示在确认摘要中。 +- Agent-local 权限:当前有序 `permissions: [{ action, resource, effect }]` 保留顺序;V1 扁平精确 action map + `permission: { action: allow|ask|deny }` 转换为 `resource="*"`,并把 `write/edit/patch/apply_patch` 归一为 + BitFun `edit` action。同一来源层内仍是 last-match-wins;它作为独立约束与宿主策略取最严格结果,不能授予工具, + 也不能放宽用户、项目、组织或父 Agent 的限制。deny/ask 若命中已选中但当前没有对应 PermissionIntent 的工具, + 整个 Agent 保持阻断;只对未激活的未知 action 做可见降级。 +- 文件资源坐标在 adapter 边界转换:当前 `read/edit` 的 workspace-relative resource 映射到 BitFun 实际使用的 + canonical workspace 绝对资源,`read/edit/external_directory` 的 `~`/`$HOME` 按 OpenCode 规则展开;bash + resource 保留原始命令文本。若 action pattern 同时跨越路径与非路径工具、workspace/home 坐标不可得,或前导 + wildcard 可能同时匹配 OpenCode 的相对 workspace 与绝对 external resource,则阻断 Agent,不以“未命中即 Allow”继续。 +- action pattern 保持 OpenCode 的平台大小写语义:Windows 导入时归一为小写 BitFun action,其他平台保持大小写 + 敏感。外部 Agent 当前不开放 `Task`,因为现有子委派 ceiling 尚不携带该 Agent 的外部约束;不能让显式工具名绕过此边界。 +- 可识别但不激活:`primary`/legacy mode、root ambient permission、V1 action pattern 或嵌套 resource pattern、variant/options、 + temperature/top_p、steps/deprecated maxSteps,以及不能精确解析的模型或工具。当前不能把这些字段静默忽略后宣称兼容。 - 展示映射:color 等只影响来源 Surface,不进入运行时权威事实。 - 未知字段:进入来源限定诊断,不作为任意数据传给 core;后续版本支持时由 OpenCode adapter 更新解释。 -全局与项目贡献在 adapter 内按稳定 OpenCode 顺序深合并并保留有序来源。Core 只消费来源无关候选,按当前 +每份 JSON/JSONC 配置文档和 Agent Markdown frontmatter 先独立按 OpenCode V1 key 判型并迁移到当前 Agent 字段, +再参与合并;不能把跨文档的 +`prompt/system`、`disable/disabled` 或 `permission/permissions` 误判为单份文档冲突。本地 Agent 来源按用户配置与 +用户 Markdown、project direct config(root-to-nearest)、project `.opencode` config/Markdown(root-to-nearest) +依次应用,`OPENCODE_CONFIG_DIR` 的物理 alias 保留所在位置而不因 scope 重排;`disabled:true` 按 OpenCode remove +语义形成 tombstone,后续同名非 disabled 文档从空定义重建。这样项目限制不会被较低优先级用户 Markdown 反向覆盖。 +Core 只消费来源无关候选,按当前 模型、工具、执行位置和本地/其他 provider 同名项生成审批与冲突内容摘要。无冲突候选首次确认一次;只有目录文案 -变化不重问,prompt 行为、来源或实际模型、工具与执行范围变化重新确认。冲突未选择时逻辑名不可用,候选 +变化不重问,prompt 行为、来源或实际模型、工具、权限约束与执行范围变化重新确认;Core 的审批 envelope 也直接 +包含约束摘要,不能依赖 provider 正确更新行为版本。当前 `permissions` 数组按文档来源顺序追加,V1 扁平对象按 +归一化 action 做确定性去重;同义 action 给出冲突效果时阻断,不能依赖 JSON 对象键序裁决。冲突未选择时逻辑名不可用,候选 变化后不静默回退。 OpenCode adapter 负责把 `provider/model` 语法解析成来源无关的 provider 提示与模型名;Core 不解释 OpenCode 字符串 @@ -254,7 +274,7 @@ OpenCode adapter 负责把 `provider/model` 语法解析成来源无关的 provi Subagent 归属模块仍通过现有 Task 执行链完成调用。新的调用在执行前取得现有运行租约,固定 `runtime_agent_key` 与模型绑定,并由前台或后台任务持有到结束;当前不支持外部 session follow-up、primary agent 替换、 -OpenCode 会话内核、permission DSL 或 package plugin。Desktop/TUI 摘要不包含 prompt +OpenCode 会话内核、完整 permission DSL 或 package plugin。Desktop/TUI 摘要不包含 prompt 正文,静态 system prompt 也不因该适配而改写。来源 `description` 只进入审批和管理界面;已批准 Agent 进入现有 `` 动态视图时使用 BitFun 生成的稳定摘要,避免只改目录文案就绕过行为重批并改变模型上下文。 diff --git a/docs/architecture/extensions/opencode-extension-compatibility.md b/docs/architecture/extensions/opencode-extension-compatibility.md index f589d475ac..49d8f74f14 100644 --- a/docs/architecture/extensions/opencode-extension-compatibility.md +++ b/docs/architecture/extensions/opencode-extension-compatibility.md @@ -107,7 +107,7 @@ OpenCode,和 OpenCode 配置/插件进入 BitFun 是两个独立验收方向 | JSON、JSONC、环境变量、文件引用 | 转换参数 + 明确降级 | 部分实现:项目 `instructions` 的 JSON/JSONC | 可主要适配 | OC-R1 | 当前只解析项目根和 `.opencode` 配置中的本地 instruction 文件/glob;完整配置 schema、环境变量与文件变量引用仍未实现 | [解析与鲁棒性](opencode-config-assets-adapter-design.md#4-解析与鲁棒性) | | 独立 `tui.json/jsonc` | 融合现有能力 + 转换参数 | 未实现 | 可完整适配 | OC-R1 | 按 global、`OPENCODE_TUI_CONFIG`、project、`.opencode` 独立顺序加载,不能复用主配置优先级 | [TUI 来源](opencode-config-assets-adapter-design.md#32-tui-独立来源顺序) | | Rules / Instructions | 转换参数 | 部分实现:项目本地文件与 glob | 可完整适配 | OC-R1 | 当前由 Workspace Instructions owner 读取项目 `instructions` 的精确文件和 glob;全局、远程 URL、完整来源合并与变化监听仍未实现 | [声明式资产](opencode-config-assets-adapter-design.md#5-声明式资产映射) | -| Agents / Modes | 融合现有能力 + 转换参数 | 部分实现:Subagent 安全子集 | 可主要适配 | OC-R1 | 已支持全局/项目 Markdown、JSON/JSONC 的 subagent/all、prompt、description、disable/hidden、精确模型与工具映射,并接入审批、冲突、更新、撤下和 fresh single-run Task;primary/mode、permission、variant/options、采样与续接明确降级 | [Agents 与 Skills](opencode-config-assets-adapter-design.md#52-agentsmodes-与-skills) | +| Agents / Modes | 融合现有能力 + 转换参数 | 部分实现:Subagent 安全子集与 Agent-local 权限约束 | 可主要适配 | OC-R1 | 已支持全局/项目 Markdown、JSON/JSONC 的逐文档 V1 `agent/prompt/disable/permission` 迁移与当前 `agents/system/disabled/permissions` 子集、subagent/all、description、精确模型和工具映射,并接入审批、冲突、更新、撤下和 fresh single-run Task;当前有序 permission rules 与 V1 扁平精确 action map 会成为只可收紧的独立约束,路径资源在 adapter 边界转换到 BitFun workspace/home 坐标。primary/mode、root ambient permission、V1 action pattern/嵌套 resource map、跨路径与命令资源域的歧义 pattern、variant/options、采样与续接明确阻断或降级 | [Agents 与 Skills](opencode-config-assets-adapter-design.md#52-agentsmodes-与-skills) | | Skills | 转换参数 | 部分实现:标准根与本地配置根 | 可完整适配 | OC-R2 | 现有 Registry 除标准用户/项目根外,也通过 `bitfun-core/external_sources` 组合边界按 OpenCode 配置来源顺序累加 V1 `skills.paths` 与当前迁移后的本地字符串数组;仅接受项目根/用户目录内的本地目录并做有界递归发现。同 scope 配置根覆盖标准 OpenCode 根,但不重排更早的 BitFun/Claude/Codex/Cursor 来源。URL、下载/缓存、完整 allow/deny/ask 顺序及外部来源策略仍未实现 | [Agents 与 Skills](opencode-config-assets-adapter-design.md#52-agentsmodes-与-skills) | | References | 补基础能力 + 转换参数 | 未实现 | 可主要适配 | OC-R2 | R1 解析;R2 支持本地目录和 Git repository/branch/description/hidden,异步准备并接入 `@alias` | [声明式资产](opencode-config-assets-adapter-design.md#5-声明式资产映射) | | Commands | 补扩展接口 + 转换参数 | 部分实现:prompt-only + 本地文本文件子集 | 可完整适配 | OC-R2 | 已支持全局/项目 JSON、JSONC、Markdown 命令、参数展开、动态目录、刷新和显式冲突选择;模板中的静态 workspace 相对 `@file` 可在调用时有界读取,动态/绝对/越界文件引用、shell、Agent/model/variant/subtask 保持受限且不做部分执行 | [Commands](opencode-config-assets-adapter-design.md#53-commands) | diff --git a/src/crates/adapters/claude-code-adapter/src/agent_source.rs b/src/crates/adapters/claude-code-adapter/src/agent_source.rs index d49be3fdfc..f0630d031b 100644 --- a/src/crates/adapters/claude-code-adapter/src/agent_source.rs +++ b/src/crates/adapters/claude-code-adapter/src/agent_source.rs @@ -560,6 +560,7 @@ fn materialize_definition( hidden: false, requested_model, requested_tools, + permission_constraints: Default::default(), compatibility, diagnostic_codes, behavior_version, diff --git a/src/crates/adapters/codex-adapter/src/agent_source.rs b/src/crates/adapters/codex-adapter/src/agent_source.rs index f2355d3a5a..d0c9aeb805 100644 --- a/src/crates/adapters/codex-adapter/src/agent_source.rs +++ b/src/crates/adapters/codex-adapter/src/agent_source.rs @@ -783,6 +783,7 @@ fn materialize_definition( hidden: false, requested_model, requested_tools, + permission_constraints: Default::default(), compatibility, diagnostic_codes, behavior_version, diff --git a/src/crates/adapters/opencode-adapter/src/agent_source.rs b/src/crates/adapters/opencode-adapter/src/agent_source.rs index 443b530202..74f459c24a 100644 --- a/src/crates/adapters/opencode-adapter/src/agent_source.rs +++ b/src/crates/adapters/opencode-adapter/src/agent_source.rs @@ -1,7 +1,6 @@ use crate::local_source_paths::{ find_project_root, local_watch_roots, ordered_local_config_directories, - project_asset_directories, project_config_directories, user_config_dir, - LocalConfigDirectoryKind, + project_config_directories, user_config_dir, LocalConfigDirectory, LocalConfigDirectoryKind, }; use bitfun_product_domains::external_sources::{ EcosystemId, ExternalSourceAssetKind, ExternalSourceContext, ExternalSourceDiagnostic, @@ -17,6 +16,10 @@ use bitfun_product_domains::external_subagents::{ ExternalSubagentProviderSnapshot, ExternalSubagentSourceProvider, ExternalSubagentToolRequest, ExternalSubagentToolSelector, SecretText, }; +use bitfun_product_domains::tool_permissions::{ + wildcard_matches, PermissionConstraintLayer, PermissionEffect, + PermissionResourceCaseSensitivity, PermissionRule, +}; use bitfun_services_core::{jsonc::strip_jsonc, markdown::FrontMatterMarkdown}; use serde_json::{Map, Value}; use sha2::{Digest, Sha256}; @@ -31,24 +34,62 @@ const MAX_AGENT_FILE_BYTES: u64 = 256 * 1024; const MAX_AGENT_FILES: usize = 2048; const MAX_TOTAL_PROMPT_BYTES: usize = 8 * 1024 * 1024; +const V1_CONFIG_KEYS: &[&str] = &[ + "logLevel", + "server", + "command", + "reference", + "snapshot", + "plugin", + "autoshare", + "disabled_providers", + "enabled_providers", + "small_model", + "mode", + "agent", + "provider", + "permission", + "tools", + "attachment", + "layout", +]; + const KNOWN_AGENT_FIELDS: &[&str] = &[ "description", "prompt", + "system", "model", "variant", "temperature", "top_p", "tools", "disable", + "disabled", "mode", "hidden", "color", "steps", "maxSteps", "permission", + "permissions", + "request", "options", ]; +const CURRENT_MARKDOWN_AGENT_FIELDS: &[&str] = &[ + "model", + "variant", + "request", + "system", + "description", + "mode", + "hidden", + "color", + "steps", + "disabled", + "permissions", +]; + const NATIVE_AGENT_IDS: &[&str] = &[ "build", "plan", @@ -111,6 +152,13 @@ impl OpenCodeSubagentProvider { .unwrap_or_else(|| find_project_root(workspace_root)) } + fn home_dir(&self) -> Option<&Path> { + self.options + .legacy_user_config_dir + .as_deref() + .and_then(Path::parent) + } + fn discover_layers( &self, context: &ExternalSourceContext, @@ -136,91 +184,88 @@ impl OpenCodeSubagentProvider { "OpenCode OPENCODE_CONFIG", ); } - if self.options.project_config_enabled { - if let Some(workspace_root) = &context.workspace_root { - let project_root = self.project_root(workspace_root); - for directory in project_config_directories(&project_root, workspace_root) { - push_config_files( - &mut layers, - &directory, - ExternalSourceScope::Project, - "OpenCode project configuration", - ); - } - } - } let project_directories = if self.options.project_config_enabled { context .workspace_root .as_ref() .map(|workspace_root| { - project_asset_directories(&self.project_root(workspace_root), workspace_root) + project_config_directories(&self.project_root(workspace_root), workspace_root) }) .unwrap_or_default() } else { Vec::new() }; - for directory in ordered_local_config_directories( + let local_directories = ordered_local_config_directories( &self.options.user_config_dir, self.options.legacy_user_config_dir.as_deref(), self.options.explicit_config_dir.as_deref(), &project_directories, - ) { - match directory.kind { - LocalConfigDirectoryKind::User => push_agent_files( - &mut layers, - &directory.path, - directory.scope, - "OpenCode user agents", - )?, - LocalConfigDirectoryKind::Project => { - push_config_files( - &mut layers, - &directory.path, - directory.scope, - "OpenCode project agent configuration", - ); - push_agent_files( - &mut layers, - &directory.path, - directory.scope, - "OpenCode project agents", - )?; - } - LocalConfigDirectoryKind::Legacy => { - push_config_files( - &mut layers, - &directory.path, - directory.scope, - "OpenCode legacy user configuration", - ); - push_agent_files( - &mut layers, - &directory.path, - directory.scope, - "OpenCode legacy user agents", - )?; - } - LocalConfigDirectoryKind::Explicit => { - push_config_files( - &mut layers, - &directory.path, - directory.scope, - "OpenCode OPENCODE_CONFIG_DIR", - ); - push_agent_files( - &mut layers, - &directory.path, - directory.scope, - "OpenCode explicit agents", - )?; - } - } + ); + for directory in local_directories.iter().filter(|directory| { + !occupies_project_directory_position(directory, &project_directories) + }) { + push_local_directory_layers(&mut layers, directory)?; + } + for directory in &project_directories { + push_config_files( + &mut layers, + directory, + ExternalSourceScope::Project, + "OpenCode project configuration", + ); + } + for directory in local_directories.iter().filter(|directory| { + occupies_project_directory_position(directory, &project_directories) + }) { + push_local_directory_layers(&mut layers, directory)?; } Ok(deduplicate_layers_keep_last(layers)) } } +fn occupies_project_directory_position( + directory: &LocalConfigDirectory, + project_directories: &[PathBuf], +) -> bool { + let identity = dunce::canonicalize(&directory.path) + .unwrap_or_else(|_| normalize_path_lexically(&directory.path)); + project_directories.iter().any(|project_directory| { + let project_identity = dunce::canonicalize(project_directory.join(".opencode")) + .unwrap_or_else(|_| normalize_path_lexically(&project_directory.join(".opencode"))); + identity == project_identity + }) +} + +fn push_local_directory_layers( + layers: &mut Vec, + directory: &LocalConfigDirectory, +) -> Result<(), ExternalSourceProviderError> { + let (config_name, agent_name, include_config) = match directory.kind { + LocalConfigDirectoryKind::User => { + ("OpenCode user configuration", "OpenCode user agents", false) + } + LocalConfigDirectoryKind::Project => ( + "OpenCode project agent configuration", + "OpenCode project agents", + true, + ), + LocalConfigDirectoryKind::Legacy => ( + "OpenCode legacy user configuration", + "OpenCode legacy user agents", + true, + ), + LocalConfigDirectoryKind::Explicit => ( + "OpenCode OPENCODE_CONFIG_DIR", + "OpenCode explicit agents", + true, + ), + }; + if include_config { + push_config_files(layers, &directory.path, directory.scope, config_name); + } + push_agent_files(layers, &directory.path, directory.scope, agent_name) +} + impl Default for OpenCodeSubagentProvider { fn default() -> Self { Self::new(OpenCodeSubagentProviderOptions::default()) @@ -331,6 +376,8 @@ impl ExternalSubagentSourceProvider for OpenCodeSubagentProvider { logical_id, contributions, &ambient_permission_sources, + self.home_dir(), + input.context.workspace_root.as_deref(), )?); } sources.sort_by(|left, right| left.key.cmp(&right.key)); @@ -475,27 +522,49 @@ fn parse_config_layer( false, )); }; - let ambient_permission = root.contains_key("permission"); + let legacy_document = root + .keys() + .any(|key| V1_CONFIG_KEYS.contains(&key.as_str())); + let ambient_permission = if legacy_document { + root.contains_key("permission") || root.contains_key("tools") + } else { + root.contains_key("permissions") + }; let mut patches = Vec::new(); let mut diagnostics = Vec::new(); - if let Some(agents) = root.get("agent") { + let collection_names: &[&str] = if legacy_document { + &["agent", "mode"] + } else { + &["agents"] + }; + for &collection_name in collection_names { + let Some(agents) = root.get(collection_name) else { + continue; + }; if let Some(agents) = agents.as_object() { for (logical_id, value) in agents { + let mut fields = value.as_object().cloned().unwrap_or_else(|| { + let mut fields = Map::new(); + fields.insert("__invalid_definition_type".to_string(), value.clone()); + fields + }); + if legacy_document { + migrate_v1_agent_fields(&mut fields); + if collection_name == "mode" { + fields.insert("mode".to_string(), Value::String("primary".to_string())); + } + } patches.push(AgentPatch { source: placeholder_source_key(), logical_id: normalize_logical_id(logical_id), - fields: value.as_object().cloned().unwrap_or_else(|| { - let mut fields = Map::new(); - fields.insert("__invalid_definition_type".to_string(), value.clone()); - fields - }), + fields, legacy: false, }); } } else { diagnostics.push(ExternalSourceDiagnostic::error( "opencode.agent.map_invalid", - "OpenCode 'agent' configuration must be an object", + format!("OpenCode '{collection_name}' configuration must be an object"), None, )); } @@ -503,7 +572,13 @@ fn parse_config_layer( Ok(ParsedAgentLayer { prompt_bytes: patches .iter() - .filter_map(|patch| patch.fields.get("prompt")?.as_str()) + .filter_map(|patch| { + patch + .fields + .get("system") + .or_else(|| patch.fields.get("prompt"))? + .as_str() + }) .map(str::len) .sum(), patches, @@ -513,6 +588,81 @@ fn parse_config_layer( }) } +fn migrate_v1_agent_fields(fields: &mut Map) { + if !fields.contains_key("system") { + if let Some(prompt) = fields.remove("prompt") { + fields.insert("system".to_string(), prompt); + } + } + if !fields.contains_key("disabled") { + if let Some(disabled) = fields.remove("disable") { + fields.insert("disabled".to_string(), disabled); + } + } + if !fields.contains_key("permissions") { + if let Some(permission) = fields + .get("permission") + .and_then(migrate_order_independent_v1_permissions) + { + fields.remove("permission"); + fields.insert("permissions".to_string(), permission); + } + } +} + +fn migrate_order_independent_v1_permissions(value: &Value) -> Option { + if let Value::String(effect) = value { + return permission_effect_is_valid(effect) + .then(|| Value::Array(vec![current_permission_rule("*", "*", effect)])); + } + let entries = value.as_object()?; + if entries.contains_key("*") && entries.len() > 1 { + return None; + } + let mut rules = Vec::<(String, String)>::new(); + for (source_action, value) in entries { + if source_action != "*" + && source_action + .bytes() + .any(|byte| matches!(byte, b'*' | b'?' | b'[' | b']')) + { + return None; + } + let action = canonical_permission_action(source_action)?; + let effect = value + .as_str() + .filter(|effect| permission_effect_is_valid(effect))?; + if let Some((_, existing_effect)) = rules + .iter() + .find(|(existing_action, _)| existing_action == action) + { + if existing_effect != effect { + return None; + } + continue; + } + rules.push((action.to_string(), effect.to_string())); + } + Some(Value::Array( + rules + .into_iter() + .map(|(action, effect)| current_permission_rule(&action, "*", &effect)) + .collect(), + )) +} + +fn permission_effect_is_valid(effect: &str) -> bool { + matches!(effect, "allow" | "ask" | "deny") +} + +fn current_permission_rule(action: &str, resource: &str, effect: &str) -> Value { + Value::Object(Map::from_iter([ + ("action".to_string(), Value::String(action.to_string())), + ("resource".to_string(), Value::String(resource.to_string())), + ("effect".to_string(), Value::String(effect.to_string())), + ])) +} + fn parse_markdown_layer( logical_id: &str, legacy: bool, @@ -545,7 +695,17 @@ fn parse_markdown_layer( } else { (Map::new(), content.to_string()) }; - fields.insert("prompt".to_string(), Value::String(body.trim().to_string())); + let legacy_schema = fields + .keys() + .any(|key| !CURRENT_MARKDOWN_AGENT_FIELDS.contains(&key.as_str())); + let prompt_field = if legacy_schema { "prompt" } else { "system" }; + fields.insert( + prompt_field.to_string(), + Value::String(body.trim().to_string()), + ); + if legacy_schema { + migrate_v1_agent_fields(&mut fields); + } if legacy { fields.insert("mode".to_string(), Value::String("primary".to_string())); } @@ -568,6 +728,8 @@ fn materialize_definition( logical_id: String, contributions: Vec, ambient_permission_sources: &[SourceKey], + home_dir: Option<&Path>, + workspace_root: Option<&Path>, ) -> Result { let local_id = ExternalSubagentLocalId::new(logical_id.clone()).map_err(|error| { ExternalSourceProviderError::new("opencode.agent.id_invalid", error.to_string(), false) @@ -575,8 +737,17 @@ fn materialize_definition( let mut effective = Value::Object(Map::new()); let mut provenance = Vec::new(); let mut legacy = false; + let mut removed = false; for (index, contribution) in contributions.iter().enumerate() { + let removes_agent = contribution + .fields + .get("disabled") + .is_some_and(|value| value == &Value::Bool(true)); + if removes_agent || removed { + effective = Value::Object(Map::new()); + } deep_merge(&mut effective, Value::Object(contribution.fields.clone())); + removed = removes_agent; provenance.push(ExternalSubagentProvenanceRef { contribution_id: ExternalSubagentContributionId::new( contribution.source.clone(), @@ -623,15 +794,18 @@ fn materialize_definition( if !ambient_permission_sources.is_empty() { blocked.push("opencode_ambient_permission_not_imported".to_string()); } - if fields.contains_key("permission") { - blocked.push("opencode_agent_permission_not_imported".to_string()); - } if fields .get("options") .is_some_and(|value| !value.as_object().is_some_and(Map::is_empty)) { blocked.push("opencode_agent_options_not_imported".to_string()); } + if fields + .get("request") + .is_some_and(|value| !value.as_object().is_some_and(Map::is_empty)) + { + blocked.push("opencode_agent_request_not_imported".to_string()); + } for field in [ "variant", "temperature", @@ -645,7 +819,10 @@ fn materialize_definition( } } - let prompt = match fields.get("prompt") { + if fields.contains_key("prompt") && fields.contains_key("system") { + blocked.push("opencode_agent_prompt_versions_conflict".to_string()); + } + let prompt = match fields.get("system").or_else(|| fields.get("prompt")) { Some(Value::String(value)) if !value.trim().is_empty() => value.clone(), Some(Value::String(_)) | None => { blocked.push("opencode_agent_prompt_not_imported".to_string()); @@ -683,7 +860,14 @@ fn materialize_definition( ExternalSubagentMode::Subagent } }; - let disabled = bool_field(fields, "disable", false, &mut invalid); + if fields.contains_key("disable") && fields.contains_key("disabled") { + blocked.push("opencode_agent_disabled_versions_conflict".to_string()); + } + let disabled = if fields.contains_key("disabled") { + bool_field(fields, "disabled", false, &mut invalid) + } else { + bool_field(fields, "disable", false, &mut invalid) + }; let hidden = bool_field(fields, "hidden", false, &mut invalid); let requested_model = match fields.get("model") { None => ExternalSubagentModelRequest::Default, @@ -704,6 +888,15 @@ fn materialize_definition( } }; let requested_tools = tool_request(fields, &mut invalid, &mut blocked, &mut degraded); + let permission_constraints = permission_constraints( + fields, + &requested_tools, + home_dir, + workspace_root, + &mut invalid, + &mut blocked, + &mut degraded, + ); let compatibility = if !invalid.is_empty() { ExternalSubagentCompatibilityState::Invalid } else if !blocked.is_empty() { @@ -734,6 +927,8 @@ fn materialize_definition( if hidden { "hidden" } else { "visible" }, &serde_json::to_string(&requested_model).expect("model request serializes"), &serde_json::to_string(&requested_tools).expect("tool request serializes"), + &serde_json::to_string(&permission_constraints) + .expect("permission constraints serialize"), &provenance .iter() .map(|item| item.contribution_id.stable_key()) @@ -757,6 +952,7 @@ fn materialize_definition( hidden, requested_model, requested_tools, + permission_constraints, compatibility, diagnostic_codes, behavior_version, @@ -809,9 +1005,7 @@ fn tool_request( degraded: &mut Vec, ) -> ExternalSubagentToolRequest { let Some(value) = fields.get("tools") else { - if !fields.contains_key("permission") { - degraded.push("opencode_default_permission_semantics_not_imported".to_string()); - } + degraded.push("opencode_default_permission_semantics_not_imported".to_string()); return ExternalSubagentToolRequest { selectors: [ ("list", "LS"), @@ -851,6 +1045,12 @@ fn tool_request( } continue; } + if name.eq_ignore_ascii_case("task") { + if allowed { + blocked.push("opencode_agent_task_tool_not_imported".to_string()); + } + continue; + } let canonical = match name.to_ascii_lowercase().as_str() { "list" => Some("LS"), "read" => Some("Read"), @@ -870,11 +1070,453 @@ fn tool_request( } } +fn permission_constraints( + fields: &Map, + requested_tools: &ExternalSubagentToolRequest, + home_dir: Option<&Path>, + workspace_root: Option<&Path>, + invalid: &mut Vec, + blocked: &mut Vec, + degraded: &mut Vec, +) -> PermissionConstraintLayer { + if fields.contains_key("permission") && fields.contains_key("permissions") { + blocked.push("opencode_agent_permission_versions_conflict".to_string()); + return PermissionConstraintLayer::default(); + } + if let Some(value) = fields.get("permissions") { + return current_permission_constraints( + value, + requested_tools, + home_dir, + workspace_root, + invalid, + blocked, + ); + } + let Some(value) = fields.get("permission") else { + return PermissionConstraintLayer::default(); + }; + let mut rules = Vec::new(); + match value { + Value::String(_) => { + if let Some(effect) = permission_effect(value, invalid) { + validate_permission_action_enforcement( + "*", + effect, + requested_tools, + blocked, + degraded, + ); + rules.push(PermissionRule::new("*", "*", effect)); + } + } + Value::Object(entries) => { + for (source_action, value) in entries { + if source_action + .bytes() + .any(|byte| matches!(byte, b'*' | b'?' | b'[' | b']')) + { + blocked.push( + "opencode_agent_legacy_permission_action_pattern_not_imported".to_string(), + ); + continue; + } + let Some(effect) = permission_effect(value, invalid) else { + if value.is_object() { + blocked.push( + "opencode_agent_permission_resource_patterns_not_imported".to_string(), + ); + } + continue; + }; + let Some(action) = canonical_permission_action(source_action) else { + validate_permission_action_enforcement( + source_action, + effect, + requested_tools, + blocked, + degraded, + ); + continue; + }; + validate_permission_action_enforcement( + source_action, + effect, + requested_tools, + blocked, + degraded, + ); + let rule = PermissionRule::new(action, "*", effect); + if rules.iter().any(|existing| { + existing.action == rule.action && existing.effect != rule.effect + }) { + blocked.push("opencode_agent_permission_alias_effect_conflict".to_string()); + continue; + } + if !rules.contains(&rule) { + rules.push(rule); + } + } + } + _ => invalid.push("opencode_agent_permission_type_invalid".to_string()), + } + PermissionConstraintLayer::new(rules) +} + +fn current_permission_constraints( + value: &Value, + requested_tools: &ExternalSubagentToolRequest, + home_dir: Option<&Path>, + workspace_root: Option<&Path>, + invalid: &mut Vec, + blocked: &mut Vec, +) -> PermissionConstraintLayer { + let Some(entries) = value.as_array() else { + invalid.push("opencode_agent_permissions_type_invalid".to_string()); + return PermissionConstraintLayer::default(); + }; + let mut rules = Vec::new(); + for entry in entries { + let Some(rule) = entry.as_object() else { + invalid.push("opencode_agent_permission_rule_type_invalid".to_string()); + continue; + }; + if rule + .keys() + .any(|key| !matches!(key.as_str(), "action" | "resource" | "effect")) + { + blocked.push("opencode_agent_permission_rule_field_not_imported".to_string()); + continue; + } + let Some(source_action) = required_permission_rule_string(rule, "action", invalid) else { + continue; + }; + let Some(resource) = required_permission_rule_string(rule, "resource", invalid) else { + continue; + }; + let Some(effect_value) = rule.get("effect") else { + invalid.push("opencode_agent_permission_rule_effect_missing".to_string()); + continue; + }; + let Some(effect) = permission_effect(effect_value, invalid) else { + continue; + }; + let action = imported_permission_action(source_action); + let resource = match translate_current_permission_resource( + source_action, + &action, + resource, + requested_tools, + home_dir, + workspace_root, + ) { + Ok(resource) => resource, + Err(code) => { + blocked.push(code.to_string()); + resource.to_string() + } + }; + validate_current_permission_action_enforcement( + source_action, + &action, + effect, + requested_tools, + blocked, + ); + rules.push(PermissionRule::new(action, resource, effect)); + } + PermissionConstraintLayer::new(rules) +} + +fn translate_current_permission_resource( + source_action: &str, + action: &str, + resource: &str, + requested_tools: &ExternalSubagentToolRequest, + home_dir: Option<&Path>, + workspace_root: Option<&Path>, +) -> Result { + let path_resource = + permission_action_uses_workspace_paths(source_action, action, requested_tools); + if !path_resource && source_action != "external_directory" { + return Ok(resource.to_string()); + } + + let translated = if let Some(home_suffix) = permission_home_suffix(resource) { + let Some(home_dir) = home_dir else { + return Err("opencode_agent_permission_home_unavailable"); + }; + let home_dir = canonical_permission_root(home_dir); + format!("{home_dir}{home_suffix}") + } else { + resource.to_string() + }; + + if !path_resource + || translated.chars().all(|character| character == '*') + || Path::new(&translated).is_absolute() + { + return Ok(translated); + } + if translated.starts_with('*') || translated.starts_with('?') { + return Err("opencode_agent_permission_resource_domain_ambiguous"); + } + let Some(workspace_root) = workspace_root else { + return Err("opencode_agent_permission_workspace_unavailable"); + }; + let workspace_root = canonical_permission_root(workspace_root); + if translated == "." { + return Ok(workspace_root); + } + Ok(format!( + "{}/{}", + workspace_root.trim_end_matches('/'), + translated.replace('\\', "/") + )) +} + +fn permission_action_uses_workspace_paths( + source_action: &str, + action: &str, + requested_tools: &ExternalSubagentToolRequest, +) -> bool { + if matches!(action, "read" | "edit") { + return true; + } + let matched_actions = requested_tools + .selectors + .iter() + .filter(|selector| selector.allowed) + .filter_map(|selector| { + permission_action_for_host_tool(selector.canonical_host_name.as_deref()) + }) + .filter(|host_action| { + wildcard_matches( + host_action, + source_action, + PermissionResourceCaseSensitivity::Sensitive, + ) + }) + .collect::>(); + !matched_actions.is_empty() + && matched_actions + .iter() + .all(|action| matches!(*action, "read" | "edit")) +} + +fn permission_home_suffix(resource: &str) -> Option<&str> { + if resource == "~" || resource == "$HOME" { + Some("") + } else if resource.starts_with("~/") { + Some(&resource[1..]) + } else if resource.starts_with("$HOME/") || resource.starts_with("$HOME\\") { + Some(&resource[5..]) + } else { + None + } +} + +fn canonical_permission_root(path: &Path) -> String { + dunce::canonicalize(path) + .unwrap_or_else(|_| path.to_path_buf()) + .to_string_lossy() + .replace('\\', "/") +} + +fn validate_current_permission_action_enforcement( + source_action: &str, + rule_action: &str, + effect: PermissionEffect, + requested_tools: &ExternalSubagentToolRequest, + blocked: &mut Vec, +) { + if effect == PermissionEffect::Allow { + return; + } + let reaches_unenforced_tool = requested_tools.selectors.iter().any(|selector| { + if !selector.allowed { + return false; + } + let host_action = permission_action_for_host_tool(selector.canonical_host_name.as_deref()); + let rule_reaches_tool = wildcard_matches( + &selector.source_name, + source_action, + PermissionResourceCaseSensitivity::Sensitive, + ) || host_action.is_some_and(|action| { + wildcard_matches( + action, + rule_action, + PermissionResourceCaseSensitivity::Sensitive, + ) + }); + rule_reaches_tool + && match host_action { + Some(action) => !wildcard_matches( + action, + rule_action, + PermissionResourceCaseSensitivity::Sensitive, + ), + None => true, + } + }); + if reaches_unenforced_tool { + blocked.push("opencode_agent_permission_action_not_enforceable".to_string()); + } +} + +fn validate_permission_action_enforcement( + source_action: &str, + effect: PermissionEffect, + requested_tools: &ExternalSubagentToolRequest, + blocked: &mut Vec, + degraded: &mut Vec, +) { + let canonical_action = canonical_permission_action(source_action); + let selected_unknown_tool = canonical_action.is_none() + && requested_tools + .selectors + .iter() + .any(|selector| selector.allowed && selector.canonical_host_name.is_none()); + let selected_named_tool_is_unenforced = source_action != "*" + && requested_tools.selectors.iter().any(|selector| { + let host_action = + permission_action_for_host_tool(selector.canonical_host_name.as_deref()); + let source_action_matches = wildcard_matches( + &selector.source_name, + source_action, + PermissionResourceCaseSensitivity::Sensitive, + ) || host_action.is_some_and(|action| { + wildcard_matches( + action, + source_action, + PermissionResourceCaseSensitivity::Sensitive, + ) + }); + selector.allowed + && source_action_matches + && (canonical_action.is_none() || host_action != canonical_action) + }); + let wildcard_reaches_unenforced_tool = source_action == "*" + && requested_tools.selectors.iter().any(|selector| { + selector.allowed + && permission_action_for_host_tool(selector.canonical_host_name.as_deref()) + .is_none() + }); + + if effect != PermissionEffect::Allow + && (selected_unknown_tool + || selected_named_tool_is_unenforced + || wildcard_reaches_unenforced_tool) + { + blocked.push("opencode_agent_permission_action_not_enforceable".to_string()); + } else if canonical_action.is_none() { + degraded.push("opencode_agent_permission_action_not_imported".to_string()); + } +} + +fn permission_action_for_host_tool(host_tool: Option<&str>) -> Option<&'static str> { + match host_tool { + Some("Read") => Some("read"), + Some("Write" | "Edit" | "Delete") => Some("edit"), + Some("Bash" | "ExecCommand") => Some("bash"), + Some("Task") => Some("task"), + Some("Skill") => Some("skill"), + Some("WebFetch") => Some("webfetch"), + Some("WebSearch") => Some("websearch"), + Some("Git") => Some("git"), + _ => None, + } +} + +fn required_permission_rule_string<'a>( + rule: &'a Map, + field: &str, + invalid: &mut Vec, +) -> Option<&'a str> { + match rule.get(field) { + Some(Value::String(value)) if !value.trim().is_empty() && value.trim() == value => { + Some(value) + } + Some(Value::String(_)) => { + invalid.push(format!("opencode_agent_permission_rule_{field}_invalid")); + None + } + Some(_) => { + invalid.push(format!( + "opencode_agent_permission_rule_{field}_type_invalid" + )); + None + } + None => { + invalid.push(format!("opencode_agent_permission_rule_{field}_missing")); + None + } + } +} + +fn permission_effect(value: &Value, invalid: &mut Vec) -> Option { + match value.as_str() { + Some("allow") => Some(PermissionEffect::Allow), + Some("ask") => Some(PermissionEffect::Ask), + Some("deny") => Some(PermissionEffect::Deny), + Some(_) => { + invalid.push("opencode_agent_permission_effect_invalid".to_string()); + None + } + None if value.is_object() => None, + None => { + invalid.push("opencode_agent_permission_effect_type_invalid".to_string()); + None + } + } +} + +fn canonical_permission_action(source_action: &str) -> Option<&'static str> { + let normalized; + let source_action = if cfg!(windows) { + normalized = source_action.to_ascii_lowercase(); + normalized.as_str() + } else { + source_action + }; + match source_action { + "*" => Some("*"), + "write" | "edit" | "patch" | "apply_patch" => Some("edit"), + "read" => Some("read"), + "bash" => Some("bash"), + "task" => Some("task"), + "skill" => Some("skill"), + "webfetch" => Some("webfetch"), + "websearch" => Some("websearch"), + "git" => Some("git"), + "external_directory" => Some("external_directory"), + _ => None, + } +} + +fn imported_permission_action(source_action: &str) -> String { + canonical_permission_action(source_action) + .map(str::to_string) + .unwrap_or_else(|| { + if cfg!(windows) { + source_action.to_ascii_lowercase() + } else { + source_action.to_string() + } + }) +} + fn deep_merge(target: &mut Value, incoming: Value) { match (target, incoming) { (Value::Object(target), Value::Object(incoming)) => { for (key, value) in incoming { match target.get_mut(&key) { + Some(existing) if key == "permissions" => match (existing, value) { + (Value::Array(existing), Value::Array(mut incoming)) => { + existing.append(&mut incoming); + } + (existing, incoming) => *existing = incoming, + }, Some(existing) => deep_merge(existing, value), None => { target.insert(key, value); diff --git a/src/crates/adapters/opencode-adapter/tests/opencode_subagent_adapter.rs b/src/crates/adapters/opencode-adapter/tests/opencode_subagent_adapter.rs index 3ab763aa9a..8dbaf1c348 100644 --- a/src/crates/adapters/opencode-adapter/tests/opencode_subagent_adapter.rs +++ b/src/crates/adapters/opencode-adapter/tests/opencode_subagent_adapter.rs @@ -6,6 +6,9 @@ use bitfun_product_domains::external_subagents::{ ExternalSubagentCompatibilityState, ExternalSubagentDiscoveryInput, ExternalSubagentMode, ExternalSubagentModelRequest, ExternalSubagentSourceProvider, }; +use bitfun_product_domains::tool_permissions::{ + PermissionEffect, PermissionEvaluator, PermissionRule, +}; use std::collections::BTreeSet; use std::fs; use std::path::PathBuf; @@ -42,6 +45,607 @@ fn discover( .expect("discover OpenCode agents") } +#[test] +fn omo_oracle_flat_permissions_become_provider_neutral_constraints() { + let temp = TempDir::new().unwrap(); + let workspace = temp.path().join("workspace"); + fs::create_dir_all(workspace.join(".git")).unwrap(); + fs::create_dir_all(temp.path().join("user")).unwrap(); + fs::write( + temp.path().join("user/opencode.json"), + r#"{ + "agent": { + "oracle": { + "description": "Read-only consultation agent", + "prompt": "Analyze the problem without modifying files", + "mode": "subagent", + "permission": { + "write": "deny", + "edit": "deny", + "apply_patch": "deny", + "task": "deny" + } + } + } + }"#, + ) + .unwrap(); + let snapshot = discover(&provider(&temp, &workspace), workspace, BTreeSet::new()); + let definition = &snapshot.definitions[0]; + + assert_eq!( + definition.permission_constraints.rules(), + [ + PermissionRule::new("edit", "*", PermissionEffect::Deny), + PermissionRule::new("task", "*", PermissionEffect::Deny), + ] + ); + assert_eq!( + definition.compatibility, + ExternalSubagentCompatibilityState::ReadyWithDegradation + ); + assert!(!definition + .diagnostic_codes + .contains(&"opencode_agent_permission_not_imported".to_string())); +} + +#[test] +fn current_opencode_agent_permissions_preserve_ordered_resource_rules() { + let temp = TempDir::new().unwrap(); + let workspace = temp.path().join("workspace"); + fs::create_dir_all(workspace.join(".git")).unwrap(); + fs::create_dir_all(temp.path().join("user")).unwrap(); + fs::write( + temp.path().join("user/opencode.json"), + r#"{ + "agents": { + "reviewer": { + "description": "Current OpenCode reviewer", + "system": "Review without changing generated files", + "mode": "subagent", + "disabled": false, + "permissions": [ + { "action": "read", "resource": "src/**", "effect": "allow" }, + { "action": "read", "resource": "src/generated/**", "effect": "ask" }, + { "action": "r*", "resource": "docs/**", "effect": "ask" }, + { "action": "edit", "resource": "*", "effect": "deny" } + ] + } + } + }"#, + ) + .unwrap(); + fs::write( + workspace.join("opencode.json"), + r#"{ + "agents": { + "reviewer": { + "permissions": [ + { "action": "read", "resource": "secrets/**", "effect": "deny" } + ] + } + } + }"#, + ) + .unwrap(); + + let workspace_resource = dunce::canonicalize(&workspace) + .unwrap() + .to_string_lossy() + .replace('\\', "/"); + let snapshot = discover(&provider(&temp, &workspace), workspace, BTreeSet::new()); + let definition = &snapshot.definitions[0]; + + assert_eq!( + definition.prompt.expose(), + "Review without changing generated files" + ); + assert!(!definition.disabled); + assert_eq!( + definition.permission_constraints.rules(), + [ + PermissionRule::new( + "read", + format!("{workspace_resource}/src/**"), + PermissionEffect::Allow, + ), + PermissionRule::new( + "read", + format!("{workspace_resource}/src/generated/**"), + PermissionEffect::Ask, + ), + PermissionRule::new( + "r*", + format!("{workspace_resource}/docs/**"), + PermissionEffect::Ask, + ), + PermissionRule::new("edit", "*", PermissionEffect::Deny), + PermissionRule::new( + "read", + format!("{workspace_resource}/secrets/**"), + PermissionEffect::Deny, + ), + ] + ); + assert_eq!( + definition.compatibility, + ExternalSubagentCompatibilityState::ReadyWithDegradation + ); +} + +#[test] +fn current_path_permission_resources_expand_like_opencode() { + let temp = TempDir::new().unwrap(); + let workspace = temp.path().join("workspace"); + let home = temp.path().join("home"); + fs::create_dir_all(workspace.join(".git")).unwrap(); + fs::create_dir_all(home.join(".opencode")).unwrap(); + fs::write( + home.join(".opencode/opencode.json"), + r#"{ + "agents": { + "reviewer": { + "system": "Review private files without editing them", + "mode": "subagent", + "permissions": [ + { "action": "read", "resource": "~/private/**", "effect": "ask" }, + { "action": "edit", "resource": "$HOME\\private\\**", "effect": "deny" }, + { "action": "bash", "resource": "$HOME/private/**", "effect": "deny" } + ] + } + } + }"#, + ) + .unwrap(); + let provider = OpenCodeSubagentProvider::new(OpenCodeSubagentProviderOptions { + user_config_dir: home.join(".opencode"), + legacy_user_config_dir: Some(home.join(".opencode")), + explicit_config_file: None, + explicit_config_dir: None, + project_config_enabled: true, + project_root_override: Some(workspace.clone()), + }); + + let snapshot = discover(&provider, workspace, BTreeSet::new()); + let rules = snapshot.definitions[0].permission_constraints.rules(); + let home = dunce::canonicalize(home) + .unwrap() + .to_string_lossy() + .replace('\\', "/"); + + assert_eq!(rules[0].resource, format!("{home}/private/**")); + assert_eq!(rules[1].resource, format!(r"{home}\private\**")); + assert_eq!( + rules[2].resource, "$HOME/private/**", + "OpenCode deliberately keeps bash resources as raw shell text" + ); +} + +#[test] +fn ambiguous_cross_domain_path_patterns_fail_closed() { + let temp = TempDir::new().unwrap(); + let workspace = temp.path().join("workspace"); + fs::create_dir_all(workspace.join(".git")).unwrap(); + fs::create_dir_all(temp.path().join("user")).unwrap(); + fs::write( + temp.path().join("user/opencode.json"), + r#"{ + "agents": { + "reviewer": { + "system": "Do not read secret files", + "mode": "subagent", + "permissions": [ + { "action": "read", "resource": "**/secret", "effect": "deny" } + ] + } + } + }"#, + ) + .unwrap(); + + let snapshot = discover(&provider(&temp, &workspace), workspace, BTreeSet::new()); + let definition = &snapshot.definitions[0]; + + assert_eq!( + definition.compatibility, + ExternalSubagentCompatibilityState::Blocked + ); + assert!(definition + .diagnostic_codes + .contains(&"opencode_agent_permission_resource_domain_ambiguous".to_string())); +} + +#[cfg(windows)] +#[test] +fn windows_permission_actions_follow_opencode_case_insensitive_matching() { + let temp = TempDir::new().unwrap(); + let workspace = temp.path().join("workspace"); + fs::create_dir_all(workspace.join(".git")).unwrap(); + fs::create_dir_all(temp.path().join("user")).unwrap(); + fs::write( + temp.path().join("user/opencode.json"), + r#"{ + "agents": { + "reviewer": { + "system": "Do not read files", + "mode": "subagent", + "permissions": [ + { "action": "R*", "resource": "*", "effect": "deny" } + ] + } + } + }"#, + ) + .unwrap(); + + let snapshot = discover(&provider(&temp, &workspace), workspace, BTreeSet::new()); + let constraints = &snapshot.definitions[0].permission_constraints; + + assert_eq!(constraints.rules()[0].action, "r*"); + assert_eq!( + PermissionEvaluator::for_current_platform().evaluate_constraint_resource( + "read", + "C:/workspace/file.txt", + constraints, + ), + PermissionEffect::Deny + ); +} + +#[test] +fn v1_and_current_config_documents_merge_after_per_document_migration() { + let temp = TempDir::new().unwrap(); + let workspace = temp.path().join("workspace"); + fs::create_dir_all(workspace.join(".git")).unwrap(); + fs::create_dir_all(temp.path().join("user")).unwrap(); + fs::write( + temp.path().join("user/opencode.json"), + r#"{ + "agent": { + "reviewer": { + "prompt": "Review using the user policy", + "mode": "subagent", + "tools": { "read": true }, + "permission": { "edit": "deny" } + } + } + }"#, + ) + .unwrap(); + fs::write( + workspace.join("opencode.json"), + r#"{ + "agents": { + "reviewer": { + "system": "Review using the project policy", + "permissions": [ + { "action": "read", "resource": "secrets/**", "effect": "deny" } + ] + } + } + }"#, + ) + .unwrap(); + + let workspace_resource = dunce::canonicalize(&workspace) + .unwrap() + .to_string_lossy() + .replace('\\', "/"); + let snapshot = discover(&provider(&temp, &workspace), workspace, BTreeSet::new()); + let definition = &snapshot.definitions[0]; + + assert_eq!( + definition.prompt.expose(), + "Review using the project policy" + ); + assert_eq!( + definition.permission_constraints.rules(), + [ + PermissionRule::new("edit", "*", PermissionEffect::Deny), + PermissionRule::new( + "read", + format!("{workspace_resource}/secrets/**"), + PermissionEffect::Deny, + ), + ] + ); + assert_eq!( + definition.compatibility, + ExternalSubagentCompatibilityState::Ready, + "{:?}", + definition.diagnostic_codes + ); +} + +#[test] +fn later_enabled_document_recreates_agent_after_disabled_tombstone() { + let temp = TempDir::new().unwrap(); + let workspace = temp.path().join("workspace"); + fs::create_dir_all(workspace.join(".git")).unwrap(); + fs::create_dir_all(temp.path().join("user")).unwrap(); + fs::write( + temp.path().join("user/opencode.json"), + r#"{ + "agents": { + "reviewer": { + "system": "Disabled user definition", + "mode": "subagent", + "disabled": true, + "permissions": [ + { "action": "edit", "resource": "*", "effect": "deny" } + ] + } + } + }"#, + ) + .unwrap(); + fs::write( + workspace.join("opencode.json"), + r#"{ + "agents": { + "reviewer": { + "system": "Enabled project definition", + "mode": "subagent" + } + } + }"#, + ) + .unwrap(); + + let snapshot = discover(&provider(&temp, &workspace), workspace, BTreeSet::new()); + let definition = &snapshot.definitions[0]; + + assert!(!definition.disabled); + assert_eq!(definition.prompt.expose(), "Enabled project definition"); + assert!(definition.permission_constraints.rules().is_empty()); +} + +#[test] +fn v1_markdown_migrates_before_current_project_overlay() { + let temp = TempDir::new().unwrap(); + let workspace = temp.path().join("workspace"); + fs::create_dir_all(workspace.join(".git")).unwrap(); + fs::create_dir_all(temp.path().join("user/agents")).unwrap(); + fs::write( + temp.path().join("user/agents/reviewer.md"), + "---\ndescription: Reviewer\nmode: subagent\ntools:\n read: true\npermission:\n edit: deny\n---\nReview using the user policy.", + ) + .unwrap(); + fs::write( + workspace.join("opencode.json"), + r#"{ + "agents": { + "reviewer": { + "system": "Review using the project policy", + "permissions": [ + { "action": "read", "resource": "*", "effect": "deny" } + ] + } + } + }"#, + ) + .unwrap(); + + let snapshot = discover(&provider(&temp, &workspace), workspace, BTreeSet::new()); + let definition = &snapshot.definitions[0]; + + assert_eq!( + definition.prompt.expose(), + "Review using the project policy" + ); + assert_eq!( + definition.permission_constraints.rules(), + [ + PermissionRule::new("edit", "*", PermissionEffect::Deny), + PermissionRule::new("read", "*", PermissionEffect::Deny), + ] + ); + assert_eq!( + definition.compatibility, + ExternalSubagentCompatibilityState::Ready + ); +} + +#[test] +fn project_permission_rules_follow_user_agent_markdown() { + let temp = TempDir::new().unwrap(); + let workspace = temp.path().join("workspace"); + fs::create_dir_all(workspace.join(".git")).unwrap(); + fs::create_dir_all(temp.path().join("user/agents")).unwrap(); + fs::write( + temp.path().join("user/agents/reviewer.md"), + "---\ndescription: Reviewer\nmode: subagent\npermissions:\n - action: read\n resource: '*'\n effect: allow\n---\nReview the project.", + ) + .unwrap(); + fs::write( + workspace.join("opencode.json"), + r#"{ + "agents": { + "reviewer": { + "permissions": [ + { "action": "read", "resource": "*", "effect": "deny" } + ] + } + } + }"#, + ) + .unwrap(); + + let snapshot = discover(&provider(&temp, &workspace), workspace, BTreeSet::new()); + let constraints = &snapshot.definitions[0].permission_constraints; + + assert_eq!( + constraints.rules(), + [ + PermissionRule::new("read", "*", PermissionEffect::Allow), + PermissionRule::new("read", "*", PermissionEffect::Deny), + ] + ); + assert_eq!( + PermissionEvaluator::for_current_platform().evaluate_constraint_resource( + "read", + "C:/workspace/secrets/key.txt", + constraints, + ), + PermissionEffect::Deny + ); +} + +#[test] +fn explicit_alias_keeps_project_opencode_directory_position() { + let temp = TempDir::new().unwrap(); + let workspace = temp.path().join("workspace"); + let project_opencode = workspace.join(".opencode"); + fs::create_dir_all(workspace.join(".git")).unwrap(); + fs::create_dir_all(&project_opencode).unwrap(); + fs::write( + workspace.join("opencode.json"), + r#"{ + "agents": { + "reviewer": { + "system": "Review the project", + "mode": "subagent", + "permissions": [ + { "action": "read", "resource": "*", "effect": "allow" } + ] + } + } + }"#, + ) + .unwrap(); + fs::write( + project_opencode.join("opencode.json"), + r#"{ + "agents": { + "reviewer": { + "permissions": [ + { "action": "read", "resource": "*", "effect": "deny" } + ] + } + } + }"#, + ) + .unwrap(); + let provider = OpenCodeSubagentProvider::new(OpenCodeSubagentProviderOptions { + user_config_dir: temp.path().join("user"), + legacy_user_config_dir: Some(temp.path().join("legacy")), + explicit_config_file: None, + explicit_config_dir: Some(project_opencode), + project_config_enabled: true, + project_root_override: Some(workspace.clone()), + }); + + let snapshot = discover(&provider, workspace, BTreeSet::new()); + + assert_eq!( + snapshot.definitions[0].permission_constraints.rules(), + [ + PermissionRule::new("read", "*", PermissionEffect::Allow), + PermissionRule::new("read", "*", PermissionEffect::Deny), + ] + ); +} + +#[test] +fn permission_shapes_that_cannot_be_enforced_fail_closed_per_agent() { + let temp = TempDir::new().unwrap(); + let workspace = temp.path().join("workspace"); + fs::create_dir_all(workspace.join(".git")).unwrap(); + fs::create_dir_all(temp.path().join("user")).unwrap(); + fs::write( + temp.path().join("user/opencode.json"), + r#"{ + "agent": { + "active-unenforceable": { + "prompt": "Do not glob", + "mode": "subagent", + "permission": { "glob": "deny" } + }, + "inactive-unknown": { + "prompt": "Do not delegate", + "mode": "subagent", + "permission": { "call_omo_agent": "deny" } + }, + "active-pattern": { + "prompt": "Do not read", + "mode": "subagent", + "permission": { "r*": "deny" } + }, + "ordered-legacy-overlap": { + "prompt": "Order matters", + "mode": "subagent", + "tools": { "read": true }, + "permission": { "read": "allow", "*": "deny" } + }, + "shorthand-enforceable": { + "prompt": "Read nothing", + "mode": "subagent", + "tools": { "read": true }, + "permission": "deny" + }, + "nested-resource-map": { + "prompt": "Restrict commands", + "mode": "subagent", + "permission": { "bash": { "rm *": "deny" } } + }, + "task-enabled": { + "prompt": "Delegate work", + "mode": "subagent", + "tools": { "Task": true } + } + } + }"#, + ) + .unwrap(); + + let snapshot = discover(&provider(&temp, &workspace), workspace, BTreeSet::new()); + let find = |id: &str| { + snapshot + .definitions + .iter() + .find(|definition| definition.logical_id == id) + .unwrap() + }; + + assert_eq!( + find("active-unenforceable").compatibility, + ExternalSubagentCompatibilityState::Blocked + ); + assert!(find("active-unenforceable") + .diagnostic_codes + .contains(&"opencode_agent_permission_action_not_enforceable".to_string())); + assert_eq!( + find("inactive-unknown").compatibility, + ExternalSubagentCompatibilityState::ReadyWithDegradation + ); + assert_eq!( + find("active-pattern").compatibility, + ExternalSubagentCompatibilityState::Blocked + ); + assert_eq!( + find("ordered-legacy-overlap").compatibility, + ExternalSubagentCompatibilityState::Blocked + ); + assert!(find("ordered-legacy-overlap") + .diagnostic_codes + .contains(&"opencode_agent_legacy_permission_action_pattern_not_imported".to_string())); + assert_eq!( + find("shorthand-enforceable").compatibility, + ExternalSubagentCompatibilityState::Ready + ); + assert_eq!( + find("nested-resource-map").compatibility, + ExternalSubagentCompatibilityState::Blocked + ); + assert_eq!( + find("task-enabled").compatibility, + ExternalSubagentCompatibilityState::Blocked + ); + assert!(find("task-enabled") + .diagnostic_codes + .contains(&"opencode_agent_task_tool_not_imported".to_string())); +} + #[test] fn global_and_project_agent_fields_deep_merge_with_ordered_provenance() { let temp = TempDir::new().unwrap(); diff --git a/src/crates/assembly/core/src/agentic/agents/definitions/external.rs b/src/crates/assembly/core/src/agentic/agents/definitions/external.rs index 9ea1a78779..66952c2462 100644 --- a/src/crates/assembly/core/src/agentic/agents/definitions/external.rs +++ b/src/crates/assembly/core/src/agentic/agents/definitions/external.rs @@ -5,6 +5,7 @@ use crate::agentic::agents::{ use crate::agentic::session::SystemPromptCacheIdentity; use crate::util::errors::BitFunResult; use async_trait::async_trait; +use bitfun_runtime_ports::PermissionConstraintLayer; /// Immutable, generation-keyed projection of an approved external definition. /// Prompt text remains backend-only and the type deliberately implements no @@ -15,6 +16,7 @@ pub(crate) struct ExternalProvidedSubagent { description: String, prompt: String, tools: Vec, + permission_constraints: PermissionConstraintLayer, readonly: bool, behavior_version: String, } @@ -26,6 +28,7 @@ impl ExternalProvidedSubagent { description: String, prompt: String, tools: Vec, + permission_constraints: PermissionConstraintLayer, readonly: bool, behavior_version: String, ) -> Self { @@ -35,6 +38,7 @@ impl ExternalProvidedSubagent { description, prompt, tools, + permission_constraints, readonly, behavior_version, } @@ -80,6 +84,10 @@ impl Agent for ExternalProvidedSubagent { self.tools.clone() } + fn permission_constraints(&self) -> &PermissionConstraintLayer { + &self.permission_constraints + } + fn user_context_policy(&self) -> UserContextPolicy { default_custom_agent_user_context_policy(CustomAgentKind::Subagent) } diff --git a/src/crates/assembly/core/src/agentic/agents/mod.rs b/src/crates/assembly/core/src/agentic/agents/mod.rs index d23d7c27e1..e1fa6c4e1b 100644 --- a/src/crates/assembly/core/src/agentic/agents/mod.rs +++ b/src/crates/assembly/core/src/agentic/agents/mod.rs @@ -21,6 +21,7 @@ pub use bitfun_agent_runtime::custom_agent::{ custom_agent_model_or_default, custom_agent_review_writable_tools, default_custom_agent_tools, default_custom_agent_user_context_policy, CustomAgentKind, CustomAgentLevel, }; +use bitfun_runtime_ports::PermissionConstraintLayer; pub use definitions::custom::{CustomMode, CustomSubagent, CustomSubagentKind}; pub(crate) use definitions::external::ExternalProvidedSubagent; pub use definitions::hidden::{CodeReviewAgent, DeepReviewAgent, GenerateDocAgent}; @@ -63,6 +64,8 @@ pub type AgentToolPolicyOverrides = IndexMap; static EMPTY_AGENT_TOOL_POLICY_OVERRIDES: std::sync::LazyLock = std::sync::LazyLock::new(AgentToolPolicyOverrides::default); +static EMPTY_PERMISSION_CONSTRAINTS: std::sync::LazyLock = + std::sync::LazyLock::new(PermissionConstraintLayer::default); pub fn shared_coding_mode_tool_exposure_overrides() -> AgentToolPolicyOverrides { // Web research is a baseline capability of the shared coding modes; keep @@ -241,6 +244,12 @@ pub trait Agent: Send + Sync + 'static { &EMPTY_AGENT_TOOL_POLICY_OVERRIDES } + /// Independent restrictions contributed by the immutable agent definition. + /// They may tighten, but never widen, the resolved host permission policy. + fn permission_constraints(&self) -> &PermissionConstraintLayer { + &EMPTY_PERMISSION_CONSTRAINTS + } + /// Whether this agent is read-only (prevents file modifications) fn is_readonly(&self) -> bool { false diff --git a/src/crates/assembly/core/src/agentic/agents/registry/query.rs b/src/crates/assembly/core/src/agentic/agents/registry/query.rs index afc7bb6894..5f423d4d07 100644 --- a/src/crates/assembly/core/src/agentic/agents/registry/query.rs +++ b/src/crates/assembly/core/src/agentic/agents/registry/query.rs @@ -83,6 +83,7 @@ impl AgentRegistry { return AgentToolPolicy { allowed_tools: Vec::new(), exposure_overrides: Default::default(), + permission_constraints: Default::default(), }; }; match entry.category { @@ -104,6 +105,7 @@ impl AgentRegistry { AgentToolPolicy { allowed_tools, exposure_overrides, + permission_constraints: entry.agent.permission_constraints().clone(), } } AgentCategory::SubAgent | AgentCategory::Hidden => { @@ -117,6 +119,7 @@ impl AgentRegistry { AgentToolPolicy { allowed_tools, exposure_overrides, + permission_constraints: entry.agent.permission_constraints().clone(), } } } diff --git a/src/crates/assembly/core/src/agentic/agents/registry/types.rs b/src/crates/assembly/core/src/agentic/agents/registry/types.rs index 301f151c83..c8b8ac6371 100644 --- a/src/crates/assembly/core/src/agentic/agents/registry/types.rs +++ b/src/crates/assembly/core/src/agentic/agents/registry/types.rs @@ -14,6 +14,7 @@ pub use bitfun_agent_runtime::agents::{ }; use bitfun_agent_runtime::custom_agent::CustomAgentLevel; use bitfun_agent_runtime::prompt_cache::prompt_cache_scope_key; +use bitfun_runtime_ports::PermissionConstraintLayer; use serde::{Deserialize, Serialize}; use std::sync::Arc; @@ -41,6 +42,7 @@ pub enum AgentSource { pub struct AgentToolPolicy { pub allowed_tools: Vec, pub exposure_overrides: AgentToolPolicyOverrides, + pub permission_constraints: PermissionConstraintLayer, } /// one agent record in registry diff --git a/src/crates/assembly/core/src/agentic/execution/execution_engine.rs b/src/crates/assembly/core/src/agentic/execution/execution_engine.rs index 3fa620af91..ed978a843b 100644 --- a/src/crates/assembly/core/src/agentic/execution/execution_engine.rs +++ b/src/crates/assembly/core/src/agentic/execution/execution_engine.rs @@ -365,6 +365,7 @@ struct TurnPromptScaffoldInput<'a> { } struct FinalizeRoundInput<'a> { + permission_constraints: bitfun_runtime_ports::PermissionConstraintLayer, context_window: usize, tool_definitions: Option>, reminder_text: &'a str, @@ -1559,6 +1560,7 @@ impl ExecutionEngine { primary_model_facts: input.primary_model_facts.clone(), agent_type: input.agent_type, context_vars: input.execution_context_vars.clone(), + permission_constraints: input.permission_constraints, permission_runtime_ceiling: input.context.permission_runtime_ceiling.clone(), delegation_policy: input.context.delegation_policy, runtime_tool_restrictions: finalize_runtime_tool_restrictions, @@ -3553,6 +3555,7 @@ impl ExecutionEngine { primary_model_facts: primary_model_facts.clone(), agent_type: agent_type.clone(), context_vars: round_context_vars, + permission_constraints: tool_policy.permission_constraints.clone(), permission_runtime_ceiling: context.permission_runtime_ceiling.clone(), delegation_policy: context.delegation_policy, runtime_tool_restrictions: context.runtime_tool_restrictions.clone(), @@ -4228,6 +4231,7 @@ impl ExecutionEngine { .ordered_reminders(); let final_round_result = self .run_finalize_round(FinalizeRoundInput { + permission_constraints: tool_policy.permission_constraints.clone(), ai_client: ai_client.clone(), context: &context, agent_type: agent_type.clone(), @@ -4258,6 +4262,7 @@ impl ExecutionEngine { ); let retry_result = self .run_finalize_round(FinalizeRoundInput { + permission_constraints: tool_policy.permission_constraints.clone(), ai_client: ai_client.clone(), context: &context, agent_type: agent_type.clone(), diff --git a/src/crates/assembly/core/src/agentic/execution/round_executor.rs b/src/crates/assembly/core/src/agentic/execution/round_executor.rs index c8dd94c8b3..c06191057c 100644 --- a/src/crates/assembly/core/src/agentic/execution/round_executor.rs +++ b/src/crates/assembly/core/src/agentic/execution/round_executor.rs @@ -14,7 +14,7 @@ use crate::agentic::memories::{ parse_bitfun_memory_citation, parse_bitfun_memory_citation_payloads, strip_bitfun_memory_citations, }; -use crate::agentic::permission_policy::resolve_effective_permission_rules; +use crate::agentic::permission_policy::resolve_effective_permission_policy; use crate::agentic::tools::computer_use_host::ComputerUseHostRef; use crate::agentic::tools::pipeline::{ SubagentBatchExecutionPolicy as PipelineSubagentBatchExecutionPolicy, ToolExecutionContext, @@ -215,16 +215,18 @@ impl RoundExecutor { } } - fn resolve_permission_rules( + fn resolve_permission_policy( global: &crate::service::config::types::GlobalConfig, project_rules: &[PermissionRule], agent_profile: Option<&AgentProfileConfig>, + agent_definition_constraints: &bitfun_runtime_ports::PermissionConstraintLayer, parent_runtime_ceiling: Option<&bitfun_runtime_ports::PermissionRuntimeCeiling>, - ) -> Vec { - resolve_effective_permission_rules( + ) -> bitfun_runtime_ports::ResolvedPermissionPolicy { + resolve_effective_permission_policy( global, project_rules, agent_profile, + Some(agent_definition_constraints), parent_runtime_ceiling, &[], ) @@ -1085,10 +1087,11 @@ impl RoundExecutor { .ai .agent_profiles .get(agent_profile_id.as_ref()); - let permission_rules = Self::resolve_permission_rules( + let permission_policy = Self::resolve_permission_policy( &global_config, &project_rules, agent_profile, + &context.permission_constraints, context.permission_runtime_ceiling.as_ref(), ); @@ -1096,7 +1099,7 @@ impl RoundExecutor { let tool_options = ToolExecutionOptions { timeout_secs: tool_execution_timeout, subagent_batch_execution_policy, - permission_rules, + permission_policy, auto_approve_ask, ..ToolExecutionOptions::default() }; @@ -1734,6 +1737,7 @@ mod tests { ), agent_type: "agentic".to_string(), context_vars: HashMap::new(), + permission_constraints: Default::default(), permission_runtime_ceiling: None, delegation_policy: DelegationPolicy::top_level(), runtime_tool_restrictions: ToolRuntimeRestrictions::default(), @@ -1766,24 +1770,29 @@ mod tests { ..AgentProfileConfig::default() }; - let resolved = - RoundExecutor::resolve_permission_rules(&global, &project_rules, Some(&agent), None); + let resolved = RoundExecutor::resolve_permission_policy( + &global, + &project_rules, + Some(&agent), + &Default::default(), + None, + ); let evaluator = PermissionEvaluator::case_sensitive(); assert_eq!( - evaluator.evaluate_resource("bash", "rm -rf target", &resolved), + evaluator.evaluate_policy_resource("bash", "rm -rf target", &resolved), PermissionEffect::Ask ); assert_eq!( - evaluator.evaluate_resource("edit", "generated/review.md", &resolved), + evaluator.evaluate_policy_resource("edit", "generated/review.md", &resolved), PermissionEffect::Allow ); assert_eq!( - evaluator.evaluate_resource("edit", "generated/api.rs", &resolved), + evaluator.evaluate_policy_resource("edit", "generated/api.rs", &resolved), PermissionEffect::Deny ); assert_eq!( - evaluator.evaluate_resource("read", "src/main.rs", &resolved), + evaluator.evaluate_policy_resource("read", "src/main.rs", &resolved), PermissionEffect::Allow ); } diff --git a/src/crates/assembly/core/src/agentic/execution/types.rs b/src/crates/assembly/core/src/agentic/execution/types.rs index 24a41fb6cc..dd12ab189b 100644 --- a/src/crates/assembly/core/src/agentic/execution/types.rs +++ b/src/crates/assembly/core/src/agentic/execution/types.rs @@ -9,8 +9,8 @@ use crate::agentic::WorkspaceBinding; pub use bitfun_agent_runtime::events::FinishReason; use bitfun_agent_tools::LoadedDeferredToolSpec; use bitfun_runtime_ports::{ - DelegationPolicy, PermissionDelegationContext, PermissionRuntimeCeiling, RemoteExecPort, - TerminalPort, + DelegationPolicy, PermissionConstraintLayer, PermissionDelegationContext, + PermissionRuntimeCeiling, RemoteExecPort, TerminalPort, }; use serde_json::Value; use std::collections::HashMap; @@ -74,6 +74,7 @@ pub struct RoundContext { pub primary_model_facts: PrimaryModelFacts, pub agent_type: String, pub context_vars: HashMap, + pub permission_constraints: PermissionConstraintLayer, pub permission_runtime_ceiling: Option, pub(crate) delegation_policy: DelegationPolicy, pub runtime_tool_restrictions: ToolRuntimeRestrictions, diff --git a/src/crates/assembly/core/src/agentic/permission_policy.rs b/src/crates/assembly/core/src/agentic/permission_policy.rs index b442e1993e..c05cef6c92 100644 --- a/src/crates/assembly/core/src/agentic/permission_policy.rs +++ b/src/crates/assembly/core/src/agentic/permission_policy.rs @@ -1,7 +1,8 @@ use crate::service::config::types::{AgentProfileConfig, GlobalConfig}; use bitfun_runtime_ports::{ resolve_child_permission_policy, resolve_permission_policy, ChildPermissionPolicyLayers, - PermissionEffect, PermissionPolicyLayers, PermissionRule, PermissionRuntimeCeiling, + PermissionConstraintLayer, PermissionEffect, PermissionPolicyLayers, PermissionRule, + PermissionRuntimeCeiling, ResolvedPermissionPolicy, }; pub(crate) fn derive_parent_permission_runtime_ceiling( @@ -21,18 +22,19 @@ pub(crate) fn derive_parent_permission_runtime_ceiling( .expect("parent permission ceiling extraction must exclude allow rules") } -pub(crate) fn resolve_effective_permission_rules( +pub(crate) fn resolve_effective_permission_policy( global: &GlobalConfig, project_rules: &[PermissionRule], agent_profile: Option<&AgentProfileConfig>, + agent_definition_constraints: Option<&PermissionConstraintLayer>, parent_runtime_ceiling: Option<&PermissionRuntimeCeiling>, enforced: &[PermissionRule], -) -> Vec { +) -> ResolvedPermissionPolicy { let agent_rules = agent_profile .map(|profile| profile.tool_permission_rules.as_slice()) .unwrap_or(&[]); - match parent_runtime_ceiling { + let resolved = match parent_runtime_ceiling { Some(parent_runtime_ceiling) => { resolve_child_permission_policy(ChildPermissionPolicyLayers { product_defaults: &[], @@ -50,7 +52,15 @@ pub(crate) fn resolve_effective_permission_rules( agent: agent_rules, enforced, }), - } + }; + let Some(agent_definition_constraints) = + agent_definition_constraints.filter(|constraints| !constraints.is_empty()) + else { + return resolved; + }; + let (rules, mut constraint_layers) = resolved.into_parts(); + constraint_layers.insert(0, agent_definition_constraints.clone()); + ResolvedPermissionPolicy::new(rules, constraint_layers) } #[cfg(test)] @@ -108,10 +118,10 @@ mod tests { }; let resolved = - resolve_effective_permission_rules(&global, &project, Some(&profile), None, &[]); + resolve_effective_permission_policy(&global, &project, Some(&profile), None, None, &[]); assert_eq!( - PermissionEvaluator::case_sensitive().evaluate_resource( + PermissionEvaluator::case_sensitive().evaluate_policy_resource( "edit", "generated/review.md", &resolved, @@ -119,7 +129,7 @@ mod tests { PermissionEffect::Allow ); assert_eq!( - PermissionEvaluator::case_sensitive().evaluate_resource( + PermissionEvaluator::case_sensitive().evaluate_policy_resource( "edit", "generated/api.rs", &resolved, @@ -145,21 +155,22 @@ mod tests { ]) .expect("test ceiling should be valid"); - let resolved = resolve_effective_permission_rules( + let resolved = resolve_effective_permission_policy( &global, &[], Some(&child_profile), + None, Some(&ceiling), &[], ); let evaluator = PermissionEvaluator::case_sensitive(); assert_eq!( - evaluator.evaluate_resource("bash", "rm -rf target", &resolved), + evaluator.evaluate_policy_resource("bash", "rm -rf target", &resolved), PermissionEffect::Deny ); assert_eq!( - evaluator.evaluate_resource("external_directory", "C:/outside", &resolved), + evaluator.evaluate_policy_resource("external_directory", "C:/outside", &resolved), PermissionEffect::Ask ); } diff --git a/src/crates/assembly/core/src/agentic/tools/file_permissions.rs b/src/crates/assembly/core/src/agentic/tools/file_permissions.rs index c9912b174e..f786cf65c3 100644 --- a/src/crates/assembly/core/src/agentic/tools/file_permissions.rs +++ b/src/crates/assembly/core/src/agentic/tools/file_permissions.rs @@ -191,6 +191,9 @@ mod tests { DeleteFileTool, FileEditTool, FileReadTool, FileWriteTool, }; use crate::agentic::WorkspaceBinding; + use bitfun_runtime_ports::{ + PermissionConstraintLayer, PermissionEffect, PermissionEvaluator, PermissionRule, + }; use serde_json::{json, Value}; use std::fs; @@ -244,6 +247,42 @@ mod tests { assert_eq!(intents[0].action, "read"); } + #[test] + fn workspace_absolute_constraint_matches_real_file_tool_intent() { + let temp = tempfile::tempdir().expect("temp dir"); + let workspace = temp.path().join("workspace"); + let secret = workspace.join("src/secrets/key.txt"); + fs::create_dir_all(secret.parent().expect("secret parent")).expect("secret parent"); + fs::write(&secret, "secret").expect("secret file"); + let context = ToolUseContext::for_tool_listing( + Some(WorkspaceBinding::new(None, workspace.clone())), + None, + ); + let intents = FileReadTool::new() + .permission_intents(&json!({ "file_path": "src/secrets/key.txt" }), &context) + .expect("read permission intent"); + let workspace_resource = canonicalize_local_path_best_effort(&workspace) + .expect("canonical workspace") + .to_string_lossy() + .replace('\\', "/"); + let constraints = PermissionConstraintLayer::new(vec![PermissionRule::new( + "read", + format!("{workspace_resource}/src/secrets/**"), + PermissionEffect::Deny, + )]); + + assert_eq!(intents.len(), 1); + assert_eq!(intents[0].resources.len(), 1); + assert_eq!( + PermissionEvaluator::for_current_platform().evaluate_constraint_resource( + &intents[0].action, + &intents[0].resources[0], + &constraints, + ), + PermissionEffect::Deny + ); + } + #[test] fn current_session_artifacts_do_not_add_external_directory_intent() { let temp = tempfile::tempdir().expect("temp dir"); diff --git a/src/crates/assembly/core/src/agentic/tools/pipeline/tool_pipeline.rs b/src/crates/assembly/core/src/agentic/tools/pipeline/tool_pipeline.rs index a36a5114cf..629be50186 100644 --- a/src/crates/assembly/core/src/agentic/tools/pipeline/tool_pipeline.rs +++ b/src/crates/assembly/core/src/agentic/tools/pipeline/tool_pipeline.rs @@ -35,7 +35,7 @@ use bitfun_agent_tools::{ use bitfun_runtime_ports::{ wildcard_matches, PermissionEffect, PermissionGrant, PermissionReply, PermissionRequest, PermissionRequestSource, PermissionRequestSourceKind, PermissionResourceCaseSensitivity, - PermissionRule, RoundInjectionToolPreemption, + ResolvedPermissionPolicy, RoundInjectionToolPreemption, }; use futures::future::join_all; use log::{debug, error, info, warn}; @@ -550,7 +550,7 @@ fn permission_resource_case_sensitivity( fn permission_intent_effect( intent: &PermissionIntent, - rules: &[PermissionRule], + policy: &ResolvedPermissionPolicy, grants: &[PermissionGrant], case_sensitivity: PermissionResourceCaseSensitivity, ) -> PermissionEffect { @@ -559,7 +559,8 @@ fn permission_intent_effect( for resource in &intent.resources { let configured_effect = if intent.action == "bash" { - rules + policy + .rules() .iter() .rev() .find(|rule| { @@ -580,8 +581,19 @@ fn permission_intent_effect( .map(|rule| rule.effect) .unwrap_or(PermissionEffect::Ask) } else { - evaluator.evaluate_resource(&intent.action, resource, rules) + evaluator.evaluate_resource(&intent.action, resource, policy.rules()) }; + let configured_effect = + policy + .constraint_layers() + .iter() + .fold(configured_effect, |effect, layer| { + effect.most_restrictive(evaluator.evaluate_constraint_resource( + &intent.action, + resource, + layer, + )) + }); match configured_effect { PermissionEffect::Deny => return PermissionEffect::Deny, @@ -701,7 +713,7 @@ impl ToolPipeline { } let (project_id, project_path) = permission_scope(&context, &intents)?; - let permission_rules = task.options.permission_rules.clone(); + let permission_policy = task.options.permission_policy.clone(); let case_sensitivity = permission_resource_case_sensitivity(&context); let round_id = task.context.round_id.clone(); let tool_call_id = task.tool_call.tool_id.clone(); @@ -724,7 +736,7 @@ impl ToolPipeline { let mut asks = Vec::new(); for intent in intents { - match permission_intent_effect(&intent, &permission_rules, &grants, case_sensitivity) { + match permission_intent_effect(&intent, &permission_policy, &grants, case_sensitivity) { PermissionEffect::Allow => {} PermissionEffect::Ask => asks.push(intent), PermissionEffect::Deny => { @@ -2398,10 +2410,10 @@ mod tests { }; use bitfun_runtime_ports::{ ClockPort, PermissionAuditEvent, PermissionAuditRecord, PermissionAuditStorePort, - PermissionGrant, PermissionGrantKey, PermissionGrantStorePort, PermissionPolicyPreset, - PermissionReplyStorePort, PortResult, RoundInjection, RoundInjectionExecutionPolicy, - RoundInjectionKind, RoundInjectionTarget, RoundInjectionToolPreemption, - RuntimeServiceCapability, RuntimeServicePort, + PermissionConstraintLayer, PermissionGrant, PermissionGrantKey, PermissionGrantStorePort, + PermissionPolicyPreset, PermissionReplyStorePort, PermissionRule, PortResult, + RoundInjection, RoundInjectionExecutionPolicy, RoundInjectionKind, RoundInjectionTarget, + RoundInjectionToolPreemption, RuntimeServiceCapability, RuntimeServicePort, }; use serde_json::json; use std::collections::HashMap; @@ -2463,11 +2475,14 @@ mod tests { #[test] fn bash_permission_allows_only_exact_command_grants() { let intent = PermissionIntent::new("bash", vec!["git status && rm -rf build".to_string()]); - let wildcard_allow = vec![PermissionRule::new( - "bash", - "git *", - PermissionEffect::Allow, - )]; + let wildcard_allow = ResolvedPermissionPolicy::new( + vec![PermissionRule::new( + "bash", + "git *", + PermissionEffect::Allow, + )], + Vec::new(), + ); assert_eq!( permission_intent_effect( &intent, @@ -2478,11 +2493,14 @@ mod tests { PermissionEffect::Ask ); - let exact_allow = vec![PermissionRule::new( - "bash", - "git status && rm -rf build", - PermissionEffect::Allow, - )]; + let exact_allow = ResolvedPermissionPolicy::new( + vec![PermissionRule::new( + "bash", + "git status && rm -rf build", + PermissionEffect::Allow, + )], + Vec::new(), + ); assert_eq!( permission_intent_effect( &intent, @@ -2493,7 +2511,10 @@ mod tests { PermissionEffect::Allow ); - let wildcard_deny = vec![PermissionRule::new("bash", "*", PermissionEffect::Deny)]; + let wildcard_deny = ResolvedPermissionPolicy::new( + vec![PermissionRule::new("bash", "*", PermissionEffect::Deny)], + Vec::new(), + ); assert_eq!( permission_intent_effect( &intent, @@ -2508,7 +2529,10 @@ mod tests { #[test] fn full_access_baseline_allows_bash_commands() { let intent = PermissionIntent::new("bash", vec!["git status && rm -rf build".to_string()]); - let full_access_rules = PermissionPolicyPreset::FullAccess.baseline_rules(); + let full_access_rules = ResolvedPermissionPolicy::new( + PermissionPolicyPreset::FullAccess.baseline_rules(), + Vec::new(), + ); assert_eq!( permission_intent_effect( @@ -2543,11 +2567,14 @@ mod tests { ) ); - let allow = vec![PermissionRule::new( - "page_publish", - "*", - PermissionEffect::Allow, - )]; + let allow = ResolvedPermissionPolicy::new( + vec![PermissionRule::new( + "page_publish", + "*", + PermissionEffect::Allow, + )], + Vec::new(), + ); assert_eq!( permission_intent_effect( &intent, @@ -2557,11 +2584,14 @@ mod tests { ), PermissionEffect::Ask ); - let deny = vec![PermissionRule::new( - "page_publish", - "*", - PermissionEffect::Deny, - )]; + let deny = ResolvedPermissionPolicy::new( + vec![PermissionRule::new( + "page_publish", + "*", + PermissionEffect::Deny, + )], + Vec::new(), + ); assert_eq!( permission_intent_effect( &intent, @@ -3053,11 +3083,14 @@ mod tests { readonly: false, })); let mut options = ToolExecutionOptions::default(); - options.permission_rules = vec![PermissionRule::new( - "custom_tool", - "UnclassifiedMutation", - PermissionEffect::Deny, - )]; + options.permission_policy = ResolvedPermissionPolicy::new( + vec![PermissionRule::new( + "custom_tool", + "UnclassifiedMutation", + PermissionEffect::Deny, + )], + Vec::new(), + ); let results = pipeline .execute_tools( @@ -3135,11 +3168,14 @@ mod tests { .await; let mut allow_options = ToolExecutionOptions::default(); - allow_options.permission_rules = vec![PermissionRule::new( - "edit", - "src/*", - PermissionEffect::Allow, - )]; + allow_options.permission_policy = ResolvedPermissionPolicy::new( + vec![PermissionRule::new( + "edit", + "src/*", + PermissionEffect::Allow, + )], + Vec::new(), + ); let results = pipeline .execute_tools( vec![test_tool_call("allow", "Write")], @@ -3153,10 +3189,13 @@ mod tests { let mut deny_options = ToolExecutionOptions::default(); deny_options.auto_approve_ask = true; - deny_options.permission_rules = vec![ - PermissionRule::new("edit", "src/*", PermissionEffect::Allow), - PermissionRule::new("edit", "src/private/*", PermissionEffect::Deny), - ]; + deny_options.permission_policy = ResolvedPermissionPolicy::new( + vec![ + PermissionRule::new("edit", "src/*", PermissionEffect::Allow), + PermissionRule::new("edit", "src/private/*", PermissionEffect::Deny), + ], + Vec::new(), + ); let results = pipeline .execute_tools( vec![test_tool_call("deny", "Write")], @@ -3177,6 +3216,74 @@ mod tests { assert_eq!(results[0].result.result["category"], "permission_denied"); } + #[tokio::test] + async fn independent_permission_constraints_tighten_but_never_widen_host_policy() { + let pipeline = test_tool_pipeline(); + let calls = Arc::new(AtomicUsize::new(0)); + register_v2_file_test_tool( + &pipeline, + vec![PermissionIntent::new( + "edit", + vec!["src/generated/output.rs".to_string()], + )], + Arc::clone(&calls), + ) + .await; + + let mut host_deny = ToolExecutionOptions::default(); + host_deny.auto_approve_ask = true; + host_deny.permission_policy = ResolvedPermissionPolicy::new( + vec![PermissionRule::new( + "edit", + "src/generated/*", + PermissionEffect::Deny, + )], + vec![PermissionConstraintLayer::new(vec![PermissionRule::new( + "edit", + "*", + PermissionEffect::Allow, + )])], + ); + pipeline + .execute_tools( + vec![test_tool_call("host-deny", "Write")], + permission_test_context(), + host_deny, + ) + .await + .expect("constraint allow must not widen host denial"); + + let mut external_deny = ToolExecutionOptions::default(); + external_deny.auto_approve_ask = true; + external_deny.permission_policy = ResolvedPermissionPolicy::new( + vec![PermissionRule::new("edit", "*", PermissionEffect::Allow)], + vec![PermissionConstraintLayer::new(vec![PermissionRule::new( + "edit", + "src/generated/*", + PermissionEffect::Deny, + )])], + ); + pipeline + .execute_tools( + vec![test_tool_call("external-deny", "Write")], + permission_test_context(), + external_deny, + ) + .await + .expect("constraint denial should tighten host allow"); + + assert_eq!(calls.load(Ordering::SeqCst), 0); + for tool_id in ["host-deny", "external-deny"] { + assert!(matches!( + pipeline + .state_manager + .get_task(tool_id) + .map(|task| task.state), + Some(ToolExecutionState::Rejected { .. }) + )); + } + } + /// A PreToolUse hook approval waives the interactive permission prompt. /// It must never widen the policy: a rule that denies the call still /// rejects it, and the tool never runs. @@ -3202,11 +3309,14 @@ mod tests { .insert("hook-approved".to_string()); let mut deny_options = ToolExecutionOptions::default(); - deny_options.permission_rules = vec![PermissionRule::new( - "edit", - "src/private/*", - PermissionEffect::Deny, - )]; + deny_options.permission_policy = ResolvedPermissionPolicy::new( + vec![PermissionRule::new( + "edit", + "src/private/*", + PermissionEffect::Deny, + )], + Vec::new(), + ); let results = pipeline .execute_tools( vec![test_tool_call("hook-approved", "Write")], @@ -3723,10 +3833,13 @@ mod tests { assert_eq!(calls.load(Ordering::SeqCst), 3); let mut deny_options = ToolExecutionOptions::default(); - deny_options.permission_rules = vec![ - PermissionRule::new("edit", "src/*", PermissionEffect::Allow), - PermissionRule::new("edit", "src/private/*", PermissionEffect::Deny), - ]; + deny_options.permission_policy = ResolvedPermissionPolicy::new( + vec![ + PermissionRule::new("edit", "src/*", PermissionEffect::Allow), + PermissionRule::new("edit", "src/private/*", PermissionEffect::Deny), + ], + Vec::new(), + ); pipeline .execute_tools( vec![test_tool_call("deny-after-grant", "Write")], diff --git a/src/crates/assembly/core/src/agentic/tools/pipeline/types.rs b/src/crates/assembly/core/src/agentic/tools/pipeline/types.rs index 124f848c15..707a730213 100644 --- a/src/crates/assembly/core/src/agentic/tools/pipeline/types.rs +++ b/src/crates/assembly/core/src/agentic/tools/pipeline/types.rs @@ -8,7 +8,8 @@ use crate::agentic::workspace::WorkspaceServices; use crate::agentic::WorkspaceBinding; use bitfun_agent_tools::ResolvedToolInvocation; use bitfun_runtime_ports::{ - DelegationPolicy, PermissionDelegationContext, PermissionRule, RemoteExecPort, TerminalPort, + DelegationPolicy, PermissionDelegationContext, RemoteExecPort, ResolvedPermissionPolicy, + TerminalPort, }; use std::collections::HashMap; use std::sync::Arc; @@ -24,8 +25,8 @@ pub struct ToolExecutionOptions { pub max_retries: usize, /// Tool execution timeout (seconds), None means infinite waiting pub timeout_secs: Option, - /// Ordered permission rules. An unmatched resource defaults to `ask`. - pub permission_rules: Vec, + /// Resolved host policy plus independent restriction layers. + pub permission_policy: ResolvedPermissionPolicy, /// Automatically reply `once` to `ask` requests through the permission manager. pub auto_approve_ask: bool, } @@ -37,7 +38,7 @@ impl Default for ToolExecutionOptions { subagent_batch_execution_policy: SubagentBatchExecutionPolicy::default(), max_retries: 0, timeout_secs: None, // Default no timeout (infinite waiting) - permission_rules: Vec::new(), + permission_policy: ResolvedPermissionPolicy::default(), auto_approve_ask: false, } } diff --git a/src/crates/assembly/core/src/external_subagents.rs b/src/crates/assembly/core/src/external_subagents.rs index 2357a2022c..fc300ee1e6 100644 --- a/src/crates/assembly/core/src/external_subagents.rs +++ b/src/crates/assembly/core/src/external_subagents.rs @@ -706,6 +706,8 @@ fn resolve_external_candidate( diagnostics.sort_by(|left, right| left.code.cmp(&right.code)); diagnostics.dedup_by(|left, right| left.code == right.code); let readonly = tools.iter().all(|tool| tool.readonly); + let permission_constraints = serde_json::to_string(&definition.permission_constraints) + .expect("validated permission constraints serialize"); let provenance = definition .provenance .iter() @@ -724,6 +726,7 @@ fn resolve_external_candidate( "visible" }, if readonly { "readonly" } else { "writable" }, + permission_constraints.as_str(), ] .into_iter() .chain(provenance.iter().map(String::as_str)) @@ -967,6 +970,7 @@ fn install_active_candidate( runtime_description, candidate.definition.prompt.expose().to_string(), tools, + candidate.definition.permission_constraints.clone(), candidate.readonly, candidate.definition.behavior_version.as_str().to_string(), )); @@ -1122,6 +1126,9 @@ mod tests { ExternalSubagentMode, ExternalSubagentProvenanceRef, ExternalSubagentToolRequest, ExternalSubagentToolSelector, SecretText, }; + use bitfun_product_domains::tool_permissions::{ + PermissionConstraintLayer, PermissionEffect, PermissionRule, + }; fn definition(behavior: &str, catalog: &str) -> (ExternalSubagentDefinition, SourceKey) { let source = SourceKey::new("fake-provider", "agents/reviewer.md").unwrap(); @@ -1155,6 +1162,7 @@ mod tests { }], uses_conservative_default: false, }, + permission_constraints: Default::default(), compatibility: ExternalSubagentCompatibilityState::Ready, diagnostic_codes: Vec::new(), behavior_version: ExternalSubagentBehaviorVersion::new(behavior).unwrap(), @@ -1559,6 +1567,75 @@ mod tests { assert!(updated.registrations.is_empty()); } + #[test] + fn permission_constraint_change_requires_new_approval_even_with_stale_provider_version() { + let empty_set = BTreeSet::new(); + let empty_map = BTreeMap::new(); + let first = reconcile_with_facts( + Some(Path::new("C:/repo")), + "local-user", + &snapshot("behavior-v1", "catalog-v1"), + ExternalSubagentDecisions { + active_ecosystems: test_active_ecosystems(), + approved_envelopes: &empty_set, + declined_decisions: &empty_map, + conflict_choices: &empty_map, + conflict_lineage_current_keys: &empty_map, + }, + &facts(), + ); + let approved = BTreeSet::from([first.summaries[0].decision_key.clone()]); + let mut tightened_snapshot = snapshot("behavior-v1", "catalog-v1"); + tightened_snapshot.definitions[0].permission_constraints = PermissionConstraintLayer::new( + vec![PermissionRule::new("edit", "*", PermissionEffect::Deny)], + ); + + let updated = reconcile_with_facts( + Some(Path::new("C:/repo")), + "local-user", + &tightened_snapshot, + ExternalSubagentDecisions { + active_ecosystems: test_active_ecosystems(), + approved_envelopes: &approved, + declined_decisions: &empty_map, + conflict_choices: &empty_map, + conflict_lineage_current_keys: &empty_map, + }, + &facts(), + ); + + assert_eq!( + updated.summaries[0].activation_state, + ExternalSubagentActivationState::ApprovalRequired + ); + assert!(updated.registrations.is_empty()); + + let reapproved = BTreeSet::from([updated.summaries[0].decision_key.clone()]); + let activated = reconcile_with_facts( + Some(Path::new("C:/repo")), + "local-user", + &tightened_snapshot, + ExternalSubagentDecisions { + active_ecosystems: test_active_ecosystems(), + approved_envelopes: &reapproved, + declined_decisions: &empty_map, + conflict_choices: &empty_map, + conflict_lineage_current_keys: &empty_map, + }, + &facts(), + ); + assert_eq!(activated.registrations.len(), 1); + assert_eq!( + activated.registrations[0] + .agent + .permission_constraints() + .rules(), + tightened_snapshot.definitions[0] + .permission_constraints + .rules() + ); + } + #[test] fn default_model_change_requires_a_new_approval_for_future_invocations() { let empty_set = BTreeSet::new(); diff --git a/src/crates/assembly/external-sources/tests/subagent_coordinator.rs b/src/crates/assembly/external-sources/tests/subagent_coordinator.rs index eed946e985..49f8f7cb41 100644 --- a/src/crates/assembly/external-sources/tests/subagent_coordinator.rs +++ b/src/crates/assembly/external-sources/tests/subagent_coordinator.rs @@ -105,6 +105,7 @@ fn snapshot( selectors: Vec::new(), uses_conservative_default: true, }, + permission_constraints: Default::default(), compatibility: ExternalSubagentCompatibilityState::ReadyWithDegradation, diagnostic_codes: vec!["fake.default_tools".to_string()], behavior_version: ExternalSubagentBehaviorVersion::new(behavior).unwrap(), diff --git a/src/crates/contracts/product-domains/src/external_subagents.rs b/src/crates/contracts/product-domains/src/external_subagents.rs index 6ce8a8562e..d4cd80ef2a 100644 --- a/src/crates/contracts/product-domains/src/external_subagents.rs +++ b/src/crates/contracts/product-domains/src/external_subagents.rs @@ -9,6 +9,7 @@ use crate::external_sources::{ ExternalSourceDiagnostic, ExternalSourceProviderError, ExternalSourceRecord, ExternalSourceScope, ExternalWatchRoot, ProviderId, SourceKey, }; +use crate::tool_permissions::PermissionConstraintLayer; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use std::collections::BTreeSet; @@ -18,6 +19,7 @@ const MAX_ID_LENGTH: usize = 160; const MAX_LABEL_LENGTH: usize = 4096; const MAX_PROMPT_BYTES: usize = 256 * 1024; const MAX_TOOL_SELECTORS: usize = 256; +const MAX_PERMISSION_CONSTRAINTS: usize = 256; const MAX_DIAGNOSTIC_CODES: usize = 256; const MAX_PROVENANCE_REFS: usize = 256; const MAX_PROVIDER_SOURCES: usize = 1024; @@ -257,6 +259,10 @@ pub struct ExternalSubagentDefinition { pub hidden: bool, pub requested_model: ExternalSubagentModelRequest, pub requested_tools: ExternalSubagentToolRequest, + /// Provider-neutral restrictions whose ask/deny rules must be enforceable + /// by the selected tools' permission intents. Providers must block a + /// definition instead of publishing a partially enforceable constraint. + pub permission_constraints: PermissionConstraintLayer, pub compatibility: ExternalSubagentCompatibilityState, pub diagnostic_codes: Vec, pub behavior_version: ExternalSubagentBehaviorVersion, @@ -277,6 +283,10 @@ impl fmt::Debug for ExternalSubagentDefinition { .field("hidden", &self.hidden) .field("requested_model", &self.requested_model) .field("requested_tools", &self.requested_tools) + .field( + "permission_constraint_count", + &self.permission_constraints.rules().len(), + ) .field("compatibility", &self.compatibility) .field("diagnostic_codes", &self.diagnostic_codes) .field("behavior_version", &self.behavior_version) @@ -372,6 +382,24 @@ impl ExternalSubagentDefinition { )); } } + if self.permission_constraints.rules().len() > MAX_PERMISSION_CONSTRAINTS { + return Err(ExternalSourceContractError::InvalidText( + "external subagent permission constraints", + )); + } + for rule in self.permission_constraints.rules() { + let valid_value = |value: &str| { + !value.is_empty() + && value.len() <= MAX_LABEL_LENGTH + && value.trim() == value + && !value.chars().any(char::is_control) + }; + if !valid_value(&rule.action) || !valid_value(&rule.resource) { + return Err(ExternalSourceContractError::InvalidText( + "external subagent permission constraint", + )); + } + } if self.diagnostic_codes.len() > MAX_DIAGNOSTIC_CODES { return Err(ExternalSourceContractError::InvalidText( "external subagent diagnostic codes", diff --git a/src/crates/contracts/product-domains/src/tool_permissions.rs b/src/crates/contracts/product-domains/src/tool_permissions.rs index 9a60dd91da..fa9e4de182 100644 --- a/src/crates/contracts/product-domains/src/tool_permissions.rs +++ b/src/crates/contracts/product-domains/src/tool_permissions.rs @@ -17,6 +17,18 @@ pub enum PermissionEffect { Deny, } +impl PermissionEffect { + /// Combines independently owned policy decisions without allowing either + /// side to widen the other. + pub const fn most_restrictive(self, other: Self) -> Self { + match (self, other) { + (Self::Deny, _) | (_, Self::Deny) => Self::Deny, + (Self::Ask, _) | (_, Self::Ask) => Self::Ask, + (Self::Allow, Self::Allow) => Self::Allow, + } + } +} + /// An ordered action/resource permission rule. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct PermissionRule { @@ -42,6 +54,68 @@ impl PermissionRule { /// A rule list whose order is significant: later matching rules win. pub type PermissionRuleset = Vec; +/// One independently evaluated restriction layer. +/// +/// Rules keep their source-local last-match-wins behavior, while an unmatched +/// request defaults to `allow`. The layer is combined with the host policy and +/// other layers by taking the most restrictive result, so an `allow` here can +/// express an exception to an earlier rule in this layer without widening the +/// host policy. +#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(transparent)] +pub struct PermissionConstraintLayer { + rules: PermissionRuleset, +} + +impl PermissionConstraintLayer { + pub fn new(rules: PermissionRuleset) -> Self { + Self { rules } + } + + pub fn rules(&self) -> &[PermissionRule] { + &self.rules + } + + pub fn into_rules(self) -> PermissionRuleset { + self.rules + } + + pub fn is_empty(&self) -> bool { + self.rules.is_empty() + } +} + +/// A resolved host policy plus independently owned restriction layers. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct ResolvedPermissionPolicy { + rules: PermissionRuleset, + constraint_layers: Vec, +} + +impl ResolvedPermissionPolicy { + pub fn new( + rules: PermissionRuleset, + constraint_layers: Vec, + ) -> Self { + Self { + rules, + constraint_layers, + } + } + + pub fn rules(&self) -> &[PermissionRule] { + &self.rules + } + + pub fn constraint_layers(&self) -> &[PermissionConstraintLayer] { + &self.constraint_layers + } + + pub fn into_parts(self) -> (PermissionRuleset, Vec) { + (self.rules, self.constraint_layers) + } +} + /// A validated runtime restriction inherited by a delegated child agent. /// /// A ceiling is intentionally unable to carry `allow` rules: delegation may @@ -200,33 +274,40 @@ pub struct ChildPermissionPolicyLayers<'a> { /// Expands the configured preset and merges every static rule layer in its /// security-significant evaluation order. -pub fn resolve_permission_policy(layers: PermissionPolicyLayers<'_>) -> PermissionRuleset { +pub fn resolve_permission_policy(layers: PermissionPolicyLayers<'_>) -> ResolvedPermissionPolicy { let baseline = layers.global.preset.baseline_rules(); - merge_permission_rule_layers(&[ - layers.product_defaults, - &baseline, - &layers.global.rules, - layers.project, - layers.agent, - layers.enforced, - ]) -} - -/// Resolves a delegated child policy without allowing parent policy to widen -/// the child's own capabilities. + ResolvedPermissionPolicy::new( + merge_permission_rule_layers(&[ + layers.product_defaults, + &baseline, + &layers.global.rules, + layers.project, + layers.agent, + layers.enforced, + ]), + Vec::new(), + ) +} + +/// Resolves a delegated child policy and evaluates the parent ceiling as an +/// independent constraint that cannot widen the child's own policy. pub fn resolve_child_permission_policy( layers: ChildPermissionPolicyLayers<'_>, -) -> PermissionRuleset { +) -> ResolvedPermissionPolicy { let baseline = layers.global.preset.baseline_rules(); - merge_permission_rule_layers(&[ - layers.product_defaults, - &baseline, - &layers.global.rules, - layers.project, - layers.child_agent, - layers.parent_runtime_ceiling.rules(), - layers.enforced, - ]) + ResolvedPermissionPolicy::new( + merge_permission_rule_layers(&[ + layers.product_defaults, + &baseline, + &layers.global.rules, + layers.project, + layers.child_agent, + layers.enforced, + ]), + vec![PermissionConstraintLayer::new( + layers.parent_runtime_ceiling.rules().to_vec(), + )], + ) } /// Identifies the boundary that originated a permission request. @@ -463,6 +544,45 @@ impl PermissionEvaluator { .unwrap_or(PermissionEffect::Ask) } + /// Evaluates the host policy and every independent restriction layer. + pub fn evaluate_policy_resource( + &self, + action: &str, + resource: &str, + policy: &ResolvedPermissionPolicy, + ) -> PermissionEffect { + policy.constraint_layers().iter().fold( + self.evaluate_resource(action, resource, policy.rules()), + |effect, layer| { + let constraint = self.evaluate_constraint_resource(action, resource, layer); + effect.most_restrictive(constraint) + }, + ) + } + + /// Evaluates one restriction layer. An unmatched request is unrestricted + /// by that layer and therefore defaults to `allow`. + pub fn evaluate_constraint_resource( + &self, + action: &str, + resource: &str, + layer: &PermissionConstraintLayer, + ) -> PermissionEffect { + layer + .rules() + .iter() + .rev() + .find(|rule| { + wildcard_matches( + action, + &rule.action, + PermissionResourceCaseSensitivity::Sensitive, + ) && wildcard_matches(resource, &rule.resource, self.resource_case_sensitivity) + }) + .map(|rule| rule.effect) + .unwrap_or(PermissionEffect::Allow) + } + /// Evaluates every resource in one tool call atomically. /// /// Any denied resource denies the call. Otherwise any resource that still diff --git a/src/crates/contracts/product-domains/tests/external_source_contracts.rs b/src/crates/contracts/product-domains/tests/external_source_contracts.rs index ad101c8d1f..9c86f2d7c1 100644 --- a/src/crates/contracts/product-domains/tests/external_source_contracts.rs +++ b/src/crates/contracts/product-domains/tests/external_source_contracts.rs @@ -42,6 +42,9 @@ use bitfun_product_domains::external_subagents::{ ExternalSubagentProviderSnapshot, ExternalSubagentToolRequest, ExternalSubagentToolSelector, SecretText, }; +use bitfun_product_domains::tool_permissions::{ + PermissionConstraintLayer, PermissionEffect, PermissionRule, +}; use sha2::{Digest, Sha256}; use std::path::PathBuf; @@ -392,12 +395,18 @@ fn external_subagent_identity_preserves_ordered_provenance_and_separate_revision }], uses_conservative_default: false, }, + permission_constraints: PermissionConstraintLayer::new(vec![PermissionRule::new( + "read", + "C:/sensitive/private/*", + PermissionEffect::Deny, + )]), compatibility: ExternalSubagentCompatibilityState::Ready, diagnostic_codes: Vec::new(), behavior_version: ExternalSubagentBehaviorVersion::new("behavior-v1").unwrap(), }; assert_eq!(definition.prompt.expose(), "Review carefully"); assert!(!format!("{definition:?}").contains("Review carefully")); + assert!(!format!("{definition:?}").contains("C:/sensitive/private")); let mut invalid_model = definition.clone(); invalid_model.requested_model = ExternalSubagentModelRequest::Exact { @@ -410,6 +419,15 @@ fn external_subagent_identity_preserves_ordered_provenance_and_separate_revision invalid_tool.requested_tools.selectors[0].source_name = "read\nsecret".to_string(); assert!(invalid_tool.validate().is_err()); + let mut invalid_permission = definition.clone(); + invalid_permission.permission_constraints = + PermissionConstraintLayer::new(vec![PermissionRule::new( + "read\nsecret", + "*", + PermissionEffect::Deny, + )]); + assert!(invalid_permission.validate().is_err()); + let mut invalid_diagnostic = definition.clone(); invalid_diagnostic.diagnostic_codes = vec!["provider.invalid:raw-source-key".to_string()]; assert!(invalid_diagnostic.validate().is_err()); diff --git a/src/crates/contracts/product-domains/tests/tool_permission_contracts.rs b/src/crates/contracts/product-domains/tests/tool_permission_contracts.rs index ca5c356ccb..1d00bdc4c1 100644 --- a/src/crates/contracts/product-domains/tests/tool_permission_contracts.rs +++ b/src/crates/contracts/product-domains/tests/tool_permission_contracts.rs @@ -1,10 +1,11 @@ use bitfun_product_domains::tool_permissions::{ merge_permission_rule_layers, resolve_child_permission_policy, resolve_permission_policy, - wildcard_matches, ChildPermissionPolicyLayers, PermissionDelegationContext, PermissionEffect, - PermissionEvaluator, PermissionPolicyConfig, PermissionPolicyLayers, PermissionPolicyPreset, - PermissionReply, PermissionReplySource, PermissionRequest, PermissionRequestEvent, - PermissionRequestSource, PermissionRequestSourceKind, PermissionResourceCaseSensitivity, - PermissionRule, PermissionRuntimeCeiling, ToolPermissionConfig, + wildcard_matches, ChildPermissionPolicyLayers, PermissionConstraintLayer, + PermissionDelegationContext, PermissionEffect, PermissionEvaluator, PermissionPolicyConfig, + PermissionPolicyLayers, PermissionPolicyPreset, PermissionReply, PermissionReplySource, + PermissionRequest, PermissionRequestEvent, PermissionRequestSource, + PermissionRequestSourceKind, PermissionResourceCaseSensitivity, PermissionRule, + PermissionRuntimeCeiling, ResolvedPermissionPolicy, ToolPermissionConfig, }; use serde_json::json; use serde_json::Map; @@ -13,6 +14,38 @@ fn rule(action: &str, resource: &str, effect: PermissionEffect) -> PermissionRul PermissionRule::new(action, resource, effect) } +#[test] +fn constraint_layers_can_only_tighten_the_resolved_host_policy() { + let evaluator = PermissionEvaluator::case_sensitive(); + let policy = ResolvedPermissionPolicy::new( + vec![ + rule("read", "secrets/*", PermissionEffect::Deny), + rule("read", "*", PermissionEffect::Allow), + ], + vec![PermissionConstraintLayer::new(vec![ + rule("read", "*", PermissionEffect::Deny), + rule("read", "public/*", PermissionEffect::Allow), + rule("edit", "*", PermissionEffect::Ask), + ])], + ); + + assert_eq!( + evaluator.evaluate_policy_resource("read", "public/README.md", &policy), + PermissionEffect::Allow, + "an allow may relax an earlier rule inside one constraint layer" + ); + assert_eq!( + evaluator.evaluate_policy_resource("read", "secrets/token.txt", &policy), + PermissionEffect::Deny, + "a constraint allow must never override a host deny" + ); + assert_eq!( + evaluator.evaluate_policy_resource("edit", "src/main.rs", &policy), + PermissionEffect::Ask, + "a constraint ask must tighten a host allow" + ); +} + fn policy(preset: PermissionPolicyPreset, rules: Vec) -> PermissionPolicyConfig { PermissionPolicyConfig { preset, rules } } @@ -60,7 +93,7 @@ fn policy_presets_expand_into_ordinary_baseline_rules() { }); assert_eq!( - ask_rules, + ask_rules.rules(), vec![ rule("*", "*", PermissionEffect::Ask), rule("read", "*", PermissionEffect::Allow), @@ -83,15 +116,15 @@ fn policy_presets_expand_into_ordinary_baseline_rules() { ] ); assert_eq!( - full_access_rules, + full_access_rules.rules(), vec![rule("*", "*", PermissionEffect::Allow)] ); assert_eq!( - evaluator.evaluate_resource("edit", "src/main.rs", &ask_rules), + evaluator.evaluate_policy_resource("edit", "src/main.rs", &ask_rules), PermissionEffect::Ask ); assert_eq!( - evaluator.evaluate_resource("edit", "src/main.rs", &full_access_rules), + evaluator.evaluate_policy_resource("edit", "src/main.rs", &full_access_rules), PermissionEffect::Allow ); } @@ -126,7 +159,7 @@ fn ask_preset_allows_low_risk_actions_and_keeps_mutations_guarded() { ("git", "git branch"), ] { assert_eq!( - evaluator.evaluate_resource(action, resource, &rules), + evaluator.evaluate_policy_resource(action, resource, &rules), PermissionEffect::Allow, "{action} {resource}" ); @@ -146,7 +179,7 @@ fn ask_preset_allows_low_risk_actions_and_keeps_mutations_guarded() { ("future_action", "resource"), ] { assert_eq!( - evaluator.evaluate_resource(action, resource, &rules), + evaluator.evaluate_policy_resource(action, resource, &rules), PermissionEffect::Ask, "{action} {resource}" ); @@ -173,7 +206,7 @@ fn resolved_policy_preserves_layer_order_and_enforced_limits() { }); assert_eq!( - resolved, + resolved.rules(), [ product_defaults, PermissionPolicyPreset::FullAccess.baseline_rules(), @@ -187,15 +220,15 @@ fn resolved_policy_preserves_layer_order_and_enforced_limits() { let evaluator = PermissionEvaluator::case_sensitive(); assert_eq!( - evaluator.evaluate_resource("bash", "rm -rf target", &resolved), + evaluator.evaluate_policy_resource("bash", "rm -rf target", &resolved), PermissionEffect::Ask ); assert_eq!( - evaluator.evaluate_resource("edit", "generated/review.md", &resolved), + evaluator.evaluate_policy_resource("edit", "generated/review.md", &resolved), PermissionEffect::Deny ); assert_eq!( - evaluator.evaluate_resource("webfetch", "https://example.com", &resolved), + evaluator.evaluate_policy_resource("webfetch", "https://example.com", &resolved), PermissionEffect::Allow ); } @@ -252,14 +285,13 @@ fn child_policy_preserves_exact_layer_order_and_security_precedence() { }); assert_eq!( - resolved, + resolved.rules(), [ product_defaults, PermissionPolicyPreset::Ask.baseline_rules(), global.rules, project, child_agent, - ceiling_rules, enforced, ] .concat() @@ -267,7 +299,7 @@ fn child_policy_preserves_exact_layer_order_and_security_precedence() { let evaluator = PermissionEvaluator::case_sensitive(); assert_eq!( - evaluator.evaluate_resource("edit", "generated/review.md", &resolved), + evaluator.evaluate_policy_resource("edit", "generated/review.md", &resolved), PermissionEffect::Deny, "enforced rules must remain later than the parent ceiling" ); @@ -291,7 +323,34 @@ fn parent_ceiling_overrides_child_agent_allow() { }); assert_eq!( - PermissionEvaluator::case_sensitive().evaluate_resource( + PermissionEvaluator::case_sensitive().evaluate_policy_resource( + "read", + "secrets/token.txt", + &resolved, + ), + PermissionEffect::Deny + ); +} + +#[test] +fn parent_ceiling_ask_does_not_loosen_child_agent_deny() { + let global = policy(PermissionPolicyPreset::FullAccess, Vec::new()); + let child_agent = vec![rule("read", "secrets/*", PermissionEffect::Deny)]; + let ceiling = + PermissionRuntimeCeiling::try_new(vec![rule("read", "secrets/*", PermissionEffect::Ask)]) + .expect("ask ceiling should be valid"); + + let resolved = resolve_child_permission_policy(ChildPermissionPolicyLayers { + product_defaults: &[], + global: &global, + project: &[], + child_agent: &child_agent, + parent_runtime_ceiling: &ceiling, + enforced: &[], + }); + + assert_eq!( + PermissionEvaluator::case_sensitive().evaluate_policy_resource( "read", "secrets/token.txt", &resolved, @@ -315,15 +374,15 @@ fn task_and_skill_default_allow_do_not_authorize_child_tools() { let evaluator = PermissionEvaluator::case_sensitive(); assert_eq!( - evaluator.evaluate_resource("task", "Explore", &resolved), + evaluator.evaluate_policy_resource("task", "Explore", &resolved), PermissionEffect::Allow ); assert_eq!( - evaluator.evaluate_resource("skill", "pdf", &resolved), + evaluator.evaluate_policy_resource("skill", "pdf", &resolved), PermissionEffect::Allow ); assert_eq!( - evaluator.evaluate_resource("edit", "src/main.rs", &resolved), + evaluator.evaluate_policy_resource("edit", "src/main.rs", &resolved), PermissionEffect::Ask ); } diff --git a/src/crates/contracts/runtime-ports/src/lib.rs b/src/crates/contracts/runtime-ports/src/lib.rs index 4d0e9a6237..0e259686e1 100644 --- a/src/crates/contracts/runtime-ports/src/lib.rs +++ b/src/crates/contracts/runtime-ports/src/lib.rs @@ -25,13 +25,13 @@ mod script_tool; pub use bitfun_product_domains::tool_permissions::{ resolve_child_permission_policy, resolve_permission_policy, wildcard_matches, ChildPermissionPolicyLayers, PermissionAuditEvent, PermissionAuditRecord, - PermissionDelegationContext, PermissionEffect, PermissionEvaluator, PermissionGrant, - PermissionGrantKey, PermissionInteractionConfig, PermissionPolicyConfig, + PermissionConstraintLayer, PermissionDelegationContext, PermissionEffect, PermissionEvaluator, + PermissionGrant, PermissionGrantKey, PermissionInteractionConfig, PermissionPolicyConfig, PermissionPolicyLayers, PermissionPolicyPreset, PermissionReply, PermissionReplySource, PermissionRequest, PermissionRequestEvent, PermissionRequestSource, PermissionRequestSourceKind, PermissionResourceCaseSensitivity, PermissionRule, PermissionRuleset, PermissionRuntimeCeiling, PermissionRuntimeCeilingValidationError, - ToolPermissionConfig, + ResolvedPermissionPolicy, ToolPermissionConfig, }; pub use local_workspace_snapshot::{ LocalWorkspaceSnapshotPort, LocalWorkspaceSnapshotSessionRequest, LocalWorkspaceSnapshotStats, From 11a0b4e505f03b622d6b2d5108f4814ff1b2a3d7 Mon Sep 17 00:00:00 2001 From: limityan Date: Fri, 31 Jul 2026 14:46:58 +0800 Subject: [PATCH 2/2] fix(opencode): align agent source merge semantics Restore upstream direct and directory source ordering. Keep V1 disable as a deep-merged field while preserving V2 tombstone and mixed-schema re-add behavior. --- .../opencode-config-assets-adapter-design.md | 37 +-- .../opencode-extension-compatibility.md | 2 +- .../opencode-adapter/src/agent_source.rs | 65 ++--- .../tests/opencode_subagent_adapter.rs | 230 +++++++++++++++++- 4 files changed, 268 insertions(+), 66 deletions(-) diff --git a/docs/architecture/extensions/opencode-config-assets-adapter-design.md b/docs/architecture/extensions/opencode-config-assets-adapter-design.md index ab881249b9..bf93e8fccb 100644 --- a/docs/architecture/extensions/opencode-config-assets-adapter-design.md +++ b/docs/architecture/extensions/opencode-config-assets-adapter-design.md @@ -102,12 +102,12 @@ OpenCode 当前版本的真实合并/去重语义,不用 BitFun 常规配置 所有来源合并后再应用 `OPENCODE_PERMISSION`、旧 `tools` 到 permission 的迁移,以及关闭自动压缩/裁剪的环境覆盖。这些属于固定版本的后处理,不是新的配置来源。 当前 Prompt Command、Subagent 与 MCP 子集只实现上述本地来源的一部分,并复用 creation-safe 监听根与路径候选 -基础设施;来源顺序仍由各资产 provider 自己定义,不能视为一套通用顺序。Command 与 MCP 保留各自既有的本地 -加载顺序。Subagent 依次应用用户配置与用户 Markdown、project direct config(root-to-nearest)、project -`.opencode` config/Markdown(root-to-nearest);`OPENCODE_CONFIG_DIR` 与其他来源指向同一物理目录时保留首次 -出现的位置,只更新该位置的加载语义和来源标签,不移动到末尾。`OPENCODE_DISABLE_PROJECT_CONFIG` 会关闭 -项目配置、项目目录资产和对应监听根;显式目录不替换 XDG 用户根,并在 BitFun 来源标签中保持 -`WorkspaceLocal`。 +基础设施;来源顺序仍由各资产 provider 按当前生产配置 owner 的阶段定义,不能抽象成所有未来资产都必须复用的 +通用顺序。Subagent 先应用用户 JSON/JSONC、`OPENCODE_CONFIG` 与 project direct config(root-to-nearest),再进入 +目录阶段:用户 Agent Markdown、project `.opencode` config/Markdown(opened-to-root)、兼容 `~/.opencode`,最后是 +未与前述目录重合的 `OPENCODE_CONFIG_DIR`。物理 alias 保留首次出现的位置,只更新该位置的加载语义和来源标签, +不移动到末尾。`OPENCODE_DISABLE_PROJECT_CONFIG` 会关闭项目配置、项目目录资产和对应监听根;显式目录不替换 +XDG 用户根,并在 BitFun 来源标签中保持 `WorkspaceLocal`。 `OPENCODE_CONFIG_CONTENT`、远程、组织、系统管理员与 MDM 来源尚未接入,不能因三类本地候选可运行就把目标来源 序列标记为完整实现。 @@ -199,7 +199,7 @@ OpenCode adapter 在来源发现、解析和审批前不 import module、不读 | 资产 | OpenCode 输入 | BitFun 归属模块 / 适配方式 | 默认行为 | 降级条件 | |---|---|---|---|---| | Rules / Instructions | 项目/全局 `AGENTS.md`、Claude fallback、`instructions` glob、本地文件、远程 URL | Workspace Instructions 归属模块保存有序来源引用 | 当前实现项目根与 `.opencode` 配置中的本地精确文件/glob;全局与远程 URL 仍是目标 | 无效 JSONC 或 glob 只排除对应配置项;文件 I/O 失败时当前构建不缓存并在下一条消息重试。 | -| Agents / Modes | V1 `agent/prompt/disable/permission`、当前 `agents/system/disabled/permissions`,以及 Markdown、description、mode、model、variant、temperature、top_p、steps、deprecated `maxSteps`、deprecated `tools`、options、hidden、color | Agent 归属模块创建兼容定义和使用范围视图;OpenCode adapter 只翻译来源语义 | 当前支持 Subagent 安全子集和 Agent-local 权限约束;首次按行为、来源、模型、工具与权限范围确认,fresh single-run 调用 | primary/mode、variant/options、采样、steps 与续接保持诊断或阻断;root ambient 权限和 V1 嵌套 resource map 尚不激活,不影响其他 Agent。 | +| Agents / Modes | 当前生产 V1 `agent/prompt/disable/permission`、Core V2 `agents/system/disabled/permissions` 输入形状,以及 Markdown、description、mode、model、variant、temperature、top_p、steps、deprecated `maxSteps`、deprecated `tools`、options、hidden、color | Agent 归属模块创建兼容定义和使用范围视图;OpenCode adapter 只翻译来源语义 | 当前支持 Subagent 安全子集和 Agent-local 权限约束;V1 是生产兼容主路径,Core V2 字段只按已验证安全子集解析;首次按行为、来源、模型、工具与权限范围确认,fresh single-run 调用 | primary/mode、variant/options、采样、steps 与续接保持诊断或阻断;root ambient 权限和 V1 嵌套 resource map 尚不激活,不影响其他 Agent。 | | Skills | `.opencode/.claude/.agents` 项目与用户根、`SKILL.md`、`skills.paths/urls` | OpenCode adapter 只由 `bitfun-core/external_sources` 组合并投影有序本地配置根;Skill 归属模块负责有界递归、解析、覆盖与按需加载 | 标准根及 V1 `skills.paths`/当前本地字符串数组可用;项目配置限项目根,用户配置限项目根或用户目录;配置根最多 64 个、每根 512 个 Skill、单文件 256 KiB、可选策略 64 KiB,实际加载再次执行有界非链接读取;配置根在同 scope 覆盖标准 OpenCode 根,但不重排更早的 BitFun/Claude/Codex/Cursor 来源 | URL、下载/缓存、脚本与外部依赖不加载;无效根不影响标准 Skill。 | | References | `references` / 旧 `reference`,本地 path 或 Git repository/branch/description/hidden | **基础能力缺失**:先补 Workspace Reference 的异步准备与 `@alias` 消费接口 | 本地引用保留相对来源;Git 拉取按 L2 确认并保留缓存/隐藏语义 | 拉取失败不阻止项目,外部目录仍遵守工具权限。 | | Commands | JSON/JSONC、Markdown、`$ARGUMENTS`、位置参数、`@file`、`!shell`、agent/model/variant/subtask | Prompt Command 专属契约;adapter 提取静态文件引用,Product Assembly 经共享本地文本服务完成有界装配 | prompt-only 与静态 workspace 相对 UTF-8 `@file` 可发送;动态/绝对/越界文件、shell、agent/model/variant/subtask 整体受限 | 任一文件失败则本次调用原子失败;最多 8 文件、单文件 64 KiB、文件总量 128 KiB、最终命令 1 MiB。 | @@ -209,7 +209,7 @@ OpenCode adapter 在来源发现、解析和审批前不 import module、不读 | Themes | builtin/user/project/cwd JSON | **部分已有**:GUI Theme 已有;TUI 主题消费边界在终端阶段补齐 | 保留覆盖顺序和语义角色 | 颜色能力不支持时做可见降级。 | | Keybinds | `tui.json` 的 leader、组合键、禁用和命令标识 | **已有行为、边界未抽取**:从现有 TUI 输入/命令路径提取最小接口 | 保留用户和项目覆盖 | 平台冲突时显示最终绑定与原因。 | | Models / Providers | `model`、`small_model`、`default_agent`、provider options/variants,以及 `enabled_providers` / `disabled_providers` | Model/Provider 与 Agent 归属模块 | 静态选择按 L1 映射;新增 Provider 连接、网络、凭据或动态适配器按 L2/L3 确认 | 动态软件包适配器交给插件运行时,未知 Provider 只禁用对应选择。 | -| Permissions / Policies | 工具、Skill、Agent 等 allow/deny/ask pattern | OpenCode adapter 生成来源无关约束;Permission 归属模块保持最终裁决 | 当前只接入外部 Subagent 的当前有序 `permissions` 与 V1 Agent-local 扁平 `permission`;约束参与行为审批并随 Agent 固定到执行链 | 约束只能收紧,不能覆盖 BitFun 用户/项目/组织策略;root ambient、V1 嵌套 resource map 和其他资产权限保持明确不支持。 | +| Permissions / Policies | 工具、Skill、Agent 等 allow/deny/ask pattern | OpenCode adapter 生成来源无关约束;Permission 归属模块保持最终裁决 | 当前只接入外部 Subagent 的 Core V2 有序 `permissions` 安全子集与生产 V1 Agent-local 扁平 `permission`;约束参与行为审批并随 Agent 固定到执行链 | 约束只能收紧,不能覆盖 BitFun 用户/项目/组织策略;root ambient、V1 嵌套 resource map 和其他资产权限保持明确不支持。 | | Plugins / Tools | config plugin 列表、`plugins/`、`tools/` | 只生成执行来源和顺序,交给 OpenCode adapter 与 `PluginRuntimeClient` | 自动发现;首次确认后才准备和 import 当前执行版本 | 不在配置解析线程加载代码。 | ### 5.1 Rules 与 Instructions @@ -227,16 +227,16 @@ OpenCode adapter 在来源发现、解析和审批前不 import module、不读 兼容定义进入现有 Agent 归属模块,而不是新建 OpenCode Agent Runtime。当前已实现范围按是否能保持行为等价划分: -- 可等价映射并激活:名称、description、V1 `prompt/disable` 与当前 `system/disabled`、`subagent|all`、隐藏状态、 +- 可等价映射并激活:名称、description、生产 V1 `prompt/disable` 与 Core V2 `system/disabled` 安全子集、`subagent|all`、隐藏状态、 可精确解析的 model,以及能映射到当前有效 Tool route 的明确工具选择;缺省工具使用 BitFun 保守 Subagent 默认集并展示在确认摘要中。 -- Agent-local 权限:当前有序 `permissions: [{ action, resource, effect }]` 保留顺序;V1 扁平精确 action map +- Agent-local 权限:Core V2 有序 `permissions: [{ action, resource, effect }]` 保留顺序;生产 V1 扁平精确 action map `permission: { action: allow|ask|deny }` 转换为 `resource="*"`,并把 `write/edit/patch/apply_patch` 归一为 BitFun `edit` action。同一来源层内仍是 last-match-wins;它作为独立约束与宿主策略取最严格结果,不能授予工具, 也不能放宽用户、项目、组织或父 Agent 的限制。deny/ask 若命中已选中但当前没有对应 PermissionIntent 的工具, 整个 Agent 保持阻断;只对未激活的未知 action 做可见降级。 -- 文件资源坐标在 adapter 边界转换:当前 `read/edit` 的 workspace-relative resource 映射到 BitFun 实际使用的 - canonical workspace 绝对资源,`read/edit/external_directory` 的 `~`/`$HOME` 按 OpenCode 规则展开;bash +- 文件资源坐标在 adapter 边界转换:Core V2 `read/edit` 的 active-Location-relative resource 映射到 BitFun 实际 + 使用的 canonical workspace 绝对资源,`read/edit/external_directory` 的 `~`/`$HOME` 按 OpenCode 规则展开;bash resource 保留原始命令文本。若 action pattern 同时跨越路径与非路径工具、workspace/home 坐标不可得,或前导 wildcard 可能同时匹配 OpenCode 的相对 workspace 与绝对 external resource,则阻断 Agent,不以“未命中即 Allow”继续。 - action pattern 保持 OpenCode 的平台大小写语义:Windows 导入时归一为小写 BitFun action,其他平台保持大小写 @@ -246,12 +246,13 @@ OpenCode adapter 在来源发现、解析和审批前不 import module、不读 - 展示映射:color 等只影响来源 Surface,不进入运行时权威事实。 - 未知字段:进入来源限定诊断,不作为任意数据传给 core;后续版本支持时由 OpenCode adapter 更新解释。 -每份 JSON/JSONC 配置文档和 Agent Markdown frontmatter 先独立按 OpenCode V1 key 判型并迁移到当前 Agent 字段, -再参与合并;不能把跨文档的 -`prompt/system`、`disable/disabled` 或 `permission/permissions` 误判为单份文档冲突。本地 Agent 来源按用户配置与 -用户 Markdown、project direct config(root-to-nearest)、project `.opencode` config/Markdown(root-to-nearest) -依次应用,`OPENCODE_CONFIG_DIR` 的物理 alias 保留所在位置而不因 scope 重排;`disabled:true` 按 OpenCode remove -语义形成 tombstone,后续同名非 disabled 文档从空定义重建。这样项目限制不会被较低优先级用户 Markdown 反向覆盖。 +每份 JSON/JSONC 配置文档和 Agent Markdown frontmatter 先独立按 OpenCode V1 key 判型并迁移到统一字段,再参与 +合并;不能把跨文档的 `prompt/system`、`disable/disabled` 或 `permission/permissions` 误判为单份文档冲突。 +本地生产来源依次应用用户 JSON/JSONC、`OPENCODE_CONFIG`、project direct config(root-to-nearest)、用户 Agent +Markdown、project `.opencode` config/Markdown(opened-to-root)、兼容用户目录与显式目录;物理 alias 保留所在 +位置而不因 scope 重排。生产 V1 `disable` 按普通字段 deep-merge,后续来源省略它时仍保持禁用,只有显式 +`disable:false` 才重新启用且不清空已合并字段;Core V2 `disabled:true` 才按逐文档 remove 形成 tombstone,后续 +同名非 disabled 文档(包括逐文档迁移后的 V1 定义)从空定义重建。 Core 只消费来源无关候选,按当前 模型、工具、执行位置和本地/其他 provider 同名项生成审批与冲突内容摘要。无冲突候选首次确认一次;只有目录文案 变化不重问,prompt 行为、来源或实际模型、工具、权限约束与执行范围变化重新确认;Core 的审批 envelope 也直接 diff --git a/docs/architecture/extensions/opencode-extension-compatibility.md b/docs/architecture/extensions/opencode-extension-compatibility.md index 49d8f74f14..3f808767d4 100644 --- a/docs/architecture/extensions/opencode-extension-compatibility.md +++ b/docs/architecture/extensions/opencode-extension-compatibility.md @@ -107,7 +107,7 @@ OpenCode,和 OpenCode 配置/插件进入 BitFun 是两个独立验收方向 | JSON、JSONC、环境变量、文件引用 | 转换参数 + 明确降级 | 部分实现:项目 `instructions` 的 JSON/JSONC | 可主要适配 | OC-R1 | 当前只解析项目根和 `.opencode` 配置中的本地 instruction 文件/glob;完整配置 schema、环境变量与文件变量引用仍未实现 | [解析与鲁棒性](opencode-config-assets-adapter-design.md#4-解析与鲁棒性) | | 独立 `tui.json/jsonc` | 融合现有能力 + 转换参数 | 未实现 | 可完整适配 | OC-R1 | 按 global、`OPENCODE_TUI_CONFIG`、project、`.opencode` 独立顺序加载,不能复用主配置优先级 | [TUI 来源](opencode-config-assets-adapter-design.md#32-tui-独立来源顺序) | | Rules / Instructions | 转换参数 | 部分实现:项目本地文件与 glob | 可完整适配 | OC-R1 | 当前由 Workspace Instructions owner 读取项目 `instructions` 的精确文件和 glob;全局、远程 URL、完整来源合并与变化监听仍未实现 | [声明式资产](opencode-config-assets-adapter-design.md#5-声明式资产映射) | -| Agents / Modes | 融合现有能力 + 转换参数 | 部分实现:Subagent 安全子集与 Agent-local 权限约束 | 可主要适配 | OC-R1 | 已支持全局/项目 Markdown、JSON/JSONC 的逐文档 V1 `agent/prompt/disable/permission` 迁移与当前 `agents/system/disabled/permissions` 子集、subagent/all、description、精确模型和工具映射,并接入审批、冲突、更新、撤下和 fresh single-run Task;当前有序 permission rules 与 V1 扁平精确 action map 会成为只可收紧的独立约束,路径资源在 adapter 边界转换到 BitFun workspace/home 坐标。primary/mode、root ambient permission、V1 action pattern/嵌套 resource map、跨路径与命令资源域的歧义 pattern、variant/options、采样与续接明确阻断或降级 | [Agents 与 Skills](opencode-config-assets-adapter-design.md#52-agentsmodes-与-skills) | +| Agents / Modes | 融合现有能力 + 转换参数 | 部分实现:Subagent 安全子集与 Agent-local 权限约束 | 可主要适配 | OC-R1 | 已支持当前生产 V1 `agent/prompt/disable/permission` 与 Core V2 `agents/system/disabled/permissions` 的已验证安全子集、全局/项目 Markdown 和 JSON/JSONC、subagent/all、description、精确模型和工具映射,并接入审批、冲突、更新、撤下和 fresh single-run Task;V1 `disable` 保持 deep-merge,V2 `disabled` 保持 remove/re-add,不能混用生命周期语义。有序 V2 permission rules 与 V1 扁平精确 action map 会成为只可收紧的独立约束。primary/mode、root ambient permission、V1 action pattern/嵌套 resource map、跨路径与命令资源域的歧义 pattern、variant/options、采样与续接明确阻断或降级 | [Agents 与 Skills](opencode-config-assets-adapter-design.md#52-agentsmodes-与-skills) | | Skills | 转换参数 | 部分实现:标准根与本地配置根 | 可完整适配 | OC-R2 | 现有 Registry 除标准用户/项目根外,也通过 `bitfun-core/external_sources` 组合边界按 OpenCode 配置来源顺序累加 V1 `skills.paths` 与当前迁移后的本地字符串数组;仅接受项目根/用户目录内的本地目录并做有界递归发现。同 scope 配置根覆盖标准 OpenCode 根,但不重排更早的 BitFun/Claude/Codex/Cursor 来源。URL、下载/缓存、完整 allow/deny/ask 顺序及外部来源策略仍未实现 | [Agents 与 Skills](opencode-config-assets-adapter-design.md#52-agentsmodes-与-skills) | | References | 补基础能力 + 转换参数 | 未实现 | 可主要适配 | OC-R2 | R1 解析;R2 支持本地目录和 Git repository/branch/description/hidden,异步准备并接入 `@alias` | [声明式资产](opencode-config-assets-adapter-design.md#5-声明式资产映射) | | Commands | 补扩展接口 + 转换参数 | 部分实现:prompt-only + 本地文本文件子集 | 可完整适配 | OC-R2 | 已支持全局/项目 JSON、JSONC、Markdown 命令、参数展开、动态目录、刷新和显式冲突选择;模板中的静态 workspace 相对 `@file` 可在调用时有界读取,动态/绝对/越界文件引用、shell、Agent/model/variant/subtask 保持受限且不做部分执行 | [Commands](opencode-config-assets-adapter-design.md#53-commands) | diff --git a/src/crates/adapters/opencode-adapter/src/agent_source.rs b/src/crates/adapters/opencode-adapter/src/agent_source.rs index 74f459c24a..6b6e7342d7 100644 --- a/src/crates/adapters/opencode-adapter/src/agent_source.rs +++ b/src/crates/adapters/opencode-adapter/src/agent_source.rs @@ -1,6 +1,7 @@ use crate::local_source_paths::{ find_project_root, local_watch_roots, ordered_local_config_directories, - project_config_directories, user_config_dir, LocalConfigDirectory, LocalConfigDirectoryKind, + project_asset_directories, project_config_directories, user_config_dir, LocalConfigDirectory, + LocalConfigDirectoryKind, }; use bitfun_product_domains::external_sources::{ EcosystemId, ExternalSourceAssetKind, ExternalSourceContext, ExternalSourceDiagnostic, @@ -184,58 +185,42 @@ impl OpenCodeSubagentProvider { "OpenCode OPENCODE_CONFIG", ); } + if self.options.project_config_enabled { + if let Some(workspace_root) = &context.workspace_root { + let project_root = self.project_root(workspace_root); + for directory in project_config_directories(&project_root, workspace_root) { + push_config_files( + &mut layers, + &directory, + ExternalSourceScope::Project, + "OpenCode project configuration", + ); + } + } + } let project_directories = if self.options.project_config_enabled { context .workspace_root .as_ref() .map(|workspace_root| { - project_config_directories(&self.project_root(workspace_root), workspace_root) + project_asset_directories(&self.project_root(workspace_root), workspace_root) }) .unwrap_or_default() } else { Vec::new() }; - let local_directories = ordered_local_config_directories( + for directory in ordered_local_config_directories( &self.options.user_config_dir, self.options.legacy_user_config_dir.as_deref(), self.options.explicit_config_dir.as_deref(), &project_directories, - ); - for directory in local_directories.iter().filter(|directory| { - !occupies_project_directory_position(directory, &project_directories) - }) { - push_local_directory_layers(&mut layers, directory)?; - } - for directory in &project_directories { - push_config_files( - &mut layers, - directory, - ExternalSourceScope::Project, - "OpenCode project configuration", - ); - } - for directory in local_directories.iter().filter(|directory| { - occupies_project_directory_position(directory, &project_directories) - }) { - push_local_directory_layers(&mut layers, directory)?; + ) { + push_local_directory_layers(&mut layers, &directory)?; } Ok(deduplicate_layers_keep_last(layers)) } } -fn occupies_project_directory_position( - directory: &LocalConfigDirectory, - project_directories: &[PathBuf], -) -> bool { - let identity = dunce::canonicalize(&directory.path) - .unwrap_or_else(|_| normalize_path_lexically(&directory.path)); - project_directories.iter().any(|project_directory| { - let project_identity = dunce::canonicalize(project_directory.join(".opencode")) - .unwrap_or_else(|_| normalize_path_lexically(&project_directory.join(".opencode"))); - identity == project_identity - }) -} - fn push_local_directory_layers( layers: &mut Vec, directory: &LocalConfigDirectory, @@ -467,6 +452,7 @@ struct AgentPatch { logical_id: String, fields: Map, legacy: bool, + disabled_is_tombstone: bool, } fn parse_layer(layer: &AgentLayer) -> Result { @@ -559,6 +545,7 @@ fn parse_config_layer( logical_id: normalize_logical_id(logical_id), fields, legacy: false, + disabled_is_tombstone: !legacy_document, }); } } else { @@ -716,6 +703,7 @@ fn parse_markdown_layer( logical_id: normalize_logical_id(logical_id), fields, legacy, + disabled_is_tombstone: !legacy_schema, }], ambient_permission: false, diagnostics: Vec::new(), @@ -739,10 +727,11 @@ fn materialize_definition( let mut legacy = false; let mut removed = false; for (index, contribution) in contributions.iter().enumerate() { - let removes_agent = contribution - .fields - .get("disabled") - .is_some_and(|value| value == &Value::Bool(true)); + let removes_agent = contribution.disabled_is_tombstone + && contribution + .fields + .get("disabled") + .is_some_and(|value| value == &Value::Bool(true)); if removes_agent || removed { effective = Value::Object(Map::new()); } diff --git a/src/crates/adapters/opencode-adapter/tests/opencode_subagent_adapter.rs b/src/crates/adapters/opencode-adapter/tests/opencode_subagent_adapter.rs index 8dbaf1c348..c92bca8454 100644 --- a/src/crates/adapters/opencode-adapter/tests/opencode_subagent_adapter.rs +++ b/src/crates/adapters/opencode-adapter/tests/opencode_subagent_adapter.rs @@ -221,6 +221,72 @@ fn current_path_permission_resources_expand_like_opencode() { ); } +#[test] +fn v2_relative_path_permissions_use_the_opened_location_coordinate() { + let temp = TempDir::new().unwrap(); + let project = temp.path().join("workspace"); + let opened = project.join("packages/app"); + fs::create_dir_all(project.join(".git")).unwrap(); + fs::create_dir_all(&opened).unwrap(); + fs::create_dir_all(temp.path().join("user")).unwrap(); + fs::write( + temp.path().join("user/opencode.json"), + r#"{ + "agents": { + "reviewer": { + "system": "Do not read location-local secrets", + "mode": "subagent", + "permissions": [ + { "action": "read", "resource": "secrets/**", "effect": "deny" } + ] + } + } + }"#, + ) + .unwrap(); + let provider = OpenCodeSubagentProvider::new(OpenCodeSubagentProviderOptions { + user_config_dir: temp.path().join("user"), + legacy_user_config_dir: None, + explicit_config_file: None, + explicit_config_dir: None, + project_config_enabled: true, + project_root_override: Some(project.clone()), + }); + + let snapshot = discover(&provider, opened.clone(), BTreeSet::new()); + let constraints = &snapshot.definitions[0].permission_constraints; + let opened = dunce::canonicalize(opened) + .unwrap() + .to_string_lossy() + .replace('\\', "/"); + let project = dunce::canonicalize(project) + .unwrap() + .to_string_lossy() + .replace('\\', "/"); + + assert_eq!( + constraints.rules()[0].resource, + format!("{opened}/secrets/**") + ); + assert_eq!( + PermissionEvaluator::for_current_platform().evaluate_constraint_resource( + "read", + &format!("{opened}/secrets/key.txt"), + constraints, + ), + PermissionEffect::Deny + ); + assert_eq!( + PermissionEvaluator::for_current_platform().evaluate_constraint_resource( + "read", + &format!("{project}/secrets/key.txt"), + constraints, + ), + PermissionEffect::Allow, + "Core V2 resources are active-Location relative; V1 worktree-relative resource maps stay unsupported" + ); +} + #[test] fn ambiguous_cross_domain_path_patterns_fail_closed() { let temp = TempDir::new().unwrap(); @@ -401,7 +467,156 @@ fn later_enabled_document_recreates_agent_after_disabled_tombstone() { } #[test] -fn v1_markdown_migrates_before_current_project_overlay() { +fn v1_disable_remains_effective_when_later_overlay_omits_it() { + let temp = TempDir::new().unwrap(); + let workspace = temp.path().join("workspace"); + fs::create_dir_all(workspace.join(".git")).unwrap(); + fs::create_dir_all(temp.path().join("user")).unwrap(); + fs::write( + temp.path().join("user/opencode.json"), + r#"{ + "agent": { + "reviewer": { + "prompt": "Disabled user definition", + "mode": "subagent", + "disable": true, + "permission": { "edit": "deny" } + } + } + }"#, + ) + .unwrap(); + fs::write( + workspace.join("opencode.json"), + r#"{ + "agent": { + "reviewer": { + "prompt": "Project overlay", + "mode": "subagent" + } + } + }"#, + ) + .unwrap(); + + let snapshot = discover(&provider(&temp, &workspace), workspace, BTreeSet::new()); + let definition = &snapshot.definitions[0]; + + assert!(definition.disabled); + assert_eq!(definition.prompt.expose(), "Project overlay"); + assert_eq!( + definition.permission_constraints.rules(), + [PermissionRule::new("edit", "*", PermissionEffect::Deny)] + ); +} + +#[test] +fn v1_explicit_false_reenables_without_erasing_merged_fields() { + let temp = TempDir::new().unwrap(); + let workspace = temp.path().join("workspace"); + fs::create_dir_all(workspace.join(".git")).unwrap(); + fs::create_dir_all(temp.path().join("user")).unwrap(); + fs::write( + temp.path().join("user/opencode.json"), + r#"{ + "agent": { + "reviewer": { + "prompt": "Disabled user definition", + "mode": "subagent", + "disable": true, + "permission": { "edit": "deny" } + } + } + }"#, + ) + .unwrap(); + fs::write( + workspace.join("opencode.json"), + r#"{ + "agent": { + "reviewer": { + "prompt": "Explicitly enabled project overlay", + "mode": "subagent", + "disable": false + } + } + }"#, + ) + .unwrap(); + + let snapshot = discover(&provider(&temp, &workspace), workspace, BTreeSet::new()); + let definition = &snapshot.definitions[0]; + + assert!(!definition.disabled); + assert_eq!( + definition.prompt.expose(), + "Explicitly enabled project overlay" + ); + assert_eq!( + definition.permission_constraints.rules(), + [PermissionRule::new("edit", "*", PermissionEffect::Deny)] + ); +} + +#[test] +fn v1_document_readds_after_v2_tombstone_without_losing_constraints() { + let temp = TempDir::new().unwrap(); + let workspace = temp.path().join("workspace"); + fs::create_dir_all(workspace.join(".git")).unwrap(); + fs::create_dir_all(workspace.join(".opencode")).unwrap(); + fs::create_dir_all(temp.path().join("user")).unwrap(); + fs::write( + temp.path().join("user/opencode.json"), + r#"{ + "agents": { + "reviewer": { + "system": "Removed user definition", + "mode": "subagent", + "disabled": true + } + } + }"#, + ) + .unwrap(); + fs::write( + workspace.join("opencode.json"), + r#"{ + "agent": { + "reviewer": { + "prompt": "V1 project definition", + "mode": "subagent", + "permission": { "edit": "deny" } + } + } + }"#, + ) + .unwrap(); + fs::write( + workspace.join(".opencode/opencode.json"), + r#"{ + "agents": { + "reviewer": { + "system": "V2 project overlay", + "mode": "subagent" + } + } + }"#, + ) + .unwrap(); + + let snapshot = discover(&provider(&temp, &workspace), workspace, BTreeSet::new()); + let definition = &snapshot.definitions[0]; + + assert_eq!(definition.prompt.expose(), "V2 project overlay"); + assert_eq!( + definition.permission_constraints.rules(), + [PermissionRule::new("edit", "*", PermissionEffect::Deny)], + "the first document after a V2 tombstone re-adds the agent regardless of source schema" + ); +} + +#[test] +fn user_markdown_applies_after_project_direct_config() { let temp = TempDir::new().unwrap(); let workspace = temp.path().join("workspace"); fs::create_dir_all(workspace.join(".git")).unwrap(); @@ -429,15 +644,12 @@ fn v1_markdown_migrates_before_current_project_overlay() { let snapshot = discover(&provider(&temp, &workspace), workspace, BTreeSet::new()); let definition = &snapshot.definitions[0]; - assert_eq!( - definition.prompt.expose(), - "Review using the project policy" - ); + assert_eq!(definition.prompt.expose(), "Review using the user policy."); assert_eq!( definition.permission_constraints.rules(), [ - PermissionRule::new("edit", "*", PermissionEffect::Deny), PermissionRule::new("read", "*", PermissionEffect::Deny), + PermissionRule::new("edit", "*", PermissionEffect::Deny), ] ); assert_eq!( @@ -447,7 +659,7 @@ fn v1_markdown_migrates_before_current_project_overlay() { } #[test] -fn project_permission_rules_follow_user_agent_markdown() { +fn user_agent_markdown_permissions_follow_project_direct_config() { let temp = TempDir::new().unwrap(); let workspace = temp.path().join("workspace"); fs::create_dir_all(workspace.join(".git")).unwrap(); @@ -477,8 +689,8 @@ fn project_permission_rules_follow_user_agent_markdown() { assert_eq!( constraints.rules(), [ - PermissionRule::new("read", "*", PermissionEffect::Allow), PermissionRule::new("read", "*", PermissionEffect::Deny), + PermissionRule::new("read", "*", PermissionEffect::Allow), ] ); assert_eq!( @@ -487,7 +699,7 @@ fn project_permission_rules_follow_user_agent_markdown() { "C:/workspace/secrets/key.txt", constraints, ), - PermissionEffect::Deny + PermissionEffect::Allow ); }