diff --git a/docs/architecture/extensions/opencode-config-assets-adapter-design.md b/docs/architecture/extensions/opencode-config-assets-adapter-design.md index ac62e3a609..bf93e8fccb 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 按当前生产配置 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 | 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`、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 | Permission 归属模块建立 OpenCode 兼容策略层 | 收紧可以自动应用;扩大权限进入确认,激活后保持 OpenCode 决策 | BitFun 用户/组织策略可进一步收紧并明确标记。 | +| 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,37 @@ 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` 与 Core V2 `system/disabled` 安全子集、`subagent|all`、隐藏状态、 + 可精确解析的 model,以及能映射到当前有效 Tool route 的明确工具选择;缺省工具使用 BitFun 保守 Subagent + 默认集并展示在确认摘要中。 +- 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 边界转换: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,其他平台保持大小写 + 敏感。外部 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 判型并迁移到统一字段,再参与 +合并;不能把跨文档的 `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 行为、来源或实际模型、工具与执行范围变化重新确认。冲突未选择时逻辑名不可用,候选 +变化不重问,prompt 行为、来源或实际模型、工具、权限约束与执行范围变化重新确认;Core 的审批 envelope 也直接 +包含约束摘要,不能依赖 provider 正确更新行为版本。当前 `permissions` 数组按文档来源顺序追加,V1 扁平对象按 +归一化 action 做确定性去重;同义 action 给出冲突效果时阻断,不能依赖 JSON 对象键序裁决。冲突未选择时逻辑名不可用,候选 变化后不静默回退。 OpenCode adapter 负责把 `provider/model` 语法解析成来源无关的 provider 提示与模型名;Core 不解释 OpenCode 字符串 @@ -254,7 +275,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..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 安全子集 | 可主要适配 | 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 | 已支持当前生产 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/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..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,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, + project_asset_directories, project_config_directories, user_config_dir, LocalConfigDirectory, LocalConfigDirectoryKind, }; use bitfun_product_domains::external_sources::{ @@ -17,6 +17,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 +35,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 +153,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, @@ -166,61 +215,42 @@ impl OpenCodeSubagentProvider { 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", - )?; - } - } + push_local_directory_layers(&mut layers, &directory)?; } Ok(deduplicate_layers_keep_last(layers)) } } +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 +361,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)); @@ -420,6 +452,7 @@ struct AgentPatch { logical_id: String, fields: Map, legacy: bool, + disabled_is_tombstone: bool, } fn parse_layer(layer: &AgentLayer) -> Result { @@ -475,27 +508,50 @@ 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, + disabled_is_tombstone: !legacy_document, }); } } 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 +559,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 +575,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 +682,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())); } @@ -556,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(), @@ -568,6 +716,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 +725,18 @@ 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.disabled_is_tombstone + && 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 +783,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 +808,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 +849,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 +877,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 +916,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 +941,7 @@ fn materialize_definition( hidden, requested_model, requested_tools, + permission_constraints, compatibility, diagnostic_codes, behavior_version, @@ -809,9 +994,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 +1034,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 +1059,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..c92bca8454 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,819 @@ 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 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(); + 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_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(); + 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 user policy."); + assert_eq!( + definition.permission_constraints.rules(), + [ + PermissionRule::new("read", "*", PermissionEffect::Deny), + PermissionRule::new("edit", "*", PermissionEffect::Deny), + ] + ); + assert_eq!( + definition.compatibility, + ExternalSubagentCompatibilityState::Ready + ); +} + +#[test] +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(); + 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::Deny), + PermissionRule::new("read", "*", PermissionEffect::Allow), + ] + ); + assert_eq!( + PermissionEvaluator::for_current_platform().evaluate_constraint_resource( + "read", + "C:/workspace/secrets/key.txt", + constraints, + ), + PermissionEffect::Allow + ); +} + +#[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,