diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..90aba4e --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,2 @@ +[target.x86_64-pc-windows-gnu] +rustflags = ["-C", "link-arg=-Wl,--no-insert-timestamp"] diff --git a/CODE_SIGNING_POLICY.md b/CODE_SIGNING_POLICY.md index 8c224f6..df744f4 100644 --- a/CODE_SIGNING_POLICY.md +++ b/CODE_SIGNING_POLICY.md @@ -4,7 +4,7 @@ Last updated: 2026-07-19 ## Current status -DS Agent `v1.2.0` is intentionally published unsigned. Both `ds-agent.exe` and +DS Agent `v1.3.0` is intentionally published unsigned. Both `ds-agent.exe` and the Windows x64 NSIS installer are expected to report Authenticode `NotSigned`. Windows may therefore display `Unknown publisher` or a Microsoft Defender SmartScreen warning. Users should download only over HTTPS from the official @@ -13,7 +13,7 @@ GitHub Release and verify the published SHA-256 before running the installer. The SignPath Foundation application is submitted and approval is pending. No DS Agent binary may be represented as SignPath-signed until the application is approved and a later-version artifact independently verifies as Authenticode -`Valid`. The project will not replace the immutable `v1.1.0` or `v1.2.0` tag, +`Valid`. The project will not replace the immutable `v1.1.0`, `v1.2.0`, or `v1.3.0` tag, Release, or asset if signing becomes available later. For releases accepted into that program: **Free code signing provided by @@ -57,7 +57,7 @@ them; guessed or placeholder identifiers are forbidden. ## Release verification -For the unsigned `v1.1.0` and `v1.2.0` exceptions, maintainers verify and disclose the actual +For the unsigned `v1.1.0`, `v1.2.0`, and `v1.3.0` exceptions, maintainers verify and disclose the actual `NotSigned` status of both the application executable and installer. Evidence must bind the exact source commit, file name, product version, byte size, and SHA-256. The installer downloaded back from GitHub must match the reviewed diff --git a/PRIVACY.md b/PRIVACY.md index 58d7f35..daf8812 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -14,7 +14,7 @@ User-selected workspaces hold approved evidence, exports, reports, work packages, screenshots, and other artifacts. This information is not silently synced to a DS Agent-operated server. -The current stable `v1.2.0` accepts one user-supplied DeepSeek API key through +The current stable `v1.3.0` accepts one user-supplied DeepSeek API key through the onboarding screen and stores it in a dedicated Windows DPAPI-protected local vault. A process-environment key remains an explicit compatibility fallback and is never silently copied into that vault. The project does not @@ -22,11 +22,14 @@ provide a shared key. The raw key exists in frontend memory only while it is being entered and submitted; it must not be retained in UI state, events, logs, exports, screenshots, work packages, or verification receipts. -Goal contracts, lifecycle events, and UI projections are bounded and designed +Goal contracts, exact-task grouped authorizations, lifecycle events, and UI +projections are bounded and designed to exclude raw secrets, provider bodies, absolute app-data or vault paths, local authority material, and internal claim tokens. The read-only UI receives stable status/reason codes, bounded goal text, revision/fingerprint values, and -coverage counts; it does not receive a local completion writer. +coverage counts plus a redacted authorization intent; it does not receive the +private capability proposal, a preparation/compiler command, or a local +completion writer. Uninstalling the application may not delete a user-selected workspace or every application-data file. Review and remove those local locations separately when @@ -64,7 +67,7 @@ the user or person operating the application: The optional local desktop bridge accepts only loopback addresses and is started and controlled by the user. DS Agent does not install or supervise that service. Production Microsoft and Google account registration and live -mail/calendar writes are disabled in `v1.2.0`; offline connector contracts do +mail/calendar writes are disabled in `v1.3.0`; offline connector contracts do not authorize a production account or external write. ## What can be included in a model request diff --git a/README.md b/README.md index 830f3e4..5154c1f 100644 --- a/README.md +++ b/README.md @@ -11,8 +11,8 @@

- v1.2.0 stable · - Download for Windows · + v1.3.0 stable · + Download for Windows · Apache-2.0

@@ -132,10 +132,13 @@ does not claim completion from model confidence alone. Local files, browser actions, Office artifacts, and Computer Use are complete only when observable evidence satisfies the task's completion criteria. -In v1.2.0, DeepSeek may propose a bounded `GoalEnvelope`, but only the local -Kernel can validate and freeze it. The chat UI shows a read-only projection; -completion remains blocked until locally authoritative verifier evidence covers -every frozen `done_when` condition and required artifact identity. +In v1.3.0, DeepSeek may propose a bounded `GoalEnvelope`, but only the local +Kernel can validate and freeze it. For the same queued task, the Kernel alone +derives the capability manifest, risk, and preview shown in one exact-task +authorization card. Approval creates only exact authority; it does not execute +a Tool, resume the task, or mark the Goal complete. Completion remains blocked +until locally authoritative verifier evidence covers every frozen `done_when` +condition and required artifact identity. ## DeepSeek and DS Agent boundary @@ -160,7 +163,7 @@ high-risk action. See the full [model boundary](docs/AGENT_MODEL_BOUNDARY.md). reconciliation contracts validated with offline adversarial fake providers. Production Microsoft/Google account registration and live external-write -authority remain disabled in v1.2.0. The release does not sign in to real +authority remain disabled in v1.3.0. The release does not sign in to real accounts, send real email, or create, change, or cancel real calendar events. ## Why Rust @@ -173,7 +176,7 @@ remain thin; the Kernel and persistent projections own business state. ## Quick start -1. Download the [Windows x64 installer](https://github.com/Lee-take/dsagent/releases/download/v1.2.0/DS.Agent_1.2.0_x64-setup.exe). +1. Download the [Windows x64 installer](https://github.com/Lee-take/dsagent/releases/download/v1.3.0/DS.Agent_1.3.0_x64-setup.exe). 2. Enter your own valid DeepSeek API key in onboarding and run the explicit balance/model verification. The key is stored locally with Windows DPAPI. 3. Choose one local workspace and let the readiness doctor verify its managed @@ -185,7 +188,7 @@ A user-supplied DeepSeek API key is a required prerequisite. DS Agent does not bundle a shared key or bypass DeepSeek access requirements; use remains subject to DeepSeek's terms and account policies. -The v1.2.0 application executable and installer are Authenticode `NotSigned`. +The v1.3.0 application executable and installer are Authenticode `NotSigned`. Windows may display `Unknown publisher` or a Microsoft Defender SmartScreen warning. Download only over HTTPS from this repository, verify the SHA-256 in the GitHub Release, and read the [installation guide](docs/INSTALLATION.md) @@ -193,10 +196,10 @@ before running the installer. ## Code signing policy -DS Agent `v1.2.0` is intentionally unsigned. The SignPath Foundation application +DS Agent `v1.3.0` is intentionally unsigned. The SignPath Foundation application is submitted and approval is pending; no release is represented as signed. If the project is accepted, signing starts with a later new version and does not -replace the immutable v1.1.0 or v1.2.0 tag or asset. For releases accepted into the program: +replace the immutable v1.1.0, v1.2.0, or v1.3.0 tag or asset. For releases accepted into the program: **Free code signing provided by SignPath.io, certificate by SignPath Foundation.** See the full [code signing policy](CODE_SIGNING_POLICY.md) and [privacy policy](PRIVACY.md). @@ -214,8 +217,8 @@ example `D:\build-target\ds-agent-v1-release`. ## Stable release -- Release: [DS Agent v1.2.0](https://github.com/Lee-take/dsagent/releases/tag/v1.2.0) -- Installer: `DS.Agent_1.2.0_x64-setup.exe` +- Release: [DS Agent v1.3.0](https://github.com/Lee-take/dsagent/releases/tag/v1.3.0) +- Installer: `DS.Agent_1.3.0_x64-setup.exe` - Integrity: verify the final byte size and SHA-256 published in the GitHub Release before running the installer. - Onboarding: one user-supplied Key, Windows DPAPI storage, explicit DeepSeek @@ -226,12 +229,16 @@ example `D:\build-target\ds-agent-v1-release`. - Goal contract: bounded model proposal, Kernel validation/freeze, read-only UI state, and fail-closed evidence coverage for every completion condition and required artifact. +- Task authorization: one Kernel-derived exact-task card with manifest/risk/ + preview binding, one user decision, per-capability audit, and exact revocation; + approval does not execute or resume the task. ## Documentation - [Installation](docs/INSTALLATION.md) - [DS Agent and DeepSeek boundary](docs/AGENT_MODEL_BOUNDARY.md) - [v1 architecture](docs/architecture/DS_AGENT_V1_ARCHITECTURE_PLAN.md) +- [v1.3.0 release notes](docs/RELEASE_NOTES_v1.3.0.md) - [v1.2.0 release notes](docs/RELEASE_NOTES_v1.2.0.md) - [v1.1.0 release notes](docs/RELEASE_NOTES_v1.1.0.md) - [v1 completion audit](docs/DS_AGENT_V1_COMPLETION_AUDIT.md) diff --git a/README.zh-CN.md b/README.zh-CN.md index ccf6ec6..7ca2797 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -11,8 +11,8 @@

- v1.2.0 正式稳定版 · - 下载 Windows 安装包 · + v1.3.0 正式稳定版 · + 下载 Windows 安装包 · Apache-2.0

@@ -118,9 +118,11 @@ Kernel 会持久保存任务和审核状态,只对失败步骤进行有边界 宣告完成。本地文件、浏览器操作、Office 产物和 Computer Use,只有在可观察证据满足任务 完成条件后才算真正完成。 -在 v1.2.0 中,DeepSeek 可以提出有界 `GoalEnvelope`,但只有本地 Kernel 能校验并冻结它。 -聊天界面只显示只读投影;只有本地权威 verifier 证据覆盖冻结目标的全部 `done_when` 和 -必需产物身份后,任务才可能进入完成状态。 +在 v1.3.0 中,DeepSeek 可以提出有界 `GoalEnvelope`,但只有本地 Kernel 能校验并冻结它。 +对于同一个排队任务,也只有 Kernel 能派生 capability manifest、风险和 preview,并显示为 +一张 exact-task 授权卡。批准只产生精确权限,不会执行 Tool、恢复任务或把 Goal 标记为完成; +只有本地权威 verifier 证据覆盖冻结目标的全部 `done_when` 和必需产物身份后,任务才可能 +进入完成状态。 ## DeepSeek 与 DS Agent 的工作边界 @@ -142,7 +144,7 @@ DeepSeek 可以提出动作,DS Agent 决定动作是否安全、是否允许 - 已通过离线对抗性 fake provider 验证的 Microsoft/Google 形态邮件、日历、同步、草稿、 外部变更和对账契约。 -v1.2.0 仍未开放生产 Microsoft/Google 账号注册和真实外部写入权限。当前正式版不会登录 +v1.3.0 仍未开放生产 Microsoft/Google 账号注册和真实外部写入权限。当前正式版不会登录 真实账号、发送真实邮件,也不会创建、修改或取消真实日历事件。 ## 为什么使用 Rust @@ -153,7 +155,7 @@ command 与 React UI 保持薄层,业务状态由 Kernel 和持久投影统一 ## 快速开始 -1. 下载 [Windows x64 安装包](https://github.com/Lee-take/dsagent/releases/download/v1.2.0/DS.Agent_1.2.0_x64-setup.exe)。 +1. 下载 [Windows x64 安装包](https://github.com/Lee-take/dsagent/releases/download/v1.3.0/DS.Agent_1.3.0_x64-setup.exe)。 2. 在首次设置中输入你自己的有效 DeepSeek API Key,并显式验证余额和模型;Key 使用 Windows DPAPI 保存在本机。 3. 选择一个本地工作目录,由 readiness doctor 检查受管目录和可写状态。 @@ -163,16 +165,16 @@ command 与 React UI 保持薄层,业务状态由 Kernel 和持久投影统一 用户自行提供有效的 DeepSeek API Key 是必备前提。DS Agent 不内置共享 Key,也不会 绕过 DeepSeek 的访问条件;实际使用仍须遵守 DeepSeek 的服务条款和账号规则。 -v1.2.0 应用程序和安装包的 Authenticode 状态均为 `NotSigned`。Windows 可能显示 +v1.3.0 应用程序和安装包的 Authenticode 状态均为 `NotSigned`。Windows 可能显示 `Unknown publisher`(未知发布者)或 Microsoft Defender SmartScreen 警告。请只通过 本仓库的 HTTPS 链接下载,运行前核对 GitHub Release 中的 SHA-256,并阅读 [安装指南](docs/INSTALLATION.md)。 ## Code signing policy(代码签名政策) -DS Agent `v1.2.0` 是如实披露的未签名版本。SignPath Foundation 申请已经提交、仍在等待 +DS Agent `v1.3.0` 是如实披露的未签名版本。SignPath Foundation 申请已经提交、仍在等待 审批,本版本不会被描述为已签名。若以后获批,只从后续新版本开始签名,不替换本版本不可 -移动的 tag 或资产,也不改写 v1.1.0。获准加入该计划的 Release 将遵循:**Free code signing provided by +移动的 tag 或资产,也不改写 v1.1.0 或 v1.2.0。获准加入该计划的 Release 将遵循:**Free code signing provided by SignPath.io, certificate by SignPath Foundation.** 完整说明见 [代码签名政策](CODE_SIGNING_POLICY.md)和[隐私政策](PRIVACY.md)。 @@ -189,8 +191,8 @@ npx pnpm@9.15.9 --filter @deepseek-agent-os/desktop tauri:dev ## 正式稳定版 -- Release:[DS Agent v1.2.0](https://github.com/Lee-take/dsagent/releases/tag/v1.2.0) -- 安装包:`DS.Agent_1.2.0_x64-setup.exe` +- Release:[DS Agent v1.3.0](https://github.com/Lee-take/dsagent/releases/tag/v1.3.0) +- 安装包:`DS.Agent_1.3.0_x64-setup.exe` - 完整性:运行安装包前,核对 GitHub Release 中发布的最终字节数和 SHA-256。 - 首次设置:单一用户 Key、Windows DPAPI 本机存储、显式 DeepSeek 余额/V4 模型验证、 无密 readiness 和 workspace doctor。 @@ -198,12 +200,15 @@ npx pnpm@9.15.9 --filter @deepseek-agent-os/desktop tauri:dev connector vault。 - 目标契约:有界模型提议、Kernel 校验/冻结、只读 UI 状态,以及覆盖全部完成条件和 必需产物身份的 fail-closed 证据门。 +- 任务授权:一张由 Kernel 派生并绑定 manifest/risk/preview 的 exact-task 卡片、一次用户 + 决策、逐能力审计和精确撤销;批准不会执行或恢复任务。 ## 文档 - [安装指南](docs/INSTALLATION.md) - [DS Agent 与 DeepSeek 的工作边界](docs/AGENT_MODEL_BOUNDARY.md) - [v1 架构计划](docs/architecture/DS_AGENT_V1_ARCHITECTURE_PLAN.md) +- [v1.3.0 发布说明](docs/RELEASE_NOTES_v1.3.0.md) - [v1.2.0 发布说明](docs/RELEASE_NOTES_v1.2.0.md) - [v1.1.0 发布说明](docs/RELEASE_NOTES_v1.1.0.md) - [v1 完成审计](docs/DS_AGENT_V1_COMPLETION_AUDIT.md) diff --git a/SECURITY.md b/SECURITY.md index 8ee86f2..5e047aa 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,6 +1,6 @@ # Security Policy -DS Agent is a local-first Windows desktop agent. DS Agent v1.2.0 is the current +DS Agent is a local-first Windows desktop agent. DS Agent v1.3.0 is the current published stable release and is not an official DeepSeek product. Security reports are welcome, especially around local credentials, permission gates, audit records, Computer Use boundaries, update integrity, code signing, and @@ -10,6 +10,7 @@ package import/export behavior. | Version | Supported | | --- | --- | +| 1.3.0 | Supported | | 1.2.0 | Supported | | 1.1.0 | Supported | | 1.0.2 | Supported | @@ -31,7 +32,7 @@ Include: ## Security Boundaries -- Current stable v1.2.0 stores one user-supplied DeepSeek API key in a dedicated +- Current stable v1.3.0 stores one user-supplied DeepSeek API key in a dedicated Windows DPAPI vault. A process-environment key is an explicit compatibility fallback and is not copied into the vault. Presence alone is never treated as verified readiness; balance and required V4 model checks produce only a @@ -43,6 +44,12 @@ Include: text, frontend state, approval state, or artifact existence cannot directly mark a goal complete; stale, unknown, failed, duplicate, mismatched, or incomplete evidence fails closed. +- A model-returned capability proposal is descriptive only. The Kernel binds it + to the exact frozen task/Goal, derives the manifest/risk/preview, and persists + one grouped authorization. Approval/rejection/revocation bind the exact task, + group, projection, manifest, preview, revision, fingerprint, hash, scopes, + targets, and expiry. Approval grants only exact authority; it does not execute + a Tool, resume a task, or mark a Goal complete. - `pnpm test:secrets` scans tracked and unignored repository files for live `sk-` style keys and non-empty `DEEPSEEK_API_KEY` assignments without printing candidate values. @@ -57,7 +64,7 @@ Include: memory. - Release identity must follow the [code signing policy](CODE_SIGNING_POLICY.md). An unsigned or invalidly signed artifact must not be represented as a signed - release. DS Agent v1.2.0 is explicitly disclosed as Authenticode `NotSigned`; + release. DS Agent v1.3.0 is explicitly disclosed as Authenticode `NotSigned`; Windows may show `Unknown publisher` or a Microsoft Defender SmartScreen warning. See also the [privacy policy](PRIVACY.md). diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 53451e9..f855bf3 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-agent-os/desktop", "private": true, - "version": "1.2.0", + "version": "1.3.0", "author": "Lee take", "license": "Apache-2.0", "type": "module", diff --git a/apps/desktop/src-tauri/Cargo.lock b/apps/desktop/src-tauri/Cargo.lock index e214b63..454d1f9 100644 --- a/apps/desktop/src-tauri/Cargo.lock +++ b/apps/desktop/src-tauri/Cargo.lock @@ -794,7 +794,7 @@ dependencies = [ [[package]] name = "deepseek-agent-os-desktop" -version = "1.2.0" +version = "1.3.0" dependencies = [ "base64 0.22.1", "chrono", diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index 1462483..82d81e9 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -1,11 +1,14 @@ [package] name = "deepseek-agent-os-desktop" -version = "1.2.0" +version = "1.3.0" description = "Local-first DeepSeek AI work platform" edition = "2021" license = "Apache-2.0" authors = ["Lee take"] +[profile.release] +strip = "symbols" + [build-dependencies] tauri-build = { version = "2.2.0", features = [] } diff --git a/apps/desktop/src-tauri/nsis/shortcut-icons.nsh b/apps/desktop/src-tauri/nsis/shortcut-icons.nsh index ed8d69a..37b6cbf 100644 --- a/apps/desktop/src-tauri/nsis/shortcut-icons.nsh +++ b/apps/desktop/src-tauri/nsis/shortcut-icons.nsh @@ -1,3 +1,9 @@ +!macro NSIS_HOOK_PREINSTALL + ; Do not embed source-file mtimes in the NSIS data block. The application PE + ; and checked-in resources are content-bound; timestamps are not identity. + SetDateSave off +!macroend + !macro NSIS_HOOK_POSTINSTALL !if "${STARTMENUFOLDER}" != "" Delete "$SMPROGRAMS\$AppStartMenuFolder\${PRODUCTNAME}.lnk" diff --git a/apps/desktop/src-tauri/src/commands.rs b/apps/desktop/src-tauri/src/commands.rs index 00b7d28..6971f04 100644 --- a/apps/desktop/src-tauri/src/commands.rs +++ b/apps/desktop/src-tauri/src/commands.rs @@ -98,7 +98,8 @@ use crate::kernel::expert_team::{ }; use crate::kernel::goal_envelope::GoalEnvelopeProposal; use crate::kernel::goal_lifecycle::{ - GoalEnvelopeUiProjection, GoalLifecycleStatus, GoalTargetBindingKind, GoalValidationContext, + GoalEnvelopeUiProjection, GoalEnvelopeUiStatus, GoalLifecycleStatus, GoalTargetBindingKind, + GoalValidationContext, }; use crate::kernel::local_directory::{ load_local_directory_state, local_directory_readiness_from_state, @@ -158,6 +159,12 @@ use crate::kernel::skill_source::{ use crate::kernel::soul::{ AgentSoulProfileUpdateAudit, AgentSoulProfileUpdateProposal, AgentSoulProfileUpdateReceipt, }; +use crate::kernel::task_capability_manifest::{ + TaskCapabilityManifestContext, TaskCapabilityProposal, +}; +use crate::kernel::task_grouped_approval::{ + TaskGroupedAuthorizationIntent, TaskGroupedAuthorizationView, +}; use crate::kernel::tool_runtime::{ builtin_tool_catalog, prepare_tool_execution, tool_approval_preview, tool_request_fingerprint, AgentToolExecutor, ToolEvidence, ToolExecutionOutput, ToolExecutionPlan, ToolExecutionRequest, @@ -314,6 +321,7 @@ const AGENT_RUN_GUIDANCE_MAX_ITEMS_PER_ROUND: usize = 8; const AGENT_RUN_GUIDANCE_PROMPT_CHAR_LIMIT: usize = 12_000; const AGENT_SKILL_CATALOG_MAX_ITEMS: usize = 24; const AGENT_CHAT_SYSTEM_PROMPT: &str = "You are the DeepSeek reasoning layer for DS Agent. DS Agent is the local execution layer. Read the full user message and return one structured agent envelope as JSON. Separate reply_to_user, goal_envelope, agent_actions, missing_prerequisites, required_confirmations, artifact_targets, memory_candidates, soul_profile_update, subagent_plan, and expert_output. When proposing a goal_envelope, use version ds-agent.goal-envelope-proposal/v1 and include user_goal, assumptions, constraints, done_when, required_artifacts, verifiers, proposed_capabilities, external_targets, and stop_conditions. assumptions, constraints, proposed_capabilities, and stop_conditions are string arrays. Each done_when item has done_when_id and description; each required_artifact has artifact_id and description; each verifier has verifier_id, done_when_id, description, and evidence_kind; each external_target has target_id and description. The goal_envelope is only a proposal: it cannot approve execution, trust a path or external target, handle a secret, or declare completion. Soul is the durable cross-conversation identity and collaboration profile, not an ordinary memory candidate. Whenever the current user message explicitly defines, changes, or confirms any Soul setting, soul_profile_update must contain fields, clear_fields, current_message_evidence, and optional confirmation_context. This includes short confirmations such as yes when the immediately preceding context proposed a Soul setting. current_message_evidence must be an exact non-empty excerpt of the current user message; confirmation_context, when needed, must be an exact excerpt of the supplied conversation context. Use only the allowed Soul field names supplied by DS Agent. Keep identity roles exact: preferred_name is the user's own name, address_as is how DS Agent addresses the user, user_calls_ds_agent is the user's name for DS Agent, and ds_agent_should_refer_to_itself_as is DS Agent's self-reference. Never put DS Agent's name into preferred_name. Do not propose Soul updates for guesses, third-party statements, transient one-turn instructions, or sensitive values. Do not tell the user the setting was saved; DS Agent appends a persistence receipt only after the update is validated and written. For a complex task that materially benefits from specialists, subagent_plan may contain 2-4 unique roles chosen from research, analysis, production, review. Every item requires key, role, prompt, depends_on, capabilities, resources, budget, output_contract, and retry_policy. Use an acyclic flow: research and analysis may run in parallel when independent; production depends on relevant evidence/analysis; review depends on production. Only production may request managed_staging_write and a logical write resource. A child never writes an approved destination. Review cannot mutate staged output and must bind its decision to the exact production revision. Never create nested subagents or desktop-control subtasks. Leave subagent_plan empty for simple work. When executing an expert attempt, return expert_output with summary, evidence-linked claims, optional staged_content/staged_relative_path for production, and an exact-revision review verdict for review; never return another subagent_plan. Do not claim local tools ran; propose actions for DS Agent to validate and execute. Write reply_to_user for an ordinary user in the user's language. Lead with the useful conclusion or next step. Do not expose internal action types, tool IDs, protocol or schema names, policy enums, target=/evidence=/output= fields, raw JSON, or English verification receipts unless the user explicitly asks for technical details."; +const AGENT_TASK_CAPABILITY_PROPOSAL_PROMPT: &str = "When a goal proposes one or more local capabilities, also return task_capability_proposal using version ds-agent.task-capability-proposal/v1. It contains expires_at and a canonically sorted capabilities array. Each capability entry contains capability, application_ids, path_target_ids, account_target_ids, recipient_target_ids, time_window_target_ids, external_target_ids, and verifier_ids. Cover every proposed capability, external target, and verifier from the same goal exactly once across the entries. Sort the capability entries and every ID array lexically. Use application_ids=[\"ds-agent\"] for work performed by DS Agent, and use only target and verifier IDs from the same goal_envelope. This is descriptive-only: never include task IDs, goal revisions or fingerprints, internal tool IDs, risk, grants, authority, actors, approvals, resolutions, claims, tokens, permission state, manifest revisions or fingerprints, or any preview/hash/schema/renderer fields. DS Agent Kernel derives and validates all authority-bearing values after it freezes the goal."; const AGENT_OFFICE_CREATE_EVIDENCE_TEXT_LIMIT: usize = 1200; const AGENT_SOUL_PROFILE_FILE_NAME: &str = "soul.md"; const AGENT_SOUL_PROFILE_CONTEXT_MAX_BYTES: usize = 800; @@ -510,6 +518,8 @@ pub struct AgentChatResponse { pub goal_envelope: Option, #[serde(default, skip_deserializing)] pub goal_projection: Option, + #[serde(skip)] + task_capability_proposal: Option, pub proposed_actions: Vec, pub missing_prerequisites: Vec, pub memory_candidates: Vec, @@ -723,6 +733,11 @@ struct AgentModelEnvelope { reply_to_user: String, #[serde(default, deserialize_with = "deserialize_agent_goal_envelope")] goal_envelope: Option, + #[serde( + default, + deserialize_with = "deserialize_agent_task_capability_proposal" + )] + task_capability_proposal: Option, #[serde(default, alias = "proposed_actions")] agent_actions: Vec, #[serde(default)] @@ -761,6 +776,18 @@ where .map_err(serde::de::Error::custom) } +fn deserialize_agent_task_capability_proposal<'de, D>( + deserializer: D, +) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + Option::::deserialize(deserializer)? + .map(TaskCapabilityProposal::parse_value) + .transpose() + .map_err(serde::de::Error::custom) +} + fn deserialize_agent_protocol_version<'de, D>(deserializer: D) -> Result, D::Error> where D: serde::Deserializer<'de>, @@ -4731,6 +4758,9 @@ fn agent_chat_response_from_telemetry( let goal_envelope = parsed_envelope .as_ref() .and_then(|envelope| envelope.goal_envelope.clone()); + let task_capability_proposal = parsed_envelope + .as_ref() + .and_then(|envelope| envelope.task_capability_proposal.clone()); let mut memory_candidate_proposals = parsed_envelope .as_ref() .map(|envelope| envelope.memory_candidates.clone()) @@ -4779,6 +4809,7 @@ fn agent_chat_response_from_telemetry( protocol_version, goal_envelope, goal_projection: None, + task_capability_proposal, proposed_actions, missing_prerequisites, memory_candidates, @@ -8386,10 +8417,12 @@ fn agent_chat_with_transport_and_runtime_context( } let protocol_user_prompt = build_agent_chat_protocol_user_prompt(&request, &runtime_context); + let system_prompt = + format!("{AGENT_CHAT_SYSTEM_PROMPT}\n\n{AGENT_TASK_CAPABILITY_PROPOSAL_PROMPT}"); let deepseek_request = build_deepseek_chat_completion_request( request.model_route, request.thinking_level, - AGENT_CHAT_SYSTEM_PROMPT, + &system_prompt, &protocol_user_prompt, )?; let execution = @@ -8665,6 +8698,19 @@ fn goal_validation_context_for_agent_chat( context } +fn task_capability_manifest_context_for_agent_chat( + runtime_context: &AgentChatRuntimeContext, +) -> TaskCapabilityManifestContext { + let mut context = + TaskCapabilityManifestContext::default().with_application("ds-agent", "DS Agent"); + if runtime_context.workspace_authority_material.is_some() { + context = context + .with_target_display("workspace", "Selected workspace") + .with_target_display("selected-workspace", "Selected workspace"); + } + context +} + fn reconcile_agent_goal_projection( store: &Mutex, response: &mut AgentChatResponse, @@ -8687,9 +8733,29 @@ fn reconcile_agent_goal_projection( .map_err(event_store_error)?; } } - response.goal_projection = store + let projection = store .goal_envelope_ui_projection(goal_id) .map_err(event_store_error)?; + if projection + .as_ref() + .is_some_and(|projection| projection.status == GoalEnvelopeUiStatus::Frozen) + { + if let Some(proposal) = response.task_capability_proposal.as_ref() { + let context = task_capability_manifest_context_for_agent_chat(runtime_context); + store + .prepare_task_grouped_approval_from_proposal( + goal_id, + proposal, + &context, + Utc::now(), + ) + .map_err(event_store_error)?; + } + } + response.goal_projection = projection; + if response.goal_projection.is_some() { + response.goal_envelope = None; + } Ok(()) } @@ -10451,6 +10517,10 @@ fn merge_agent_chat_followup_response( if followup_response.goal_projection.is_none() { followup_response.goal_projection = initial_response.goal_projection.take(); } + if followup_response.task_capability_proposal.is_none() { + followup_response.task_capability_proposal = + initial_response.task_capability_proposal.take(); + } initial_response .proposed_actions .append(&mut followup_response.proposed_actions); @@ -12171,6 +12241,7 @@ fn dispatch_agent_action_proposals_with_store_mutex( protocol_version: response.protocol_version.clone(), goal_envelope: None, goal_projection: None, + task_capability_proposal: None, proposed_actions: vec![action.clone()], missing_prerequisites: Vec::new(), memory_candidates: Vec::new(), @@ -12251,6 +12322,7 @@ fn resume_agent_chat_action_with_clients_and_computer_use( protocol_version: "ds-agent-action-resume-v1".to_string(), goal_envelope: None, goal_projection: None, + task_capability_proposal: None, proposed_actions: vec![action], missing_prerequisites: Vec::new(), memory_candidates: Vec::new(), @@ -16511,6 +16583,39 @@ pub fn list_pending_capability_access_records( .map_err(event_store_error) } +#[tauri::command] +pub fn list_task_grouped_authorizations( + state: State<'_, AppState>, +) -> Result, String> { + let store = state.event_store.lock().map_err(|_| lock_error())?; + store + .list_task_grouped_authorizations(Utc::now()) + .map_err(event_store_error) +} + +#[tauri::command] +pub fn resolve_task_grouped_authorization( + intent: TaskGroupedAuthorizationIntent, + approved: bool, + state: State<'_, AppState>, +) -> Result { + let store = state.event_store.lock().map_err(|_| lock_error())?; + store + .resolve_task_grouped_authorization(&intent, approved, Utc::now()) + .map_err(event_store_error) +} + +#[tauri::command] +pub fn revoke_task_grouped_authorization( + intent: TaskGroupedAuthorizationIntent, + state: State<'_, AppState>, +) -> Result { + let store = state.event_store.lock().map_err(|_| lock_error())?; + store + .revoke_task_grouped_authorization(&intent, Utc::now()) + .map_err(event_store_error) +} + #[tauri::command] pub fn request_capability_access( access_mode: AccessMode, @@ -17729,8 +17834,10 @@ mod tests { }; use crate::kernel::deepseek_pricing::DeepSeekPricingSettings; use crate::kernel::event_store::EventStore; + use crate::kernel::goal_lifecycle::GoalEnvelopeUiStatus; use crate::kernel::local_directory::{ - LocalDirectorySettings, LocalDirectoryState, LOCAL_DIRECTORY_SETTINGS_FILE, + LocalDirectorySettings, LocalDirectoryState, WorkspaceReadinessCode, + LOCAL_DIRECTORY_SETTINGS_FILE, }; use crate::kernel::models::{ AccessMode, ComputerControlBackend, ComputerScreenshotBackend, LargeModelProvider, @@ -18076,6 +18183,7 @@ mod tests { protocol_version: "ds-agent-envelope-v1".to_string(), goal_envelope: None, goal_projection: None, + task_capability_proposal: None, proposed_actions: vec![AgentChatActionProposal { action_type: "app_update_check".to_string(), title: Some("检查 DS Agent 版本更新".to_string()), @@ -24297,6 +24405,154 @@ mod tests { } } + #[test] + fn c3d_production_chat_seam_prepares_one_durable_group_without_executing_tools() { + let evidence_kind = builtin_tool_catalog() + .into_iter() + .find(|contract| contract.id == FILE_READ_TOOL_ID) + .and_then(|contract| { + contract + .verification + .required_evidence_kinds + .into_iter() + .next() + }) + .expect("file read verifier kind"); + let model_envelope = serde_json::json!({ + "protocol_version": "ds-agent-envelope/v1", + "reply_to_user": "I prepared one exact task authorization for review.", + "goal_envelope": { + "version": "ds-agent.goal-envelope-proposal/v1", + "user_goal": "Read the selected workspace sources and verify the receipt.", + "assumptions": [], + "constraints": ["Use only the selected workspace."], + "done_when": [{ + "done_when_id": "sources-verified", + "description": "The selected sources have a verified local read receipt." + }], + "required_artifacts": [], + "verifiers": [{ + "verifier_id": "source-verifier-v1", + "done_when_id": "sources-verified", + "description": "Verify the local file read receipt.", + "evidence_kind": evidence_kind + }], + "proposed_capabilities": [FILE_READ_TOOL_ID], + "external_targets": [{ + "target_id": "workspace", + "description": "The locally selected workspace." + }], + "stop_conditions": ["Stop if the workspace binding changes."] + }, + "task_capability_proposal": { + "version": "ds-agent.task-capability-proposal/v1", + "expires_at": "2099-01-02T03:04:05Z", + "capabilities": [{ + "capability": CapabilityKind::FileRead.as_str(), + "application_ids": ["ds-agent"], + "path_target_ids": ["workspace"], + "account_target_ids": [], + "recipient_target_ids": [], + "time_window_target_ids": [], + "external_target_ids": ["workspace"], + "verifier_ids": ["source-verifier-v1"] + }] + }, + "agent_actions": [], + "missing_prerequisites": [] + }); + let transport = RecordingDeepSeekTransport::new(model_envelope.to_string()); + let cache = DeepSeekMemoryChatCompletionCache::default(); + let store = Mutex::new(EventStore::open_memory().unwrap()); + let start = AgentRunStart::queued( + "c3d-production-conversation".to_string(), + "Read and verify the selected workspace sources.".to_string(), + 0, + ) + .expect("queued run start"); + store + .lock() + .expect("store lock") + .append_agent_run_start(&start) + .expect("queued run appends"); + let runtime_context = AgentChatRuntimeContext { + workspace_ready: AgentChatReadiness::Ready, + workspace_readiness_code: WorkspaceReadinessCode::Ready, + workspace_authority_material: Some(b"c3d-workspace-authority".to_vec()), + ..AgentChatRuntimeContext::default() + }; + let file_client = RecordingFileContentClient::new("must not be read"); + let file_write_client = RecordingFileWriteClient::new(); + let search_client = RecordingNetworkSearchClient::new(); + let browser_client = RecordingBrowserPageClient::new(); + + let outcome = run_next_queued_agent_chat_with_clients_and_api_keys( + &store, + &transport, + &cache, + &["test-secret".to_string()], + "c3d-production-worker".to_string(), + ModelRoute::Flash, + ThinkingLevel::Fast, + AccessMode::AskEveryStep, + runtime_context.clone(), + None, + &file_client, + &file_write_client, + &search_client, + &browser_client, + ) + .expect("ordinary queued production chat seam succeeds") + .expect("queued run exists"); + assert_eq!(outcome.record.id, start.id); + assert_eq!(outcome.record.status, AgentRunStatus::Completed); + let mut response = outcome.response; + + let projection = response + .goal_projection + .as_ref() + .expect("frozen goal projection"); + assert_eq!(projection.goal_id, start.id); + assert_eq!(projection.status, GoalEnvelopeUiStatus::Frozen); + let goal_id = projection.goal_id; + assert!(response.goal_envelope.is_none()); + let serialized_response = serde_json::to_string(&response).unwrap(); + assert!(!serialized_response.contains("task_capability_proposal")); + assert!(!serialized_response.contains(FILE_READ_TOOL_ID)); + + let mut replay_context = runtime_context; + replay_context.active_run_id = Some(start.id); + super::reconcile_agent_goal_projection( + &store, + &mut response, + &replay_context, + AccessMode::AskEveryStep, + ) + .expect("duplicate chat reconciliation is idempotent"); + let views = store + .lock() + .unwrap() + .list_task_grouped_authorizations(Utc::now()) + .unwrap(); + assert_eq!(views.len(), 1); + assert_eq!(views[0].intent.task_id, goal_id); + assert_eq!( + views[0].status, + crate::kernel::task_grouped_approval::TaskGroupedApprovalStatus::Pending + ); + assert_eq!(views[0].applications, vec!["DS Agent"]); + assert_eq!(views[0].paths, vec!["Selected workspace"]); + assert_eq!(views[0].capability_audits.len(), 1); + assert_eq!( + views[0].capability_audits[0].capability, + CapabilityKind::FileRead + ); + assert!(file_client.recorded_calls().is_empty()); + assert!(file_write_client.recorded_calls().is_empty()); + assert!(search_client.recorded_calls().is_empty()); + assert!(browser_client.recorded_calls().is_empty()); + } + #[test] fn agent_chat_parses_object_reply_and_descriptive_prerequisite_fields() { let model_envelope = serde_json::json!({ diff --git a/apps/desktop/src-tauri/src/kernel/app_update.rs b/apps/desktop/src-tauri/src/kernel/app_update.rs index 108e975..a551baa 100644 --- a/apps/desktop/src-tauri/src/kernel/app_update.rs +++ b/apps/desktop/src-tauri/src/kernel/app_update.rs @@ -17,8 +17,8 @@ pub(crate) const APP_UPDATE_RELEASES_API_URL: &str = const APP_UPDATE_RELEASE_DOWNLOAD_PATH_PREFIX: &str = "/Lee-take/dsagent/releases/download/"; const APP_UPDATE_LEGACY_RELEASE_DOWNLOAD_PATH_PREFIX: &str = "/Lee-take/deepseek-agent-os/releases/download/"; -const APP_UPDATE_USER_AGENT: &str = "DS-Agent-Updater/1.2.0"; -const APP_UPDATE_CURRENT_RELEASE_TAG: &str = "v1.2.0"; +const APP_UPDATE_USER_AGENT: &str = "DS-Agent-Updater/1.3.0"; +const APP_UPDATE_CURRENT_RELEASE_TAG: &str = "v1.3.0"; #[cfg(windows)] const WINDOWS_CREATE_NO_WINDOW: u32 = 0x08000000; @@ -653,7 +653,7 @@ mod tests { let status = update_status_from_releases(releases, app_update_current_version()); assert!(!status.update_available); - assert_eq!(status.current_version, "v1.2.0"); + assert_eq!(status.current_version, "v1.3.0"); assert_eq!(status.latest_version.as_deref(), Some("0.3.0")); assert!(status.asset_name.is_none()); } diff --git a/apps/desktop/src-tauri/src/kernel/deepseek.rs b/apps/desktop/src-tauri/src/kernel/deepseek.rs index f4cd857..95cf300 100644 --- a/apps/desktop/src-tauri/src/kernel/deepseek.rs +++ b/apps/desktop/src-tauri/src/kernel/deepseek.rs @@ -265,7 +265,7 @@ impl Drop for DeepSeekOperationsBriefingSynt impl HttpDeepSeekChatCompletionTransport { pub fn new() -> Result { let client = reqwest::blocking::Client::builder() - .user_agent("DS-Agent/1.2.0 deepseek-v4") + .user_agent("DS-Agent/1.3.0 deepseek-v4") .timeout(std::time::Duration::from_secs( DEEPSEEK_CHAT_HTTP_TIMEOUT_SECS, )) @@ -982,7 +982,7 @@ mod tests { assert_eq!(response.first_text(), Some("ok")); assert!(recorded.raw.starts_with("POST / HTTP/1.1")); - assert!(normalized_headers.contains("user-agent: ds-agent/1.2.0 deepseek-v4")); + assert!(normalized_headers.contains("user-agent: ds-agent/1.3.0 deepseek-v4")); } #[test] diff --git a/apps/desktop/src-tauri/src/kernel/event_store.rs b/apps/desktop/src-tauri/src/kernel/event_store.rs index 76481e5..43fe87e 100644 --- a/apps/desktop/src-tauri/src/kernel/event_store.rs +++ b/apps/desktop/src-tauri/src/kernel/event_store.rs @@ -3,6 +3,7 @@ mod artifact; mod computer_use; mod connector_draft; +mod grouped_approval; mod read_execution; mod revocation; mod workspace_undo; @@ -1977,6 +1978,7 @@ impl EventStore { END"#, [], )?; + grouped_approval::migrate(self)?; self.replay_execution_projection_events()?; self.fail_legacy_connector_attachment_tools()?; Ok(()) @@ -10330,6 +10332,30 @@ impl EventStore { } pub fn append(&self, event: &KernelEvent) -> EventStoreResult<()> { + if event.event_type.starts_with("task_grouped_approval.") { + return Err(EventStoreError::InvalidState( + "task grouped approval events require the dedicated transactional state machine" + .to_string(), + )); + } + if event.event_type == PERMISSION_RESOLUTION_RECORDED_EVENT { + let resolution: PermissionResolution = serde_json::from_str(&event.payload_json)?; + if grouped_approval::is_grouped_request(self, resolution.request_id)? { + return Err(EventStoreError::InvalidState( + "task grouped approval resolution cannot use the generic event path" + .to_string(), + )); + } + } + if event.event_type == CAPABILITY_ACCESS_REQUESTED_EVENT { + let request: CapabilityAccessRequest = serde_json::from_str(&event.payload_json)?; + if grouped_approval::is_grouped_request(self, request.id)? { + return Err(EventStoreError::InvalidState( + "task grouped approval request cannot be replaced through the generic event path" + .to_string(), + )); + } + } let transaction = self.conn.unchecked_transaction()?; Self::insert_kernel_event(&transaction, event)?; transaction.commit()?; @@ -14051,11 +14077,15 @@ impl EventStore { pub fn list_pending_capability_access_records( &self, ) -> EventStoreResult> { - Ok(self - .list_capability_access_records()? - .into_iter() - .filter(|record| record.effective_status == CapabilityAccessStatus::PendingApproval) - .collect()) + let mut pending = Vec::new(); + for record in self.list_capability_access_records()? { + if record.effective_status == CapabilityAccessStatus::PendingApproval + && !grouped_approval::is_grouped_request(self, record.request.id)? + { + pending.push(record); + } + } + Ok(pending) } pub fn has_user_approved_capability( @@ -14090,6 +14120,11 @@ impl EventStore { approved: bool, note: String, ) -> EventStoreResult { + if grouped_approval::is_grouped_request(self, request_id)? { + return Err(EventStoreError::InvalidState( + "task grouped approval requires the single exact-task resolver".to_string(), + )); + } let record = self.capability_access_record_by_id(request_id)?; if record.request.capability == CapabilityKind::ConnectorAttachmentRead { @@ -14126,6 +14161,11 @@ impl EventStore { expected_preview_revision: u32, expected_preview_hash: &str, ) -> EventStoreResult { + if grouped_approval::is_grouped_request(self, request_id)? { + return Err(EventStoreError::InvalidState( + "task grouped approval requires the single exact-task resolver".to_string(), + )); + } let record = self.capability_access_record_by_id(request_id)?; if record.request.capability != CapabilityKind::ConnectorWrite || record.effective_status != CapabilityAccessStatus::PendingApproval @@ -14171,6 +14211,11 @@ impl EventStore { expected_preview_revision: u32, expected_preview_hash: &str, ) -> EventStoreResult { + if grouped_approval::is_grouped_request(self, request_id)? { + return Err(EventStoreError::InvalidState( + "task grouped approval requires the single exact-task resolver".to_string(), + )); + } let record = self.capability_access_record_by_id(request_id)?; if record.request.capability != CapabilityKind::ConnectorAttachmentRead || record.effective_status != CapabilityAccessStatus::PendingApproval diff --git a/apps/desktop/src-tauri/src/kernel/event_store/grouped_approval.rs b/apps/desktop/src-tauri/src/kernel/event_store/grouped_approval.rs new file mode 100644 index 0000000..cfc66c3 --- /dev/null +++ b/apps/desktop/src-tauri/src/kernel/event_store/grouped_approval.rs @@ -0,0 +1,2375 @@ +use chrono::{DateTime, SecondsFormat, Utc}; +use rusqlite::{params, OptionalExtension, Transaction, TransactionBehavior}; +use uuid::Uuid; + +use super::{ + EventStore, EventStoreError, EventStoreResult, CAPABILITY_ACCESS_REQUESTED_EVENT, + PERMISSION_RESOLUTION_RECORDED_EVENT, +}; +use crate::kernel::models::{AccessMode, KernelEvent}; +use crate::kernel::policy::{ + exact_tool_preview_hash, request_capability_access, CapabilityAccessRequest, + CapabilityAccessStatus, PermissionResolution, PolicyDecision, +}; +use crate::kernel::task_capability_manifest::{ + compile_task_capability_manifest, task_authorization_preview, TaskAuthorizationPreview, + TaskCapabilityManifest, TaskCapabilityManifestContext, TaskCapabilityProposal, +}; +use crate::kernel::task_grouped_approval::{ + capability_request_event_id_for, event_id_for, item_event_id_for, legacy_consumption_id_for, + permission_resolution_event_id_for, permission_resolution_id_for, TaskGroupedApproval, + TaskGroupedApprovalActor, TaskGroupedApprovalError, TaskGroupedApprovalResolutionClaim, + TaskGroupedApprovalStatus, TaskGroupedAuthorizationIntent, TaskGroupedAuthorizationView, + TaskGroupedCapabilityAudit, TaskGroupedCapabilityClaim, TaskGroupedCapabilityGrant, + TASK_GROUPED_APPROVAL_VERSION, +}; + +const TASK_GROUPED_APPROVAL_PREPARED_EVENT: &str = "task_grouped_approval.prepared"; +const TASK_GROUPED_APPROVAL_RESOLVED_EVENT: &str = "task_grouped_approval.resolved"; +const TASK_GROUPED_APPROVAL_REVOKED_EVENT: &str = "task_grouped_approval.revoked"; +const TASK_GROUPED_APPROVAL_EXPIRED_EVENT: &str = "task_grouped_approval.expired"; +const TASK_GROUPED_APPROVAL_SCOPE_CHANGED_EVENT: &str = "task_grouped_approval.scope_changed"; + +pub(super) fn is_grouped_request(store: &EventStore, request_id: Uuid) -> EventStoreResult { + Ok(store + .conn + .query_row( + r#"SELECT 1 FROM task_grouped_approval_item_audit + WHERE approval_request_id = ?1 + UNION ALL + SELECT 1 FROM task_grouped_approval_state AS state, + json_each(state.projection_json, '$.capability_audits') AS item + WHERE json_extract(item.value, '$.approval_request_id') = ?1 + LIMIT 1"#, + params![request_id.to_string()], + |_| Ok(()), + ) + .optional()? + .is_some()) +} + +pub(super) fn migrate(store: &EventStore) -> EventStoreResult<()> { + store.conn.execute_batch( + r#" + CREATE TABLE IF NOT EXISTS task_grouped_approval_state ( + group_id TEXT PRIMARY KEY NOT NULL, + task_id TEXT NOT NULL, + schema_version TEXT NOT NULL, + manifest_revision TEXT NOT NULL, + manifest_fingerprint TEXT NOT NULL, + preview_schema_revision INTEGER NOT NULL, + preview_renderer_revision INTEGER NOT NULL, + preview_hash TEXT NOT NULL, + expires_at TEXT NOT NULL, + status TEXT NOT NULL, + row_revision INTEGER NOT NULL, + projection_json TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + + CREATE INDEX IF NOT EXISTS idx_task_grouped_approval_task_status + ON task_grouped_approval_state (task_id, status, updated_at); + + CREATE UNIQUE INDEX IF NOT EXISTS idx_task_grouped_approval_one_active + ON task_grouped_approval_state (task_id) + WHERE status IN ('pending', 'approved'); + + CREATE TABLE IF NOT EXISTS task_grouped_approval_item_audit ( + audit_event_id TEXT PRIMARY KEY NOT NULL, + group_id TEXT NOT NULL, + item_id TEXT NOT NULL, + capability TEXT NOT NULL, + risk_level TEXT NOT NULL, + tool_id TEXT NOT NULL, + approval_request_id TEXT NOT NULL, + transition TEXT NOT NULL, + group_revision INTEGER NOT NULL, + created_at TEXT NOT NULL, + UNIQUE (group_id, item_id, group_revision) + ); + + CREATE INDEX IF NOT EXISTS idx_task_grouped_approval_item_group + ON task_grouped_approval_item_audit + (group_id, group_revision, item_id); + "#, + )?; + validate_all_rows(store) +} + +impl EventStore { + pub fn prepare_task_grouped_approval_from_proposal( + &self, + task_id: Uuid, + proposal: &TaskCapabilityProposal, + context: &TaskCapabilityManifestContext, + now: DateTime, + ) -> EventStoreResult { + let goal = self + .goal_envelope_projection(task_id)? + .ok_or_else(|| invalid("task capability proposal requires a frozen goal"))?; + let bound_proposal = proposal + .bind_to_frozen_goal(task_id, &goal) + .map_err(|_| invalid("task capability proposal is stale or invalid"))?; + let manifest = compile_task_capability_manifest(task_id, &goal, &bound_proposal, context) + .map_err(|_| { + invalid("task capability proposal cannot compile for the frozen goal") + })?; + let preview = task_authorization_preview(&manifest) + .map_err(|_| invalid("task authorization preview cannot be derived"))?; + self.prepare_task_grouped_approval(task_id, &manifest, &preview, now) + } + + pub fn prepare_task_grouped_approval( + &self, + task_id: Uuid, + manifest: &TaskCapabilityManifest, + preview: &TaskAuthorizationPreview, + now: DateTime, + ) -> EventStoreResult { + let goal = self + .goal_envelope_projection(task_id)? + .ok_or_else(|| invalid("task grouped approval requires a frozen goal"))?; + manifest + .validate_for_goal(&goal) + .map_err(|_| invalid("task grouped approval manifest is stale or invalid"))?; + preview + .validate_for_manifest(manifest) + .map_err(|_| invalid("task grouped approval preview is stale or invalid"))?; + let prepared = TaskGroupedApproval::new(manifest.clone(), preview.clone(), now) + .map_err(group_error)?; + if prepared.task_id != task_id { + return Err(invalid("task grouped approval task binding changed")); + } + + let transaction = Transaction::new_unchecked(&self.conn, TransactionBehavior::Immediate)?; + if let Some(existing) = load_group(&transaction, prepared.id)? { + transaction.commit()?; + return Ok(existing); + } + + let task_group_ids = { + let mut statement = transaction.prepare( + r#"SELECT group_id FROM task_grouped_approval_state + WHERE task_id = ?1 + ORDER BY created_at ASC, group_id ASC"#, + )?; + let ids = statement + .query_map(params![task_id.to_string()], |row| row.get::<_, String>(0))? + .collect::, _>>()?; + ids + }; + let mut task_already_resolved = false; + for existing_id in task_group_ids { + let existing_id = Uuid::parse_str(&existing_id)?; + let existing = load_group(&transaction, existing_id)? + .ok_or_else(|| invalid("task grouped approval projection disappeared"))?; + task_already_resolved |= existing.resolution.is_some(); + if !matches!( + existing.status, + TaskGroupedApprovalStatus::Pending | TaskGroupedApprovalStatus::Approved + ) { + continue; + } + let existing_expired = now >= existing.manifest.expires_at; + if existing.status == TaskGroupedApprovalStatus::Pending { + resolve_item_requests( + &transaction, + &existing, + false, + if existing_expired { + "Task grouped approval expired before replacement." + } else { + "Task grouped approval invalidated because the exact scope changed." + }, + now, + )?; + } + let changed = if existing_expired { + existing.expire(now).map_err(group_error)? + } else { + existing.scope_changed(now).map_err(group_error)? + }; + persist_transition(&transaction, &existing, &changed)?; + } + if task_already_resolved { + transaction.commit()?; + return Err(invalid( + "task grouped approval already has a user resolution; a new exact task is required", + )); + } + + insert_item_requests(&transaction, &prepared)?; + persist_new_group(&transaction, &prepared)?; + transaction.commit()?; + Ok(prepared) + } + + pub fn task_grouped_approval( + &self, + group_id: Uuid, + ) -> EventStoreResult> { + let group = load_group_connection(self, group_id)?; + if let Some(group) = &group { + validate_audit_history(self, group)?; + } + Ok(group) + } + + pub fn list_task_grouped_authorizations( + &self, + now: DateTime, + ) -> EventStoreResult> { + let group_ids = { + let mut statement = self.conn.prepare( + r#"SELECT group_id FROM task_grouped_approval_state + ORDER BY updated_at DESC, group_id ASC"#, + )?; + let ids = statement + .query_map([], |row| row.get::<_, String>(0))? + .collect::, _>>()?; + ids + }; + let mut views = Vec::with_capacity(group_ids.len()); + for group_id in group_ids { + let group_id = Uuid::parse_str(&group_id)?; + let group = self.refresh_task_grouped_approval_state(group_id, None, now)?; + let goal = self + .goal_envelope_projection(group.task_id)? + .ok_or_else(|| invalid("task grouped authorization lost its frozen goal"))?; + views.push(group.authorization_view(&goal).map_err(group_error)?); + } + Ok(views) + } + + pub fn resolve_task_grouped_authorization( + &self, + intent: &TaskGroupedAuthorizationIntent, + approved: bool, + now: DateTime, + ) -> EventStoreResult { + self.refresh_task_grouped_approval_state(intent.group_id, Some(intent.task_id), now)?; + let resolved = + self.resolve_task_grouped_approval(&intent.resolution_claim(), approved, now)?; + let goal = self + .goal_envelope_projection(resolved.task_id)? + .ok_or_else(|| invalid("task grouped authorization lost its frozen goal"))?; + resolved.authorization_view(&goal).map_err(group_error) + } + + pub fn revoke_task_grouped_authorization( + &self, + intent: &TaskGroupedAuthorizationIntent, + now: DateTime, + ) -> EventStoreResult { + self.refresh_task_grouped_approval_state(intent.group_id, Some(intent.task_id), now)?; + let revoked = self.revoke_task_grouped_approval(&intent.resolution_claim(), now)?; + let goal = self + .goal_envelope_projection(revoked.task_id)? + .ok_or_else(|| invalid("task grouped authorization lost its frozen goal"))?; + revoked.authorization_view(&goal).map_err(group_error) + } + + fn refresh_task_grouped_approval_state( + &self, + group_id: Uuid, + expected_task_id: Option, + now: DateTime, + ) -> EventStoreResult { + let current = self + .task_grouped_approval(group_id)? + .ok_or_else(|| EventStoreError::NotFound("task grouped approval".to_string()))?; + if expected_task_id.is_some_and(|task_id| task_id != current.task_id) { + return Err(invalid("task grouped authorization task binding changed")); + } + if !matches!( + current.status, + TaskGroupedApprovalStatus::Pending | TaskGroupedApprovalStatus::Approved + ) { + return Ok(current); + } + if now >= current.manifest.expires_at { + return self.expire_task_grouped_approval(group_id, current.task_id, now); + } + let goal_is_current = self + .goal_envelope_projection(current.task_id)? + .as_ref() + .is_some_and(|goal| current.manifest.validate_for_goal(goal).is_ok()); + if goal_is_current { + return Ok(current); + } + + let transaction = Transaction::new_unchecked(&self.conn, TransactionBehavior::Immediate)?; + let current = load_group(&transaction, group_id)? + .ok_or_else(|| EventStoreError::NotFound("task grouped approval".to_string()))?; + if !matches!( + current.status, + TaskGroupedApprovalStatus::Pending | TaskGroupedApprovalStatus::Approved + ) { + transaction.commit()?; + return Ok(current); + } + if current.status == TaskGroupedApprovalStatus::Pending { + resolve_item_requests( + &transaction, + ¤t, + false, + "Task grouped approval invalidated because the frozen goal changed.", + now, + )?; + } + let changed = current.scope_changed(now).map_err(group_error)?; + persist_transition(&transaction, ¤t, &changed)?; + transaction.commit()?; + Ok(changed) + } + + pub fn resolve_task_grouped_approval( + &self, + claim: &TaskGroupedApprovalResolutionClaim, + approved: bool, + now: DateTime, + ) -> EventStoreResult { + if claim.actor != TaskGroupedApprovalActor::User { + return Err(invalid( + "DeepSeek and frontend payloads cannot resolve task grouped approval", + )); + } + let transaction = Transaction::new_unchecked(&self.conn, TransactionBehavior::Immediate)?; + let current = load_group(&transaction, claim.group_id)? + .ok_or_else(|| EventStoreError::NotFound("task grouped approval".to_string()))?; + if current.resolution_replay_matches(claim, approved) + && matches!( + current.status, + TaskGroupedApprovalStatus::Approved | TaskGroupedApprovalStatus::Rejected + ) + { + transaction.commit()?; + return Ok(current); + } + if current.status != TaskGroupedApprovalStatus::Pending + || !current.binding_matches_resolution_claim(claim) + { + return Err(invalid( + "task grouped approval resolution is stale or already terminal", + )); + } + if now >= current.manifest.expires_at { + resolve_item_requests( + &transaction, + ¤t, + false, + "Task grouped approval expired before user resolution.", + now, + )?; + let expired = current.expire(now).map_err(group_error)?; + persist_transition(&transaction, ¤t, &expired)?; + transaction.commit()?; + return Err(invalid("task grouped approval expired")); + } + + resolve_item_requests( + &transaction, + ¤t, + approved, + if approved { + "Task grouped approval approved by the user." + } else { + "Task grouped approval rejected by the user." + }, + now, + )?; + let resolved = current.resolve(claim, approved, now).map_err(group_error)?; + persist_transition(&transaction, ¤t, &resolved)?; + transaction.commit()?; + Ok(resolved) + } + + pub fn revoke_task_grouped_approval( + &self, + claim: &TaskGroupedApprovalResolutionClaim, + now: DateTime, + ) -> EventStoreResult { + if !matches!( + claim.actor, + TaskGroupedApprovalActor::User | TaskGroupedApprovalActor::KernelLifecycle + ) { + return Err(invalid( + "DeepSeek and frontend payloads cannot revoke task grouped approval", + )); + } + let transaction = Transaction::new_unchecked(&self.conn, TransactionBehavior::Immediate)?; + let current = load_group(&transaction, claim.group_id)? + .ok_or_else(|| EventStoreError::NotFound("task grouped approval".to_string()))?; + if current.status == TaskGroupedApprovalStatus::Revoked + && terminal_replay_matches(¤t, claim) + { + transaction.commit()?; + return Ok(current); + } + if current.status != TaskGroupedApprovalStatus::Approved + || !current.binding_matches_resolution_claim(claim) + { + return Err(invalid( + "task grouped approval revocation is stale or unavailable", + )); + } + let revoked = current.revoke(claim.actor, now).map_err(group_error)?; + persist_transition(&transaction, ¤t, &revoked)?; + transaction.commit()?; + Ok(revoked) + } + + pub fn expire_task_grouped_approval( + &self, + group_id: Uuid, + task_id: Uuid, + now: DateTime, + ) -> EventStoreResult { + let transaction = Transaction::new_unchecked(&self.conn, TransactionBehavior::Immediate)?; + let current = load_group(&transaction, group_id)? + .ok_or_else(|| EventStoreError::NotFound("task grouped approval".to_string()))?; + if current.task_id != task_id { + return Err(invalid("task grouped approval task binding changed")); + } + if current.status == TaskGroupedApprovalStatus::Expired { + transaction.commit()?; + return Ok(current); + } + if now < current.manifest.expires_at + || !matches!( + current.status, + TaskGroupedApprovalStatus::Pending | TaskGroupedApprovalStatus::Approved + ) + { + return Err(invalid( + "task grouped approval cannot expire from its current state", + )); + } + if current.status == TaskGroupedApprovalStatus::Pending { + resolve_item_requests( + &transaction, + ¤t, + false, + "Task grouped approval expired before user resolution.", + now, + )?; + } + let expired = current.expire(now).map_err(group_error)?; + persist_transition(&transaction, ¤t, &expired)?; + transaction.commit()?; + Ok(expired) + } + + pub fn authorize_task_grouped_capability( + &self, + claim: &TaskGroupedCapabilityClaim, + now: DateTime, + ) -> EventStoreResult { + let current_goal = self.goal_envelope_projection(claim.task_id)?; + let transaction = Transaction::new_unchecked(&self.conn, TransactionBehavior::Immediate)?; + let current = load_group(&transaction, claim.group_id)? + .ok_or_else(|| EventStoreError::NotFound("task grouped approval".to_string()))?; + + if now >= current.manifest.expires_at + && matches!( + current.status, + TaskGroupedApprovalStatus::Pending | TaskGroupedApprovalStatus::Approved + ) + { + if current.status == TaskGroupedApprovalStatus::Pending { + resolve_item_requests( + &transaction, + ¤t, + false, + "Task grouped approval expired before capability use.", + now, + )?; + } + let expired = current.expire(now).map_err(group_error)?; + persist_transition(&transaction, ¤t, &expired)?; + transaction.commit()?; + return Err(invalid("task grouped approval expired")); + } + + let goal_is_current = current_goal + .as_ref() + .is_some_and(|goal| current.manifest.validate_for_goal(goal).is_ok()); + if !goal_is_current + && matches!( + current.status, + TaskGroupedApprovalStatus::Pending | TaskGroupedApprovalStatus::Approved + ) + { + if current.status == TaskGroupedApprovalStatus::Pending { + resolve_item_requests( + &transaction, + ¤t, + false, + "Task grouped approval invalidated because the frozen goal changed.", + now, + )?; + } + let changed = current.scope_changed(now).map_err(group_error)?; + persist_transition(&transaction, ¤t, &changed)?; + transaction.commit()?; + return Err(invalid("task grouped approval frozen scope changed")); + } + + if current.status != TaskGroupedApprovalStatus::Approved { + return Err(invalid("task grouped approval carries no authority")); + } + let item = current + .capability_item(claim) + .map_err(|_| invalid("task grouped capability claim is stale or mismatched"))?; + validate_underlying_item_approved(&transaction, ¤t, item)?; + let grant = TaskGroupedCapabilityGrant { + group_id: current.id, + task_id: current.task_id, + projection_revision: current.projection_revision, + manifest_revision: current.manifest.revision.clone(), + manifest_fingerprint: current.manifest.fingerprint.clone(), + preview_renderer_revision: current.preview.renderer_revision, + preview_hash: current.preview.preview_hash.clone(), + capability: item.capability, + tool_id: item.tool_id.clone(), + request_fingerprint: item.request_fingerprint.clone(), + approval_request_id: item.approval_request_id, + expires_at: current.manifest.expires_at, + }; + transaction.commit()?; + Ok(grant) + } +} + +fn insert_item_requests( + transaction: &Transaction<'_>, + group: &TaskGroupedApproval, +) -> EventStoreResult<()> { + for item in &group.capability_audits { + let mut request = request_capability_access(AccessMode::AskEveryStep, item.capability) + .map_err(invalid)?; + request.id = item.approval_request_id; + request.created_at = group.created_at; + request + .bind_exact_tool( + item.tool_id.clone(), + item.request_fingerprint.clone(), + item.exact_preview.clone(), + ) + .map_err(invalid)?; + if request.status != CapabilityAccessStatus::PendingApproval + || request.decision != PolicyDecision::Ask + || request.risk_level != item.risk_level + { + return Err(invalid( + "task grouped approval did not derive a pending exact policy request", + )); + } + let event = KernelEvent { + id: capability_request_event_id_for(group.id, &item.item_id), + event_type: CAPABILITY_ACCESS_REQUESTED_EVENT.to_string(), + payload_json: serde_json::to_string(&request)?, + created_at: group.created_at, + }; + EventStore::insert_kernel_event(transaction, &event)?; + } + Ok(()) +} + +fn resolve_item_requests( + transaction: &Transaction<'_>, + group: &TaskGroupedApproval, + approved: bool, + note: &str, + now: DateTime, +) -> EventStoreResult<()> { + for item in &group.capability_audits { + let (request_json, resolution_json, effective_status_json, row_revision) = transaction + .query_row( + r#"SELECT request_json, resolution_json, effective_status, row_revision + FROM capability_access_state WHERE request_id = ?1"#, + params![item.approval_request_id.to_string()], + |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, Option>(1)?, + row.get::<_, String>(2)?, + row.get::<_, u64>(3)?, + )) + }, + ) + .optional()? + .ok_or_else(|| invalid("task grouped approval exact item request is missing"))?; + let request: CapabilityAccessRequest = serde_json::from_str(&request_json)?; + let effective_status: CapabilityAccessStatus = + serde_json::from_str(&effective_status_json)?; + validate_underlying_request(group, item, &request)?; + if resolution_json.is_some() + || effective_status != CapabilityAccessStatus::PendingApproval + || row_revision != 0 + { + return Err(invalid( + "task grouped approval exact item request is stale or resolved", + )); + } + let scope = request + .exact_tool + .as_ref() + .ok_or_else(|| invalid("task grouped approval exact preview is missing"))?; + let mut resolution = PermissionResolution::new_exact( + request.id, + approved, + note.to_string(), + row_revision, + scope, + ) + .map_err(invalid)?; + resolution.id = permission_resolution_id_for(group.id, &item.item_id, approved); + resolution.created_at = now; + let event = KernelEvent { + id: permission_resolution_event_id_for(resolution.id), + event_type: PERMISSION_RESOLUTION_RECORDED_EVENT.to_string(), + payload_json: serde_json::to_string(&resolution)?, + created_at: now, + }; + EventStore::insert_kernel_event(transaction, &event)?; + if approved { + transaction.execute( + r#"INSERT INTO capability_approval_consumptions + (request_id, capability_invocation_id, consumed_at) + VALUES (?1, ?2, ?3)"#, + params![ + item.approval_request_id.to_string(), + legacy_consumption_id_for(group.id, &item.item_id).to_string(), + timestamp(now), + ], + )?; + } + } + Ok(()) +} + +fn validate_underlying_request( + group: &TaskGroupedApproval, + item: &TaskGroupedCapabilityAudit, + request: &CapabilityAccessRequest, +) -> EventStoreResult<()> { + let scope = request + .exact_tool + .as_ref() + .ok_or_else(|| invalid("task grouped approval exact preview is missing"))?; + if request.id != item.approval_request_id + || request.access_mode != AccessMode::AskEveryStep + || request.capability != item.capability + || request.risk_level != item.risk_level + || request.decision != PolicyDecision::Ask + || request.status != CapabilityAccessStatus::PendingApproval + || request.created_at != group.created_at + || scope.tool_id != item.tool_id + || scope.request_fingerprint != item.request_fingerprint + || scope.preview != item.exact_preview + || scope.preview_revision != item.exact_preview_revision + || scope.preview_hash != item.exact_preview_hash + || scope.preview_hash != exact_tool_preview_hash(scope.preview_revision, &scope.preview) + { + return Err(invalid( + "task grouped approval exact item binding is invalid", + )); + } + Ok(()) +} + +fn validate_underlying_item_approved( + transaction: &Transaction<'_>, + group: &TaskGroupedApproval, + item: &TaskGroupedCapabilityAudit, +) -> EventStoreResult<()> { + let (request_json, resolution_json, effective_status_json, row_revision) = transaction + .query_row( + r#"SELECT request_json, resolution_json, effective_status, row_revision + FROM capability_access_state WHERE request_id = ?1"#, + params![item.approval_request_id.to_string()], + |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, Option>(1)?, + row.get::<_, String>(2)?, + row.get::<_, u64>(3)?, + )) + }, + ) + .optional()? + .ok_or_else(|| invalid("task grouped approval exact item request is missing"))?; + let request: CapabilityAccessRequest = serde_json::from_str(&request_json)?; + validate_underlying_request(group, item, &request)?; + let resolution: PermissionResolution = serde_json::from_str( + resolution_json + .as_deref() + .ok_or_else(|| invalid("task grouped approval exact item resolution is missing"))?, + )?; + let effective_status: CapabilityAccessStatus = serde_json::from_str(&effective_status_json)?; + let scope = request + .exact_tool + .as_ref() + .ok_or_else(|| invalid("task grouped approval exact preview is missing"))?; + if !resolution.approved + || effective_status != CapabilityAccessStatus::Approved + || row_revision != 1 + || resolution.request_id != request.id + || resolution.expected_request_revision != Some(0) + || resolution.exact_preview_revision != Some(scope.preview_revision) + || resolution.exact_preview_hash.as_deref() != Some(scope.preview_hash.as_str()) + || resolution.id != permission_resolution_id_for(group.id, &item.item_id, true) + { + return Err(invalid( + "task grouped approval exact item resolution is invalid", + )); + } + let consumption: Option = transaction + .query_row( + r#"SELECT capability_invocation_id FROM capability_approval_consumptions + WHERE request_id = ?1"#, + params![item.approval_request_id.to_string()], + |row| row.get(0), + ) + .optional()?; + if consumption.as_deref() + != Some( + legacy_consumption_id_for(group.id, &item.item_id) + .to_string() + .as_str(), + ) + { + return Err(invalid( + "task grouped approval item escaped the task-only authority boundary", + )); + } + Ok(()) +} + +fn persist_new_group( + transaction: &Transaction<'_>, + group: &TaskGroupedApproval, +) -> EventStoreResult<()> { + group.validate_integrity().map_err(group_error)?; + transaction.execute( + r#"INSERT INTO task_grouped_approval_state + (group_id, task_id, schema_version, manifest_revision, + manifest_fingerprint, preview_schema_revision, + preview_renderer_revision, preview_hash, expires_at, status, + row_revision, projection_json, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)"#, + state_params(group)?, + )?; + insert_group_event(transaction, group)?; + insert_item_audit_events(transaction, group) +} + +fn persist_transition( + transaction: &Transaction<'_>, + previous: &TaskGroupedApproval, + next: &TaskGroupedApproval, +) -> EventStoreResult<()> { + previous.validate_integrity().map_err(group_error)?; + next.validate_integrity().map_err(group_error)?; + if next.id != previous.id + || next.task_id != previous.task_id + || next.projection_revision != previous.projection_revision + 1 + || next.manifest != previous.manifest + || next.preview != previous.preview + { + return Err(invalid("task grouped approval transition binding changed")); + } + let changed = transaction.execute( + r#"UPDATE task_grouped_approval_state SET + status = ?2, row_revision = ?3, projection_json = ?4, updated_at = ?5 + WHERE group_id = ?1 AND row_revision = ?6 + AND manifest_revision = ?7 AND manifest_fingerprint = ?8 + AND preview_schema_revision = ?9 + AND preview_renderer_revision = ?10 AND preview_hash = ?11"#, + params![ + next.id.to_string(), + next.status.as_str(), + next.projection_revision, + next.canonical_json().map_err(group_error)?, + timestamp(next.updated_at), + previous.projection_revision, + next.manifest.revision, + next.manifest.fingerprint, + next.preview.schema_revision, + next.preview.renderer_revision, + next.preview.preview_hash, + ], + )?; + if changed != 1 { + return Err(invalid("task grouped approval projection revision changed")); + } + insert_group_event(transaction, next)?; + insert_item_audit_events(transaction, next) +} + +fn insert_group_event( + transaction: &Transaction<'_>, + group: &TaskGroupedApproval, +) -> EventStoreResult<()> { + let event = KernelEvent { + id: event_id_for(group), + event_type: event_type_for(group.status).to_string(), + payload_json: serde_json::to_string(&group.event_receipt())?, + created_at: group.updated_at, + }; + EventStore::insert_kernel_event(transaction, &event) +} + +fn insert_item_audit_events( + transaction: &Transaction<'_>, + group: &TaskGroupedApproval, +) -> EventStoreResult<()> { + for item in &group.capability_audits { + let changed = transaction.execute( + r#"INSERT OR IGNORE INTO task_grouped_approval_item_audit + (audit_event_id, group_id, item_id, capability, risk_level, + tool_id, approval_request_id, transition, group_revision, created_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)"#, + params![ + item_event_id_for(group, item).to_string(), + group.id.to_string(), + item.item_id, + item.capability.as_str(), + risk_name(item.risk_level), + item.tool_id, + item.approval_request_id.to_string(), + group.status.as_str(), + group.projection_revision, + timestamp(group.updated_at), + ], + )?; + if changed != 1 { + return Err(invalid( + "task grouped approval duplicate item audit transition", + )); + } + } + Ok(()) +} + +fn state_params(group: &TaskGroupedApproval) -> EventStoreResult<[rusqlite::types::Value; 14]> { + Ok([ + group.id.to_string().into(), + group.task_id.to_string().into(), + TASK_GROUPED_APPROVAL_VERSION.to_string().into(), + group.manifest.revision.clone().into(), + group.manifest.fingerprint.clone().into(), + i64::from(group.preview.schema_revision).into(), + i64::from(group.preview.renderer_revision).into(), + group.preview.preview_hash.clone().into(), + timestamp(group.manifest.expires_at).into(), + group.status.as_str().to_string().into(), + i64::try_from(group.projection_revision) + .map_err(|_| invalid("task grouped approval revision is too large"))? + .into(), + group.canonical_json().map_err(group_error)?.into(), + timestamp(group.created_at).into(), + timestamp(group.updated_at).into(), + ]) +} + +fn load_group( + transaction: &Transaction<'_>, + group_id: Uuid, +) -> EventStoreResult> { + let row = transaction + .query_row( + r#"SELECT task_id, schema_version, manifest_revision, + manifest_fingerprint, preview_schema_revision, + preview_renderer_revision, preview_hash, expires_at, + status, row_revision, projection_json, created_at, updated_at + FROM task_grouped_approval_state WHERE group_id = ?1"#, + params![group_id.to_string()], + read_group_row, + ) + .optional()?; + row.map(|row| validate_row(group_id, row)).transpose() +} + +fn load_group_connection( + store: &EventStore, + group_id: Uuid, +) -> EventStoreResult> { + let row = store + .conn + .query_row( + r#"SELECT task_id, schema_version, manifest_revision, + manifest_fingerprint, preview_schema_revision, + preview_renderer_revision, preview_hash, expires_at, + status, row_revision, projection_json, created_at, updated_at + FROM task_grouped_approval_state WHERE group_id = ?1"#, + params![group_id.to_string()], + read_group_row, + ) + .optional()?; + row.map(|row| validate_row(group_id, row)).transpose() +} + +type GroupRow = ( + String, + String, + String, + String, + u32, + u32, + String, + String, + String, + u64, + String, + String, + String, +); + +fn read_group_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(( + row.get(0)?, + row.get(1)?, + row.get(2)?, + row.get(3)?, + row.get(4)?, + row.get(5)?, + row.get(6)?, + row.get(7)?, + row.get(8)?, + row.get(9)?, + row.get(10)?, + row.get(11)?, + row.get(12)?, + )) +} + +fn validate_row(group_id: Uuid, row: GroupRow) -> EventStoreResult { + let ( + task_id, + schema_version, + manifest_revision, + manifest_fingerprint, + preview_schema_revision, + preview_renderer_revision, + preview_hash, + expires_at, + status, + row_revision, + projection_json, + created_at, + updated_at, + ) = row; + let group = TaskGroupedApproval::parse_json(&projection_json).map_err(group_error)?; + if group.id != group_id + || group.task_id != Uuid::parse_str(&task_id)? + || schema_version != TASK_GROUPED_APPROVAL_VERSION + || group.manifest.revision != manifest_revision + || group.manifest.fingerprint != manifest_fingerprint + || group.preview.schema_revision != preview_schema_revision + || group.preview.renderer_revision != preview_renderer_revision + || group.preview.preview_hash != preview_hash + || timestamp(group.manifest.expires_at) != expires_at + || group.status.as_str() != status + || group.projection_revision != row_revision + || timestamp(group.created_at) != created_at + || timestamp(group.updated_at) != updated_at + { + return Err(invalid("task grouped approval projection columns drifted")); + } + Ok(group) +} + +fn validate_all_rows(store: &EventStore) -> EventStoreResult<()> { + let ids = { + let mut statement = store + .conn + .prepare("SELECT group_id FROM task_grouped_approval_state ORDER BY group_id")?; + let ids = statement + .query_map([], |row| row.get::<_, String>(0))? + .collect::, _>>()?; + ids + }; + for id in ids { + let id = Uuid::parse_str(&id)?; + let group = load_group_connection(store, id)? + .ok_or_else(|| invalid("task grouped approval migration lost a projection"))?; + validate_audit_history(store, &group)?; + } + Ok(()) +} + +fn validate_audit_history(store: &EventStore, group: &TaskGroupedApproval) -> EventStoreResult<()> { + let mut statement = store.conn.prepare( + r#"SELECT audit_event_id, item_id, capability, risk_level, tool_id, + approval_request_id, transition, group_revision + FROM task_grouped_approval_item_audit + WHERE group_id = ?1 ORDER BY group_revision ASC, item_id ASC"#, + )?; + let rows = statement + .query_map(params![group.id.to_string()], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, String>(3)?, + row.get::<_, String>(4)?, + row.get::<_, String>(5)?, + row.get::<_, String>(6)?, + row.get::<_, u64>(7)?, + )) + })? + .collect::, _>>()?; + let expected_count = group.capability_audits.len().checked_mul( + usize::try_from(group.projection_revision) + .map_err(|_| invalid("task grouped approval revision is too large"))? + + 1, + ); + if expected_count != Some(rows.len()) { + return Err(invalid( + "task grouped approval per-capability audit history is incomplete", + )); + } + for revision in 0..=group.projection_revision { + let transition = if revision == 0 { + TaskGroupedApprovalStatus::Pending + } else if revision == group.projection_revision { + group.status + } else { + TaskGroupedApprovalStatus::Approved + }; + let mut revision_group = group.clone(); + revision_group.status = transition; + revision_group.projection_revision = revision; + for item in &group.capability_audits { + let expected_event = item_event_id_for(&revision_group, item).to_string(); + let row = rows + .iter() + .find(|row| row.1 == item.item_id && row.7 == revision) + .ok_or_else(|| invalid("task grouped approval item audit transition is missing"))?; + if row.0 != expected_event + || row.2 != item.capability.as_str() + || row.3 != risk_name(item.risk_level) + || row.4 != item.tool_id + || row.5 != item.approval_request_id.to_string() + || row.6 != transition.as_str() + { + return Err(invalid( + "task grouped approval item audit transition was tampered", + )); + } + } + } + Ok(()) +} + +fn terminal_replay_matches( + group: &TaskGroupedApproval, + claim: &TaskGroupedApprovalResolutionClaim, +) -> bool { + group.id == claim.group_id + && group.task_id == claim.task_id + && group.projection_revision == claim.expected_projection_revision.saturating_add(1) + && group.manifest.revision == claim.manifest_revision + && group.manifest.fingerprint == claim.manifest_fingerprint + && group.preview.schema_revision == claim.preview_schema_revision + && group.preview.renderer_revision == claim.preview_renderer_revision + && group.preview.preview_hash == claim.preview_hash +} + +fn event_type_for(status: TaskGroupedApprovalStatus) -> &'static str { + match status { + TaskGroupedApprovalStatus::Pending => TASK_GROUPED_APPROVAL_PREPARED_EVENT, + TaskGroupedApprovalStatus::Approved | TaskGroupedApprovalStatus::Rejected => { + TASK_GROUPED_APPROVAL_RESOLVED_EVENT + } + TaskGroupedApprovalStatus::Revoked => TASK_GROUPED_APPROVAL_REVOKED_EVENT, + TaskGroupedApprovalStatus::Expired => TASK_GROUPED_APPROVAL_EXPIRED_EVENT, + TaskGroupedApprovalStatus::ScopeChanged => TASK_GROUPED_APPROVAL_SCOPE_CHANGED_EVENT, + } +} + +fn risk_name(risk: crate::kernel::policy::RiskLevel) -> &'static str { + match risk { + crate::kernel::policy::RiskLevel::Low => "low", + crate::kernel::policy::RiskLevel::Medium => "medium", + crate::kernel::policy::RiskLevel::High => "high", + crate::kernel::policy::RiskLevel::Critical => "critical", + } +} + +fn timestamp(value: DateTime) -> String { + value.to_rfc3339_opts(SecondsFormat::Nanos, true) +} + +fn group_error(error: TaskGroupedApprovalError) -> EventStoreError { + invalid(error.to_string()) +} + +fn invalid(message: impl Into) -> EventStoreError { + EventStoreError::InvalidState(message.into()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::kernel::goal_envelope::{ + GoalDoneWhenProposal, GoalEnvelopeProposal, GoalExternalTargetProposal, + GoalVerifierProposal, GOAL_ENVELOPE_PROPOSAL_VERSION, + }; + use crate::kernel::goal_lifecycle::{GoalTargetBindingKind, GoalValidationContext}; + use crate::kernel::local_directory::WorkspaceReadinessCode; + use crate::kernel::policy::{request_capability_access, CapabilityGrantState, CapabilityKind}; + use crate::kernel::task_capability_manifest::{ + compile_task_capability_manifest, task_authorization_preview, + TaskCapabilityDescriptionProposal, TaskCapabilityManifestContext, + TaskCapabilityManifestProposal, TaskCapabilityNeedProposal, TaskCapabilityProposal, + TASK_CAPABILITY_MANIFEST_VERSION, TASK_CAPABILITY_PROPOSAL_VERSION, + }; + use crate::kernel::task_grouped_approval::TaskGroupedCapabilityAuditStatus; + use crate::kernel::tool_runtime::{CONNECTOR_MUTATE_TOOL_ID, FILE_WRITE_TOOL_ID}; + use rusqlite::Connection; + use tempfile::tempdir; + + const TASK_ID: Uuid = Uuid::from_u128(0x33b); + const OTHER_TASK_ID: Uuid = Uuid::from_u128(0x33c); + + fn fixed_now() -> DateTime { + "2029-01-02T03:04:05Z".parse().unwrap() + } + + fn fixed_expiry() -> DateTime { + "2030-01-02T03:04:05Z".parse().unwrap() + } + + fn frozen_goal_for( + store: &EventStore, + task_id: Uuid, + path_authority: &[u8], + ) -> crate::kernel::goal_lifecycle::GoalLifecycleProjection { + let proposal = GoalEnvelopeProposal { + version: GOAL_ENVELOPE_PROPOSAL_VERSION.to_string(), + user_goal: "Create a verified report and send one approved external update." + .to_string(), + assumptions: Vec::new(), + constraints: vec!["Use only locally bound task scopes.".to_string()], + done_when: vec![ + GoalDoneWhenProposal { + done_when_id: "external-state-verified".to_string(), + description: "The external state is independently verified.".to_string(), + }, + GoalDoneWhenProposal { + done_when_id: "report-verified".to_string(), + description: "The local report is independently verified.".to_string(), + }, + ], + required_artifacts: Vec::new(), + verifiers: vec![ + GoalVerifierProposal { + verifier_id: "external-verifier-v1".to_string(), + done_when_id: "external-state-verified".to_string(), + description: "Verify the remote object state after the operation.".to_string(), + evidence_kind: "connector_remote_state".to_string(), + }, + GoalVerifierProposal { + verifier_id: "report-verifier-v1".to_string(), + done_when_id: "report-verified".to_string(), + description: "Verify the report hash and rendered output.".to_string(), + evidence_kind: "artifact_hash".to_string(), + }, + ], + proposed_capabilities: vec![ + CONNECTOR_MUTATE_TOOL_ID.to_string(), + FILE_WRITE_TOOL_ID.to_string(), + ], + external_targets: vec![ + GoalExternalTargetProposal { + target_id: "finance-account".to_string(), + description: "A locally bound external account.".to_string(), + }, + GoalExternalTargetProposal { + target_id: "finance-recipient".to_string(), + description: "A locally bound recipient.".to_string(), + }, + GoalExternalTargetProposal { + target_id: "report-folder".to_string(), + description: "A locally bound workspace path scope.".to_string(), + }, + GoalExternalTargetProposal { + target_id: "weekday-window".to_string(), + description: "A locally bound schedule window.".to_string(), + }, + ], + stop_conditions: vec!["Stop when any exact scope changes.".to_string()], + }; + let context = + GoalValidationContext::new(AccessMode::FullAccess, WorkspaceReadinessCode::Ready) + .with_max_risk(crate::kernel::policy::RiskLevel::Critical) + .with_enabled_tool(CONNECTOR_MUTATE_TOOL_ID, true) + .with_enabled_tool(FILE_WRITE_TOOL_ID, true) + .with_approval_route(CONNECTOR_MUTATE_TOOL_ID) + .with_approval_route(FILE_WRITE_TOOL_ID) + .with_verifier_kind("artifact_hash") + .with_verifier_kind("connector_remote_state") + .with_target_binding( + "finance-account", + GoalTargetBindingKind::Account, + b"account-authority-v1", + ) + .with_target_binding( + "finance-recipient", + GoalTargetBindingKind::Recipient, + b"recipient-authority-v1", + ) + .with_target_binding("report-folder", GoalTargetBindingKind::Path, path_authority) + .with_target_binding( + "weekday-window", + GoalTargetBindingKind::TimeWindow, + b"time-authority-v1", + ) + .allowing_local_effects() + .allowing_external_effects(); + let validated = store + .submit_goal_proposal(task_id, &proposal, &context) + .expect("goal validates"); + store + .freeze_goal_envelope(task_id, validated.revision().expect("goal revision")) + .expect("goal freezes") + } + + fn compiled_fixture_for( + store: &EventStore, + task_id: Uuid, + path_authority: &[u8], + path_label: &str, + expires_at: DateTime, + ) -> (TaskCapabilityManifest, TaskAuthorizationPreview) { + let goal = frozen_goal_for(store, task_id, path_authority); + let frozen = goal.frozen().expect("frozen goal"); + let proposal = TaskCapabilityManifestProposal { + version: TASK_CAPABILITY_MANIFEST_VERSION.to_string(), + task_id, + goal_id: task_id, + goal_revision: frozen.revision.clone(), + goal_fingerprint: frozen.fingerprint.clone(), + expires_at, + capabilities: vec![ + TaskCapabilityNeedProposal { + capability: "connector_write".to_string(), + tool_ids: vec![CONNECTOR_MUTATE_TOOL_ID.to_string()], + application_ids: vec!["outlook".to_string()], + path_target_ids: Vec::new(), + account_target_ids: vec!["finance-account".to_string()], + recipient_target_ids: vec!["finance-recipient".to_string()], + time_window_target_ids: vec!["weekday-window".to_string()], + external_target_ids: vec![ + "finance-account".to_string(), + "finance-recipient".to_string(), + "weekday-window".to_string(), + ], + verifier_ids: vec!["external-verifier-v1".to_string()], + }, + TaskCapabilityNeedProposal { + capability: "file_write".to_string(), + tool_ids: vec![FILE_WRITE_TOOL_ID.to_string()], + application_ids: vec!["excel".to_string()], + path_target_ids: vec!["report-folder".to_string()], + account_target_ids: Vec::new(), + recipient_target_ids: Vec::new(), + time_window_target_ids: Vec::new(), + external_target_ids: vec!["report-folder".to_string()], + verifier_ids: vec!["report-verifier-v1".to_string()], + }, + ], + }; + let context = manifest_context(path_label); + let manifest = compile_task_capability_manifest(task_id, &goal, &proposal, &context) + .expect("manifest compiles"); + let preview = task_authorization_preview(&manifest).expect("preview renders"); + (manifest, preview) + } + + fn manifest_context(path_label: &str) -> TaskCapabilityManifestContext { + TaskCapabilityManifestContext::default() + .with_application("excel", "Microsoft Excel") + .with_application("outlook", "Microsoft Outlook") + .with_target_display("finance-account", "Work mailbox account") + .with_target_display("finance-recipient", "finance-test@example.com") + .with_target_display("report-folder", path_label) + .with_target_display("weekday-window", "Weekdays 09:00-17:00 Asia/Shanghai") + } + + fn descriptive_proposal(expires_at: DateTime) -> TaskCapabilityProposal { + TaskCapabilityProposal { + version: TASK_CAPABILITY_PROPOSAL_VERSION.to_string(), + expires_at, + capabilities: vec![ + TaskCapabilityDescriptionProposal { + capability: "connector_write".to_string(), + application_ids: vec!["outlook".to_string()], + path_target_ids: Vec::new(), + account_target_ids: vec!["finance-account".to_string()], + recipient_target_ids: vec!["finance-recipient".to_string()], + time_window_target_ids: vec!["weekday-window".to_string()], + external_target_ids: vec![ + "finance-account".to_string(), + "finance-recipient".to_string(), + "weekday-window".to_string(), + ], + verifier_ids: vec!["external-verifier-v1".to_string()], + }, + TaskCapabilityDescriptionProposal { + capability: "file_write".to_string(), + application_ids: vec!["excel".to_string()], + path_target_ids: vec!["report-folder".to_string()], + account_target_ids: Vec::new(), + recipient_target_ids: Vec::new(), + time_window_target_ids: Vec::new(), + external_target_ids: vec!["report-folder".to_string()], + verifier_ids: vec!["report-verifier-v1".to_string()], + }, + ], + } + } + + fn compiled_fixture(store: &EventStore) -> (TaskCapabilityManifest, TaskAuthorizationPreview) { + compiled_fixture_for( + store, + TASK_ID, + b"path-authority-v1", + "Workspace / reports", + fixed_expiry(), + ) + } + + fn prepare(store: &EventStore) -> TaskGroupedApproval { + let (manifest, preview) = compiled_fixture(store); + store + .prepare_task_grouped_approval(TASK_ID, &manifest, &preview, fixed_now()) + .expect("group prepares") + } + + fn approve(store: &EventStore, group: &TaskGroupedApproval) -> TaskGroupedApproval { + store + .resolve_task_grouped_approval( + &TaskGroupedApprovalResolutionClaim::from_group( + group, + TaskGroupedApprovalActor::User, + ), + true, + fixed_now() + chrono::Duration::minutes(1), + ) + .expect("group approves") + } + + fn event_count(store: &EventStore, event_type: &str) -> i64 { + store + .conn + .query_row( + "SELECT COUNT(*) FROM kernel_events WHERE event_type = ?1", + params![event_type], + |row| row.get(0), + ) + .unwrap() + } + + #[test] + fn exact_group_approval_uses_one_user_resolution_and_per_capability_audit() { + let store = EventStore::open_memory().unwrap(); + let pending = prepare(&store); + assert_eq!(pending.capability_audits.len(), 2); + assert_eq!(event_count(&store, TASK_GROUPED_APPROVAL_PREPARED_EVENT), 1); + assert!(store + .list_pending_capability_access_records() + .unwrap() + .is_empty()); + assert_eq!( + store + .list_capability_access_records() + .unwrap() + .into_iter() + .filter(|record| { + pending + .capability_audits + .iter() + .any(|item| item.approval_request_id == record.request.id) + }) + .count(), + 2 + ); + for item in &pending.capability_audits { + assert!(store + .resolve_capability_access_request( + item.approval_request_id, + true, + "attempted per-item approval".to_string(), + ) + .is_err()); + assert!(store + .append_permission_resolution(&PermissionResolution::new( + item.approval_request_id, + true, + "forged model resolution".to_string(), + )) + .is_err()); + } + let connector_item = pending + .capability_audits + .iter() + .find(|item| item.capability == CapabilityKind::ConnectorWrite) + .unwrap(); + assert!(store + .resolve_connector_mutation_access_request( + connector_item.approval_request_id, + true, + "attempted connector bypass".to_string(), + 0, + connector_item.exact_preview_revision, + &connector_item.exact_preview_hash, + ) + .is_err()); + + let approved = approve(&store, &pending); + assert_eq!(approved.status, TaskGroupedApprovalStatus::Approved); + assert!(approved.status.carries_authority()); + assert_eq!( + store + .prepare_task_grouped_approval( + TASK_ID, + &approved.manifest, + &approved.preview, + fixed_now() + chrono::Duration::minutes(2), + ) + .unwrap(), + approved + ); + assert_eq!(event_count(&store, TASK_GROUPED_APPROVAL_RESOLVED_EVENT), 1); + assert_eq!( + store + .conn + .query_row( + "SELECT COUNT(*) FROM task_grouped_approval_item_audit WHERE group_id = ?1", + params![approved.id.to_string()], + |row| row.get::<_, i64>(0), + ) + .unwrap(), + 4 + ); + assert_eq!( + store + .list_permission_resolutions() + .unwrap() + .into_iter() + .filter(|resolution| { + approved + .capability_audits + .iter() + .any(|item| item.approval_request_id == resolution.request_id) + }) + .count(), + 2 + ); + assert_eq!( + store + .available_capability_grant_request_id(CapabilityKind::FileWrite) + .unwrap(), + None, + "group item approvals must never escape through the legacy reusable grant lookup" + ); + for item in &approved.capability_audits { + let claim = TaskGroupedCapabilityClaim::from_group_item(&approved, item); + let grant = store + .authorize_task_grouped_capability( + &claim, + fixed_now() + chrono::Duration::minutes(2), + ) + .expect("exact item authorizes"); + assert_eq!(grant.group_id, approved.id); + assert_eq!(grant.task_id, TASK_ID); + assert_eq!(grant.capability, item.capability); + assert_eq!(grant.tool_id, item.tool_id); + assert_eq!(grant.request_fingerprint, item.request_fingerprint); + assert_eq!(grant.approval_request_id, item.approval_request_id); + assert_eq!(grant.manifest_revision, approved.manifest.revision); + assert_eq!(grant.manifest_fingerprint, approved.manifest.fingerprint); + assert_eq!(grant.preview_hash, approved.preview.preview_hash); + assert_eq!( + grant.preview_renderer_revision, + approved.preview.renderer_revision + ); + assert_eq!(grant.projection_revision, 1); + assert_eq!(grant.expires_at, fixed_expiry()); + } + } + + #[test] + fn exact_group_rejection_and_resolution_replay_are_safe_and_idempotent() { + let store = EventStore::open_memory().unwrap(); + let pending = prepare(&store); + let claim = TaskGroupedApprovalResolutionClaim::from_group( + &pending, + TaskGroupedApprovalActor::User, + ); + let rejected = store + .resolve_task_grouped_approval( + &claim, + false, + fixed_now() + chrono::Duration::minutes(1), + ) + .unwrap(); + assert_eq!(rejected.status, TaskGroupedApprovalStatus::Rejected); + assert!(!rejected.status.carries_authority()); + assert_eq!( + store + .resolve_task_grouped_approval( + &claim, + false, + fixed_now() + chrono::Duration::minutes(2), + ) + .unwrap(), + rejected + ); + assert!( + store + .resolve_task_grouped_approval( + &claim, + true, + fixed_now() + chrono::Duration::minutes(2), + ) + .is_err() + ); + let capability_claim = + TaskGroupedCapabilityClaim::from_group_item(&rejected, &rejected.capability_audits[0]); + assert!(store + .authorize_task_grouped_capability( + &capability_claim, + fixed_now() + chrono::Duration::minutes(2), + ) + .is_err()); + assert_eq!(event_count(&store, TASK_GROUPED_APPROVAL_RESOLVED_EVENT), 1); + } + + #[test] + fn revoke_is_exact_idempotent_and_model_or_frontend_self_authority_is_rejected() { + let store = EventStore::open_memory().unwrap(); + let pending = prepare(&store); + for actor in [ + TaskGroupedApprovalActor::DeepSeekModel, + TaskGroupedApprovalActor::FrontendPayload, + ] { + let claim = TaskGroupedApprovalResolutionClaim::from_group(&pending, actor); + assert!(store + .resolve_task_grouped_approval( + &claim, + true, + fixed_now() + chrono::Duration::minutes(1), + ) + .is_err()); + } + assert!(store + .task_grouped_approval(pending.id) + .unwrap() + .unwrap() + .resolution + .is_none()); + + let approved = approve(&store, &pending); + for actor in [ + TaskGroupedApprovalActor::DeepSeekModel, + TaskGroupedApprovalActor::FrontendPayload, + ] { + let claim = TaskGroupedApprovalResolutionClaim::from_group(&approved, actor); + assert!(store + .revoke_task_grouped_approval(&claim, fixed_now() + chrono::Duration::minutes(2),) + .is_err()); + } + let revoke_claim = TaskGroupedApprovalResolutionClaim::from_group( + &approved, + TaskGroupedApprovalActor::User, + ); + let revoked = store + .revoke_task_grouped_approval(&revoke_claim, fixed_now() + chrono::Duration::minutes(2)) + .unwrap(); + assert_eq!(revoked.status, TaskGroupedApprovalStatus::Revoked); + assert_eq!( + store + .revoke_task_grouped_approval( + &revoke_claim, + fixed_now() + chrono::Duration::minutes(3), + ) + .unwrap(), + revoked + ); + let capability_claim = + TaskGroupedCapabilityClaim::from_group_item(&approved, &approved.capability_audits[0]); + assert!(store + .authorize_task_grouped_capability( + &capability_claim, + fixed_now() + chrono::Duration::minutes(3), + ) + .is_err()); + assert_eq!(event_count(&store, TASK_GROUPED_APPROVAL_REVOKED_EVENT), 1); + + let kernel_store = EventStore::open_memory().unwrap(); + let kernel_pending = prepare(&kernel_store); + let kernel_approved = approve(&kernel_store, &kernel_pending); + let kernel_claim = TaskGroupedApprovalResolutionClaim::from_group( + &kernel_approved, + TaskGroupedApprovalActor::KernelLifecycle, + ); + assert_eq!( + kernel_store + .revoke_task_grouped_approval( + &kernel_claim, + fixed_now() + chrono::Duration::minutes(2), + ) + .unwrap() + .status, + TaskGroupedApprovalStatus::Revoked + ); + } + + #[test] + fn expiry_is_durable_for_pending_and_approved_groups() { + let store = EventStore::open_memory().unwrap(); + let pending = prepare(&store); + let after_expiry = fixed_expiry() + chrono::Duration::seconds(1); + let resolution_claim = TaskGroupedApprovalResolutionClaim::from_group( + &pending, + TaskGroupedApprovalActor::User, + ); + assert!(store + .resolve_task_grouped_approval(&resolution_claim, true, after_expiry) + .is_err()); + let expired = store.task_grouped_approval(pending.id).unwrap().unwrap(); + assert_eq!(expired.status, TaskGroupedApprovalStatus::Expired); + assert_eq!( + store + .expire_task_grouped_approval(pending.id, TASK_ID, after_expiry) + .unwrap(), + expired + ); + assert!(store + .expire_task_grouped_approval(pending.id, OTHER_TASK_ID, after_expiry) + .is_err()); + + let store = EventStore::open_memory().unwrap(); + let pending = prepare(&store); + let approved = approve(&store, &pending); + let claim = + TaskGroupedCapabilityClaim::from_group_item(&approved, &approved.capability_audits[0]); + assert!(store + .authorize_task_grouped_capability(&claim, after_expiry) + .is_err()); + assert_eq!( + store + .task_grouped_approval(approved.id) + .unwrap() + .unwrap() + .status, + TaskGroupedApprovalStatus::Expired + ); + } + + #[test] + fn wrong_and_stale_resolution_or_capability_bindings_have_zero_authority() { + let store = EventStore::open_memory().unwrap(); + let pending = prepare(&store); + let base = TaskGroupedApprovalResolutionClaim::from_group( + &pending, + TaskGroupedApprovalActor::User, + ); + let mut wrong_claims = Vec::new(); + let mut claim = base.clone(); + claim.group_id = Uuid::new_v4(); + wrong_claims.push(claim); + let mut claim = base.clone(); + claim.task_id = OTHER_TASK_ID; + wrong_claims.push(claim); + let mut claim = base.clone(); + claim.expected_projection_revision += 1; + wrong_claims.push(claim); + let mut claim = base.clone(); + claim.manifest_revision = "a".repeat(64); + wrong_claims.push(claim); + let mut claim = base.clone(); + claim.manifest_fingerprint = "b".repeat(64); + wrong_claims.push(claim); + let mut claim = base.clone(); + claim.preview_schema_revision += 1; + wrong_claims.push(claim); + let mut claim = base.clone(); + claim.preview_renderer_revision += 1; + wrong_claims.push(claim); + let mut claim = base.clone(); + claim.preview_hash = "c".repeat(64); + wrong_claims.push(claim); + for claim in wrong_claims { + assert!(store + .resolve_task_grouped_approval( + &claim, + true, + fixed_now() + chrono::Duration::minutes(1), + ) + .is_err()); + } + let approved = approve(&store, &pending); + let base = + TaskGroupedCapabilityClaim::from_group_item(&approved, &approved.capability_audits[0]); + let mut wrong_claims = Vec::new(); + let mut claim = base.clone(); + claim.task_id = OTHER_TASK_ID; + wrong_claims.push(claim); + let mut claim = base.clone(); + claim.expected_projection_revision += 1; + wrong_claims.push(claim); + let mut claim = base.clone(); + claim.manifest_revision = "d".repeat(64); + wrong_claims.push(claim); + let mut claim = base.clone(); + claim.manifest_fingerprint = "e".repeat(64); + wrong_claims.push(claim); + let mut claim = base.clone(); + claim.preview_hash = "f".repeat(64); + wrong_claims.push(claim); + let mut claim = base.clone(); + claim.capability = CapabilityKind::FileRead; + wrong_claims.push(claim); + let mut claim = base.clone(); + claim.tool_id = FILE_WRITE_TOOL_ID.to_string(); + wrong_claims.push(claim); + let mut claim = base; + claim.request_fingerprint = "0".repeat(64); + wrong_claims.push(claim); + for claim in wrong_claims { + assert!(store + .authorize_task_grouped_capability( + &claim, + fixed_now() + chrono::Duration::minutes(2), + ) + .is_err()); + } + } + + #[test] + fn scope_or_external_target_change_invalidates_old_group_and_cross_task_reuse() { + let store = EventStore::open_memory().unwrap(); + let pending = prepare(&store); + let approved = approve(&store, &pending); + let old_claim = + TaskGroupedCapabilityClaim::from_group_item(&approved, &approved.capability_audits[0]); + + let (changed_manifest, changed_preview) = compiled_fixture_for( + &store, + TASK_ID, + b"path-authority-v2", + "Workspace / approved-reports", + fixed_expiry(), + ); + assert!(store + .prepare_task_grouped_approval( + TASK_ID, + &changed_manifest, + &changed_preview, + fixed_now() + chrono::Duration::minutes(3), + ) + .is_err()); + assert_eq!( + store + .task_grouped_approval(approved.id) + .unwrap() + .unwrap() + .status, + TaskGroupedApprovalStatus::ScopeChanged + ); + assert_eq!( + store + .list_task_grouped_authorizations(fixed_now() + chrono::Duration::minutes(3)) + .unwrap() + .into_iter() + .find(|view| view.intent.group_id == approved.id) + .unwrap() + .status, + TaskGroupedApprovalStatus::ScopeChanged + ); + assert!(store + .authorize_task_grouped_capability( + &old_claim, + fixed_now() + chrono::Duration::minutes(4), + ) + .is_err()); + + assert_eq!( + store + .conn + .query_row( + "SELECT COUNT(*) FROM task_grouped_approval_state WHERE task_id = ?1", + params![TASK_ID.to_string()], + |row| row.get::<_, i64>(0), + ) + .unwrap(), + 1, + "a resolved exact task cannot prompt for a second grouped resolution" + ); + + let pending_store = EventStore::open_memory().unwrap(); + let old_pending = prepare(&pending_store); + let (changed_manifest, changed_preview) = compiled_fixture_for( + &pending_store, + TASK_ID, + b"path-authority-v2", + "Workspace / approved-reports", + fixed_expiry(), + ); + let replacement = pending_store + .prepare_task_grouped_approval( + TASK_ID, + &changed_manifest, + &changed_preview, + fixed_now() + chrono::Duration::minutes(3), + ) + .unwrap(); + assert_ne!(replacement.id, old_pending.id); + assert_eq!(replacement.status, TaskGroupedApprovalStatus::Pending); + assert_eq!( + pending_store + .task_grouped_approval(old_pending.id) + .unwrap() + .unwrap() + .status, + TaskGroupedApprovalStatus::ScopeChanged + ); + let mut cross_task = old_claim; + cross_task.task_id = OTHER_TASK_ID; + assert!(store + .authorize_task_grouped_capability( + &cross_task, + fixed_now() + chrono::Duration::minutes(4), + ) + .is_err()); + } + + #[test] + fn duplicate_prepare_and_restart_are_deterministic_without_duplicate_events() { + let directory = tempdir().unwrap(); + let path = directory.path().join("grouped-approval.db"); + let store = EventStore::open(&path).unwrap(); + let (manifest, preview) = compiled_fixture(&store); + let first = store + .prepare_task_grouped_approval(TASK_ID, &manifest, &preview, fixed_now()) + .unwrap(); + let duplicate = store + .prepare_task_grouped_approval( + TASK_ID, + &manifest, + &preview, + fixed_now() + chrono::Duration::seconds(1), + ) + .unwrap(); + assert_eq!(first, duplicate); + assert_eq!(event_count(&store, TASK_GROUPED_APPROVAL_PREPARED_EVENT), 1); + assert!(store + .append( + &KernelEvent::new(TASK_GROUPED_APPROVAL_PREPARED_EVENT, first.event_receipt(),) + .unwrap(), + ) + .is_err()); + assert_eq!(event_count(&store, TASK_GROUPED_APPROVAL_PREPARED_EVENT), 1); + assert_eq!( + store + .conn + .query_row( + "SELECT COUNT(*) FROM task_grouped_approval_item_audit", + [], + |row| row.get::<_, i64>(0), + ) + .unwrap(), + 2 + ); + let approved = approve(&store, &first); + drop(store); + + let reopened = EventStore::open(&path).unwrap(); + let restored = reopened.task_grouped_approval(first.id).unwrap().unwrap(); + assert_eq!(restored, approved); + let claim = + TaskGroupedCapabilityClaim::from_group_item(&restored, &restored.capability_audits[0]); + reopened + .authorize_task_grouped_capability(&claim, fixed_now() + chrono::Duration::minutes(4)) + .expect("restart preserves exact authority"); + } + + #[test] + fn c3d_production_producer_is_idempotent_across_reconciliation_restart_and_terminal_state() { + let directory = tempdir().unwrap(); + let path = directory.path().join("c3d-producer.db"); + let store = EventStore::open(&path).unwrap(); + frozen_goal_for(&store, TASK_ID, b"path-authority-v1"); + let proposal = descriptive_proposal(fixed_expiry()); + let context = manifest_context("Workspace / reports"); + + let first = store + .prepare_task_grouped_approval_from_proposal(TASK_ID, &proposal, &context, fixed_now()) + .unwrap(); + let duplicate = store + .prepare_task_grouped_approval_from_proposal( + TASK_ID, + &proposal, + &context, + fixed_now() + chrono::Duration::seconds(1), + ) + .unwrap(); + assert_eq!(first, duplicate); + assert_eq!(event_count(&store, TASK_GROUPED_APPROVAL_PREPARED_EVENT), 1); + assert_eq!( + store + .list_task_grouped_authorizations(fixed_now()) + .unwrap() + .len(), + 1 + ); + drop(store); + + let reopened = EventStore::open(&path).unwrap(); + let replay = reopened + .prepare_task_grouped_approval_from_proposal( + TASK_ID, + &proposal, + &context, + fixed_now() + chrono::Duration::seconds(2), + ) + .unwrap(); + assert_eq!(first, replay); + assert_eq!( + event_count(&reopened, TASK_GROUPED_APPROVAL_PREPARED_EVENT), + 1 + ); + + let approved = approve(&reopened, &replay); + let terminal_replay = reopened + .prepare_task_grouped_approval_from_proposal( + TASK_ID, + &proposal, + &context, + fixed_now() + chrono::Duration::minutes(2), + ) + .unwrap(); + assert_eq!(terminal_replay, approved); + assert_eq!(terminal_replay.status, TaskGroupedApprovalStatus::Approved); + assert_eq!( + event_count(&reopened, TASK_GROUPED_APPROVAL_PREPARED_EVENT), + 1 + ); + + let expired_store = EventStore::open_memory().unwrap(); + frozen_goal_for(&expired_store, TASK_ID, b"path-authority-v1"); + let expired = descriptive_proposal(fixed_now()); + assert!(expired_store + .prepare_task_grouped_approval_from_proposal(TASK_ID, &expired, &context, fixed_now(),) + .is_err()); + assert!(expired_store + .list_task_grouped_authorizations(fixed_now()) + .unwrap() + .is_empty()); + } + + #[test] + fn additive_migration_is_idempotent_and_preserves_legacy_exact_tool_approval() { + let directory = tempdir().unwrap(); + let path = directory.path().join("legacy.db"); + let store = EventStore::open(&path).unwrap(); + let mut request = + request_capability_access(AccessMode::AskEveryStep, CapabilityKind::FileWrite).unwrap(); + request + .bind_exact_tool( + FILE_WRITE_TOOL_ID, + "1".repeat(64), + "Legacy exact file write preview", + ) + .unwrap(); + let request_id = request.id; + store.append_capability_access_request(&request).unwrap(); + store + .resolve_capability_access_request(request_id, true, "legacy user approval".to_string()) + .unwrap(); + store + .conn + .execute_batch( + "DROP TABLE task_grouped_approval_item_audit; + DROP TABLE task_grouped_approval_state;", + ) + .unwrap(); + drop(store); + + for _ in 0..2 { + let reopened = EventStore::open(&path).unwrap(); + let record = reopened + .list_capability_access_records() + .unwrap() + .into_iter() + .find(|record| record.request.id == request_id) + .unwrap(); + assert_eq!(record.effective_status, CapabilityAccessStatus::Approved); + assert_eq!(record.grant_state, CapabilityGrantState::OneShotAvailable); + assert_eq!( + reopened + .conn + .query_row( + "SELECT COUNT(*) FROM task_grouped_approval_state", + [], + |row| row.get::<_, i64>(0), + ) + .unwrap(), + 0 + ); + drop(reopened); + } + } + + #[test] + fn tampered_projection_or_per_item_audit_fails_closed_on_restart() { + let directory = tempdir().unwrap(); + let projection_path = directory.path().join("tampered-projection.db"); + let store = EventStore::open(&projection_path).unwrap(); + let group = prepare(&store); + drop(store); + let connection = Connection::open(&projection_path).unwrap(); + let json: String = connection + .query_row( + "SELECT projection_json FROM task_grouped_approval_state WHERE group_id = ?1", + params![group.id.to_string()], + |row| row.get(0), + ) + .unwrap(); + let mut value: serde_json::Value = serde_json::from_str(&json).unwrap(); + value["preview"]["preview_hash"] = serde_json::Value::String("0".repeat(64)); + connection + .execute( + "UPDATE task_grouped_approval_state SET projection_json = ?2 WHERE group_id = ?1", + params![group.id.to_string(), serde_json::to_string(&value).unwrap()], + ) + .unwrap(); + drop(connection); + assert!(EventStore::open(&projection_path).is_err()); + + let audit_path = directory.path().join("tampered-audit.db"); + let store = EventStore::open(&audit_path).unwrap(); + let group = prepare(&store); + drop(store); + let connection = Connection::open(&audit_path).unwrap(); + connection + .execute( + r#"UPDATE task_grouped_approval_item_audit SET transition = 'approved' + WHERE group_id = ?1 AND group_revision = 0"#, + params![group.id.to_string()], + ) + .unwrap(); + drop(connection); + assert!(EventStore::open(&audit_path).is_err()); + } + + #[test] + fn c3c_ui_projection_is_exact_redacted_and_keeps_per_capability_audit_visible() { + let store = EventStore::open_memory().unwrap(); + let pending = prepare(&store); + let views = store + .list_task_grouped_authorizations(fixed_now()) + .expect("authorization views load"); + assert_eq!(views.len(), 1); + let view = &views[0]; + assert_eq!(view.status, TaskGroupedApprovalStatus::Pending); + assert_eq!(view.intent.group_id, pending.id); + assert_eq!(view.intent.task_id, TASK_ID); + assert_eq!(view.intent.expected_projection_revision, 0); + assert_eq!( + view.goal, + "Create a verified report and send one approved external update." + ); + assert_eq!( + view.applications, + vec!["Microsoft Excel", "Microsoft Outlook"] + ); + assert_eq!(view.paths, vec!["Workspace / reports"]); + assert_eq!(view.accounts, vec!["Work mailbox account"]); + assert_eq!(view.recipients, vec!["finance-test@example.com"]); + assert_eq!( + view.time_windows, + vec!["Weekdays 09:00-17:00 Asia/Shanghai"] + ); + assert_eq!(view.verifiers.len(), 2); + assert_eq!(view.capability_audits.len(), 2); + assert!(view + .capability_audits + .iter() + .all(|audit| audit.status == TaskGroupedCapabilityAuditStatus::Pending)); + + let json = serde_json::to_string(view).unwrap().to_ascii_lowercase(); + for forbidden in [ + "approval_request_id", + "request_fingerprint", + "authority_fingerprint", + "exact_preview", + "tool_id", + "credential", + "provider_ref", + "claim", + "token", + "c:\\users\\private\\appdata", + ] { + assert!( + !json.contains(forbidden), + "UI projection leaked {forbidden}" + ); + } + } + + #[test] + fn c3c_exact_ui_intent_rejects_tamper_replay_and_frontend_authority_fields() { + let store = EventStore::open_memory().unwrap(); + let pending = prepare(&store); + let goal = store.goal_envelope_projection(TASK_ID).unwrap().unwrap(); + let intent = pending.authorization_view(&goal).unwrap().intent; + + let mut tampered = Vec::new(); + let mut wrong_group = intent.clone(); + wrong_group.group_id = Uuid::from_u128(0xdead); + tampered.push(wrong_group); + let mut wrong_task = intent.clone(); + wrong_task.task_id = OTHER_TASK_ID; + tampered.push(wrong_task); + let mut wrong_projection_revision = intent.clone(); + wrong_projection_revision.expected_projection_revision += 1; + tampered.push(wrong_projection_revision); + let mut wrong_manifest_revision = intent.clone(); + wrong_manifest_revision.manifest_revision = "0".repeat(64); + tampered.push(wrong_manifest_revision); + let mut wrong_manifest_fingerprint = intent.clone(); + wrong_manifest_fingerprint.manifest_fingerprint = "1".repeat(64); + tampered.push(wrong_manifest_fingerprint); + let mut wrong_preview_schema = intent.clone(); + wrong_preview_schema.preview_schema_revision += 1; + tampered.push(wrong_preview_schema); + let mut wrong_preview_renderer = intent.clone(); + wrong_preview_renderer.preview_renderer_revision += 1; + tampered.push(wrong_preview_renderer); + let mut wrong_preview_hash = intent.clone(); + wrong_preview_hash.preview_hash = "2".repeat(64); + tampered.push(wrong_preview_hash); + + for tampered_intent in tampered { + assert!(store + .resolve_task_grouped_authorization( + &tampered_intent, + true, + fixed_now() + chrono::Duration::minutes(1), + ) + .is_err()); + assert_eq!( + store + .task_grouped_approval(pending.id) + .unwrap() + .unwrap() + .status, + TaskGroupedApprovalStatus::Pending + ); + } + + let base_value = serde_json::to_value(&intent).unwrap(); + for forbidden in [ + "capability", + "risk", + "scope", + "target", + "authority", + "preview", + "grant", + "actor", + "claim", + "token", + ] { + let mut forged = base_value.clone(); + forged.as_object_mut().unwrap().insert( + forbidden.to_string(), + serde_json::Value::String("frontend-forgery".to_string()), + ); + assert!(serde_json::from_value::(forged).is_err()); + } + + let approved = store + .resolve_task_grouped_authorization( + &intent, + true, + fixed_now() + chrono::Duration::minutes(1), + ) + .unwrap(); + assert!(approved + .capability_audits + .iter() + .all(|audit| audit.status == TaskGroupedCapabilityAuditStatus::Approved)); + assert_eq!(event_count(&store, TASK_GROUPED_APPROVAL_RESOLVED_EVENT), 1); + let duplicate = store + .resolve_task_grouped_authorization( + &intent, + true, + fixed_now() + chrono::Duration::minutes(2), + ) + .unwrap(); + assert_eq!(approved, duplicate); + assert!(store + .resolve_task_grouped_authorization( + &intent, + false, + fixed_now() + chrono::Duration::minutes(2), + ) + .is_err()); + for item in &pending.capability_audits { + assert!(store + .resolve_capability_access_request( + item.approval_request_id, + true, + "forged per-item approval".to_string(), + ) + .is_err()); + } + + let revoked = store + .revoke_task_grouped_authorization( + &approved.intent, + fixed_now() + chrono::Duration::minutes(3), + ) + .unwrap(); + let duplicate_revoke = store + .revoke_task_grouped_authorization( + &approved.intent, + fixed_now() + chrono::Duration::minutes(4), + ) + .unwrap(); + assert_eq!(revoked, duplicate_revoke); + assert_eq!(revoked.status, TaskGroupedApprovalStatus::Revoked); + + let reject_store = EventStore::open_memory().unwrap(); + let reject_pending = prepare(&reject_store); + let reject_goal = reject_store + .goal_envelope_projection(TASK_ID) + .unwrap() + .unwrap(); + let rejected = reject_store + .resolve_task_grouped_authorization( + &reject_pending + .authorization_view(&reject_goal) + .unwrap() + .intent, + false, + fixed_now() + chrono::Duration::minutes(1), + ) + .unwrap(); + assert_eq!(rejected.status, TaskGroupedApprovalStatus::Rejected); + assert!(rejected + .capability_audits + .iter() + .all(|audit| audit.status == TaskGroupedCapabilityAuditStatus::Rejected)); + assert_eq!( + event_count(&reject_store, TASK_GROUPED_APPROVAL_RESOLVED_EVENT), + 1 + ); + } + + #[test] + fn c3c_ui_read_refreshes_expiry_and_survives_restart_without_new_authority() { + let directory = tempdir().unwrap(); + let path = directory.path().join("c3c-ui.db"); + let store = EventStore::open(&path).unwrap(); + let expiry = fixed_now() + chrono::Duration::minutes(1); + let (manifest, preview) = compiled_fixture_for( + &store, + TASK_ID, + b"path-authority-v1", + "Workspace / reports", + expiry, + ); + let group = store + .prepare_task_grouped_approval(TASK_ID, &manifest, &preview, fixed_now()) + .unwrap(); + drop(store); + + let reopened = EventStore::open(&path).unwrap(); + let views = reopened + .list_task_grouped_authorizations(expiry + chrono::Duration::seconds(1)) + .unwrap(); + assert_eq!(views.len(), 1); + assert_eq!(views[0].intent.group_id, group.id); + assert_eq!(views[0].status, TaskGroupedApprovalStatus::Expired); + assert!(reopened + .resolve_task_grouped_authorization( + &views[0].intent, + true, + expiry + chrono::Duration::seconds(2), + ) + .is_err()); + assert_eq!( + event_count(&reopened, TASK_GROUPED_APPROVAL_RESOLVED_EVENT), + 0 + ); + } + + #[test] + fn malformed_input_creates_zero_group_permission_or_execution_authority() { + let store = EventStore::open_memory().unwrap(); + let malformed = r#"{ + "version":"ds-agent.task-capability-manifest/v1", + "task_id":"00000000-0000-0000-0000-00000000033b", + "unexpected_authority":true + }"#; + assert!(TaskCapabilityManifest::parse_json(malformed).is_err()); + assert_eq!( + store + .conn + .query_row( + "SELECT COUNT(*) FROM task_grouped_approval_state", + [], + |row| row.get::<_, i64>(0), + ) + .unwrap(), + 0 + ); + assert!(store.list_capability_access_records().unwrap().is_empty()); + assert!(store.list_tool_invocations().unwrap().is_empty()); + assert_eq!(event_count(&store, TASK_GROUPED_APPROVAL_PREPARED_EVENT), 0); + } + + #[test] + fn grouped_projection_and_events_keep_secret_path_and_provider_refs_out() { + let store = EventStore::open_memory().unwrap(); + let group = prepare(&store); + let serialized = group.canonical_json().unwrap(); + let event_payloads = { + let mut statement = store + .conn + .prepare("SELECT payload_json FROM kernel_events ORDER BY rowid") + .unwrap(); + statement + .query_map([], |row| row.get::<_, String>(0)) + .unwrap() + .collect::, _>>() + .unwrap() + .join("\n") + }; + let combined = format!("{serialized}\n{event_payloads}").to_ascii_lowercase(); + for forbidden in [ + "sk-live-secret-marker", + "c:\\users\\private\\appdata", + "provider-message-id", + "credential_handle", + "claim_token", + "bearer ", + ] { + assert!(!combined.contains(forbidden), "leaked {forbidden}"); + } + for item in &group.capability_audits { + assert!(!item.exact_preview.contains("finance-test@example.com")); + assert!(!item.exact_preview.contains("Workspace / reports")); + } + } +} diff --git a/apps/desktop/src-tauri/src/kernel/mod.rs b/apps/desktop/src-tauri/src/kernel/mod.rs index 6600c71..bbb5d7a 100644 --- a/apps/desktop/src-tauri/src/kernel/mod.rs +++ b/apps/desktop/src-tauri/src/kernel/mod.rs @@ -31,6 +31,8 @@ pub mod sandbox; pub mod skill; pub mod skill_source; pub mod soul; +pub mod task_capability_manifest; +pub mod task_grouped_approval; pub mod task_lifecycle; pub mod tool_runtime; pub mod tool_strategy; diff --git a/apps/desktop/src-tauri/src/kernel/policy.rs b/apps/desktop/src-tauri/src/kernel/policy.rs index 8909091..d49113b 100644 --- a/apps/desktop/src-tauri/src/kernel/policy.rs +++ b/apps/desktop/src-tauri/src/kernel/policy.rs @@ -32,6 +32,33 @@ pub enum CapabilityKind { SkillUse, } +impl CapabilityKind { + pub const fn as_str(self) -> &'static str { + match self { + Self::FileRead => "file_read", + Self::FileWrite => "file_write", + Self::NetworkSearch => "network_search", + Self::BrowserBrowse => "browser_browse", + Self::BrowserSubmit => "browser_submit", + Self::EmailRead => "email_read", + Self::EmailDraft => "email_draft", + Self::EmailSend => "email_send", + Self::ConnectorAttachmentRead => "connector_attachment_read", + Self::ConnectorWrite => "connector_write", + Self::DriveRead => "drive_read", + Self::DriveWrite => "drive_write", + Self::TerminalRead => "terminal_read", + Self::TerminalWrite => "terminal_write", + Self::ComputerScreenshot => "computer_screenshot", + Self::ComputerControl => "computer_control", + Self::AppUpdateCheck => "app_update_check", + Self::AppUpdateDownload => "app_update_download", + Self::AppUpdateInstall => "app_update_install", + Self::SkillUse => "skill_use", + } + } +} + #[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(rename_all = "snake_case")] pub enum CapabilityFamily { diff --git a/apps/desktop/src-tauri/src/kernel/task_capability_manifest.rs b/apps/desktop/src-tauri/src/kernel/task_capability_manifest.rs new file mode 100644 index 0000000..7cc73c6 --- /dev/null +++ b/apps/desktop/src-tauri/src/kernel/task_capability_manifest.rs @@ -0,0 +1,2334 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::fmt; + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use sha2::{Digest, Sha256}; +use uuid::Uuid; + +use crate::kernel::goal_lifecycle::{GoalLifecycleProjection, GoalTargetBindingKind}; +use crate::kernel::policy::{ + builtin_capability_catalog, capability_risk, CapabilityDescriptor, CapabilityKind, RiskLevel, +}; +use crate::kernel::tool_runtime::{builtin_tool_catalog, ToolContract}; + +pub const TASK_CAPABILITY_MANIFEST_VERSION: &str = "ds-agent.task-capability-manifest/v1"; +pub const TASK_CAPABILITY_PROPOSAL_VERSION: &str = "ds-agent.task-capability-proposal/v1"; +pub const TASK_CAPABILITY_MANIFEST_SCHEMA_REVISION: u32 = 1; +pub const TASK_AUTHORIZATION_PREVIEW_VERSION: &str = "ds-agent.task-authorization-preview/v1"; +pub const TASK_AUTHORIZATION_PREVIEW_SCHEMA_REVISION: u32 = 1; +pub const TASK_AUTHORIZATION_PREVIEW_RENDERER_REVISION: u32 = 1; + +const MAX_JSON_BYTES: usize = 64 * 1024; +const MAX_CAPABILITIES: usize = 32; +const MAX_ITEMS_PER_FIELD: usize = 64; +const MAX_ID_BYTES: usize = 128; +const MAX_DISPLAY_BYTES: usize = 512; +const MANIFEST_REVISION_DOMAIN: &[u8] = b"ds-agent.task-capability-manifest-revision.v1\0"; +const MANIFEST_FINGERPRINT_DOMAIN: &[u8] = b"ds-agent.task-capability-manifest-fingerprint.v1\0"; +const PREVIEW_HASH_DOMAIN: &[u8] = b"ds-agent.task-authorization-preview-hash.v1\0"; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum TaskCapabilityManifestError { + JsonTooLarge, + InvalidJson, + UnsupportedVersion, + InvalidIdentifier, + InvalidBindingHash, + InvalidDisplay, + SecretLikeContent, + CollectionOutOfBounds, + DuplicateValue, + NonCanonicalOrder, + GoalNotFrozen, + TaskGoalIdentityMismatch, + GoalRevisionMismatch, + GoalFingerprintMismatch, + UnknownCapability, + UnknownTool, + ToolCapabilityMismatch, + CatalogRiskMismatch, + GoalCapabilityMismatch, + ScopeBindingMismatch, + UnknownApplication, + UnknownVerifier, + IncompleteGoalBinding, + ManifestIntegrityMismatch, + PreviewIntegrityMismatch, +} + +impl fmt::Display for TaskCapabilityManifestError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(match self { + Self::JsonTooLarge => "task capability manifest json is too large", + Self::InvalidJson => "task capability manifest json is invalid", + Self::UnsupportedVersion => "task capability manifest version is unsupported", + Self::InvalidIdentifier => "task capability manifest contains an invalid identifier", + Self::InvalidBindingHash => "task capability manifest contains an invalid binding hash", + Self::InvalidDisplay => "task capability manifest contains invalid display text", + Self::SecretLikeContent => "task capability manifest contains secret-like content", + Self::CollectionOutOfBounds => { + "task capability manifest collection is outside its bounds" + } + Self::DuplicateValue => "task capability manifest contains a duplicate value", + Self::NonCanonicalOrder => "task capability manifest is not canonically ordered", + Self::GoalNotFrozen => "task capability manifest requires a frozen goal", + Self::TaskGoalIdentityMismatch => { + "task capability manifest task or goal identity is stale" + } + Self::GoalRevisionMismatch => "task capability manifest goal revision is stale", + Self::GoalFingerprintMismatch => "task capability manifest goal fingerprint is stale", + Self::UnknownCapability => "task capability manifest capability is unknown", + Self::UnknownTool => "task capability manifest tool is unknown", + Self::ToolCapabilityMismatch => { + "task capability manifest tool and capability do not match" + } + Self::CatalogRiskMismatch => "task capability manifest catalog risk is inconsistent", + Self::GoalCapabilityMismatch => { + "task capability manifest exceeds or omits the frozen goal capability scope" + } + Self::ScopeBindingMismatch => "task capability manifest scope binding is stale", + Self::UnknownApplication => "task capability manifest application is not locally bound", + Self::UnknownVerifier => "task capability manifest verifier is not frozen in the goal", + Self::IncompleteGoalBinding => { + "task capability manifest does not bind the complete frozen goal" + } + Self::ManifestIntegrityMismatch => "task capability manifest integrity check failed", + Self::PreviewIntegrityMismatch => "task authorization preview integrity check failed", + }) + } +} + +impl std::error::Error for TaskCapabilityManifestError {} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct TaskCapabilityNeedProposal { + pub capability: String, + pub tool_ids: Vec, + pub application_ids: Vec, + pub path_target_ids: Vec, + pub account_target_ids: Vec, + pub recipient_target_ids: Vec, + pub time_window_target_ids: Vec, + pub external_target_ids: Vec, + pub verifier_ids: Vec, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct TaskCapabilityDescriptionProposal { + pub capability: String, + pub application_ids: Vec, + pub path_target_ids: Vec, + pub account_target_ids: Vec, + pub recipient_target_ids: Vec, + pub time_window_target_ids: Vec, + pub external_target_ids: Vec, + pub verifier_ids: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +pub struct TaskCapabilityProposal { + pub version: String, + pub expires_at: DateTime, + pub capabilities: Vec, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct TaskCapabilityProposalWire { + version: String, + expires_at: DateTime, + capabilities: Vec, +} + +impl From for TaskCapabilityProposal { + fn from(wire: TaskCapabilityProposalWire) -> Self { + Self { + version: wire.version, + expires_at: wire.expires_at, + capabilities: wire.capabilities, + } + } +} + +impl<'de> Deserialize<'de> for TaskCapabilityProposal { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let proposal = Self::from(TaskCapabilityProposalWire::deserialize(deserializer)?); + proposal.validate().map_err(serde::de::Error::custom)?; + Ok(proposal) + } +} + +impl TaskCapabilityProposal { + pub fn parse_json(json: &str) -> Result { + if json.len() > MAX_JSON_BYTES { + return Err(TaskCapabilityManifestError::JsonTooLarge); + } + let wire: TaskCapabilityProposalWire = + serde_json::from_str(json).map_err(|_| TaskCapabilityManifestError::InvalidJson)?; + let proposal = Self::from(wire); + proposal.validate()?; + Ok(proposal) + } + + pub fn parse_value(value: Value) -> Result { + let encoded = + serde_json::to_vec(&value).map_err(|_| TaskCapabilityManifestError::InvalidJson)?; + if encoded.len() > MAX_JSON_BYTES { + return Err(TaskCapabilityManifestError::JsonTooLarge); + } + let wire: TaskCapabilityProposalWire = + serde_json::from_value(value).map_err(|_| TaskCapabilityManifestError::InvalidJson)?; + let proposal = Self::from(wire); + proposal.validate()?; + Ok(proposal) + } + + pub fn to_json(&self) -> Result { + self.validate()?; + serde_json::to_string(self).map_err(|_| TaskCapabilityManifestError::InvalidJson) + } + + pub fn validate(&self) -> Result<(), TaskCapabilityManifestError> { + if self.version != TASK_CAPABILITY_PROPOSAL_VERSION { + return Err(TaskCapabilityManifestError::UnsupportedVersion); + } + if self.capabilities.is_empty() || self.capabilities.len() > MAX_CAPABILITIES { + return Err(TaskCapabilityManifestError::CollectionOutOfBounds); + } + validate_strict_order( + self.capabilities + .iter() + .map(|entry| entry.capability.as_str()), + )?; + let capability_catalog = builtin_capability_catalog(); + for entry in &self.capabilities { + validate_id(&entry.capability)?; + if capability_from_name(&capability_catalog, &entry.capability).is_none() { + return Err(TaskCapabilityManifestError::UnknownCapability); + } + validate_id_list(&entry.application_ids)?; + validate_id_list(&entry.path_target_ids)?; + validate_id_list(&entry.account_target_ids)?; + validate_id_list(&entry.recipient_target_ids)?; + validate_id_list(&entry.time_window_target_ids)?; + validate_id_list(&entry.external_target_ids)?; + validate_nonempty_id_list(&entry.verifier_ids)?; + for target_id in entry + .path_target_ids + .iter() + .chain(&entry.account_target_ids) + .chain(&entry.recipient_target_ids) + .chain(&entry.time_window_target_ids) + { + if entry.external_target_ids.binary_search(target_id).is_err() { + return Err(TaskCapabilityManifestError::IncompleteGoalBinding); + } + } + } + let encoded = + serde_json::to_vec(self).map_err(|_| TaskCapabilityManifestError::InvalidJson)?; + if encoded.len() > MAX_JSON_BYTES { + return Err(TaskCapabilityManifestError::JsonTooLarge); + } + Ok(()) + } + + pub fn bind_to_frozen_goal( + &self, + canonical_task_id: Uuid, + goal: &GoalLifecycleProjection, + ) -> Result { + self.validate()?; + let frozen = goal + .frozen() + .ok_or(TaskCapabilityManifestError::GoalNotFrozen)?; + if canonical_task_id != goal.goal_id { + return Err(TaskCapabilityManifestError::TaskGoalIdentityMismatch); + } + + let mut capabilities = Vec::with_capacity(self.capabilities.len()); + for entry in &self.capabilities { + let mut tool_ids = frozen + .envelope + .validated_capabilities + .iter() + .filter(|binding| binding.capability == entry.capability) + .map(|binding| binding.tool_id.clone()) + .collect::>(); + tool_ids.sort(); + if tool_ids.is_empty() { + return Err(TaskCapabilityManifestError::GoalCapabilityMismatch); + } + capabilities.push(TaskCapabilityNeedProposal { + capability: entry.capability.clone(), + tool_ids, + application_ids: entry.application_ids.clone(), + path_target_ids: entry.path_target_ids.clone(), + account_target_ids: entry.account_target_ids.clone(), + recipient_target_ids: entry.recipient_target_ids.clone(), + time_window_target_ids: entry.time_window_target_ids.clone(), + external_target_ids: entry.external_target_ids.clone(), + verifier_ids: entry.verifier_ids.clone(), + }); + } + + let proposal = TaskCapabilityManifestProposal { + version: TASK_CAPABILITY_MANIFEST_VERSION.to_string(), + task_id: canonical_task_id, + goal_id: goal.goal_id, + goal_revision: frozen.revision.clone(), + goal_fingerprint: frozen.fingerprint.clone(), + expires_at: self.expires_at, + capabilities, + }; + proposal.validate()?; + Ok(proposal) + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +pub struct TaskCapabilityManifestProposal { + pub version: String, + pub task_id: Uuid, + pub goal_id: Uuid, + pub goal_revision: String, + pub goal_fingerprint: String, + pub expires_at: DateTime, + pub capabilities: Vec, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct TaskCapabilityManifestProposalWire { + version: String, + task_id: Uuid, + goal_id: Uuid, + goal_revision: String, + goal_fingerprint: String, + expires_at: DateTime, + capabilities: Vec, +} + +impl From for TaskCapabilityManifestProposal { + fn from(wire: TaskCapabilityManifestProposalWire) -> Self { + Self { + version: wire.version, + task_id: wire.task_id, + goal_id: wire.goal_id, + goal_revision: wire.goal_revision, + goal_fingerprint: wire.goal_fingerprint, + expires_at: wire.expires_at, + capabilities: wire.capabilities, + } + } +} + +impl<'de> Deserialize<'de> for TaskCapabilityManifestProposal { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let proposal = Self::from(TaskCapabilityManifestProposalWire::deserialize( + deserializer, + )?); + proposal.validate().map_err(serde::de::Error::custom)?; + Ok(proposal) + } +} + +impl TaskCapabilityManifestProposal { + pub fn parse_json(json: &str) -> Result { + if json.len() > MAX_JSON_BYTES { + return Err(TaskCapabilityManifestError::JsonTooLarge); + } + let wire: TaskCapabilityManifestProposalWire = + serde_json::from_str(json).map_err(|_| TaskCapabilityManifestError::InvalidJson)?; + let proposal = Self::from(wire); + proposal.validate()?; + Ok(proposal) + } + + pub fn parse_value(value: Value) -> Result { + let encoded = + serde_json::to_vec(&value).map_err(|_| TaskCapabilityManifestError::InvalidJson)?; + if encoded.len() > MAX_JSON_BYTES { + return Err(TaskCapabilityManifestError::JsonTooLarge); + } + let wire: TaskCapabilityManifestProposalWire = + serde_json::from_value(value).map_err(|_| TaskCapabilityManifestError::InvalidJson)?; + let proposal = Self::from(wire); + proposal.validate()?; + Ok(proposal) + } + + pub fn to_json(&self) -> Result { + self.validate()?; + serde_json::to_string(self).map_err(|_| TaskCapabilityManifestError::InvalidJson) + } + + pub fn validate(&self) -> Result<(), TaskCapabilityManifestError> { + if self.version != TASK_CAPABILITY_MANIFEST_VERSION { + return Err(TaskCapabilityManifestError::UnsupportedVersion); + } + if self.task_id != self.goal_id { + return Err(TaskCapabilityManifestError::TaskGoalIdentityMismatch); + } + validate_hash(&self.goal_revision)?; + validate_hash(&self.goal_fingerprint)?; + if self.capabilities.is_empty() || self.capabilities.len() > MAX_CAPABILITIES { + return Err(TaskCapabilityManifestError::CollectionOutOfBounds); + } + + validate_strict_order( + self.capabilities + .iter() + .map(|entry| entry.capability.as_str()), + )?; + let capability_catalog = builtin_capability_catalog(); + for entry in &self.capabilities { + validate_id(&entry.capability)?; + if capability_from_name(&capability_catalog, &entry.capability).is_none() { + return Err(TaskCapabilityManifestError::UnknownCapability); + } + validate_nonempty_id_list(&entry.tool_ids)?; + validate_id_list(&entry.application_ids)?; + validate_id_list(&entry.path_target_ids)?; + validate_id_list(&entry.account_target_ids)?; + validate_id_list(&entry.recipient_target_ids)?; + validate_id_list(&entry.time_window_target_ids)?; + validate_id_list(&entry.external_target_ids)?; + validate_nonempty_id_list(&entry.verifier_ids)?; + for target_id in entry + .path_target_ids + .iter() + .chain(&entry.account_target_ids) + .chain(&entry.recipient_target_ids) + .chain(&entry.time_window_target_ids) + { + if entry.external_target_ids.binary_search(target_id).is_err() { + return Err(TaskCapabilityManifestError::IncompleteGoalBinding); + } + } + } + let encoded = + serde_json::to_vec(self).map_err(|_| TaskCapabilityManifestError::InvalidJson)?; + if encoded.len() > MAX_JSON_BYTES { + return Err(TaskCapabilityManifestError::JsonTooLarge); + } + Ok(()) + } +} + +#[derive(Clone, Debug, Default)] +pub struct TaskCapabilityManifestContext { + application_labels: BTreeMap, + target_labels: BTreeMap, +} + +impl TaskCapabilityManifestContext { + pub fn with_application( + mut self, + application_id: impl Into, + display_label: impl Into, + ) -> Self { + self.application_labels + .insert(application_id.into(), display_label.into()); + self + } + + pub fn with_target_display( + mut self, + target_id: impl Into, + display_label: impl Into, + ) -> Self { + self.target_labels + .insert(target_id.into(), display_label.into()); + self + } +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct TaskToolBinding { + pub tool_id: String, + pub tool_version: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct TaskApplicationBinding { + pub application_id: String, + pub display_label: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct TaskScopeBinding { + pub target_id: String, + pub binding_kind: GoalTargetBindingKind, + pub display_label: String, + pub authority_fingerprint: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct TaskVerifierSummary { + pub verifier_id: String, + pub evidence_kind: String, + pub summary: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct TaskCapabilityManifestEntry { + pub capability: CapabilityKind, + pub risk_level: RiskLevel, + pub tools: Vec, + pub applications: Vec, + pub path_scopes: Vec, + pub account_scopes: Vec, + pub recipient_scopes: Vec, + pub time_windows: Vec, + pub external_targets: Vec, + pub verifiers: Vec, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct TaskCapabilityManifest { + pub version: String, + pub schema_revision: u32, + pub task_id: Uuid, + pub goal_id: Uuid, + pub goal_revision: String, + pub goal_fingerprint: String, + pub expires_at: DateTime, + pub aggregate_risk: RiskLevel, + pub capabilities: Vec, + pub revision: String, + pub fingerprint: String, +} + +impl TaskCapabilityManifest { + pub fn parse_json(json: &str) -> Result { + if json.len() > MAX_JSON_BYTES { + return Err(TaskCapabilityManifestError::JsonTooLarge); + } + let manifest: Self = + serde_json::from_str(json).map_err(|_| TaskCapabilityManifestError::InvalidJson)?; + manifest.validate_integrity()?; + Ok(manifest) + } + + pub fn canonical_json(&self) -> Result { + self.validate_integrity()?; + serde_json::to_string(self).map_err(|_| TaskCapabilityManifestError::InvalidJson) + } + + pub fn validate_integrity(&self) -> Result<(), TaskCapabilityManifestError> { + if self.version != TASK_CAPABILITY_MANIFEST_VERSION + || self.schema_revision != TASK_CAPABILITY_MANIFEST_SCHEMA_REVISION + { + return Err(TaskCapabilityManifestError::UnsupportedVersion); + } + if self.task_id != self.goal_id { + return Err(TaskCapabilityManifestError::TaskGoalIdentityMismatch); + } + validate_hash(&self.goal_revision)?; + validate_hash(&self.goal_fingerprint)?; + validate_hash(&self.revision)?; + validate_hash(&self.fingerprint)?; + if self.capabilities.is_empty() || self.capabilities.len() > MAX_CAPABILITIES { + return Err(TaskCapabilityManifestError::CollectionOutOfBounds); + } + validate_strict_order( + self.capabilities + .iter() + .map(|entry| entry.capability.as_str()), + )?; + let tool_catalog = builtin_tool_catalog() + .into_iter() + .map(|tool| (tool.id.clone(), tool)) + .collect::>(); + let capability_catalog = builtin_capability_catalog(); + let mut aggregate = RiskLevel::Low; + let mut seen_tools = BTreeSet::new(); + for entry in &self.capabilities { + let descriptor = capability_catalog + .iter() + .find(|descriptor| descriptor.capability == entry.capability) + .ok_or(TaskCapabilityManifestError::UnknownCapability)?; + if entry.risk_level != capability_risk(entry.capability) + || entry.risk_level != descriptor.risk_level + { + return Err(TaskCapabilityManifestError::CatalogRiskMismatch); + } + if risk_rank(entry.risk_level) > risk_rank(aggregate) { + aggregate = entry.risk_level; + } + validate_tool_bindings(&entry.tools)?; + for tool in &entry.tools { + let contract = tool_catalog + .get(&tool.tool_id) + .ok_or(TaskCapabilityManifestError::UnknownTool)?; + if contract.version != tool.tool_version + || contract.capability != entry.capability + || contract.risk_level != entry.risk_level + { + return Err(TaskCapabilityManifestError::ToolCapabilityMismatch); + } + if !seen_tools.insert(tool.tool_id.as_str()) { + return Err(TaskCapabilityManifestError::DuplicateValue); + } + } + validate_application_bindings(&entry.applications)?; + validate_scope_bindings(&entry.path_scopes, scope_is_path)?; + validate_scope_bindings(&entry.account_scopes, |kind| { + kind == GoalTargetBindingKind::Account + })?; + validate_scope_bindings(&entry.recipient_scopes, |kind| { + kind == GoalTargetBindingKind::Recipient + })?; + validate_scope_bindings(&entry.time_windows, |kind| { + kind == GoalTargetBindingKind::TimeWindow + })?; + validate_scope_bindings(&entry.external_targets, |_| true)?; + validate_verifier_summaries(&entry.verifiers)?; + for scope in entry + .path_scopes + .iter() + .chain(&entry.account_scopes) + .chain(&entry.recipient_scopes) + .chain(&entry.time_windows) + { + if !entry.external_targets.contains(scope) { + return Err(TaskCapabilityManifestError::IncompleteGoalBinding); + } + } + } + if aggregate != self.aggregate_risk { + return Err(TaskCapabilityManifestError::CatalogRiskMismatch); + } + if self.revision != manifest_revision_for(self) + || self.fingerprint != manifest_fingerprint_for(self) + { + return Err(TaskCapabilityManifestError::ManifestIntegrityMismatch); + } + Ok(()) + } + + pub fn validate_for_goal( + &self, + goal: &GoalLifecycleProjection, + ) -> Result<(), TaskCapabilityManifestError> { + validate_manifest_against_goal( + self, + goal, + &builtin_tool_catalog(), + &builtin_capability_catalog(), + ) + } +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct TaskPreviewLabel { + pub id: String, + pub display_label: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct TaskCapabilityPreviewEntry { + pub capability: CapabilityKind, + pub risk_level: RiskLevel, + pub applications: Vec, + pub path_scopes: Vec, + pub account_scopes: Vec, + pub recipient_scopes: Vec, + pub time_windows: Vec, + pub external_targets: Vec, + pub verifiers: Vec, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct TaskAuthorizationPreview { + pub version: String, + pub schema_revision: u32, + pub renderer_revision: u32, + pub task_id: Uuid, + pub goal_id: Uuid, + pub goal_revision: String, + pub goal_fingerprint: String, + pub manifest_revision: String, + pub manifest_fingerprint: String, + pub expires_at: DateTime, + pub aggregate_risk: RiskLevel, + pub capabilities: Vec, + pub preview_hash: String, +} + +impl TaskAuthorizationPreview { + pub fn parse_json(json: &str) -> Result { + if json.len() > MAX_JSON_BYTES { + return Err(TaskCapabilityManifestError::JsonTooLarge); + } + let preview: Self = + serde_json::from_str(json).map_err(|_| TaskCapabilityManifestError::InvalidJson)?; + preview.validate_integrity()?; + Ok(preview) + } + + pub fn canonical_json(&self) -> Result { + self.validate_integrity()?; + serde_json::to_string(self).map_err(|_| TaskCapabilityManifestError::InvalidJson) + } + + pub fn validate_integrity(&self) -> Result<(), TaskCapabilityManifestError> { + if self.version != TASK_AUTHORIZATION_PREVIEW_VERSION + || self.schema_revision != TASK_AUTHORIZATION_PREVIEW_SCHEMA_REVISION + || self.renderer_revision != TASK_AUTHORIZATION_PREVIEW_RENDERER_REVISION + { + return Err(TaskCapabilityManifestError::UnsupportedVersion); + } + if self.task_id != self.goal_id { + return Err(TaskCapabilityManifestError::TaskGoalIdentityMismatch); + } + for hash in [ + &self.goal_revision, + &self.goal_fingerprint, + &self.manifest_revision, + &self.manifest_fingerprint, + &self.preview_hash, + ] { + validate_hash(hash)?; + } + if self.capabilities.is_empty() || self.capabilities.len() > MAX_CAPABILITIES { + return Err(TaskCapabilityManifestError::CollectionOutOfBounds); + } + validate_strict_order( + self.capabilities + .iter() + .map(|entry| entry.capability.as_str()), + )?; + let mut aggregate = RiskLevel::Low; + for entry in &self.capabilities { + if entry.risk_level != capability_risk(entry.capability) { + return Err(TaskCapabilityManifestError::CatalogRiskMismatch); + } + if risk_rank(entry.risk_level) > risk_rank(aggregate) { + aggregate = entry.risk_level; + } + validate_preview_labels(&entry.applications)?; + validate_preview_labels(&entry.path_scopes)?; + validate_preview_labels(&entry.account_scopes)?; + validate_preview_labels(&entry.recipient_scopes)?; + validate_preview_labels(&entry.time_windows)?; + validate_preview_labels(&entry.external_targets)?; + validate_verifier_summaries(&entry.verifiers)?; + } + if self.aggregate_risk != aggregate { + return Err(TaskCapabilityManifestError::CatalogRiskMismatch); + } + if self.preview_hash != preview_hash_for(self) { + return Err(TaskCapabilityManifestError::PreviewIntegrityMismatch); + } + Ok(()) + } + + pub fn validate_for_manifest( + &self, + manifest: &TaskCapabilityManifest, + ) -> Result<(), TaskCapabilityManifestError> { + let expected = task_authorization_preview(manifest)?; + if *self != expected { + return Err(TaskCapabilityManifestError::PreviewIntegrityMismatch); + } + Ok(()) + } +} + +pub fn compile_task_capability_manifest( + canonical_task_id: Uuid, + goal: &GoalLifecycleProjection, + proposal: &TaskCapabilityManifestProposal, + context: &TaskCapabilityManifestContext, +) -> Result { + let tool_catalog = builtin_tool_catalog(); + let capability_catalog = builtin_capability_catalog(); + compile_with_catalogs( + canonical_task_id, + goal, + proposal, + context, + &tool_catalog, + &capability_catalog, + ) +} + +pub fn task_authorization_preview( + manifest: &TaskCapabilityManifest, +) -> Result { + manifest.validate_integrity()?; + let capabilities = manifest + .capabilities + .iter() + .map(|entry| TaskCapabilityPreviewEntry { + capability: entry.capability, + risk_level: entry.risk_level, + applications: entry + .applications + .iter() + .map(|binding| TaskPreviewLabel { + id: binding.application_id.clone(), + display_label: binding.display_label.clone(), + }) + .collect(), + path_scopes: preview_scope_labels(&entry.path_scopes), + account_scopes: preview_scope_labels(&entry.account_scopes), + recipient_scopes: preview_scope_labels(&entry.recipient_scopes), + time_windows: preview_scope_labels(&entry.time_windows), + external_targets: preview_scope_labels(&entry.external_targets), + verifiers: entry.verifiers.clone(), + }) + .collect(); + let mut preview = TaskAuthorizationPreview { + version: TASK_AUTHORIZATION_PREVIEW_VERSION.to_string(), + schema_revision: TASK_AUTHORIZATION_PREVIEW_SCHEMA_REVISION, + renderer_revision: TASK_AUTHORIZATION_PREVIEW_RENDERER_REVISION, + task_id: manifest.task_id, + goal_id: manifest.goal_id, + goal_revision: manifest.goal_revision.clone(), + goal_fingerprint: manifest.goal_fingerprint.clone(), + manifest_revision: manifest.revision.clone(), + manifest_fingerprint: manifest.fingerprint.clone(), + expires_at: manifest.expires_at, + aggregate_risk: manifest.aggregate_risk, + capabilities, + preview_hash: String::new(), + }; + preview.preview_hash = preview_hash_for(&preview); + Ok(preview) +} + +fn compile_with_catalogs( + canonical_task_id: Uuid, + goal: &GoalLifecycleProjection, + proposal: &TaskCapabilityManifestProposal, + context: &TaskCapabilityManifestContext, + tool_catalog: &[ToolContract], + capability_catalog: &[CapabilityDescriptor], +) -> Result { + proposal.validate()?; + let frozen = goal + .frozen() + .ok_or(TaskCapabilityManifestError::GoalNotFrozen)?; + if canonical_task_id != goal.goal_id + || proposal.task_id != canonical_task_id + || proposal.goal_id != goal.goal_id + { + return Err(TaskCapabilityManifestError::TaskGoalIdentityMismatch); + } + if proposal.goal_revision != frozen.revision { + return Err(TaskCapabilityManifestError::GoalRevisionMismatch); + } + if proposal.goal_fingerprint != frozen.fingerprint { + return Err(TaskCapabilityManifestError::GoalFingerprintMismatch); + } + + let goal_capabilities = frozen + .envelope + .validated_capabilities + .iter() + .map(|capability| (capability.tool_id.as_str(), capability)) + .collect::>(); + let goal_targets = frozen + .envelope + .bound_targets + .iter() + .map(|target| (target.target_id.as_str(), target)) + .collect::>(); + let goal_verifiers = frozen + .envelope + .verifiers + .iter() + .map(|verifier| (verifier.verifier_id.as_str(), verifier)) + .collect::>(); + let tools = tool_catalog + .iter() + .map(|tool| (tool.id.as_str(), tool)) + .collect::>(); + let mut covered_tools = BTreeSet::new(); + let mut covered_targets = BTreeSet::new(); + let mut covered_verifiers = BTreeSet::new(); + let mut aggregate_risk = RiskLevel::Low; + let mut capabilities = Vec::with_capacity(proposal.capabilities.len()); + + for entry in &proposal.capabilities { + let capability = capability_from_name(capability_catalog, &entry.capability) + .ok_or(TaskCapabilityManifestError::UnknownCapability)?; + let descriptor = capability_catalog + .iter() + .find(|descriptor| descriptor.capability == capability) + .ok_or(TaskCapabilityManifestError::UnknownCapability)?; + let risk_level = capability_risk(capability); + if descriptor.risk_level != risk_level { + return Err(TaskCapabilityManifestError::CatalogRiskMismatch); + } + if risk_rank(risk_level) > risk_rank(aggregate_risk) { + aggregate_risk = risk_level; + } + + let mut tool_bindings = Vec::with_capacity(entry.tool_ids.len()); + for tool_id in &entry.tool_ids { + let tool = tools + .get(tool_id.as_str()) + .ok_or(TaskCapabilityManifestError::UnknownTool)?; + if tool.capability != capability { + return Err(TaskCapabilityManifestError::ToolCapabilityMismatch); + } + if tool.risk_level != risk_level { + return Err(TaskCapabilityManifestError::CatalogRiskMismatch); + } + let goal_capability = goal_capabilities + .get(tool_id.as_str()) + .ok_or(TaskCapabilityManifestError::GoalCapabilityMismatch)?; + if goal_capability.capability != capability.as_str() + || goal_capability.risk_level != risk_level + { + return Err(TaskCapabilityManifestError::GoalCapabilityMismatch); + } + if !covered_tools.insert(tool_id.as_str()) { + return Err(TaskCapabilityManifestError::DuplicateValue); + } + tool_bindings.push(TaskToolBinding { + tool_id: tool.id.clone(), + tool_version: tool.version.clone(), + }); + } + + let applications = entry + .application_ids + .iter() + .map(|application_id| { + let display_label = context + .application_labels + .get(application_id) + .ok_or(TaskCapabilityManifestError::UnknownApplication)?; + validate_display(display_label)?; + Ok(TaskApplicationBinding { + application_id: application_id.clone(), + display_label: display_label.clone(), + }) + }) + .collect::, TaskCapabilityManifestError>>()?; + let path_scopes = compile_scopes( + &entry.path_target_ids, + &goal_targets, + context, + scope_is_path, + )?; + let account_scopes = + compile_scopes(&entry.account_target_ids, &goal_targets, context, |kind| { + kind == GoalTargetBindingKind::Account + })?; + let recipient_scopes = compile_scopes( + &entry.recipient_target_ids, + &goal_targets, + context, + |kind| kind == GoalTargetBindingKind::Recipient, + )?; + let time_windows = compile_scopes( + &entry.time_window_target_ids, + &goal_targets, + context, + |kind| kind == GoalTargetBindingKind::TimeWindow, + )?; + let external_targets = + compile_scopes(&entry.external_target_ids, &goal_targets, context, |_| true)?; + covered_targets.extend(entry.external_target_ids.iter().map(String::as_str)); + + let verifiers = entry + .verifier_ids + .iter() + .map(|verifier_id| { + let verifier = goal_verifiers + .get(verifier_id.as_str()) + .ok_or(TaskCapabilityManifestError::UnknownVerifier)?; + covered_verifiers.insert(verifier_id.as_str()); + validate_display(&verifier.description)?; + Ok(TaskVerifierSummary { + verifier_id: verifier.verifier_id.clone(), + evidence_kind: verifier.evidence_kind.clone(), + summary: verifier.description.clone(), + }) + }) + .collect::, TaskCapabilityManifestError>>()?; + + capabilities.push(TaskCapabilityManifestEntry { + capability, + risk_level, + tools: tool_bindings, + applications, + path_scopes, + account_scopes, + recipient_scopes, + time_windows, + external_targets, + verifiers, + }); + } + + if covered_tools != goal_capabilities.keys().copied().collect() + || covered_targets != goal_targets.keys().copied().collect() + || covered_verifiers != goal_verifiers.keys().copied().collect() + { + return Err(TaskCapabilityManifestError::IncompleteGoalBinding); + } + + let mut manifest = TaskCapabilityManifest { + version: TASK_CAPABILITY_MANIFEST_VERSION.to_string(), + schema_revision: TASK_CAPABILITY_MANIFEST_SCHEMA_REVISION, + task_id: canonical_task_id, + goal_id: goal.goal_id, + goal_revision: frozen.revision.clone(), + goal_fingerprint: frozen.fingerprint.clone(), + expires_at: proposal.expires_at, + aggregate_risk, + capabilities, + revision: String::new(), + fingerprint: String::new(), + }; + manifest.revision = manifest_revision_for(&manifest); + manifest.fingerprint = manifest_fingerprint_for(&manifest); + manifest.validate_integrity()?; + validate_manifest_against_goal(&manifest, goal, tool_catalog, capability_catalog)?; + Ok(manifest) +} + +fn validate_manifest_against_goal( + manifest: &TaskCapabilityManifest, + goal: &GoalLifecycleProjection, + tool_catalog: &[ToolContract], + capability_catalog: &[CapabilityDescriptor], +) -> Result<(), TaskCapabilityManifestError> { + manifest.validate_integrity()?; + let frozen = goal + .frozen() + .ok_or(TaskCapabilityManifestError::GoalNotFrozen)?; + if manifest.task_id != goal.goal_id || manifest.goal_id != goal.goal_id { + return Err(TaskCapabilityManifestError::TaskGoalIdentityMismatch); + } + if manifest.goal_revision != frozen.revision { + return Err(TaskCapabilityManifestError::GoalRevisionMismatch); + } + if manifest.goal_fingerprint != frozen.fingerprint { + return Err(TaskCapabilityManifestError::GoalFingerprintMismatch); + } + let expected_tools = frozen + .envelope + .validated_capabilities + .iter() + .map(|capability| capability.tool_id.as_str()) + .collect::>(); + let manifest_tools = manifest + .capabilities + .iter() + .flat_map(|entry| entry.tools.iter().map(|tool| tool.tool_id.as_str())) + .collect::>(); + if manifest_tools != expected_tools { + return Err(TaskCapabilityManifestError::GoalCapabilityMismatch); + } + let expected_targets = frozen + .envelope + .bound_targets + .iter() + .map(|target| (target.target_id.as_str(), target)) + .collect::>(); + let expected_verifiers = frozen + .envelope + .verifiers + .iter() + .map(|verifier| (verifier.verifier_id.as_str(), verifier)) + .collect::>(); + let mut manifest_targets = BTreeSet::new(); + let mut manifest_verifiers = BTreeSet::new(); + for entry in &manifest.capabilities { + let descriptor = capability_catalog + .iter() + .find(|descriptor| descriptor.capability == entry.capability) + .ok_or(TaskCapabilityManifestError::UnknownCapability)?; + if descriptor.risk_level != entry.risk_level + || capability_risk(entry.capability) != entry.risk_level + { + return Err(TaskCapabilityManifestError::CatalogRiskMismatch); + } + for tool in &entry.tools { + let contract = tool_catalog + .iter() + .find(|contract| contract.id == tool.tool_id) + .ok_or(TaskCapabilityManifestError::UnknownTool)?; + if contract.version != tool.tool_version + || contract.capability != entry.capability + || contract.risk_level != entry.risk_level + { + return Err(TaskCapabilityManifestError::ToolCapabilityMismatch); + } + let goal_capability = frozen + .envelope + .validated_capabilities + .iter() + .find(|capability| capability.tool_id == tool.tool_id) + .ok_or(TaskCapabilityManifestError::GoalCapabilityMismatch)?; + if goal_capability.capability != entry.capability.as_str() + || goal_capability.risk_level != entry.risk_level + { + return Err(TaskCapabilityManifestError::GoalCapabilityMismatch); + } + } + for scope in entry + .path_scopes + .iter() + .chain(&entry.account_scopes) + .chain(&entry.recipient_scopes) + .chain(&entry.time_windows) + .chain(&entry.external_targets) + { + let target = expected_targets + .get(scope.target_id.as_str()) + .ok_or(TaskCapabilityManifestError::ScopeBindingMismatch)?; + if target.binding_kind != scope.binding_kind + || target.authority_fingerprint != scope.authority_fingerprint + { + return Err(TaskCapabilityManifestError::ScopeBindingMismatch); + } + } + manifest_targets.extend( + entry + .external_targets + .iter() + .map(|target| target.target_id.as_str()), + ); + for verifier in &entry.verifiers { + let expected = expected_verifiers + .get(verifier.verifier_id.as_str()) + .ok_or(TaskCapabilityManifestError::UnknownVerifier)?; + if expected.evidence_kind != verifier.evidence_kind + || expected.description != verifier.summary + { + return Err(TaskCapabilityManifestError::UnknownVerifier); + } + manifest_verifiers.insert(verifier.verifier_id.as_str()); + } + } + if manifest_targets != expected_targets.keys().copied().collect() + || manifest_verifiers != expected_verifiers.keys().copied().collect() + { + return Err(TaskCapabilityManifestError::IncompleteGoalBinding); + } + Ok(()) +} + +fn compile_scopes( + target_ids: &[String], + goal_targets: &BTreeMap<&str, &crate::kernel::goal_lifecycle::BoundGoalTarget>, + context: &TaskCapabilityManifestContext, + kind_allowed: impl Fn(GoalTargetBindingKind) -> bool, +) -> Result, TaskCapabilityManifestError> { + target_ids + .iter() + .map(|target_id| { + let target = goal_targets + .get(target_id.as_str()) + .ok_or(TaskCapabilityManifestError::ScopeBindingMismatch)?; + if !kind_allowed(target.binding_kind) { + return Err(TaskCapabilityManifestError::ScopeBindingMismatch); + } + validate_hash(&target.authority_fingerprint)?; + let display_label = context + .target_labels + .get(target_id) + .ok_or(TaskCapabilityManifestError::ScopeBindingMismatch)?; + validate_display(display_label)?; + Ok(TaskScopeBinding { + target_id: target_id.clone(), + binding_kind: target.binding_kind, + display_label: display_label.clone(), + authority_fingerprint: target.authority_fingerprint.clone(), + }) + }) + .collect() +} + +fn capability_from_name(catalog: &[CapabilityDescriptor], name: &str) -> Option { + catalog + .iter() + .find(|descriptor| descriptor.capability.as_str() == name) + .map(|descriptor| descriptor.capability) +} + +fn preview_scope_labels(bindings: &[TaskScopeBinding]) -> Vec { + bindings + .iter() + .map(|binding| TaskPreviewLabel { + id: binding.target_id.clone(), + display_label: binding.display_label.clone(), + }) + .collect() +} + +#[derive(Serialize)] +struct ManifestRevisionCanonical<'a> { + version: &'a str, + schema_revision: u32, + task_id: Uuid, + goal_id: Uuid, + goal_revision: &'a str, + goal_fingerprint: &'a str, + expires_at: DateTime, + aggregate_risk: RiskLevel, + capabilities: &'a [TaskCapabilityManifestEntry], +} + +#[derive(Serialize)] +struct ManifestFingerprintCanonical<'a> { + #[serde(flatten)] + manifest: ManifestRevisionCanonical<'a>, + revision: &'a str, +} + +#[derive(Serialize)] +struct PreviewCanonical<'a> { + version: &'a str, + schema_revision: u32, + renderer_revision: u32, + task_id: Uuid, + goal_id: Uuid, + goal_revision: &'a str, + goal_fingerprint: &'a str, + manifest_revision: &'a str, + manifest_fingerprint: &'a str, + expires_at: DateTime, + aggregate_risk: RiskLevel, + capabilities: &'a [TaskCapabilityPreviewEntry], +} + +fn manifest_revision_canonical(manifest: &TaskCapabilityManifest) -> ManifestRevisionCanonical<'_> { + ManifestRevisionCanonical { + version: &manifest.version, + schema_revision: manifest.schema_revision, + task_id: manifest.task_id, + goal_id: manifest.goal_id, + goal_revision: &manifest.goal_revision, + goal_fingerprint: &manifest.goal_fingerprint, + expires_at: manifest.expires_at, + aggregate_risk: manifest.aggregate_risk, + capabilities: &manifest.capabilities, + } +} + +fn manifest_revision_for(manifest: &TaskCapabilityManifest) -> String { + domain_hash( + MANIFEST_REVISION_DOMAIN, + &serde_json::to_vec(&manifest_revision_canonical(manifest)).unwrap_or_default(), + ) +} + +fn manifest_fingerprint_for(manifest: &TaskCapabilityManifest) -> String { + domain_hash( + MANIFEST_FINGERPRINT_DOMAIN, + &serde_json::to_vec(&ManifestFingerprintCanonical { + manifest: manifest_revision_canonical(manifest), + revision: &manifest.revision, + }) + .unwrap_or_default(), + ) +} + +fn preview_hash_for(preview: &TaskAuthorizationPreview) -> String { + domain_hash( + PREVIEW_HASH_DOMAIN, + &serde_json::to_vec(&PreviewCanonical { + version: &preview.version, + schema_revision: preview.schema_revision, + renderer_revision: preview.renderer_revision, + task_id: preview.task_id, + goal_id: preview.goal_id, + goal_revision: &preview.goal_revision, + goal_fingerprint: &preview.goal_fingerprint, + manifest_revision: &preview.manifest_revision, + manifest_fingerprint: &preview.manifest_fingerprint, + expires_at: preview.expires_at, + aggregate_risk: preview.aggregate_risk, + capabilities: &preview.capabilities, + }) + .unwrap_or_default(), + ) +} + +fn domain_hash(domain: &[u8], value: &[u8]) -> String { + let mut digest = Sha256::new(); + digest.update(domain); + digest.update((value.len() as u64).to_be_bytes()); + digest.update(value); + format!("{:x}", digest.finalize()) +} + +fn validate_hash(value: &str) -> Result<(), TaskCapabilityManifestError> { + if value.len() != 64 + || !value + .bytes() + .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f')) + { + return Err(TaskCapabilityManifestError::InvalidBindingHash); + } + Ok(()) +} + +fn validate_id(value: &str) -> Result<(), TaskCapabilityManifestError> { + if value.is_empty() + || value != value.trim() + || value.len() > MAX_ID_BYTES + || !value + .bytes() + .next() + .is_some_and(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit()) + || !value.bytes().all(|byte| { + byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'.' | b'_' | b'-') + }) + { + return Err(TaskCapabilityManifestError::InvalidIdentifier); + } + if contains_secret_like_content(value) || contains_private_internal_reference(value) { + return Err(TaskCapabilityManifestError::SecretLikeContent); + } + Ok(()) +} + +fn validate_display(value: &str) -> Result<(), TaskCapabilityManifestError> { + if value.is_empty() + || value != value.trim() + || value.len() > MAX_DISPLAY_BYTES + || value.chars().any(char::is_control) + { + return Err(TaskCapabilityManifestError::InvalidDisplay); + } + if contains_secret_like_content(value) || contains_private_internal_reference(value) { + return Err(TaskCapabilityManifestError::SecretLikeContent); + } + Ok(()) +} + +fn validate_id_list(values: &[String]) -> Result<(), TaskCapabilityManifestError> { + if values.len() > MAX_ITEMS_PER_FIELD { + return Err(TaskCapabilityManifestError::CollectionOutOfBounds); + } + for value in values { + validate_id(value)?; + } + validate_strict_order(values.iter().map(String::as_str)) +} + +fn validate_nonempty_id_list(values: &[String]) -> Result<(), TaskCapabilityManifestError> { + if values.is_empty() { + return Err(TaskCapabilityManifestError::CollectionOutOfBounds); + } + validate_id_list(values) +} + +fn validate_strict_order<'a>( + values: impl IntoIterator, +) -> Result<(), TaskCapabilityManifestError> { + let mut previous: Option<&str> = None; + for value in values { + if let Some(previous) = previous { + if previous == value { + return Err(TaskCapabilityManifestError::DuplicateValue); + } + if previous > value { + return Err(TaskCapabilityManifestError::NonCanonicalOrder); + } + } + previous = Some(value); + } + Ok(()) +} + +fn validate_tool_bindings(values: &[TaskToolBinding]) -> Result<(), TaskCapabilityManifestError> { + if values.is_empty() || values.len() > MAX_ITEMS_PER_FIELD { + return Err(TaskCapabilityManifestError::CollectionOutOfBounds); + } + validate_strict_order(values.iter().map(|value| value.tool_id.as_str()))?; + for value in values { + validate_id(&value.tool_id)?; + validate_id(&value.tool_version)?; + } + Ok(()) +} + +fn validate_application_bindings( + values: &[TaskApplicationBinding], +) -> Result<(), TaskCapabilityManifestError> { + if values.len() > MAX_ITEMS_PER_FIELD { + return Err(TaskCapabilityManifestError::CollectionOutOfBounds); + } + validate_strict_order(values.iter().map(|value| value.application_id.as_str()))?; + for value in values { + validate_id(&value.application_id)?; + validate_display(&value.display_label)?; + } + Ok(()) +} + +fn validate_scope_bindings( + values: &[TaskScopeBinding], + kind_allowed: impl Fn(GoalTargetBindingKind) -> bool, +) -> Result<(), TaskCapabilityManifestError> { + if values.len() > MAX_ITEMS_PER_FIELD { + return Err(TaskCapabilityManifestError::CollectionOutOfBounds); + } + validate_strict_order(values.iter().map(|value| value.target_id.as_str()))?; + for value in values { + validate_id(&value.target_id)?; + validate_display(&value.display_label)?; + validate_hash(&value.authority_fingerprint)?; + if !kind_allowed(value.binding_kind) { + return Err(TaskCapabilityManifestError::ScopeBindingMismatch); + } + } + Ok(()) +} + +fn validate_verifier_summaries( + values: &[TaskVerifierSummary], +) -> Result<(), TaskCapabilityManifestError> { + if values.is_empty() || values.len() > MAX_ITEMS_PER_FIELD { + return Err(TaskCapabilityManifestError::CollectionOutOfBounds); + } + validate_strict_order(values.iter().map(|value| value.verifier_id.as_str()))?; + for value in values { + validate_id(&value.verifier_id)?; + validate_id(&value.evidence_kind)?; + validate_display(&value.summary)?; + } + Ok(()) +} + +fn validate_preview_labels(values: &[TaskPreviewLabel]) -> Result<(), TaskCapabilityManifestError> { + if values.len() > MAX_ITEMS_PER_FIELD { + return Err(TaskCapabilityManifestError::CollectionOutOfBounds); + } + validate_strict_order(values.iter().map(|value| value.id.as_str()))?; + for value in values { + validate_id(&value.id)?; + validate_display(&value.display_label)?; + } + Ok(()) +} + +fn scope_is_path(kind: GoalTargetBindingKind) -> bool { + matches!( + kind, + GoalTargetBindingKind::Workspace | GoalTargetBindingKind::Path + ) +} + +fn risk_rank(risk: RiskLevel) -> u8 { + match risk { + RiskLevel::Low => 0, + RiskLevel::Medium => 1, + RiskLevel::High => 2, + RiskLevel::Critical => 3, + } +} + +fn contains_private_internal_reference(value: &str) -> bool { + let lower = value.to_ascii_lowercase(); + let bytes = lower.as_bytes(); + let windows_absolute = bytes.windows(3).any(|window| { + window[0].is_ascii_alphabetic() && window[1] == b':' && matches!(window[2], b'\\' | b'/') + }); + windows_absolute + || lower.starts_with("/") + || lower.starts_with("\\\\") + || lower.contains("\\appdata\\") + || lower.contains("/appdata/") + || lower.contains("/users/") + || lower.contains("/home/") + || lower.contains("vault://") + || lower.contains("provider_ref") + || lower.contains("provider-ref") + || lower.contains("provider://") + || lower.contains("remote_ref") + || lower.contains("remote-ref") + || lower.contains("credential_handle") + || lower.contains("credential-handle") + || lower.contains("credential handle") + || lower.contains("claim_token") + || lower.contains("claim-token") +} + +fn contains_secret_like_content(value: &str) -> bool { + let lower = value.to_ascii_lowercase(); + contains_token_after(&lower, "bearer ", 12) + || contains_token_after(&lower, "api_key=", 12) + || contains_token_after(&lower, "api_key:", 12) + || contains_token_after(&lower, "api-key=", 12) + || contains_token_after(&lower, "api-key:", 12) + || contains_token_after(&lower, "password=", 12) + || contains_token_after(&lower, "password:", 12) + || contains_token_after(&lower, "secret=", 12) + || contains_token_after(&lower, "secret:", 12) + || contains_token_after(&lower, "token=", 12) + || contains_token_after(&lower, "token:", 12) + || lower.match_indices("sk-").any(|(index, _)| { + lower[index + 3..] + .bytes() + .take_while(|byte| byte.is_ascii_alphanumeric() || matches!(*byte, b'_' | b'-')) + .count() + >= 12 + }) +} + +fn contains_token_after(value: &str, marker: &str, minimum_length: usize) -> bool { + value.match_indices(marker).any(|(index, _)| { + value[index + marker.len()..] + .trim_start_matches([' ', '\'', '"']) + .bytes() + .take_while(|byte| byte.is_ascii_alphanumeric() || matches!(*byte, b'_' | b'-' | b'.')) + .count() + >= minimum_length + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::kernel::event_store::EventStore; + use crate::kernel::goal_envelope::{ + GoalDoneWhenProposal, GoalEnvelopeProposal, GoalExternalTargetProposal, + GoalVerifierProposal, GOAL_ENVELOPE_PROPOSAL_VERSION, + }; + use crate::kernel::goal_lifecycle::{GoalTargetBindingKind, GoalValidationContext}; + use crate::kernel::local_directory::WorkspaceReadinessCode; + use crate::kernel::models::AccessMode; + use crate::kernel::policy::{ + exact_tool_preview_hash, CapabilityAccessRequest, PermissionResolution, + TOOL_APPROVAL_PREVIEW_REVISION, + }; + use crate::kernel::tool_runtime::{CONNECTOR_MUTATE_TOOL_ID, FILE_WRITE_TOOL_ID}; + use tempfile::tempdir; + + const TASK_ID: Uuid = Uuid::from_u128(0x31a); + + fn frozen_goal_for(store: &EventStore, task_id: Uuid) -> GoalLifecycleProjection { + let proposal = GoalEnvelopeProposal { + version: GOAL_ENVELOPE_PROPOSAL_VERSION.to_string(), + user_goal: "Create a verified report and send one approved external update." + .to_string(), + assumptions: Vec::new(), + constraints: vec!["Use only locally bound task scopes.".to_string()], + done_when: vec![ + GoalDoneWhenProposal { + done_when_id: "external-state-verified".to_string(), + description: "The external state is independently verified.".to_string(), + }, + GoalDoneWhenProposal { + done_when_id: "report-verified".to_string(), + description: "The local report is independently verified.".to_string(), + }, + ], + required_artifacts: Vec::new(), + verifiers: vec![ + GoalVerifierProposal { + verifier_id: "external-verifier-v1".to_string(), + done_when_id: "external-state-verified".to_string(), + description: "Verify the remote object state after the operation.".to_string(), + evidence_kind: "connector_remote_state".to_string(), + }, + GoalVerifierProposal { + verifier_id: "report-verifier-v1".to_string(), + done_when_id: "report-verified".to_string(), + description: "Verify the report hash and rendered output.".to_string(), + evidence_kind: "artifact_hash".to_string(), + }, + ], + proposed_capabilities: vec![ + CONNECTOR_MUTATE_TOOL_ID.to_string(), + FILE_WRITE_TOOL_ID.to_string(), + ], + external_targets: vec![ + GoalExternalTargetProposal { + target_id: "finance-account".to_string(), + description: "A locally bound external account.".to_string(), + }, + GoalExternalTargetProposal { + target_id: "finance-recipient".to_string(), + description: "A locally bound recipient.".to_string(), + }, + GoalExternalTargetProposal { + target_id: "report-folder".to_string(), + description: "A locally bound workspace path scope.".to_string(), + }, + GoalExternalTargetProposal { + target_id: "weekday-window".to_string(), + description: "A locally bound schedule window.".to_string(), + }, + ], + stop_conditions: vec!["Stop when any exact scope changes.".to_string()], + }; + let context = + GoalValidationContext::new(AccessMode::FullAccess, WorkspaceReadinessCode::Ready) + .with_max_risk(RiskLevel::Critical) + .with_enabled_tool(CONNECTOR_MUTATE_TOOL_ID, true) + .with_enabled_tool(FILE_WRITE_TOOL_ID, true) + .with_approval_route(CONNECTOR_MUTATE_TOOL_ID) + .with_approval_route(FILE_WRITE_TOOL_ID) + .with_verifier_kind("artifact_hash") + .with_verifier_kind("connector_remote_state") + .with_target_binding( + "finance-account", + GoalTargetBindingKind::Account, + b"account-authority-v1", + ) + .with_target_binding( + "finance-recipient", + GoalTargetBindingKind::Recipient, + b"recipient-authority-v1", + ) + .with_target_binding( + "report-folder", + GoalTargetBindingKind::Path, + b"path-authority-v1", + ) + .with_target_binding( + "weekday-window", + GoalTargetBindingKind::TimeWindow, + b"time-authority-v1", + ) + .allowing_local_effects() + .allowing_external_effects(); + let validated = store + .submit_goal_proposal(task_id, &proposal, &context) + .expect("goal validates"); + store + .freeze_goal_envelope(task_id, validated.revision().expect("goal revision")) + .expect("goal freezes") + } + + fn frozen_goal(store: &EventStore) -> GoalLifecycleProjection { + frozen_goal_for(store, TASK_ID) + } + + fn manifest_context() -> TaskCapabilityManifestContext { + TaskCapabilityManifestContext::default() + .with_application("excel", "Microsoft Excel") + .with_application("outlook", "Microsoft Outlook") + .with_target_display("finance-account", "Work mailbox account") + .with_target_display("finance-recipient", "finance-test@example.com") + .with_target_display("report-folder", "Workspace / reports") + .with_target_display("weekday-window", "Weekdays 09:00-17:00 Asia/Shanghai") + } + + fn manifest_proposal(goal: &GoalLifecycleProjection) -> TaskCapabilityManifestProposal { + let frozen = goal.frozen().expect("frozen goal"); + TaskCapabilityManifestProposal { + version: TASK_CAPABILITY_MANIFEST_VERSION.to_string(), + task_id: TASK_ID, + goal_id: TASK_ID, + goal_revision: frozen.revision.clone(), + goal_fingerprint: frozen.fingerprint.clone(), + expires_at: "2030-01-02T03:04:05Z".parse().unwrap(), + capabilities: vec![ + TaskCapabilityNeedProposal { + capability: "connector_write".to_string(), + tool_ids: vec![CONNECTOR_MUTATE_TOOL_ID.to_string()], + application_ids: vec!["outlook".to_string()], + path_target_ids: Vec::new(), + account_target_ids: vec!["finance-account".to_string()], + recipient_target_ids: vec!["finance-recipient".to_string()], + time_window_target_ids: vec!["weekday-window".to_string()], + external_target_ids: vec![ + "finance-account".to_string(), + "finance-recipient".to_string(), + "weekday-window".to_string(), + ], + verifier_ids: vec!["external-verifier-v1".to_string()], + }, + TaskCapabilityNeedProposal { + capability: "file_write".to_string(), + tool_ids: vec![FILE_WRITE_TOOL_ID.to_string()], + application_ids: vec!["excel".to_string()], + path_target_ids: vec!["report-folder".to_string()], + account_target_ids: Vec::new(), + recipient_target_ids: Vec::new(), + time_window_target_ids: Vec::new(), + external_target_ids: vec!["report-folder".to_string()], + verifier_ids: vec!["report-verifier-v1".to_string()], + }, + ], + } + } + + fn descriptive_proposal() -> TaskCapabilityProposal { + TaskCapabilityProposal { + version: TASK_CAPABILITY_PROPOSAL_VERSION.to_string(), + expires_at: "2030-01-02T03:04:05Z".parse().unwrap(), + capabilities: vec![ + TaskCapabilityDescriptionProposal { + capability: "connector_write".to_string(), + application_ids: vec!["outlook".to_string()], + path_target_ids: Vec::new(), + account_target_ids: vec!["finance-account".to_string()], + recipient_target_ids: vec!["finance-recipient".to_string()], + time_window_target_ids: vec!["weekday-window".to_string()], + external_target_ids: vec![ + "finance-account".to_string(), + "finance-recipient".to_string(), + "weekday-window".to_string(), + ], + verifier_ids: vec!["external-verifier-v1".to_string()], + }, + TaskCapabilityDescriptionProposal { + capability: "file_write".to_string(), + application_ids: vec!["excel".to_string()], + path_target_ids: vec!["report-folder".to_string()], + account_target_ids: Vec::new(), + recipient_target_ids: Vec::new(), + time_window_target_ids: Vec::new(), + external_target_ids: vec!["report-folder".to_string()], + verifier_ids: vec!["report-verifier-v1".to_string()], + }, + ], + } + } + + fn compiled_fixture() -> ( + GoalLifecycleProjection, + TaskCapabilityManifestProposal, + TaskCapabilityManifestContext, + TaskCapabilityManifest, + TaskAuthorizationPreview, + ) { + let store = EventStore::open_memory().unwrap(); + let goal = frozen_goal(&store); + let proposal = manifest_proposal(&goal); + let context = manifest_context(); + let manifest = compile_task_capability_manifest(TASK_ID, &goal, &proposal, &context) + .expect("manifest compiles"); + let preview = task_authorization_preview(&manifest).expect("preview renders"); + (goal, proposal, context, manifest, preview) + } + + fn rehash_manifest(manifest: &mut TaskCapabilityManifest) { + manifest.revision = manifest_revision_for(manifest); + manifest.fingerprint = manifest_fingerprint_for(manifest); + } + + #[test] + fn c3d_descriptive_proposal_is_strict_bounded_and_contains_no_kernel_authority() { + let proposal = descriptive_proposal(); + let serialized = proposal.to_json().unwrap(); + for forbidden in [ + "task_id", + "goal_id", + "goal_revision", + "goal_fingerprint", + "tool_ids", + "risk", + "grant", + "actor", + "approval", + "preview", + "claim", + "token", + ] { + assert!(!serialized.contains(forbidden), "{forbidden}"); + } + + let mut unsupported = serde_json::to_value(&proposal).unwrap(); + unsupported["version"] = Value::String("ds-agent.task-capability-proposal/v2".to_string()); + assert_eq!( + TaskCapabilityProposal::parse_value(unsupported), + Err(TaskCapabilityManifestError::UnsupportedVersion) + ); + + let mut missing = serde_json::to_value(&proposal).unwrap(); + missing.as_object_mut().unwrap().remove("expires_at"); + assert_eq!( + TaskCapabilityProposal::parse_value(missing), + Err(TaskCapabilityManifestError::InvalidJson) + ); + + for field in [ + "risk", + "grant", + "authority", + "actor", + "approval", + "resolution", + "claim", + "token", + "permission_state", + "manifest_revision", + "manifest_fingerprint", + "preview", + "preview_hash", + "renderer_revision", + ] { + let mut top = serde_json::to_value(&proposal).unwrap(); + top[field] = Value::String("forged".to_string()); + assert_eq!( + TaskCapabilityProposal::parse_value(top), + Err(TaskCapabilityManifestError::InvalidJson), + "top-level {field}" + ); + + let mut nested = serde_json::to_value(&proposal).unwrap(); + nested["capabilities"][0][field] = Value::String("forged".to_string()); + assert_eq!( + TaskCapabilityProposal::parse_value(nested), + Err(TaskCapabilityManifestError::InvalidJson), + "nested {field}" + ); + } + + let mut duplicate = proposal.clone(); + duplicate.capabilities[1] = duplicate.capabilities[0].clone(); + assert_eq!( + duplicate.validate(), + Err(TaskCapabilityManifestError::DuplicateValue) + ); + let mut noncanonical = proposal.clone(); + noncanonical.capabilities.reverse(); + assert_eq!( + noncanonical.validate(), + Err(TaskCapabilityManifestError::NonCanonicalOrder) + ); + let mut private_reference = proposal.clone(); + private_reference.capabilities[0].application_ids = vec!["provider_ref".to_string()]; + assert_eq!( + private_reference.validate(), + Err(TaskCapabilityManifestError::SecretLikeContent) + ); + let mut secret = proposal.clone(); + secret.capabilities[0].application_ids = + vec![format!("{}{}", "sk", "-abcdefghijklmnopqrstuvwxyz")]; + assert_eq!( + secret.validate(), + Err(TaskCapabilityManifestError::SecretLikeContent) + ); + let mut absolute_path = proposal.clone(); + absolute_path.capabilities[1].path_target_ids = vec!["c:\\private".to_string()]; + assert!(absolute_path.validate().is_err()); + assert_eq!( + TaskCapabilityProposal::parse_json(&format!( + "{{\"padding\":\"{}\"}}", + "x".repeat(MAX_JSON_BYTES) + )), + Err(TaskCapabilityManifestError::JsonTooLarge) + ); + } + + #[test] + fn c3d_kernel_binds_descriptive_needs_to_the_exact_frozen_goal_and_catalog_tools() { + let store = EventStore::open_memory().unwrap(); + let goal = frozen_goal(&store); + let proposal = descriptive_proposal(); + let bound = proposal.bind_to_frozen_goal(TASK_ID, &goal).unwrap(); + let frozen = goal.frozen().unwrap(); + + assert_eq!(bound.task_id, TASK_ID); + assert_eq!(bound.goal_id, TASK_ID); + assert_eq!(bound.goal_revision, frozen.revision); + assert_eq!(bound.goal_fingerprint, frozen.fingerprint); + assert_eq!( + bound.capabilities[0].tool_ids, + vec![CONNECTOR_MUTATE_TOOL_ID] + ); + assert_eq!(bound.capabilities[1].tool_ids, vec![FILE_WRITE_TOOL_ID]); + assert_eq!( + proposal.bind_to_frozen_goal(Uuid::from_u128(0xdead), &goal), + Err(TaskCapabilityManifestError::TaskGoalIdentityMismatch) + ); + } + + #[test] + fn strict_proposal_rejects_versions_missing_unknown_and_oversized_inputs() { + let (_, proposal, _, _, _) = compiled_fixture(); + let mut value = serde_json::to_value(&proposal).unwrap(); + value["version"] = Value::String("ds-agent.task-capability-manifest/v2".to_string()); + assert_eq!( + TaskCapabilityManifestProposal::parse_value(value), + Err(TaskCapabilityManifestError::UnsupportedVersion) + ); + + let mut missing = serde_json::to_value(&proposal).unwrap(); + missing.as_object_mut().unwrap().remove("expires_at"); + assert_eq!( + TaskCapabilityManifestProposal::parse_value(missing), + Err(TaskCapabilityManifestError::InvalidJson) + ); + + let mut unknown = serde_json::to_value(&proposal).unwrap(); + unknown["authorization"] = Value::String("approved".to_string()); + assert_eq!( + TaskCapabilityManifestProposal::parse_value(unknown), + Err(TaskCapabilityManifestError::InvalidJson) + ); + + assert_eq!( + TaskCapabilityManifestProposal::parse_json(&format!( + "{{\"padding\":\"{}\"}}", + "x".repeat(MAX_JSON_BYTES) + )), + Err(TaskCapabilityManifestError::JsonTooLarge) + ); + assert_eq!( + TaskCapabilityManifestProposal::parse_json(&proposal.to_json().unwrap()).unwrap(), + proposal + ); + } + + #[test] + fn model_and_frontend_authority_or_risk_fields_are_never_accepted() { + let (_, proposal, _, _, _) = compiled_fixture(); + for field in ["risk", "risk_level", "approved", "grant", "decision"] { + let mut top = serde_json::to_value(&proposal).unwrap(); + top[field] = Value::String("critical".to_string()); + assert_eq!( + TaskCapabilityManifestProposal::parse_value(top), + Err(TaskCapabilityManifestError::InvalidJson), + "top-level {field}" + ); + + let mut nested = serde_json::to_value(&proposal).unwrap(); + nested["capabilities"][0][field] = Value::String("low".to_string()); + assert_eq!( + TaskCapabilityManifestProposal::parse_value(nested), + Err(TaskCapabilityManifestError::InvalidJson), + "nested {field}" + ); + } + } + + #[test] + fn unknown_duplicate_and_noncanonical_capabilities_fail_closed() { + let (_, proposal, _, _, _) = compiled_fixture(); + let mut unknown = proposal.clone(); + unknown.capabilities[1].capability = "future_unknown".to_string(); + assert_eq!( + unknown.validate(), + Err(TaskCapabilityManifestError::UnknownCapability) + ); + + let mut duplicate = proposal.clone(); + duplicate.capabilities[1].capability = "connector_write".to_string(); + assert_eq!( + duplicate.validate(), + Err(TaskCapabilityManifestError::DuplicateValue) + ); + + let mut unordered = proposal.clone(); + unordered.capabilities.reverse(); + assert_eq!( + unordered.validate(), + Err(TaskCapabilityManifestError::NonCanonicalOrder) + ); + + let mut unordered_tools = proposal.clone(); + unordered_tools.capabilities[0].tool_ids = vec!["z.tool".to_string(), "a.tool".to_string()]; + assert_eq!( + unordered_tools.validate(), + Err(TaskCapabilityManifestError::NonCanonicalOrder) + ); + } + + #[test] + fn exact_task_goal_revision_and_fingerprint_are_mandatory() { + let (goal, proposal, context, _, _) = compiled_fixture(); + assert_eq!( + compile_task_capability_manifest(Uuid::from_u128(99), &goal, &proposal, &context), + Err(TaskCapabilityManifestError::TaskGoalIdentityMismatch) + ); + + let mut wrong_task = proposal.clone(); + wrong_task.task_id = Uuid::from_u128(99); + wrong_task.goal_id = wrong_task.task_id; + assert_eq!( + compile_task_capability_manifest(wrong_task.task_id, &goal, &wrong_task, &context), + Err(TaskCapabilityManifestError::TaskGoalIdentityMismatch) + ); + + let mut wrong_revision = proposal.clone(); + wrong_revision.goal_revision = "1".repeat(64); + assert_eq!( + compile_task_capability_manifest(TASK_ID, &goal, &wrong_revision, &context), + Err(TaskCapabilityManifestError::GoalRevisionMismatch) + ); + + let mut wrong_fingerprint = proposal.clone(); + wrong_fingerprint.goal_fingerprint = "2".repeat(64); + assert_eq!( + compile_task_capability_manifest(TASK_ID, &goal, &wrong_fingerprint, &context), + Err(TaskCapabilityManifestError::GoalFingerprintMismatch) + ); + + let (_, _, _, mut tampered_scope, _) = compiled_fixture(); + let entry = &mut tampered_scope.capabilities[0]; + for binding in entry + .account_scopes + .iter_mut() + .chain(entry.external_targets.iter_mut()) + .filter(|binding| binding.target_id == "finance-account") + { + binding.authority_fingerprint = "3".repeat(64); + } + rehash_manifest(&mut tampered_scope); + assert!(tampered_scope.validate_integrity().is_ok()); + assert_eq!( + tampered_scope.validate_for_goal(&goal), + Err(TaskCapabilityManifestError::ScopeBindingMismatch) + ); + + let (_, _, _, mut tampered_verifier, _) = compiled_fixture(); + tampered_verifier.capabilities[0].verifiers[0].summary = + "A different verifier summary.".to_string(); + rehash_manifest(&mut tampered_verifier); + assert!(tampered_verifier.validate_integrity().is_ok()); + assert_eq!( + tampered_verifier.validate_for_goal(&goal), + Err(TaskCapabilityManifestError::UnknownVerifier) + ); + } + + #[test] + fn kernel_catalog_alone_derives_capability_and_aggregate_risk() { + let (goal, proposal, context, manifest, preview) = compiled_fixture(); + assert_eq!(manifest.aggregate_risk, RiskLevel::Critical); + assert_eq!(manifest.capabilities[0].risk_level, RiskLevel::Critical); + assert_eq!(manifest.capabilities[1].risk_level, RiskLevel::High); + assert_eq!(preview.aggregate_risk, RiskLevel::Critical); + assert!(manifest.validate_integrity().is_ok()); + assert!(preview.validate_for_manifest(&manifest).is_ok()); + + let mut tool_catalog = builtin_tool_catalog(); + tool_catalog + .iter_mut() + .find(|tool| tool.id == FILE_WRITE_TOOL_ID) + .unwrap() + .risk_level = RiskLevel::Low; + assert_eq!( + compile_with_catalogs( + TASK_ID, + &goal, + &proposal, + &context, + &tool_catalog, + &builtin_capability_catalog(), + ), + Err(TaskCapabilityManifestError::CatalogRiskMismatch) + ); + } + + #[test] + fn capability_tool_and_complete_goal_coverage_cannot_drift() { + let (goal, proposal, context, _, _) = compiled_fixture(); + let mut mismatch = proposal.clone(); + mismatch.capabilities[0].tool_ids = vec![FILE_WRITE_TOOL_ID.to_string()]; + mismatch.capabilities[1].tool_ids = vec![CONNECTOR_MUTATE_TOOL_ID.to_string()]; + assert_eq!( + compile_task_capability_manifest(TASK_ID, &goal, &mismatch, &context), + Err(TaskCapabilityManifestError::ToolCapabilityMismatch) + ); + + let mut omitted = proposal.clone(); + omitted.capabilities.pop(); + assert_eq!( + compile_task_capability_manifest(TASK_ID, &goal, &omitted, &context), + Err(TaskCapabilityManifestError::IncompleteGoalBinding) + ); + } + + #[test] + fn path_account_recipient_schedule_expiry_application_and_verifier_drift_change_binding() { + let (goal, proposal, context, manifest, preview) = compiled_fixture(); + let variants = [ + context + .clone() + .with_target_display("report-folder", "Workspace / revised-reports"), + context + .clone() + .with_target_display("finance-account", "Revised work mailbox account"), + context + .clone() + .with_target_display("finance-recipient", "other-test@example.com"), + context + .clone() + .with_target_display("weekday-window", "Weekdays 10:00-18:00 Asia/Shanghai"), + ]; + for changed_context in variants { + let changed = + compile_task_capability_manifest(TASK_ID, &goal, &proposal, &changed_context) + .unwrap(); + assert_ne!(changed.fingerprint, manifest.fingerprint); + assert_eq!( + preview.validate_for_manifest(&changed), + Err(TaskCapabilityManifestError::PreviewIntegrityMismatch) + ); + } + + let mut changed_expiry = proposal.clone(); + changed_expiry.expires_at = "2031-01-02T03:04:05Z".parse().unwrap(); + let changed = + compile_task_capability_manifest(TASK_ID, &goal, &changed_expiry, &context).unwrap(); + assert_ne!(changed.fingerprint, manifest.fingerprint); + + let mut changed_application = proposal.clone(); + changed_application.capabilities[1].application_ids = vec!["outlook".to_string()]; + let changed = + compile_task_capability_manifest(TASK_ID, &goal, &changed_application, &context) + .unwrap(); + assert_ne!(changed.fingerprint, manifest.fingerprint); + + let mut changed_verifier = proposal.clone(); + changed_verifier.capabilities[0].verifier_ids = vec!["report-verifier-v1".to_string()]; + changed_verifier.capabilities[1].verifier_ids = vec!["external-verifier-v1".to_string()]; + let changed = + compile_task_capability_manifest(TASK_ID, &goal, &changed_verifier, &context).unwrap(); + assert_ne!(changed.fingerprint, manifest.fingerprint); + } + + #[test] + fn preview_revision_hash_tamper_replay_and_cross_domain_reuse_fail() { + let (_goal, proposal, context, manifest, preview) = compiled_fixture(); + assert_ne!(preview.preview_hash, manifest.revision); + assert_ne!(preview.preview_hash, manifest.fingerprint); + + let mut wrong_revision = preview.clone(); + wrong_revision.renderer_revision += 1; + assert_eq!( + wrong_revision.validate_integrity(), + Err(TaskCapabilityManifestError::UnsupportedVersion) + ); + + let mut tampered = preview.clone(); + tampered.capabilities[0].account_scopes[0].display_label = "Tampered account".to_string(); + assert_eq!( + tampered.validate_integrity(), + Err(TaskCapabilityManifestError::PreviewIntegrityMismatch) + ); + + let mut wrong_hash = preview.clone(); + wrong_hash.preview_hash = manifest.fingerprint.clone(); + assert_eq!( + wrong_hash.validate_integrity(), + Err(TaskCapabilityManifestError::PreviewIntegrityMismatch) + ); + + let other_store = EventStore::open_memory().unwrap(); + let other_task_id = Uuid::from_u128(0x31b); + let other_goal = frozen_goal_for(&other_store, other_task_id); + let mut replay_proposal = proposal.clone(); + replay_proposal.task_id = other_task_id; + replay_proposal.goal_id = other_task_id; + replay_proposal.goal_revision = other_goal.frozen().unwrap().revision.clone(); + replay_proposal.goal_fingerprint = other_goal.frozen().unwrap().fingerprint.clone(); + let other_manifest = compile_task_capability_manifest( + other_task_id, + &other_goal, + &replay_proposal, + &context, + ) + .unwrap(); + assert_ne!(other_manifest.fingerprint, manifest.fingerprint); + assert_eq!( + preview.validate_for_manifest(&other_manifest), + Err(TaskCapabilityManifestError::PreviewIntegrityMismatch) + ); + } + + #[test] + fn canonical_manifest_and_preview_survive_restart_deterministically() { + let directory = tempdir().unwrap(); + let database = directory.path().join("kernel.sqlite3"); + let first_manifest; + let first_preview; + let proposal; + { + let store = EventStore::open(&database).unwrap(); + let goal = frozen_goal(&store); + proposal = manifest_proposal(&goal); + first_manifest = + compile_task_capability_manifest(TASK_ID, &goal, &proposal, &manifest_context()) + .unwrap(); + first_preview = task_authorization_preview(&first_manifest).unwrap(); + } + + let reopened = EventStore::open(&database).unwrap(); + let goal = reopened + .goal_envelope_projection(TASK_ID) + .unwrap() + .expect("legacy GoalEnvelope projection reopens"); + let next_manifest = + compile_task_capability_manifest(TASK_ID, &goal, &proposal, &manifest_context()) + .unwrap(); + let next_preview = task_authorization_preview(&next_manifest).unwrap(); + assert!(next_manifest.validate_for_goal(&goal).is_ok()); + assert_eq!(next_manifest, first_manifest); + assert_eq!(next_preview, first_preview); + assert_eq!( + TaskCapabilityManifest::parse_json(&first_manifest.canonical_json().unwrap()).unwrap(), + first_manifest + ); + assert_eq!( + TaskAuthorizationPreview::parse_json(&first_preview.canonical_json().unwrap()).unwrap(), + first_preview + ); + } + + #[test] + fn secret_provider_credential_claim_and_private_path_labels_are_rejected() { + let (goal, proposal, context, manifest, preview) = compiled_fixture(); + for forbidden in [ + format!("sk-{}", "a".repeat(20)), + "provider_ref=message-123".to_string(), + "credential_handle=vault-item".to_string(), + "claim_token=opaque-claim".to_string(), + r"C:\Users\owner\AppData\Local\private".to_string(), + ] { + let changed = context + .clone() + .with_target_display("report-folder", forbidden); + assert_eq!( + compile_task_capability_manifest(TASK_ID, &goal, &proposal, &changed), + Err(TaskCapabilityManifestError::SecretLikeContent) + ); + } + + let public_preview = serde_json::to_string(&preview).unwrap(); + for forbidden in [ + "authority_fingerprint", + "tool_id", + "provider_ref", + "credential_handle", + "claim_token", + "AppData", + ] { + assert!(!public_preview.contains(forbidden)); + } + let manifest_json = serde_json::to_string(&manifest).unwrap(); + for forbidden in [ + "provider_ref", + "credential_handle", + "claim_token", + "AppData", + ] { + assert!(!manifest_json.contains(forbidden)); + } + } + + #[test] + fn malformed_proposal_creates_no_permission_execution_event_or_completion_state() { + let store = EventStore::open_memory().unwrap(); + let malformed = serde_json::json!({ + "version": TASK_CAPABILITY_MANIFEST_VERSION, + "task_id": TASK_ID, + "goal_id": TASK_ID, + "goal_revision": "1".repeat(64), + "goal_fingerprint": "2".repeat(64), + "expires_at": "2030-01-02T03:04:05Z", + "capabilities": [], + "approved": true + }); + assert!(TaskCapabilityManifestProposal::parse_value(malformed).is_err()); + assert!(store.list_recent(10).unwrap().is_empty()); + assert!(store.list_capability_access_records().unwrap().is_empty()); + assert!(store.goal_envelope_projection(TASK_ID).unwrap().is_none()); + assert!(store.goal_completion_projection(TASK_ID).unwrap().is_none()); + } + + #[test] + fn existing_exact_tool_approval_contract_remains_compatible() { + let mut request = CapabilityAccessRequest { + id: Uuid::from_u128(400), + access_mode: AccessMode::AskEveryStep, + family: crate::kernel::policy::CapabilityFamily::File, + capability: CapabilityKind::FileWrite, + title: "Write local files".to_string(), + summary: "Write one exact file.".to_string(), + risk_level: RiskLevel::High, + decision: crate::kernel::policy::PolicyDecision::Ask, + status: crate::kernel::policy::CapabilityAccessStatus::PendingApproval, + reason: "exact approval required".to_string(), + exact_tool: None, + created_at: Utc::now(), + }; + request + .bind_exact_tool(FILE_WRITE_TOOL_ID, "a".repeat(64), "Write reports/brief.md") + .unwrap(); + let scope = request.exact_tool.as_ref().unwrap(); + assert_eq!(scope.preview_revision, TOOL_APPROVAL_PREVIEW_REVISION); + assert_eq!( + scope.preview_hash, + exact_tool_preview_hash(scope.preview_revision, &scope.preview) + ); + assert!(PermissionResolution::new_exact( + request.id, + true, + "Approved exact tool".to_string(), + 0, + scope, + ) + .is_ok()); + } + + #[test] + fn manifest_or_preview_deserialization_rejects_unknown_and_tampered_fields() { + let (_, _, _, manifest, preview) = compiled_fixture(); + let mut manifest_value = serde_json::to_value(&manifest).unwrap(); + manifest_value["capabilities"][0]["approved"] = Value::Bool(true); + assert_eq!( + TaskCapabilityManifest::parse_json(&manifest_value.to_string()), + Err(TaskCapabilityManifestError::InvalidJson) + ); + + let mut tampered_manifest = manifest.clone(); + tampered_manifest.capabilities[0].risk_level = RiskLevel::Low; + assert_eq!( + tampered_manifest.validate_integrity(), + Err(TaskCapabilityManifestError::CatalogRiskMismatch) + ); + + let mut unknown_tool = manifest.clone(); + unknown_tool.capabilities[0].tools[0].tool_id = "future.unknown".to_string(); + rehash_manifest(&mut unknown_tool); + assert_eq!( + unknown_tool.validate_integrity(), + Err(TaskCapabilityManifestError::UnknownTool) + ); + + let mut preview_value = serde_json::to_value(&preview).unwrap(); + preview_value["grant"] = Value::String("reusable".to_string()); + assert_eq!( + TaskAuthorizationPreview::parse_json(&preview_value.to_string()), + Err(TaskCapabilityManifestError::InvalidJson) + ); + + let mut downgraded_preview = preview.clone(); + downgraded_preview.aggregate_risk = RiskLevel::Low; + downgraded_preview.preview_hash = preview_hash_for(&downgraded_preview); + assert_eq!( + downgraded_preview.validate_integrity(), + Err(TaskCapabilityManifestError::CatalogRiskMismatch) + ); + } +} diff --git a/apps/desktop/src-tauri/src/kernel/task_grouped_approval.rs b/apps/desktop/src-tauri/src/kernel/task_grouped_approval.rs new file mode 100644 index 0000000..a71b9a8 --- /dev/null +++ b/apps/desktop/src-tauri/src/kernel/task_grouped_approval.rs @@ -0,0 +1,1123 @@ +use std::collections::BTreeSet; + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use uuid::Uuid; + +use crate::kernel::goal_lifecycle::GoalLifecycleProjection; +use crate::kernel::policy::{ + exact_tool_preview_hash, CapabilityKind, RiskLevel, TOOL_APPROVAL_PREVIEW_REVISION, +}; +use crate::kernel::task_capability_manifest::{ + TaskAuthorizationPreview, TaskCapabilityManifest, TaskCapabilityManifestError, +}; + +pub const TASK_GROUPED_APPROVAL_VERSION: &str = "ds-agent.task-grouped-approval/v1"; +pub const TASK_GROUPED_APPROVAL_EVENT_VERSION: &str = "ds-agent.task-grouped-approval-event/v1"; +pub const TASK_GROUPED_AUTHORIZATION_UI_VERSION: &str = "ds-agent.task-grouped-authorization-ui/v1"; + +const GROUP_ID_DOMAIN: &[u8] = b"ds-agent.task-grouped-approval-id.v1\0"; +const GROUP_INTEGRITY_DOMAIN: &[u8] = b"ds-agent.task-grouped-approval-integrity.v1\0"; +const ITEM_ID_DOMAIN: &[u8] = b"ds-agent.task-grouped-approval-item-id.v1\0"; +const REQUEST_ID_DOMAIN: &[u8] = b"ds-agent.task-grouped-approval-request-id.v1\0"; +const REQUEST_FINGERPRINT_DOMAIN: &[u8] = + b"ds-agent.task-grouped-approval-request-fingerprint.v1\0"; +const RESOLUTION_ID_DOMAIN: &[u8] = b"ds-agent.task-grouped-approval-resolution-id.v1\0"; +const EVENT_ID_DOMAIN: &[u8] = b"ds-agent.task-grouped-approval-event-id.v1\0"; +const ITEM_EVENT_ID_DOMAIN: &[u8] = b"ds-agent.task-grouped-approval-item-event-id.v1\0"; +const LEGACY_CONSUMPTION_ID_DOMAIN: &[u8] = + b"ds-agent.task-grouped-approval-legacy-consumption-id.v1\0"; +const MAX_GROUPED_APPROVAL_JSON_BYTES: usize = 192 * 1024; +const MAX_AUDIT_ITEMS: usize = 2048; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum TaskGroupedApprovalActor { + User, + KernelLifecycle, + DeepSeekModel, + FrontendPayload, +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TaskGroupedApprovalStatus { + Pending, + Approved, + Rejected, + Revoked, + Expired, + ScopeChanged, +} + +impl TaskGroupedApprovalStatus { + pub const fn as_str(self) -> &'static str { + match self { + Self::Pending => "pending", + Self::Approved => "approved", + Self::Rejected => "rejected", + Self::Revoked => "revoked", + Self::Expired => "expired", + Self::ScopeChanged => "scope_changed", + } + } + + pub const fn carries_authority(self) -> bool { + matches!(self, Self::Approved) + } +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TaskGroupedCapabilityAuditStatus { + Pending, + Approved, + Rejected, + Revoked, + Expired, + ScopeChanged, +} + +impl From for TaskGroupedCapabilityAuditStatus { + fn from(value: TaskGroupedApprovalStatus) -> Self { + match value { + TaskGroupedApprovalStatus::Pending => Self::Pending, + TaskGroupedApprovalStatus::Approved => Self::Approved, + TaskGroupedApprovalStatus::Rejected => Self::Rejected, + TaskGroupedApprovalStatus::Revoked => Self::Revoked, + TaskGroupedApprovalStatus::Expired => Self::Expired, + TaskGroupedApprovalStatus::ScopeChanged => Self::ScopeChanged, + } + } +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TaskGroupedApprovalResolutionSource { + User, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct TaskGroupedCapabilityAudit { + pub item_id: String, + pub capability: CapabilityKind, + pub risk_level: RiskLevel, + pub tool_id: String, + pub tool_version: String, + pub approval_request_id: Uuid, + pub request_fingerprint: String, + pub exact_preview: String, + pub exact_preview_revision: u32, + pub exact_preview_hash: String, + pub status: TaskGroupedCapabilityAuditStatus, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct TaskGroupedApprovalResolution { + pub id: Uuid, + pub approved: bool, + pub source: TaskGroupedApprovalResolutionSource, + pub expected_projection_revision: u64, + pub task_id: Uuid, + pub manifest_revision: String, + pub manifest_fingerprint: String, + pub preview_schema_revision: u32, + pub preview_renderer_revision: u32, + pub preview_hash: String, + pub created_at: DateTime, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TaskGroupedApprovalResolutionClaim { + pub group_id: Uuid, + pub task_id: Uuid, + pub expected_projection_revision: u64, + pub manifest_revision: String, + pub manifest_fingerprint: String, + pub preview_schema_revision: u32, + pub preview_renderer_revision: u32, + pub preview_hash: String, + pub actor: TaskGroupedApprovalActor, +} + +impl TaskGroupedApprovalResolutionClaim { + pub fn from_group(group: &TaskGroupedApproval, actor: TaskGroupedApprovalActor) -> Self { + Self { + group_id: group.id, + task_id: group.task_id, + expected_projection_revision: group.projection_revision, + manifest_revision: group.manifest.revision.clone(), + manifest_fingerprint: group.manifest.fingerprint.clone(), + preview_schema_revision: group.preview.schema_revision, + preview_renderer_revision: group.preview.renderer_revision, + preview_hash: group.preview.preview_hash.clone(), + actor, + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TaskGroupedCapabilityClaim { + pub group_id: Uuid, + pub task_id: Uuid, + pub expected_projection_revision: u64, + pub manifest_revision: String, + pub manifest_fingerprint: String, + pub preview_schema_revision: u32, + pub preview_renderer_revision: u32, + pub preview_hash: String, + pub capability: CapabilityKind, + pub tool_id: String, + pub request_fingerprint: String, +} + +impl TaskGroupedCapabilityClaim { + pub fn from_group_item(group: &TaskGroupedApproval, item: &TaskGroupedCapabilityAudit) -> Self { + Self { + group_id: group.id, + task_id: group.task_id, + expected_projection_revision: group.projection_revision, + manifest_revision: group.manifest.revision.clone(), + manifest_fingerprint: group.manifest.fingerprint.clone(), + preview_schema_revision: group.preview.schema_revision, + preview_renderer_revision: group.preview.renderer_revision, + preview_hash: group.preview.preview_hash.clone(), + capability: item.capability, + tool_id: item.tool_id.clone(), + request_fingerprint: item.request_fingerprint.clone(), + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TaskGroupedCapabilityGrant { + pub group_id: Uuid, + pub task_id: Uuid, + pub projection_revision: u64, + pub manifest_revision: String, + pub manifest_fingerprint: String, + pub preview_renderer_revision: u32, + pub preview_hash: String, + pub capability: CapabilityKind, + pub tool_id: String, + pub request_fingerprint: String, + pub approval_request_id: Uuid, + pub expires_at: DateTime, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct TaskGroupedApproval { + pub version: String, + pub id: Uuid, + pub task_id: Uuid, + pub manifest: TaskCapabilityManifest, + pub preview: TaskAuthorizationPreview, + pub status: TaskGroupedApprovalStatus, + pub projection_revision: u64, + pub capability_audits: Vec, + pub resolution: Option, + pub created_at: DateTime, + pub updated_at: DateTime, + pub integrity_hash: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct TaskGroupedApprovalEventReceipt { + pub version: String, + pub group_id: Uuid, + pub task_id: Uuid, + pub status: TaskGroupedApprovalStatus, + pub projection_revision: u64, + pub manifest_revision: String, + pub manifest_fingerprint: String, + pub preview_renderer_revision: u32, + pub preview_hash: String, + pub capability_audit_ids: Vec, + pub created_at: DateTime, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct TaskGroupedAuthorizationIntent { + pub group_id: Uuid, + pub task_id: Uuid, + pub expected_projection_revision: u64, + pub manifest_revision: String, + pub manifest_fingerprint: String, + pub preview_schema_revision: u32, + pub preview_renderer_revision: u32, + pub preview_hash: String, +} + +impl TaskGroupedAuthorizationIntent { + pub fn resolution_claim(&self) -> TaskGroupedApprovalResolutionClaim { + TaskGroupedApprovalResolutionClaim { + group_id: self.group_id, + task_id: self.task_id, + expected_projection_revision: self.expected_projection_revision, + manifest_revision: self.manifest_revision.clone(), + manifest_fingerprint: self.manifest_fingerprint.clone(), + preview_schema_revision: self.preview_schema_revision, + preview_renderer_revision: self.preview_renderer_revision, + preview_hash: self.preview_hash.clone(), + actor: TaskGroupedApprovalActor::User, + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +pub struct TaskGroupedCapabilityAuditView { + pub capability: CapabilityKind, + pub risk_level: RiskLevel, + pub status: TaskGroupedCapabilityAuditStatus, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +pub struct TaskGroupedAuthorizationView { + pub version: String, + pub intent: TaskGroupedAuthorizationIntent, + pub status: TaskGroupedApprovalStatus, + pub goal: String, + pub applications: Vec, + pub paths: Vec, + pub accounts: Vec, + pub recipients: Vec, + pub time_windows: Vec, + pub external_targets: Vec, + pub expires_at: DateTime, + pub risk_level: RiskLevel, + pub verifiers: Vec, + pub capability_audits: Vec, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum TaskGroupedApprovalError { + ManifestInvalid, + PreviewInvalid, + BindingMismatch, + IntegrityMismatch, + InvalidState, + InvalidActor, + Expired, + CollectionOutOfBounds, + NonCanonicalAudit, +} + +impl std::fmt::Display for TaskGroupedApprovalError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(match self { + Self::ManifestInvalid => "task grouped approval manifest is invalid", + Self::PreviewInvalid => "task grouped approval preview is invalid", + Self::BindingMismatch => "task grouped approval binding changed", + Self::IntegrityMismatch => "task grouped approval integrity check failed", + Self::InvalidState => "task grouped approval state is invalid", + Self::InvalidActor => "task grouped approval actor has no authority", + Self::Expired => "task grouped approval expired", + Self::CollectionOutOfBounds => "task grouped approval audit collection is invalid", + Self::NonCanonicalAudit => "task grouped approval audit order is invalid", + }) + } +} + +impl std::error::Error for TaskGroupedApprovalError {} + +impl From for TaskGroupedApprovalError { + fn from(_: TaskCapabilityManifestError) -> Self { + Self::ManifestInvalid + } +} + +#[derive(Serialize)] +struct GroupIdCanonical<'a> { + task_id: Uuid, + goal_revision: &'a str, + goal_fingerprint: &'a str, + manifest_revision: &'a str, + manifest_fingerprint: &'a str, + preview_schema_revision: u32, + preview_renderer_revision: u32, + preview_hash: &'a str, + expires_at: DateTime, +} + +#[derive(Serialize)] +struct ItemCanonical<'a> { + group_id: Uuid, + capability: &'a str, + tool_id: &'a str, + tool_version: &'a str, +} + +#[derive(Serialize)] +struct RequestFingerprintCanonical<'a> { + group_id: Uuid, + task_id: Uuid, + manifest_revision: &'a str, + manifest_fingerprint: &'a str, + preview_schema_revision: u32, + preview_renderer_revision: u32, + preview_hash: &'a str, + capability: &'a str, + tool_id: &'a str, + tool_version: &'a str, + expires_at: DateTime, +} + +#[derive(Serialize)] +struct GroupIntegrityCanonical<'a> { + version: &'a str, + id: Uuid, + task_id: Uuid, + manifest: &'a TaskCapabilityManifest, + preview: &'a TaskAuthorizationPreview, + status: TaskGroupedApprovalStatus, + projection_revision: u64, + capability_audits: &'a [TaskGroupedCapabilityAudit], + resolution: &'a Option, + created_at: DateTime, + updated_at: DateTime, +} + +impl TaskGroupedApproval { + pub fn new( + manifest: TaskCapabilityManifest, + preview: TaskAuthorizationPreview, + now: DateTime, + ) -> Result { + manifest + .validate_integrity() + .map_err(|_| TaskGroupedApprovalError::ManifestInvalid)?; + preview + .validate_for_manifest(&manifest) + .map_err(|_| TaskGroupedApprovalError::PreviewInvalid)?; + if manifest.expires_at <= now { + return Err(TaskGroupedApprovalError::Expired); + } + let id = group_id_for(&manifest, &preview); + let mut capability_audits = Vec::new(); + for entry in &manifest.capabilities { + for tool in &entry.tools { + let item_id = item_id_for(id, entry.capability, &tool.tool_id, &tool.tool_version); + let request_fingerprint = request_fingerprint_for( + id, + &manifest, + &preview, + entry.capability, + &tool.tool_id, + &tool.tool_version, + ); + let exact_preview = + compact_exact_preview(&manifest, &preview, entry.capability, &tool.tool_id); + capability_audits.push(TaskGroupedCapabilityAudit { + approval_request_id: approval_request_id_for(id, &item_id), + item_id, + capability: entry.capability, + risk_level: entry.risk_level, + tool_id: tool.tool_id.clone(), + tool_version: tool.tool_version.clone(), + request_fingerprint, + exact_preview_hash: exact_tool_preview_hash( + TOOL_APPROVAL_PREVIEW_REVISION, + &exact_preview, + ), + exact_preview, + exact_preview_revision: TOOL_APPROVAL_PREVIEW_REVISION, + status: TaskGroupedCapabilityAuditStatus::Pending, + }); + } + } + capability_audits.sort_by(|left, right| left.item_id.cmp(&right.item_id)); + let mut group = Self { + version: TASK_GROUPED_APPROVAL_VERSION.to_string(), + id, + task_id: manifest.task_id, + manifest, + preview, + status: TaskGroupedApprovalStatus::Pending, + projection_revision: 0, + capability_audits, + resolution: None, + created_at: now, + updated_at: now, + integrity_hash: String::new(), + }; + group.integrity_hash = integrity_hash_for(&group); + group.validate_integrity()?; + Ok(group) + } + + pub fn parse_json(json: &str) -> Result { + if json.len() > MAX_GROUPED_APPROVAL_JSON_BYTES { + return Err(TaskGroupedApprovalError::CollectionOutOfBounds); + } + let group: Self = + serde_json::from_str(json).map_err(|_| TaskGroupedApprovalError::IntegrityMismatch)?; + group.validate_integrity()?; + Ok(group) + } + + pub fn canonical_json(&self) -> Result { + self.validate_integrity()?; + let json = + serde_json::to_string(self).map_err(|_| TaskGroupedApprovalError::IntegrityMismatch)?; + if json.len() > MAX_GROUPED_APPROVAL_JSON_BYTES { + return Err(TaskGroupedApprovalError::CollectionOutOfBounds); + } + Ok(json) + } + + pub fn validate_integrity(&self) -> Result<(), TaskGroupedApprovalError> { + if self.version != TASK_GROUPED_APPROVAL_VERSION { + return Err(TaskGroupedApprovalError::IntegrityMismatch); + } + self.manifest + .validate_integrity() + .map_err(|_| TaskGroupedApprovalError::ManifestInvalid)?; + self.preview + .validate_for_manifest(&self.manifest) + .map_err(|_| TaskGroupedApprovalError::PreviewInvalid)?; + if self.task_id != self.manifest.task_id + || self.id != group_id_for(&self.manifest, &self.preview) + || self.created_at >= self.manifest.expires_at + || self.updated_at < self.created_at + { + return Err(TaskGroupedApprovalError::BindingMismatch); + } + if self.capability_audits.is_empty() || self.capability_audits.len() > MAX_AUDIT_ITEMS { + return Err(TaskGroupedApprovalError::CollectionOutOfBounds); + } + if self + .capability_audits + .windows(2) + .any(|pair| pair[0].item_id >= pair[1].item_id) + { + return Err(TaskGroupedApprovalError::NonCanonicalAudit); + } + let mut expected = Vec::new(); + for entry in &self.manifest.capabilities { + for tool in &entry.tools { + expected.push((entry.capability, entry.risk_level, tool)); + } + } + expected.sort_by_key(|(capability, _, tool)| { + item_id_for(self.id, *capability, &tool.tool_id, &tool.tool_version) + }); + if expected.len() != self.capability_audits.len() { + return Err(TaskGroupedApprovalError::BindingMismatch); + } + let expected_status = TaskGroupedCapabilityAuditStatus::from(self.status); + for (audit, (capability, risk_level, tool)) in + self.capability_audits.iter().zip(expected.into_iter()) + { + let item_id = item_id_for(self.id, capability, &tool.tool_id, &tool.tool_version); + let request_fingerprint = request_fingerprint_for( + self.id, + &self.manifest, + &self.preview, + capability, + &tool.tool_id, + &tool.tool_version, + ); + let exact_preview = + compact_exact_preview(&self.manifest, &self.preview, capability, &tool.tool_id); + if audit.item_id != item_id + || audit.capability != capability + || audit.risk_level != risk_level + || audit.tool_id != tool.tool_id + || audit.tool_version != tool.tool_version + || audit.approval_request_id != approval_request_id_for(self.id, &item_id) + || audit.request_fingerprint != request_fingerprint + || audit.exact_preview != exact_preview + || audit.exact_preview_revision != TOOL_APPROVAL_PREVIEW_REVISION + || audit.exact_preview_hash + != exact_tool_preview_hash(TOOL_APPROVAL_PREVIEW_REVISION, &exact_preview) + || audit.status != expected_status + { + return Err(TaskGroupedApprovalError::BindingMismatch); + } + } + match self.status { + TaskGroupedApprovalStatus::Pending => { + if self.projection_revision != 0 || self.resolution.is_some() { + return Err(TaskGroupedApprovalError::InvalidState); + } + } + TaskGroupedApprovalStatus::Approved | TaskGroupedApprovalStatus::Rejected => { + let resolution = self + .resolution + .as_ref() + .ok_or(TaskGroupedApprovalError::InvalidState)?; + if self.projection_revision != 1 + || resolution.approved + != matches!(self.status, TaskGroupedApprovalStatus::Approved) + { + return Err(TaskGroupedApprovalError::InvalidState); + } + validate_resolution(self, resolution)?; + } + TaskGroupedApprovalStatus::Revoked => { + let resolution = self + .resolution + .as_ref() + .ok_or(TaskGroupedApprovalError::InvalidState)?; + if !resolution.approved || self.projection_revision != 2 { + return Err(TaskGroupedApprovalError::InvalidState); + } + validate_resolution(self, resolution)?; + } + TaskGroupedApprovalStatus::Expired | TaskGroupedApprovalStatus::ScopeChanged => { + match &self.resolution { + None if self.projection_revision == 1 => {} + Some(resolution) if resolution.approved && self.projection_revision == 2 => { + validate_resolution(self, resolution)?; + } + _ => return Err(TaskGroupedApprovalError::InvalidState), + } + } + } + if self.integrity_hash != integrity_hash_for(self) { + return Err(TaskGroupedApprovalError::IntegrityMismatch); + } + Ok(()) + } + + pub fn binding_matches_resolution_claim( + &self, + claim: &TaskGroupedApprovalResolutionClaim, + ) -> bool { + self.id == claim.group_id + && self.task_id == claim.task_id + && self.projection_revision == claim.expected_projection_revision + && self.manifest.revision == claim.manifest_revision + && self.manifest.fingerprint == claim.manifest_fingerprint + && self.preview.schema_revision == claim.preview_schema_revision + && self.preview.renderer_revision == claim.preview_renderer_revision + && self.preview.preview_hash == claim.preview_hash + } + + pub fn resolution_replay_matches( + &self, + claim: &TaskGroupedApprovalResolutionClaim, + approved: bool, + ) -> bool { + let Some(resolution) = &self.resolution else { + return false; + }; + resolution.approved == approved + && self.id == claim.group_id + && self.task_id == claim.task_id + && resolution.expected_projection_revision == claim.expected_projection_revision + && resolution.manifest_revision == claim.manifest_revision + && resolution.manifest_fingerprint == claim.manifest_fingerprint + && resolution.preview_schema_revision == claim.preview_schema_revision + && resolution.preview_renderer_revision == claim.preview_renderer_revision + && resolution.preview_hash == claim.preview_hash + } + + pub fn capability_item( + &self, + claim: &TaskGroupedCapabilityClaim, + ) -> Result<&TaskGroupedCapabilityAudit, TaskGroupedApprovalError> { + if self.id != claim.group_id + || self.task_id != claim.task_id + || self.projection_revision != claim.expected_projection_revision + || self.manifest.revision != claim.manifest_revision + || self.manifest.fingerprint != claim.manifest_fingerprint + || self.preview.schema_revision != claim.preview_schema_revision + || self.preview.renderer_revision != claim.preview_renderer_revision + || self.preview.preview_hash != claim.preview_hash + { + return Err(TaskGroupedApprovalError::BindingMismatch); + } + self.capability_audits + .iter() + .find(|item| { + item.capability == claim.capability + && item.tool_id == claim.tool_id + && item.request_fingerprint == claim.request_fingerprint + }) + .ok_or(TaskGroupedApprovalError::BindingMismatch) + } + + pub fn authorization_view( + &self, + goal: &GoalLifecycleProjection, + ) -> Result { + self.validate_integrity()?; + let frozen = goal + .frozen() + .ok_or(TaskGroupedApprovalError::BindingMismatch)?; + if goal.goal_id != self.task_id { + return Err(TaskGroupedApprovalError::BindingMismatch); + } + if matches!( + self.status, + TaskGroupedApprovalStatus::Pending | TaskGroupedApprovalStatus::Approved + ) { + self.manifest + .validate_for_goal(goal) + .map_err(|_| TaskGroupedApprovalError::BindingMismatch)?; + } + + let mut applications = BTreeSet::new(); + let mut paths = BTreeSet::new(); + let mut accounts = BTreeSet::new(); + let mut recipients = BTreeSet::new(); + let mut time_windows = BTreeSet::new(); + let mut external_targets = BTreeSet::new(); + let mut verifiers = BTreeSet::new(); + for capability in &self.preview.capabilities { + applications.extend( + capability + .applications + .iter() + .map(|value| value.display_label.clone()), + ); + paths.extend( + capability + .path_scopes + .iter() + .map(|value| value.display_label.clone()), + ); + accounts.extend( + capability + .account_scopes + .iter() + .map(|value| value.display_label.clone()), + ); + recipients.extend( + capability + .recipient_scopes + .iter() + .map(|value| value.display_label.clone()), + ); + time_windows.extend( + capability + .time_windows + .iter() + .map(|value| value.display_label.clone()), + ); + external_targets.extend( + capability + .external_targets + .iter() + .map(|value| value.display_label.clone()), + ); + verifiers.extend( + capability + .verifiers + .iter() + .map(|value| value.summary.clone()), + ); + } + + Ok(TaskGroupedAuthorizationView { + version: TASK_GROUPED_AUTHORIZATION_UI_VERSION.to_string(), + intent: TaskGroupedAuthorizationIntent { + group_id: self.id, + task_id: self.task_id, + expected_projection_revision: self.projection_revision, + manifest_revision: self.manifest.revision.clone(), + manifest_fingerprint: self.manifest.fingerprint.clone(), + preview_schema_revision: self.preview.schema_revision, + preview_renderer_revision: self.preview.renderer_revision, + preview_hash: self.preview.preview_hash.clone(), + }, + status: self.status, + goal: frozen.envelope.user_goal.clone(), + applications: applications.into_iter().collect(), + paths: paths.into_iter().collect(), + accounts: accounts.into_iter().collect(), + recipients: recipients.into_iter().collect(), + time_windows: time_windows.into_iter().collect(), + external_targets: external_targets.into_iter().collect(), + expires_at: self.manifest.expires_at, + risk_level: self.manifest.aggregate_risk, + verifiers: verifiers.into_iter().collect(), + capability_audits: self + .capability_audits + .iter() + .map(|audit| TaskGroupedCapabilityAuditView { + capability: audit.capability, + risk_level: audit.risk_level, + status: audit.status, + }) + .collect(), + }) + } + + pub(crate) fn resolve( + &self, + claim: &TaskGroupedApprovalResolutionClaim, + approved: bool, + now: DateTime, + ) -> Result { + if claim.actor != TaskGroupedApprovalActor::User { + return Err(TaskGroupedApprovalError::InvalidActor); + } + if self.status != TaskGroupedApprovalStatus::Pending + || !self.binding_matches_resolution_claim(claim) + { + return Err(TaskGroupedApprovalError::BindingMismatch); + } + if now >= self.manifest.expires_at { + return Err(TaskGroupedApprovalError::Expired); + } + let resolution = TaskGroupedApprovalResolution { + id: resolution_id_for( + self.id, + approved, + claim.expected_projection_revision, + &claim.preview_hash, + ), + approved, + source: TaskGroupedApprovalResolutionSource::User, + expected_projection_revision: claim.expected_projection_revision, + task_id: claim.task_id, + manifest_revision: claim.manifest_revision.clone(), + manifest_fingerprint: claim.manifest_fingerprint.clone(), + preview_schema_revision: claim.preview_schema_revision, + preview_renderer_revision: claim.preview_renderer_revision, + preview_hash: claim.preview_hash.clone(), + created_at: now, + }; + self.transition( + if approved { + TaskGroupedApprovalStatus::Approved + } else { + TaskGroupedApprovalStatus::Rejected + }, + Some(resolution), + now, + ) + } + + pub(crate) fn revoke( + &self, + actor: TaskGroupedApprovalActor, + now: DateTime, + ) -> Result { + if !matches!( + actor, + TaskGroupedApprovalActor::User | TaskGroupedApprovalActor::KernelLifecycle + ) { + return Err(TaskGroupedApprovalError::InvalidActor); + } + if self.status != TaskGroupedApprovalStatus::Approved { + return Err(TaskGroupedApprovalError::InvalidState); + } + self.transition( + TaskGroupedApprovalStatus::Revoked, + self.resolution.clone(), + now, + ) + } + + pub(crate) fn expire(&self, now: DateTime) -> Result { + if now < self.manifest.expires_at + || !matches!( + self.status, + TaskGroupedApprovalStatus::Pending | TaskGroupedApprovalStatus::Approved + ) + { + return Err(TaskGroupedApprovalError::InvalidState); + } + self.transition( + TaskGroupedApprovalStatus::Expired, + self.resolution.clone(), + now, + ) + } + + pub(crate) fn scope_changed( + &self, + now: DateTime, + ) -> Result { + if !matches!( + self.status, + TaskGroupedApprovalStatus::Pending | TaskGroupedApprovalStatus::Approved + ) { + return Err(TaskGroupedApprovalError::InvalidState); + } + self.transition( + TaskGroupedApprovalStatus::ScopeChanged, + self.resolution.clone(), + now, + ) + } + + pub fn event_receipt(&self) -> TaskGroupedApprovalEventReceipt { + TaskGroupedApprovalEventReceipt { + version: TASK_GROUPED_APPROVAL_EVENT_VERSION.to_string(), + group_id: self.id, + task_id: self.task_id, + status: self.status, + projection_revision: self.projection_revision, + manifest_revision: self.manifest.revision.clone(), + manifest_fingerprint: self.manifest.fingerprint.clone(), + preview_renderer_revision: self.preview.renderer_revision, + preview_hash: self.preview.preview_hash.clone(), + capability_audit_ids: self + .capability_audits + .iter() + .map(|item| item.item_id.clone()) + .collect(), + created_at: self.updated_at, + } + } + + fn transition( + &self, + status: TaskGroupedApprovalStatus, + resolution: Option, + now: DateTime, + ) -> Result { + let mut next = self.clone(); + next.status = status; + next.projection_revision = next + .projection_revision + .checked_add(1) + .ok_or(TaskGroupedApprovalError::InvalidState)?; + next.resolution = resolution; + next.updated_at = now; + for item in &mut next.capability_audits { + item.status = status.into(); + } + next.integrity_hash = integrity_hash_for(&next); + next.validate_integrity()?; + Ok(next) + } +} + +fn validate_resolution( + group: &TaskGroupedApproval, + resolution: &TaskGroupedApprovalResolution, +) -> Result<(), TaskGroupedApprovalError> { + if resolution.source != TaskGroupedApprovalResolutionSource::User + || resolution.task_id != group.task_id + || resolution.manifest_revision != group.manifest.revision + || resolution.manifest_fingerprint != group.manifest.fingerprint + || resolution.preview_schema_revision != group.preview.schema_revision + || resolution.preview_renderer_revision != group.preview.renderer_revision + || resolution.preview_hash != group.preview.preview_hash + || resolution.expected_projection_revision != 0 + || resolution.id + != resolution_id_for( + group.id, + resolution.approved, + resolution.expected_projection_revision, + &resolution.preview_hash, + ) + || resolution.created_at < group.created_at + || resolution.created_at >= group.manifest.expires_at + || resolution.created_at > group.updated_at + { + return Err(TaskGroupedApprovalError::BindingMismatch); + } + Ok(()) +} + +pub(crate) fn group_id_for( + manifest: &TaskCapabilityManifest, + preview: &TaskAuthorizationPreview, +) -> Uuid { + let canonical = GroupIdCanonical { + task_id: manifest.task_id, + goal_revision: &manifest.goal_revision, + goal_fingerprint: &manifest.goal_fingerprint, + manifest_revision: &manifest.revision, + manifest_fingerprint: &manifest.fingerprint, + preview_schema_revision: preview.schema_revision, + preview_renderer_revision: preview.renderer_revision, + preview_hash: &preview.preview_hash, + expires_at: manifest.expires_at, + }; + deterministic_uuid( + GROUP_ID_DOMAIN, + &serde_json::to_vec(&canonical).unwrap_or_default(), + ) +} + +pub(crate) fn event_id_for(group: &TaskGroupedApproval) -> Uuid { + deterministic_uuid( + EVENT_ID_DOMAIN, + format!( + "{}\0{}\0{}", + group.id, + group.status.as_str(), + group.projection_revision + ) + .as_bytes(), + ) +} + +pub(crate) fn item_event_id_for( + group: &TaskGroupedApproval, + item: &TaskGroupedCapabilityAudit, +) -> Uuid { + deterministic_uuid( + ITEM_EVENT_ID_DOMAIN, + format!( + "{}\0{}\0{}\0{}", + group.id, + item.item_id, + group.status.as_str(), + group.projection_revision + ) + .as_bytes(), + ) +} + +pub(crate) fn capability_request_event_id_for(group_id: Uuid, item_id: &str) -> Uuid { + deterministic_uuid( + EVENT_ID_DOMAIN, + format!("capability-request\0{group_id}\0{item_id}").as_bytes(), + ) +} + +pub(crate) fn permission_resolution_event_id_for(resolution_id: Uuid) -> Uuid { + deterministic_uuid( + EVENT_ID_DOMAIN, + format!("permission-resolution\0{resolution_id}").as_bytes(), + ) +} + +pub(crate) fn permission_resolution_id_for(group_id: Uuid, item_id: &str, approved: bool) -> Uuid { + deterministic_uuid( + RESOLUTION_ID_DOMAIN, + format!("item\0{group_id}\0{item_id}\0{approved}").as_bytes(), + ) +} + +pub(crate) fn legacy_consumption_id_for(group_id: Uuid, item_id: &str) -> Uuid { + deterministic_uuid( + LEGACY_CONSUMPTION_ID_DOMAIN, + format!("{group_id}\0{item_id}").as_bytes(), + ) +} + +fn item_id_for( + group_id: Uuid, + capability: CapabilityKind, + tool_id: &str, + tool_version: &str, +) -> String { + domain_hash( + ITEM_ID_DOMAIN, + &serde_json::to_vec(&ItemCanonical { + group_id, + capability: capability.as_str(), + tool_id, + tool_version, + }) + .unwrap_or_default(), + ) +} + +fn approval_request_id_for(group_id: Uuid, item_id: &str) -> Uuid { + deterministic_uuid( + REQUEST_ID_DOMAIN, + format!("{group_id}\0{item_id}").as_bytes(), + ) +} + +fn request_fingerprint_for( + group_id: Uuid, + manifest: &TaskCapabilityManifest, + preview: &TaskAuthorizationPreview, + capability: CapabilityKind, + tool_id: &str, + tool_version: &str, +) -> String { + domain_hash( + REQUEST_FINGERPRINT_DOMAIN, + &serde_json::to_vec(&RequestFingerprintCanonical { + group_id, + task_id: manifest.task_id, + manifest_revision: &manifest.revision, + manifest_fingerprint: &manifest.fingerprint, + preview_schema_revision: preview.schema_revision, + preview_renderer_revision: preview.renderer_revision, + preview_hash: &preview.preview_hash, + capability: capability.as_str(), + tool_id, + tool_version, + expires_at: manifest.expires_at, + }) + .unwrap_or_default(), + ) +} + +fn compact_exact_preview( + manifest: &TaskCapabilityManifest, + preview: &TaskAuthorizationPreview, + capability: CapabilityKind, + tool_id: &str, +) -> String { + format!( + "task={};manifest={}:{};preview={}/{}:{};capability={};tool={};expires={}", + manifest.task_id, + manifest.revision, + manifest.fingerprint, + preview.schema_revision, + preview.renderer_revision, + preview.preview_hash, + capability.as_str(), + tool_id, + manifest.expires_at.to_rfc3339(), + ) +} + +fn resolution_id_for( + group_id: Uuid, + approved: bool, + expected_projection_revision: u64, + preview_hash: &str, +) -> Uuid { + deterministic_uuid( + RESOLUTION_ID_DOMAIN, + format!("group\0{group_id}\0{approved}\0{expected_projection_revision}\0{preview_hash}") + .as_bytes(), + ) +} + +fn integrity_hash_for(group: &TaskGroupedApproval) -> String { + domain_hash( + GROUP_INTEGRITY_DOMAIN, + &serde_json::to_vec(&GroupIntegrityCanonical { + version: &group.version, + id: group.id, + task_id: group.task_id, + manifest: &group.manifest, + preview: &group.preview, + status: group.status, + projection_revision: group.projection_revision, + capability_audits: &group.capability_audits, + resolution: &group.resolution, + created_at: group.created_at, + updated_at: group.updated_at, + }) + .unwrap_or_default(), + ) +} + +fn domain_hash(domain: &[u8], value: &[u8]) -> String { + let mut digest = Sha256::new(); + digest.update(domain); + digest.update((value.len() as u64).to_be_bytes()); + digest.update(value); + format!("{:x}", digest.finalize()) +} + +fn deterministic_uuid(domain: &[u8], value: &[u8]) -> Uuid { + let digest = Sha256::digest([domain, value].concat()); + let mut bytes = [0_u8; 16]; + bytes.copy_from_slice(&digest[..16]); + bytes[6] = (bytes[6] & 0x0f) | 0x50; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + Uuid::from_bytes(bytes) +} diff --git a/apps/desktop/src-tauri/src/main.rs b/apps/desktop/src-tauri/src/main.rs index 2d144bb..678ba3d 100644 --- a/apps/desktop/src-tauri/src/main.rs +++ b/apps/desktop/src-tauri/src/main.rs @@ -48,10 +48,11 @@ use commands::{ list_memory_maintenance_reviews, list_memory_records, list_operations_briefing_runs, list_pending_capability_access_records, list_permission_audit_entries, list_selected_memory_feedback, list_skill_execution_records, list_skill_records, - list_task_records, merge_memory_candidate_with_conflicts, prepare_skill_execution_record, - preview_local_skill_package_manifest, preview_local_skill_zip_package, - preview_memory_candidate_merge, preview_memory_candidate_replace, - preview_remote_skill_zip_package, preview_work_package_import, propose_memory_candidate, + list_task_grouped_authorizations, list_task_records, merge_memory_candidate_with_conflicts, + prepare_skill_execution_record, preview_local_skill_package_manifest, + preview_local_skill_zip_package, preview_memory_candidate_merge, + preview_memory_candidate_replace, preview_remote_skill_zip_package, + preview_work_package_import, propose_memory_candidate, propose_memory_update_candidate_from_feedback, queue_agent_run_guidance_record, queue_expert_team_retries, queue_parent_agent_synthesis, read_drive_boundary, read_email_boundary, read_local_file, record_agent_run_artifact_record, @@ -59,13 +60,14 @@ use commands::{ record_selected_memory_feedback, remove_deepseek_api_key, reobserve_durable_computer_use_session, replace_memory_candidate_conflicts, request_agent_run_cancel_record, request_capability_access, reset_skill_trust, - resolve_capability_access_request, resolve_memory_candidate, resume_agent_chat_action, - run_agent_chat, run_durable_computer_use_step, run_memory_background_maintenance, - run_next_queued_agent_chat_worker, run_operations_briefing, run_skill_update_sweep, - run_terminal_read, run_terminal_write, save_agent_soul_profile, save_deepseek_api_key, - save_deepseek_pricing_settings, save_local_directory_settings, search_memory_records, - search_network_boundary, seed_operations_briefing_evidence_templates, send_email_boundary, - set_skill_enabled, stage_agent_attachments, start_agent_run_record, + resolve_capability_access_request, resolve_memory_candidate, + resolve_task_grouped_authorization, resume_agent_chat_action, + revoke_task_grouped_authorization, run_agent_chat, run_durable_computer_use_step, + run_memory_background_maintenance, run_next_queued_agent_chat_worker, run_operations_briefing, + run_skill_update_sweep, run_terminal_read, run_terminal_write, save_agent_soul_profile, + save_deepseek_api_key, save_deepseek_pricing_settings, save_local_directory_settings, + search_memory_records, search_network_boundary, seed_operations_briefing_evidence_templates, + send_email_boundary, set_skill_enabled, stage_agent_attachments, start_agent_run_record, start_durable_computer_use_session, submit_browser_boundary, take_over_durable_computer_use_step, uninstall_skill, unlock_computer_control, update_memory_candidate_conflict, update_memory_record, verify_deepseek_api_key, @@ -416,10 +418,13 @@ fn main() { list_capability_catalog, list_capability_access_records, list_pending_capability_access_records, + list_task_grouped_authorizations, list_capability_invocations, list_operations_briefing_runs, request_capability_access, resolve_capability_access_request, + resolve_task_grouped_authorization, + revoke_task_grouped_authorization, browse_url, submit_browser_boundary, search_network_boundary, diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index e1a27d8..8da85e5 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -2,7 +2,7 @@ "$schema": "https://schema.tauri.app/config/2", "productName": "DS Agent", "mainBinaryName": "ds-agent", - "version": "1.2.0", + "version": "1.3.0", "identifier": "ai.deepseek-agent-os.desktop", "build": { "beforeDevCommand": "npx pnpm@9.15.9 dev", diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index e3b1179..d724ab6 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -143,6 +143,8 @@ import type { SkillRecord, SkillUpdateSweepResult, TaskRecord, + TaskGroupedAuthorizationIntent, + TaskGroupedAuthorizationView, TerminalReadCommand, ThemeStyle, ThinkingLevel, @@ -222,7 +224,7 @@ const fallbackOnboardingReadiness: OnboardingReadinessProjection = { message_key: "onboarding.workspace.workspace_missing", }, version: { - current_version: "1.2.0", + current_version: "1.3.0", status: "current", blocking: false, message_key: "onboarding.version.current", @@ -880,6 +882,154 @@ function agentChatRunFromRecord(record: AgentRunRecord, displayPrompt?: string): }; } +function taskGroupedAuthorizationStatusLabel( + status: TaskGroupedAuthorizationView["status"], + language: Language, +) { + const labels = language === "zh" + ? { + pending: "等待确认", + approved: "已授权", + rejected: "已拒绝", + revoked: "已撤销", + expired: "已过期", + scope_changed: "范围已变化", + } + : { + pending: "Pending", + approved: "Approved", + rejected: "Rejected", + revoked: "Revoked", + expired: "Expired", + scope_changed: "Scope changed", + }; + return labels[status]; +} + +function TaskGroupedAuthorizationCard({ + authorization, + language, + capabilityLabels, + riskLabels, + pending, + onResolve, + onRevoke, +}: { + authorization: TaskGroupedAuthorizationView; + language: Language; + capabilityLabels: Record; + riskLabels: Record; + pending: boolean; + onResolve: (intent: TaskGroupedAuthorizationIntent, approved: boolean) => void; + onRevoke: (intent: TaskGroupedAuthorizationIntent) => void; +}) { + const field = (values: string[]) => values.join(language === "zh" ? "、" : ", ") || "—"; + const labels = language === "zh" + ? { + title: "本次任务授权", + applications: "应用", + paths: "路径", + accounts: "账户", + recipients: "收件人", + timeWindows: "时间范围", + externalTargets: "外部目标", + expiry: "有效期", + verifiers: "验证方式", + audits: "逐能力审计", + approve: "确认授权", + reject: "拒绝", + revoke: "撤销授权", + updating: "正在核对…", + } + : { + title: "Task authorization", + applications: "Applications", + paths: "Paths", + accounts: "Accounts", + recipients: "Recipients", + timeWindows: "Time range", + externalTargets: "External targets", + expiry: "Expires", + verifiers: "Verification", + audits: "Capability audit", + approve: "Approve task", + reject: "Reject", + revoke: "Revoke authorization", + updating: "Checking…", + }; + + return ( +
+
+
+

{authorization.goal}

+
+ + {riskLabels[authorization.risk_level]} + + + {taskGroupedAuthorizationStatusLabel(authorization.status, language)} + +
+
+
    +
  • {labels.applications}

    {field(authorization.applications)}

  • +
  • {labels.paths}

    {field(authorization.paths)}

  • +
  • {labels.accounts}

    {field(authorization.accounts)}

  • +
  • {labels.recipients}

    {field(authorization.recipients)}

  • +
  • {labels.timeWindows}

    {field(authorization.time_windows)}

  • +
  • {labels.externalTargets}

    {field(authorization.external_targets)}

  • +
  • {labels.expiry}

    {formatTaskDate(authorization.expires_at, language)}

  • +
  • {labels.verifiers}

    {field(authorization.verifiers)}

  • +
+
+
+
{labels.audits}
+
+ {authorization.capability_audits.map((audit, index) => ( +
+
+ {capabilityLabels[audit.capability]} + {riskLabels[audit.risk_level]} +
+ + {taskGroupedAuthorizationStatusLabel(audit.status, language)} + +
+ ))} +
+
+ {authorization.status === "pending" ? ( +
+ + +
+ ) : authorization.status === "approved" ? ( +
+ +
+ ) : null} +
+ ); +} + function capabilityFamilyIcon(family: CapabilityFamily) { switch (family) { case "file": @@ -948,6 +1098,14 @@ export function App() { const [capabilityCatalog, setCapabilityCatalog] = useState([]); const [capabilityRecords, setCapabilityRecords] = useState([]); const [capabilityInvocations, setCapabilityInvocations] = useState([]); + const [taskGroupedAuthorizations, setTaskGroupedAuthorizations] = useState< + TaskGroupedAuthorizationView[] + >([]); + const [taskGroupedAuthorizationPending, setTaskGroupedAuthorizationPending] = useState< + string | null + >(null); + const [taskGroupedAuthorizationNotice, setTaskGroupedAuthorizationNotice] = useState(""); + const [taskGroupedAuthorizationError, setTaskGroupedAuthorizationError] = useState(""); const [agentToolContracts, setAgentToolContracts] = useState([]); const [toolInvocations, setToolInvocations] = useState([]); const [agentContextReceipts, setAgentContextReceipts] = useState([]); @@ -2122,6 +2280,7 @@ export function App() { setCapabilityCatalog([]); setCapabilityRecords([]); setCapabilityInvocations([]); + setTaskGroupedAuthorizations([]); setAgentToolContracts([]); setToolInvocations([]); setAgentContextReceipts([]); @@ -2141,6 +2300,7 @@ export function App() { invoke("list_permission_audit_entries"), invoke("list_capability_catalog"), invoke("list_capability_access_records"), + invoke("list_task_grouped_authorizations"), invoke("list_capability_invocations"), invoke("list_agent_tool_contracts"), invoke("list_agent_tool_invocations"), @@ -2158,6 +2318,7 @@ export function App() { audits, catalog, capabilityAccessRecords, + groupedAuthorizations, invocations, toolContracts, recordedToolInvocations, @@ -2174,6 +2335,7 @@ export function App() { setPermissionAudits(audits); setCapabilityCatalog(catalog); setCapabilityRecords(capabilityAccessRecords); + setTaskGroupedAuthorizations(groupedAuthorizations); setCapabilityInvocations(invocations); setAgentToolContracts(toolContracts); setToolInvocations(recordedToolInvocations); @@ -2204,6 +2366,9 @@ export function App() { setMemoryCandidateError(copy.memory.loadFailed); setAuditError(copy.audit.loadFailed); setCapabilityError(copy.capabilities.loadFailed); + setTaskGroupedAuthorizationError( + language === "zh" ? "无法读取任务授权状态。" : "Could not load task authorization state.", + ); setBriefingError(copy.operationsBriefing.loadFailed); }); }, [ @@ -2213,6 +2378,7 @@ export function App() { copy.operationsBriefing.loadFailed, copy.package.loadFailed, copy.skills.updateFailed, + language, ]); const setLocalSkillEnabled = async (record: SkillRecord, enabled: boolean) => { @@ -2580,20 +2746,30 @@ export function App() { }; async function refreshCapabilityState() { - const [records, audits, invocations, contextReceipts, tools] = await Promise.all([ + const [records, groupedAuthorizations, audits, invocations, contextReceipts, tools] = + await Promise.all([ invoke("list_capability_access_records"), + invoke("list_task_grouped_authorizations"), invoke("list_permission_audit_entries"), invoke("list_capability_invocations"), invoke("list_agent_context_receipts"), invoke("list_agent_tool_invocations"), - ]); + ]); setCapabilityRecords(records); + setTaskGroupedAuthorizations(groupedAuthorizations); setPermissionAudits(audits); setCapabilityInvocations(invocations); setAgentContextReceipts(contextReceipts); setToolInvocations(tools); } + async function refreshTaskGroupedAuthorizationState() { + const groupedAuthorizations = await invoke( + "list_task_grouped_authorizations", + ); + setTaskGroupedAuthorizations(groupedAuthorizations); + } + async function refreshDurableComputerUseState() { const sessions = await invoke( "list_durable_computer_use_sessions", @@ -2813,6 +2989,7 @@ export function App() { content: workerResult.response.content, model: workerResult.response.model, protocol_version: workerResult.response.protocol_version, + goal_projection: workerResult.response.goal_projection, proposed_actions: workerResult.response.proposed_actions, missing_prerequisites: workerResult.response.missing_prerequisites, memory_candidates: workerResult.response.memory_candidates, @@ -3946,6 +4123,92 @@ export function App() { } }; + const resolveTaskGroupedAuthorization = async ( + intent: TaskGroupedAuthorizationIntent, + approved: boolean, + ) => { + setTaskGroupedAuthorizationPending(intent.group_id); + setTaskGroupedAuthorizationNotice(""); + setTaskGroupedAuthorizationError(""); + let rejectedByKernel = false; + try { + await invoke("resolve_task_grouped_authorization", { + intent, + approved, + }); + setTaskGroupedAuthorizationNotice( + language === "zh" + ? approved + ? "已按 Kernel 中的精确任务范围完成授权。" + : "已拒绝本次任务授权。" + : approved + ? "The exact Kernel-owned task scope is approved." + : "The task authorization was rejected.", + ); + } catch { + rejectedByKernel = true; + } finally { + try { + await refreshTaskGroupedAuthorizationState(); + if (rejectedByKernel) { + setTaskGroupedAuthorizationError( + language === "zh" + ? "授权意图已过期或与当前范围冲突;已刷新为 Kernel 权威状态。" + : "The intent was stale or conflicted with the current scope; Kernel state was refreshed.", + ); + } + } catch { + setTaskGroupedAuthorizations([]); + setTaskGroupedAuthorizationNotice(""); + setTaskGroupedAuthorizationError( + language === "zh" + ? "无法重读 Kernel 权威状态;旧授权操作已隐藏,未授予任何新权限。" + : "Kernel authority could not be re-read; stale actions were hidden and no new authority was granted.", + ); + } + setTaskGroupedAuthorizationPending(null); + } + }; + + const revokeTaskGroupedAuthorization = async (intent: TaskGroupedAuthorizationIntent) => { + setTaskGroupedAuthorizationPending(intent.group_id); + setTaskGroupedAuthorizationNotice(""); + setTaskGroupedAuthorizationError(""); + let rejectedByKernel = false; + try { + await invoke("revoke_task_grouped_authorization", { + intent, + }); + setTaskGroupedAuthorizationNotice( + language === "zh" + ? "已撤销本次任务授权。" + : "The task authorization was revoked.", + ); + } catch { + rejectedByKernel = true; + } finally { + try { + await refreshTaskGroupedAuthorizationState(); + if (rejectedByKernel) { + setTaskGroupedAuthorizationError( + language === "zh" + ? "撤销意图已过期或与当前范围冲突;已刷新为 Kernel 权威状态。" + : "The revoke intent was stale or conflicted with the current scope; Kernel state was refreshed.", + ); + } + } catch { + setTaskGroupedAuthorizations([]); + setTaskGroupedAuthorizationNotice(""); + setTaskGroupedAuthorizationError( + language === "zh" + ? "无法重读 Kernel 权威状态;旧授权操作已隐藏,未授予任何新权限。" + : "Kernel authority could not be re-read; stale actions were hidden and no new authority was granted.", + ); + } + setTaskGroupedAuthorizationPending(null); + } + }; + const clearPackageStatus = () => { setPackageNotice(""); setPackageError(""); @@ -6036,7 +6299,14 @@ export function App() { ) : null, ); + const taskGroupedAuthorization = message.goal_projection + ? taskGroupedAuthorizations.find( + (authorization) => + authorization.intent.task_id === message.goal_projection?.goal_id, + ) + : undefined; const taskApprovalReady = + !taskGroupedAuthorization && messageApprovalActions.length > 0 && messageApprovalActions.every(({ action }, index) => { if (!action.permission_request_id) { @@ -6143,6 +6413,22 @@ export function App() { ) : null} ) : null} + {message.role === "assistant" && taskGroupedAuthorization ? ( + + void resolveTaskGroupedAuthorization(intent, approved) + } + onRevoke={(intent) => void revokeTaskGroupedAuthorization(intent)} + /> + ) : null} {message.role === "assistant" && message.missing_prerequisites?.length ? (
{copy.chatWorkbench.missingPrerequisitesLabel} @@ -7822,6 +8108,32 @@ export function App() { {copy.capabilities.title}
{capabilityError ?

{capabilityError}

: null} + {taskGroupedAuthorizationNotice ? ( +

{taskGroupedAuthorizationNotice}

+ ) : null} + {taskGroupedAuthorizationError ? ( +

{taskGroupedAuthorizationError}

+ ) : null} + {taskGroupedAuthorizations.length > 0 ? ( +
+ {taskGroupedAuthorizations.map((authorization) => ( + + void resolveTaskGroupedAuthorization(intent, approved) + } + onRevoke={(intent) => void revokeTaskGroupedAuthorization(intent)} + /> + ))} +
+ ) : null}