@qilin-agent/user-approval 0.0.0-stage → 3.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 kkutysllb
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,88 @@
1
+ # Bilingual-pair consistency record for README.md (docs/i18n/README.md): per heading
2
+ # section, a hash of its English and Chinese blocks outside code blocks and generated regions.
3
+ # After editing either side, bring the other along and re-record with:
4
+ # pnpm run verify-translation-pairing --write packages/interaction/user-approval/README.md
5
+ /:
6
+ en: 515e991a5671711d
7
+ zh: 194d48a8da9b51b0
8
+ /qilin-agent-user-approval:
9
+ en: e82b16eb4321d7b6
10
+ zh: 662bf61284ab31a8
11
+ /qilin-agent-user-approval/summary:
12
+ en: 9b438f002e664c9e
13
+ zh: 1f1adf3db6a56ddc
14
+ /qilin-agent-user-approval/table-of-contents:
15
+ en: d152484eb41ac6b4
16
+ zh: 09388d293f9be9cb
17
+ /qilin-agent-user-approval/use-this-package:
18
+ en: dd034141bc1a7634
19
+ zh: a44f1f90dac2a444
20
+ /qilin-agent-user-approval/use-this-package/composing-answerers:
21
+ en: 3618ddc3386e65f0
22
+ zh: d6c0ac91aae122bb
23
+ /qilin-agent-user-approval/use-this-package/setting-the-policy:
24
+ en: 3e70aa2dad4aab95
25
+ zh: dc2b44b6d2f2459e
26
+ /qilin-agent-user-approval/use-this-package/requesting-a-decision:
27
+ en: add08f0d0dace4c8
28
+ zh: e45268e0f0ed2e95
29
+ /qilin-agent-user-approval/use-this-package/what-the-model-and-user-see:
30
+ en: 016bf3cd1891d3d7
31
+ zh: 33e7e5ffaf95f5e7
32
+ /qilin-agent-user-approval/understand-the-implementation:
33
+ en: d07be7ba08baf010
34
+ zh: 665e68e9188e5d60
35
+ /qilin-agent-user-approval/understand-the-implementation/source-map:
36
+ en: ac27642b3a91082f
37
+ zh: 769a56eaad4218f7
38
+ /qilin-agent-user-approval/understand-the-implementation/dispatch:
39
+ en: 38c3646e45d3dddd
40
+ zh: 3363dde8c786d6b2
41
+ /qilin-agent-user-approval/understand-the-implementation/policy-and-the-runtime-context-snapshot:
42
+ en: a8df8823aa0b015d
43
+ zh: ed0906f11645e7fd
44
+ /qilin-agent-user-approval/understand-the-implementation/audit:
45
+ en: 145530678bac5912
46
+ zh: a464effa3a29fce0
47
+ /qilin-agent-user-approval/further-exploration:
48
+ en: f605b73345732588
49
+ zh: ac9a76e77dbed4a7
50
+ /qilin-agent-user-approval/model-experience:
51
+ en: 215e7ba838619b7b
52
+ zh: a311da3843709f90
53
+ /qilin-agent-user-approval/model-experience/current-approval-policy-context:
54
+ en: d23980f003b42fbb
55
+ zh: 532f0174de5d87b5
56
+ /qilin-agent-user-approval/model-experience/current-approval-policy-context/what-the-model-sees:
57
+ en: a38775858ccd61f3
58
+ zh: c19c76827be7aabf
59
+ /qilin-agent-user-approval/model-experience/current-approval-policy-context/what-the-model-sees/ask-policy-contribution:
60
+ en: b9f1262c97ec7568
61
+ zh: 8a6613695f896f70
62
+ /qilin-agent-user-approval/model-experience/current-approval-policy-context/what-the-model-sees/never-policy-contribution:
63
+ en: f13f09fc2466513b
64
+ zh: 4a755a682551c481
65
+ /qilin-agent-user-approval/model-experience/current-approval-policy-context/token-effect:
66
+ en: e96807422bdce44d
67
+ zh: 9fdcfafcc96bd004
68
+ /qilin-agent-user-approval/model-experience/current-approval-policy-context/kv-cache-effect:
69
+ en: b4bac06588fdd1c3
70
+ zh: 60271562fdd52f72
71
+ /qilin-agent-user-approval/model-experience/tool-outcome:
72
+ en: 54c140b4a1b4a5bc
73
+ zh: 8573891441963be4
74
+ /qilin-agent-user-approval/model-experience/tool-outcome/what-the-model-sees:
75
+ en: fd0c40c0a6b4c967
76
+ zh: c7e18063e11080e9
77
+ /qilin-agent-user-approval/model-experience/tool-outcome/token-effect:
78
+ en: 972a63ca590fecde
79
+ zh: 217b63d98365dbdb
80
+ /qilin-agent-user-approval/model-experience/tool-outcome/kv-cache-effect:
81
+ en: f5d5f97a153f7c70
82
+ zh: 4db81881ebdcfcc4
83
+ /qilin-agent-user-approval/known-limitations-and-deferred-work:
84
+ en: c6928515ff91ee7e
85
+ zh: c23bd844b4907f12
86
+ /qilin-agent-user-approval/known-limitations-and-deferred-work/dev-note:
87
+ en: e67f633dc6fe7773
88
+ zh: 3bb0c20c93f16f98
package/README.md CHANGED
@@ -1,3 +1,166 @@
1
- # Temporary Holding Version
1
+ ---
2
+ description: "Channel-neutral one-shot approval seam for users and maintainers composing answerers, setting policy, or debugging fail-closed permission decisions."
3
+ kind: "package-reference"
4
+ ---
2
5
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
6
+ # @qilin-agent/user-approval
7
+
8
+ English | [中文](README.zh.md)
9
+
10
+ ## Summary
11
+
12
+ Use this package to require a one-shot decision before a sensitive tool action proceeds. The `ask` policy sends each request to the deployment's human or machine answerers; `never` rejects it without prompting. Missing or failed answerers return `unavailable`, so the action fails closed, and an approval applies only to that request. Every request and outcome is recorded in the requesting session's audit log. The model sees the resulting tool outcome and current policy, but not the human permission UI or audit events.
13
+
14
+ ## Table of Contents
15
+
16
+ - [Use this package](#use-this-package)
17
+ - [Understand the implementation](#understand-the-implementation)
18
+ - [Further Exploration](#further-exploration)
19
+ - [Model Experience](#model-experience)
20
+ - [Known Limitations and Deferred Work](#known-limitations-and-deferred-work)
21
+ - [Dev Note](#dev-note)
22
+
23
+ -----
24
+
25
+ <a id="use-this-package"></a>
26
+ ## Use this package
27
+
28
+ Compose this service when sensitive tool actions should pause for a human or machine decision instead of running unconditionally. The tools pipeline and the sandboxed bash tool route their `ask` decisions through this seam and fail closed when it is absent, so interactive deployments mount it with at least one answerer.
29
+
30
+ ### Composing answerers
31
+
32
+ Answerers are `approval/request` waterfall listeners: return an outcome to answer for an owned agent, or call `next()` to delegate. Agent-scoped listeners receive only that agent's requests, and a deployment composes one terminal answerer — sibling listener order is not a policy-priority mechanism. Without a terminal answerer, requests resolve `unavailable` and fail closed; the service itself never prompts a human.
33
+
34
+ ### Setting the policy
35
+
36
+ The effective policy is the one set for the session, falling back to the configured default. `ask` (the default) delegates to the composed answerers; `never` rejects every request deterministically before interactive dispatch — the strict headless stance for CI and unattended runs.
37
+
38
+ ```yaml
39
+ - name: '@qilin-agent/user-approval'
40
+ config:
41
+ policy: ask
42
+ ```
43
+
44
+ | Field | Default | Meaning |
45
+ |---|---|---|
46
+ | `policy` | `ask` | Default for sessions without an `approval/policy` override |
47
+
48
+ The generated [configuration catalog](../../../docs/config-catalog.md#qilin-agentuser-approval) is the exhaustive source for every accepted field and its JSDoc. `setPolicy(agent, policy)` switches a live agent and queues a "changed by the user" message for its next model step; `setApprovalPolicy(session, policy)` is the direct durable write path used by session initialization.
49
+
50
+ ### Requesting a decision
51
+
52
+ `request(req)` names the agent, tool, optional call id and reason, and an abort signal. It requires an open turn: an idle or between-turn caller throws before auditing anything. Aborting withdraws the question — the request settles `cancelled` and a late answer is discarded. A failure that prevents either audit append from committing rejects instead of returning an unlogged decision.
53
+
54
+ ### What the model and user see
55
+
56
+ The model sees only the asking consumer's eventual tool outcome — allowed, rejected, cancelled, or unavailable — plus the current policy in the runtime-context snapshot; the audit events and the human permission UI are not model context. A `never` switch is announced to the model by a sourced user message, and both policies contribute their complete current meaning to the snapshot.
57
+
58
+ -----
59
+
60
+ <a id="understand-the-implementation"></a>
61
+ ## Understand the implementation
62
+
63
+ <details>
64
+ <summary>Implementation internals — click to expand</summary>
65
+
66
+ The observable behavior is covered in [Use this package](#use-this-package); this section explains dispatch, policy enforcement, and the audit path.
67
+
68
+ ### Source map
69
+
70
+ | File | Role |
71
+ |---|---|
72
+ | [`src/index.ts`](src/index.ts) | `ApprovalService`: request dispatch, policy fold and write path, runtime-context contribution |
73
+ | [`src/types.ts`](src/types.ts) | `ApprovalRequestId` brand and outcome types |
74
+
75
+ ### Dispatch
76
+
77
+ `decide()` races the answerer waterfall against the request signal and contains every answerer failure: a throwing listener fails the question closed to `unavailable`, and a rogue non-vocabulary return is normalized to `unavailable`. The `never` policy is enforced inside the service before waterfall dispatch, so a listener registered later with `prepend` cannot bypass the deterministic rejection. The request must be turn-enclosed because the turn is the durable log's commit/replay boundary — a bare event between turns is indistinguishable from a crash tail.
78
+
79
+ ### Policy and the runtime-context snapshot
80
+
81
+ The system-prompt contribution `approval:policy` states the complete current meaning of the effective policy — `ask` with its fail-closed consequence, or `never` with its non-escalation consequence — after retained history, so switching policy appends a new full snapshot instead of rewriting the stable request header. `setPolicy()` also injects a sourced user message announcing the change for the next step.
82
+
83
+ ### Audit
84
+
85
+ `request()` appends `approval/asked` with the request identity and tool, then `approval/decided` with the closed outcome; the exact appended fields live in [`src/index.ts`](src/index.ts). Both are log-only.
86
+
87
+ </details>
88
+
89
+ -----
90
+
91
+ <a id="further-exploration"></a>
92
+ ## Further Exploration
93
+
94
+ Read these pages when the package-level contract is not enough. They move from the approval vocabulary to the consumers and the design rationale.
95
+
96
+ - [Approval subsystem reference](../../../docs/subsystems/approval.md) — the shared request/outcome vocabulary and the `ctx.approval` Cordis surface.
97
+ - [Approval seam Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-approval-seam.md) — design rationale for the seam.
98
+ - [Sandbox Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md) — how the sandboxed bash tool consumes approvals for escalated retries.
99
+ - [Interaction group map](../README.md) — adjacent permission preset and question packages.
100
+
101
+ -----
102
+
103
+ <a id="model-experience"></a>
104
+ ## Model Experience
105
+
106
+ ### Current approval policy context
107
+
108
+ #### What the model sees
109
+
110
+ The first request and each effective policy change append a full runtime-context snapshot after retained history. Under `ask`, the approval contribution states that configured answerers may be consulted and absence fails closed. Under `never`, it states the deterministic rejection and non-escalation consequence. Unchanged requests retain the earlier snapshot without adding another message.
111
+
112
+ ##### Ask-policy contribution
113
+
114
+ ```markdown
115
+ Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed.
116
+ ```
117
+
118
+ ##### Never-policy contribution
119
+
120
+ ```markdown
121
+ Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).
122
+ ```
123
+
124
+ #### Token effect
125
+
126
+ One concise context message on the first request and on an effective change; unchanged requests add no duplicate policy tokens.
127
+
128
+ #### KV Cache effect
129
+
130
+ Append-only after retained history. An `ask`/`never` switch preserves the stable system and conversation prefix instead of rewriting the first wire message.
131
+
132
+ ### Tool outcome
133
+
134
+ #### What the model sees
135
+
136
+ `approval/asked` and `approval/decided` are log-only. The model sees only the asking consumer's eventual allowed, rejected, cancelled, or unavailable tool outcome; the human permission UI is not context.
137
+
138
+ #### Token effect
139
+
140
+ Zero duplicate audit tokens. A rejection may replace a normal tool result with a small retained error, while an allowance leaves the consumer's ordinary result.
141
+
142
+ #### KV Cache effect
143
+
144
+ Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries.
145
+
146
+ ## Known Limitations and Deferred Work
147
+
148
+ <a id="known-limitations-and-deferred-work"></a>
149
+
150
+
151
+ These limits define when the seam is a poor fit or needs special composition care. They are current package constraints, not a general permission comparison.
152
+
153
+ - **Requests are valid only inside an open turn** — an idle or between-turn caller throws before auditing; a durable out-of-turn approval workflow is deferred.
154
+ - **Only one-shot grants exist** — the outcome vocabulary has `allowed-once` but no `allow-always`, remembered rule, revocation, or grant store; session policy is only `ask` / `never`.
155
+ - **The request carries no tool arguments** — an answerer sees the tool name, reason, and optional call id; the ACP machine channel requires a call id and delegates requests without one.
156
+ - **No built-in answerer** — headless or incompletely composed deployments resolve `unavailable` and fail closed; the service itself never prompts a human.
157
+
158
+ <a id="dev-note"></a>
159
+ ### Dev Note
160
+
161
+ <details>
162
+ <summary>Working context for maintainers — click to expand</summary>
163
+
164
+ None.
165
+
166
+ </details>
package/README.zh.md ADDED
@@ -0,0 +1,166 @@
1
+ ---
2
+ description: "与通道无关的一次性审批 seam;供组合应答者、设置策略或排查以拒绝方式关闭的权限决定的用户与维护者阅读。"
3
+ kind: "package-reference"
4
+ ---
5
+
6
+ # @qilin-agent/user-approval
7
+
8
+ [English](README.md) | 中文
9
+
10
+ ## 概述
11
+
12
+ 使用本包可要求敏感工具操作在继续前取得一次性决定。`ask` 策略将每个请求发送给部署中的人类或机器应答者;`never` 则直接拒绝,不发出提示。应答者缺失或失败时返回 `unavailable`,使操作以拒绝方式关闭;每项批准也只适用于对应请求。每个请求与结果都会记录在发起请求的会话审计日志中。模型会看到最终工具结果与当前策略,但不会看到人类权限 UI 或审计事件。
13
+
14
+ ## 目录
15
+
16
+ - [使用本包](#use-this-package)
17
+ - [理解实现](#understand-the-implementation)
18
+ - [进一步探索](#further-exploration)
19
+ - [模型体验](#model-experience)
20
+ - [已知限制与延期工作](#known-limitations-and-deferred-work)
21
+ - [开发备注](#dev-note)
22
+
23
+ -----
24
+
25
+ <a id="use-this-package"></a>
26
+ ## 使用本包
27
+
28
+ 当敏感工具操作应当暂停等待人或机器的决定、而非无条件执行时,组合此服务。工具流水线与沙箱 bash 工具会通过此 seam 路由 `ask` 决定,并在该 seam 缺失时以拒绝方式关闭,因此交互式部署至少应组合一个应答者。
29
+
30
+ ### 组合应答者
31
+
32
+ 应答者是 `approval/request` waterfall(瀑布式事件)监听器:返回一个结果即为所负责的 agent(智能体)作答,否则调用 `next()` 委托。限定到 agent 的监听器只接收该 agent 的请求,且每项部署应组合一个最终应答者——同级监听器的顺序不是策略优先级机制。没有最终应答者时,请求解析为 `unavailable` 并以拒绝方式关闭;服务自身绝不会提示人类。
33
+
34
+ ### 设置策略
35
+
36
+ 有效策略取会话中已设置的策略,并回退到配置的默认值。`ask`(默认)委托给已组合的应答者;`never` 在交互式分发之前确定性地拒绝每个请求——这是 CI 与无人值守运行采用的严格无头模式。
37
+
38
+ ```yaml
39
+ - name: '@qilin-agent/user-approval'
40
+ config:
41
+ policy: ask
42
+ ```
43
+
44
+ | 字段 | 默认值 | 含义 |
45
+ |---|---|---|
46
+ | `policy` | `ask` | 没有 `approval/policy` 覆盖的会话的默认策略 |
47
+
48
+ 生成的[配置目录](../../../docs/config-catalog.zh.md#qilin-agentuser-approval)是每个受支持字段及其 JSDoc 的穷尽式真源。`setPolicy(agent, policy)` 切换运行中的 agent 的策略,并为它的下一个模型步骤排队一条「由用户更改」消息;`setApprovalPolicy(session, policy)` 是会话初始化使用的直接持久写入路径。
49
+
50
+ ### 请求决定
51
+
52
+ `request(req)` 指名 agent、工具、原因、可选的调用 id,以及一个中止信号。它要求当前处于尚未结束的轮次中:空闲或在轮次之间调用会在审计前抛出异常。中止会撤回问题——请求以 `cancelled` 结算,迟到的回答被丢弃。若任一审计事件在提交前失败,请求会被拒绝,而不会返回一项未记录的决定。
53
+
54
+ ### 模型与用户看到什么
55
+
56
+ 模型只会看到发起请求的消费方最终给出的工具结果——允许、拒绝、取消或不可用——以及运行时上下文快照中的当前策略;审计事件与面向人类的权限 UI 不属于模型上下文。`never` 切换会以一条带来源的用户消息告知模型,两种策略都会把各自的完整当前含义贡献给快照。
57
+
58
+ -----
59
+
60
+ <a id="understand-the-implementation"></a>
61
+ ## 理解实现
62
+
63
+ <details>
64
+ <summary>实现细节——点击展开</summary>
65
+
66
+ 可观察行为已在[使用本包](#use-this-package)中说明;本节解释分发、策略执行与审计路径。
67
+
68
+ ### 源码地图
69
+
70
+ | 文件 | 职责 |
71
+ |---|---|
72
+ | [`src/index.ts`](src/index.ts) | `ApprovalService`:请求分发、策略折叠与写入路径、运行时上下文贡献 |
73
+ | [`src/types.ts`](src/types.ts) | `ApprovalRequestId` brand 与结果类型 |
74
+
75
+ ### 分发
76
+
77
+ `decide()` 让应答者 waterfall 与请求信号赛跑,并隔离所有应答者故障:抛出异常的监听器会使问题以 `unavailable` 关闭,不属于结果词汇的异常返回值也会规范化为 `unavailable`。`never` 策略在服务内部、waterfall 分发之前执行,因此之后以 `prepend` 注册的监听器也无法绕过确定性的拒绝。请求必须处于未结束的轮次内,因为轮次是持久日志的提交/回放边界——轮次之间的裸事件与崩溃尾部无法区分。
78
+
79
+ ### 策略与运行时上下文快照
80
+
81
+ 系统提示词贡献 `approval:policy` 在保留历史之后陈述有效策略的完整当前含义——`ask` 及其以拒绝方式关闭的后果,或 `never` 及其非升权后果——因此切换策略会追加一份新的完整快照,而不会改写稳定的请求头。`setPolicy()` 还会注入一条带来源的用户消息,为下一步宣布变更。
82
+
83
+ ### 审计
84
+
85
+ `request()` 先追加携带请求身份与工具的 `approval/asked`,再追加携带最终结果的 `approval/decided`;确切追加字段见 [`src/index.ts`](src/index.ts)。两者都只写入日志。
86
+
87
+ </details>
88
+
89
+ -----
90
+
91
+ <a id="further-exploration"></a>
92
+ ## 进一步探索
93
+
94
+ 当包级约定不够用时阅读以下页面。它们从审批词汇逐步进入消费方与设计依据。
95
+
96
+ - [审批子系统参考](../../../docs/subsystems/approval.zh.md)——共享的请求/结果词汇与 `ctx.approval` 的 Cordis 接口面。
97
+ - [审批 seam Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-approval-seam.zh.md)——该 seam 的设计依据。
98
+ - [沙箱 Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md)——沙箱 bash 工具如何为升权重试消费审批。
99
+ - [交互组映射](../README.zh.md)——相邻的权限预设与问答包。
100
+
101
+ -----
102
+
103
+ <a id="model-experience"></a>
104
+ ## 模型体验
105
+
106
+ ### 当前审批策略上下文
107
+
108
+ #### 模型看到的内容
109
+
110
+ 首次请求与有效策略每次变化时,都会在保留的历史后追加一份完整运行时上下文快照。在 `ask` 下,审批上下文内容会说明系统可以咨询已配置的应答者,缺少可用应答者时则以拒绝方式关闭。在 `never` 下,它会说明确定性的拒绝与非升权后果。未变化的请求会保留先前快照,不增加另一条消息。
111
+
112
+ ##### Ask 策略贡献
113
+
114
+ ```markdown
115
+ Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed.
116
+ ```
117
+
118
+ ##### Never 策略贡献
119
+
120
+ ```markdown
121
+ Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).
122
+ ```
123
+
124
+ #### Token 影响
125
+
126
+ 首次请求和策略实际变化时增加一条简洁的上下文消息;未变化的请求不增加重复的策略 token。
127
+
128
+ #### KV Cache 影响
129
+
130
+ 在保留的历史之后仅追加。`ask`/`never` 切换会保留稳定的系统与对话前缀,而不会改写第一条 wire 消息。
131
+
132
+ ### 工具结果
133
+
134
+ #### 模型看到的内容
135
+
136
+ `approval/asked` 和 `approval/decided` 只写入日志。模型只会看到发起请求的消费方最终给出的允许、拒绝、取消或不可用工具结果;面向人类的权限 UI 不属于上下文。
137
+
138
+ #### Token 影响
139
+
140
+ 不会产生重复的审计 token。拒绝可能以一条简短且会保留的错误信息替换正常工具结果,而允许会保留消费方的普通结果。
141
+
142
+ #### KV Cache 影响
143
+
144
+ 仅追加;新出现的可见内容位于可复用请求前缀之后,不会使现有 KV Cache 条目失效。
145
+
146
+ ## 已知限制与延期工作
147
+
148
+ <a id="known-limitations-and-deferred-work"></a>
149
+
150
+
151
+ 这些限制说明该 seam 不适用的场景,以及组合时需要特别注意的场景。它们是当前包约束,不是通用权限对比。
152
+
153
+ - **请求只在尚未结束的轮次内有效**:在空闲时或轮次之间发起调用,会在审计前抛出异常;持久化的轮次外审批工作流仍属延期工作。
154
+ - **仅存在一次性授权**:结果词汇包含 `allowed-once`,但不含 `allow-always`、已记住的规则、撤销或授权存储;会话策略只有 `ask`/`never`。
155
+ - **请求不携带工具参数**:应答者会看到工具名称、原因和可选调用 id;ACP(Agent Client Protocol)机器通道要求调用 id,并会委托不含 id 的请求。
156
+ - **没有内置应答者**:无头或组合不完整的部署会返回 `unavailable` 并以拒绝方式关闭;服务自身绝不会提示人类。
157
+
158
+ <a id="dev-note"></a>
159
+ ### 开发备注
160
+
161
+ <details>
162
+ <summary>维护者的工作上下文——点击展开</summary>
163
+
164
+ 无。
165
+
166
+ </details>
package/lib/index.js ADDED
@@ -0,0 +1,192 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { Service } from "@qilin-agent/kylin";
3
+ import z from "@qilin-agent/schemastery";
4
+ import { createUserMessage } from "@qilin-agent/llm";
5
+ import { scopeTarget } from "@qilin-agent/scope";
6
+ import { SessionSeq } from "@qilin-agent/session";
7
+ //#region lib/types/types.js
8
+ /**
9
+ * Wire-safe approval identifiers and outcome vocabulary, free of
10
+ * cordis/service imports so browser type chains can
11
+ * consume them without loading this package's Context augmentation.
12
+ * @module @qilin-agent/user-approval/types
13
+ */
14
+ /**
15
+ * Brand a string as an {@link ApprovalRequestId}.
16
+ * @param id - the raw id string to brand.
17
+ * @returns the same string carrying the brand.
18
+ */
19
+ function ApprovalRequestId(id) {
20
+ return id;
21
+ }
22
+ //#endregion
23
+ //#region lib/types/index.js
24
+ /**
25
+ * Service Definition for the approval capability seam, covering requests, cancellation, audit, and per-session policy. Missing
26
+ * answerers fail closed; grants apply only to the requested action.
27
+ * @module @qilin-agent/user-approval
28
+ */
29
+ /** Every {@link ApprovalOutcome}, for runtime normalization of answerer returns. */
30
+ const OUTCOMES = [
31
+ "allowed-once",
32
+ "rejected",
33
+ "cancelled",
34
+ "unavailable"
35
+ ];
36
+ /** Every {@link ApprovalPolicy}, for option advertisement and runtime validation of untrusted policy strings. */
37
+ const APPROVAL_POLICIES = ["ask", "never"];
38
+ /** Model-facing statement for the deterministic `'never'` policy. */
39
+ const NEVER_SENTENCE = "Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).";
40
+ /** Model-facing statement for an interactive policy that may still fail closed. */
41
+ const ASK_SENTENCE = "Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed.";
42
+ /**
43
+ * Whether the log currently sits inside an open turn (a `turn/start` not yet
44
+ * closed by a `turn/end`) — the {@link ApprovalService.request} precondition.
45
+ * The audit pair must be turn-enclosed: the turn is the durable log's
46
+ * commit/replay boundary, so a bare event appended between turns is
47
+ * indistinguishable from a crash tail and silently dropped on reload.
48
+ */
49
+ function hasOpenTurn(session) {
50
+ for (let seq = session.seq - 1; seq >= 0; seq -= 1) {
51
+ const type = session.eventAt(SessionSeq(seq))?.type;
52
+ if (type === "turn/start") return true;
53
+ if (type === "turn/end") return false;
54
+ }
55
+ return false;
56
+ }
57
+ /**
58
+ * Append the sole durable representation of a session policy override. Invalid
59
+ * values throw before the log changes; consumers fold the new value on each read.
60
+ * @param session - the session the override belongs to.
61
+ * @param policy - the policy in effect until the next switch.
62
+ */
63
+ function setApprovalPolicy(session, policy) {
64
+ if (!APPROVAL_POLICIES.includes(policy)) throw new TypeError("approval policy must be one of \"ask\" or \"never\"");
65
+ session.append("approval/policy", { policy });
66
+ }
67
+ /**
68
+ * Approval service that applies session policy before answerers and logs every
69
+ * ask/outcome pair to the requesting session. It exposes deterministic policy
70
+ * changes to the model through the runtime-context snapshot and switch notices.
71
+ */
72
+ var ApprovalService = class extends Service {
73
+ config;
74
+ static Config = z.object({ policy: z.union(["ask", "never"]).default("ask") });
75
+ constructor(ctx, config) {
76
+ super(ctx, "approval");
77
+ this.config = config;
78
+ const effective = (agent) => this.effectivePolicy(agent.session);
79
+ ctx.inject(["systemPrompt"], (scope) => {
80
+ scope.systemPrompt.context({
81
+ name: "approval:policy",
82
+ order: scope.systemPrompt.getContextOrder("APPROVAL_POLICY"),
83
+ text: (context) => {
84
+ const agent = context.agent;
85
+ if (agent === void 0) return "";
86
+ return effective(agent) === "never" ? NEVER_SENTENCE : ASK_SENTENCE;
87
+ }
88
+ });
89
+ });
90
+ }
91
+ /**
92
+ * Switch one live agent's policy and queue the transition for its next model
93
+ * step. Session initialization uses {@link setApprovalPolicy} directly
94
+ * because there is no previously visible policy to change.
95
+ * @param agent - the live agent whose policy is changing.
96
+ * @param policy - the new effective policy.
97
+ */
98
+ setPolicy(agent, policy) {
99
+ const previous = this.effectivePolicy(agent.session);
100
+ if (previous === policy) return;
101
+ setApprovalPolicy(agent.session, policy);
102
+ agent.inject(createUserMessage({
103
+ content: [{
104
+ type: "text",
105
+ text: `The approval policy changed from "${previous}" to "${policy}" (changed by the user).`
106
+ }],
107
+ source: { kind: "user-approval" }
108
+ }));
109
+ }
110
+ /**
111
+ * Ask the composed answerers to decide one readonly same-process request.
112
+ * The service borrows the request, agent, session, and live signal directly.
113
+ * The request requires an open turn because the audit pair must be enclosed
114
+ * by the durable log's commit/replay boundary; an idle ask rejects before
115
+ * appending anything. The answerer phase always produces an outcome: an
116
+ * aborted signal yields `'cancelled'`, a missing or throwing answerer yields
117
+ * `'unavailable'` (fail closed), and a rogue non-vocabulary return value is
118
+ * normalized to `'unavailable'`. A failure that prevents either audit append
119
+ * from committing still rejects because returning an unlogged decision would
120
+ * violate the pair. Session contains post-commit observer failures, so an
121
+ * authoritative append cannot reject the request or suppress its matching
122
+ * audit event.
123
+ * @param req - the pending decision (agent, tool identity, reason, signal).
124
+ * @returns the closed outcome; `'allowed-once'` is the only grant.
125
+ * @throws when no turn is open or either audit event fails before the session
126
+ * append commit point.
127
+ */
128
+ async request(req) {
129
+ const session = req.agent.session;
130
+ if (!hasOpenTurn(session)) throw new Error("approval.request() outside an open turn: the approval/asked + approval/decided audit pair must be turn-enclosed (a bare event between turns is crash-tail garbage on reload). Ask from inside the turn that needs the decision.");
131
+ const id = ApprovalRequestId(randomUUID());
132
+ session.append("approval/asked", {
133
+ id,
134
+ toolName: req.toolName,
135
+ ...req.callId !== void 0 ? { callId: req.callId } : {},
136
+ ...req.reason !== void 0 ? { reason: req.reason } : {}
137
+ });
138
+ const outcome = await this.decide(req, session);
139
+ session.append("approval/decided", {
140
+ id,
141
+ outcome
142
+ });
143
+ return outcome;
144
+ }
145
+ /**
146
+ * The session's effective policy: its own `approval/policy` fold, else the
147
+ * configured default (the schema already defaulted an omitted policy to
148
+ * `'ask'`; the `??` only narrows the optional-input TYPE).
149
+ * @param session - the exact accepted session whose policy applies.
150
+ * @returns the policy every ask for this session resolves under right now.
151
+ */
152
+ effectivePolicy(session) {
153
+ return this.overrideOf(session) ?? this.config.policy ?? "ask";
154
+ }
155
+ /**
156
+ * Read the session override without applying the configured default.
157
+ * @param session - session whose log supplies the override.
158
+ * @returns the last logged policy, or `undefined` without one.
159
+ */
160
+ overrideOf(session) {
161
+ for (let seq = session.seq - 1; seq >= 0; seq -= 1) {
162
+ const event = session.eventAt(SessionSeq(seq));
163
+ if (event?.type === "approval/policy") return event.data.policy;
164
+ }
165
+ }
166
+ /**
167
+ * Dispatch the waterfall, contained and raced against the request signal.
168
+ * @param req - the borrowed public request.
169
+ * @param session - the request agent's session used for policy lookup.
170
+ * @returns the normalized closed outcome.
171
+ */
172
+ async decide(req, session) {
173
+ const signal = req.signal;
174
+ if (signal?.aborted) return "cancelled";
175
+ if (this.effectivePolicy(session) === "never") return "rejected";
176
+ const answer = Promise.resolve().then(() => this.ctx.waterfall(scopeTarget(req.agent, req.agent), "approval/request", req, () => Promise.resolve("unavailable"))).then((outcome) => OUTCOMES.includes(outcome) ? outcome : "unavailable", () => "unavailable");
177
+ if (signal === void 0) return answer;
178
+ return await new Promise((resolve) => {
179
+ const onAbort = () => {
180
+ signal.removeEventListener("abort", onAbort);
181
+ resolve("cancelled");
182
+ };
183
+ signal.addEventListener("abort", onAbort, { once: true });
184
+ answer.then((outcome) => {
185
+ signal.removeEventListener("abort", onAbort);
186
+ resolve(outcome);
187
+ });
188
+ });
189
+ }
190
+ };
191
+ //#endregion
192
+ export { APPROVAL_POLICIES, ApprovalRequestId, ApprovalService, ApprovalService as default, setApprovalPolicy };
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Service Definition for the approval capability seam, covering requests, cancellation, audit, and per-session policy. Missing
3
+ * answerers fail closed; grants apply only to the requested action.
4
+ * @module @qilin-agent/user-approval
5
+ */
6
+ import { Context, Service } from '@qilin-agent/kylin';
7
+ import z from '@qilin-agent/schemastery';
8
+ import type { Agent } from '@qilin-agent/agent';
9
+ import { type ToolCallId } from '@qilin-agent/llm';
10
+ import type { ContextFormed } from '@qilin-agent/llm';
11
+ declare module '@qilin-agent/llm' {
12
+ interface MessageSourceMap {
13
+ 'user-approval': {
14
+ kind: 'user-approval';
15
+ } & ContextFormed;
16
+ }
17
+ }
18
+ import type { Session } from '@qilin-agent/session';
19
+ declare module '@qilin-agent/kylin' {
20
+ interface Context {
21
+ approval: ApprovalService;
22
+ }
23
+ }
24
+ declare module '@qilin-agent/session/types' {
25
+ interface SessionEventMap {
26
+ /**
27
+ * The session's approval policy was switched — log-only, durable,
28
+ * replayable, never in the model transcript (the model learns the policy
29
+ * from the runtime-context snapshot and live switch notices). The LAST
30
+ * such event is the session's override.
31
+ * `source: 'delegation'` marks an override seeded into a child; an absent
32
+ * source is a runtime switch.
33
+ */
34
+ 'approval/policy': {
35
+ policy: ApprovalPolicy;
36
+ /** Marks an override seeded into a child at delegation. */
37
+ source?: 'delegation';
38
+ };
39
+ }
40
+ }
41
+ import type { ApprovalOutcome, ApprovalRequestEvent } from './types.ts';
42
+ export { ApprovalRequestId } from './types.ts';
43
+ export type { ApprovalOutcome } from './types.ts';
44
+ /**
45
+ * A session's approval policy — what happens to an {@link ApprovalService}
46
+ * ask BEFORE any interactive answerer sees it:
47
+ *
48
+ * - `'ask'` (the default) — delegate to the composed answerers; with none
49
+ * composed the chain falls through to the fail-closed `'unavailable'`.
50
+ * - `'never'` — never prompt anyone: every ask resolves `'rejected'`
51
+ * deterministically. The strict headless stance (CI, unattended runs) and
52
+ * the policy whose outcome is knowable without asking.
53
+ */
54
+ export type ApprovalPolicy = 'ask' | 'never';
55
+ /** Every {@link ApprovalPolicy}, for option advertisement and runtime validation of untrusted policy strings. */
56
+ export declare const APPROVAL_POLICIES: readonly ApprovalPolicy[];
57
+ /**
58
+ * Append the sole durable representation of a session policy override. Invalid
59
+ * values throw before the log changes; consumers fold the new value on each read.
60
+ * @param session - the session the override belongs to.
61
+ * @param policy - the policy in effect until the next switch.
62
+ */
63
+ export declare function setApprovalPolicy(session: Session, policy: ApprovalPolicy): void;
64
+ /**
65
+ * Readonly same-process permission question. `callId` links to an already
66
+ * presented tool call, so arguments are not duplicated here.
67
+ */
68
+ export interface ApprovalRequest extends ApprovalRequestEvent {
69
+ /**
70
+ * The agent on whose behalf the question is asked. Routes the question (a
71
+ * UI answerer only answers for agents it owns) and receives the audit
72
+ * events on its session log.
73
+ */
74
+ readonly agent: Agent;
75
+ /** The tool the question is about (presentation and audit). */
76
+ readonly toolName: string;
77
+ /**
78
+ * The exact tool call being decided, when the asker has one — lets a UI
79
+ * attach the prompt to the tool call it already streamed.
80
+ */
81
+ readonly callId?: ToolCallId;
82
+ /** The asker's human-readable explanation of WHY it is asking. */
83
+ readonly reason?: string;
84
+ /**
85
+ * Aborting withdraws the question: the request settles `'cancelled'`
86
+ * immediately and a late answer from a still-pending answerer is discarded.
87
+ */
88
+ readonly signal?: AbortSignal;
89
+ }
90
+ /** Plugin config. All optional — `static Config` supplies the defaults. */
91
+ export interface Config {
92
+ /**
93
+ * The deployment's default {@link ApprovalPolicy} for sessions without an
94
+ * `approval/policy` override — `'ask'` delegates to the composed answerers
95
+ * (fail-closed with none); `'never'` auto-rejects every ask without
96
+ * prompting (the deterministic CI/unattended stance).
97
+ */
98
+ readonly policy?: ApprovalPolicy;
99
+ }
100
+ /**
101
+ * Approval service that applies session policy before answerers and logs every
102
+ * ask/outcome pair to the requesting session. It exposes deterministic policy
103
+ * changes to the model through the runtime-context snapshot and switch notices.
104
+ */
105
+ export declare class ApprovalService extends Service {
106
+ config: Config;
107
+ static Config: z<Config>;
108
+ constructor(ctx: Context, config: Config);
109
+ /**
110
+ * Switch one live agent's policy and queue the transition for its next model
111
+ * step. Session initialization uses {@link setApprovalPolicy} directly
112
+ * because there is no previously visible policy to change.
113
+ * @param agent - the live agent whose policy is changing.
114
+ * @param policy - the new effective policy.
115
+ */
116
+ setPolicy(agent: Agent, policy: ApprovalPolicy): void;
117
+ /**
118
+ * Ask the composed answerers to decide one readonly same-process request.
119
+ * The service borrows the request, agent, session, and live signal directly.
120
+ * The request requires an open turn because the audit pair must be enclosed
121
+ * by the durable log's commit/replay boundary; an idle ask rejects before
122
+ * appending anything. The answerer phase always produces an outcome: an
123
+ * aborted signal yields `'cancelled'`, a missing or throwing answerer yields
124
+ * `'unavailable'` (fail closed), and a rogue non-vocabulary return value is
125
+ * normalized to `'unavailable'`. A failure that prevents either audit append
126
+ * from committing still rejects because returning an unlogged decision would
127
+ * violate the pair. Session contains post-commit observer failures, so an
128
+ * authoritative append cannot reject the request or suppress its matching
129
+ * audit event.
130
+ * @param req - the pending decision (agent, tool identity, reason, signal).
131
+ * @returns the closed outcome; `'allowed-once'` is the only grant.
132
+ * @throws when no turn is open or either audit event fails before the session
133
+ * append commit point.
134
+ */
135
+ request(req: ApprovalRequest): Promise<ApprovalOutcome>;
136
+ /**
137
+ * The session's effective policy: its own `approval/policy` fold, else the
138
+ * configured default (the schema already defaulted an omitted policy to
139
+ * `'ask'`; the `??` only narrows the optional-input TYPE).
140
+ * @param session - the exact accepted session whose policy applies.
141
+ * @returns the policy every ask for this session resolves under right now.
142
+ */
143
+ private effectivePolicy;
144
+ /**
145
+ * Read the session override without applying the configured default.
146
+ * @param session - session whose log supplies the override.
147
+ * @returns the last logged policy, or `undefined` without one.
148
+ */
149
+ overrideOf(session: Session): ApprovalPolicy | undefined;
150
+ /**
151
+ * Dispatch the waterfall, contained and raced against the request signal.
152
+ * @param req - the borrowed public request.
153
+ * @param session - the request agent's session used for policy lookup.
154
+ * @returns the normalized closed outcome.
155
+ */
156
+ private decide;
157
+ }
158
+ export default ApprovalService;
159
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,209 @@
1
+ /**
2
+ * Service Definition for the approval capability seam, covering requests, cancellation, audit, and per-session policy. Missing
3
+ * answerers fail closed; grants apply only to the requested action.
4
+ * @module @qilin-agent/user-approval
5
+ */
6
+ import { randomUUID } from 'node:crypto';
7
+ import { Service } from '@qilin-agent/kylin';
8
+ import z from '@qilin-agent/schemastery';
9
+ import { createUserMessage } from '@qilin-agent/llm';
10
+ import { scopeTarget } from '@qilin-agent/scope';
11
+ import { SessionSeq } from '@qilin-agent/session';
12
+ import { ApprovalRequestId } from "./types.js";
13
+ export { ApprovalRequestId } from "./types.js";
14
+ /** Every {@link ApprovalOutcome}, for runtime normalization of answerer returns. */
15
+ const OUTCOMES = ['allowed-once', 'rejected', 'cancelled', 'unavailable'];
16
+ /** Every {@link ApprovalPolicy}, for option advertisement and runtime validation of untrusted policy strings. */
17
+ export const APPROVAL_POLICIES = ['ask', 'never'];
18
+ /** Model-facing statement for the deterministic `'never'` policy. */
19
+ const NEVER_SENTENCE = 'Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).';
20
+ /** Model-facing statement for an interactive policy that may still fail closed. */
21
+ const ASK_SENTENCE = 'Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed.';
22
+ /**
23
+ * Whether the log currently sits inside an open turn (a `turn/start` not yet
24
+ * closed by a `turn/end`) — the {@link ApprovalService.request} precondition.
25
+ * The audit pair must be turn-enclosed: the turn is the durable log's
26
+ * commit/replay boundary, so a bare event appended between turns is
27
+ * indistinguishable from a crash tail and silently dropped on reload.
28
+ */
29
+ function hasOpenTurn(session) {
30
+ for (let seq = session.seq - 1; seq >= 0; seq -= 1) {
31
+ // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred.
32
+ const type = session.eventAt(SessionSeq(seq))?.type;
33
+ if (type === 'turn/start')
34
+ return true;
35
+ if (type === 'turn/end')
36
+ return false;
37
+ }
38
+ return false;
39
+ }
40
+ /**
41
+ * Append the sole durable representation of a session policy override. Invalid
42
+ * values throw before the log changes; consumers fold the new value on each read.
43
+ * @param session - the session the override belongs to.
44
+ * @param policy - the policy in effect until the next switch.
45
+ */
46
+ export function setApprovalPolicy(session, policy) {
47
+ if (!APPROVAL_POLICIES.includes(policy)) {
48
+ throw new TypeError('approval policy must be one of "ask" or "never"');
49
+ }
50
+ session.append('approval/policy', { policy });
51
+ }
52
+ /**
53
+ * Approval service that applies session policy before answerers and logs every
54
+ * ask/outcome pair to the requesting session. It exposes deterministic policy
55
+ * changes to the model through the runtime-context snapshot and switch notices.
56
+ */
57
+ export class ApprovalService extends Service {
58
+ config;
59
+ static Config = z.object({
60
+ policy: z.union(['ask', 'never']).default('ask'),
61
+ });
62
+ constructor(ctx, config) {
63
+ super(ctx, 'approval');
64
+ this.config = config;
65
+ const effective = (agent) => this.effectivePolicy(agent.session);
66
+ // The complete current value travels after retained history, so switching
67
+ // policy does not rewrite the stable system-prompt cache prefix.
68
+ ctx.inject(['systemPrompt'], (scope) => {
69
+ scope.systemPrompt.context({
70
+ name: 'approval:policy',
71
+ order: scope.systemPrompt.getContextOrder('APPROVAL_POLICY'),
72
+ text: (context) => {
73
+ const agent = context.agent;
74
+ // A bare assemble() (tests, diagnostics) has no session to state.
75
+ if (agent === undefined)
76
+ return '';
77
+ const policy = effective(agent);
78
+ return policy === 'never' ? NEVER_SENTENCE : ASK_SENTENCE;
79
+ },
80
+ });
81
+ });
82
+ }
83
+ /**
84
+ * Switch one live agent's policy and queue the transition for its next model
85
+ * step. Session initialization uses {@link setApprovalPolicy} directly
86
+ * because there is no previously visible policy to change.
87
+ * @param agent - the live agent whose policy is changing.
88
+ * @param policy - the new effective policy.
89
+ */
90
+ setPolicy(agent, policy) {
91
+ const previous = this.effectivePolicy(agent.session);
92
+ if (previous === policy)
93
+ return;
94
+ setApprovalPolicy(agent.session, policy);
95
+ agent.inject(createUserMessage({
96
+ content: [{
97
+ type: 'text',
98
+ text: `The approval policy changed from "${previous}" to "${policy}" (changed by the user).`,
99
+ }],
100
+ source: { kind: 'user-approval' },
101
+ }));
102
+ }
103
+ /**
104
+ * Ask the composed answerers to decide one readonly same-process request.
105
+ * The service borrows the request, agent, session, and live signal directly.
106
+ * The request requires an open turn because the audit pair must be enclosed
107
+ * by the durable log's commit/replay boundary; an idle ask rejects before
108
+ * appending anything. The answerer phase always produces an outcome: an
109
+ * aborted signal yields `'cancelled'`, a missing or throwing answerer yields
110
+ * `'unavailable'` (fail closed), and a rogue non-vocabulary return value is
111
+ * normalized to `'unavailable'`. A failure that prevents either audit append
112
+ * from committing still rejects because returning an unlogged decision would
113
+ * violate the pair. Session contains post-commit observer failures, so an
114
+ * authoritative append cannot reject the request or suppress its matching
115
+ * audit event.
116
+ * @param req - the pending decision (agent, tool identity, reason, signal).
117
+ * @returns the closed outcome; `'allowed-once'` is the only grant.
118
+ * @throws when no turn is open or either audit event fails before the session
119
+ * append commit point.
120
+ */
121
+ async request(req) {
122
+ const session = req.agent.session;
123
+ if (!hasOpenTurn(session)) {
124
+ throw new Error('approval.request() outside an open turn: the approval/asked + approval/decided audit pair '
125
+ + 'must be turn-enclosed (a bare event between turns is crash-tail garbage on reload). '
126
+ + 'Ask from inside the turn that needs the decision.');
127
+ }
128
+ const id = ApprovalRequestId(randomUUID());
129
+ session.append('approval/asked', {
130
+ id,
131
+ toolName: req.toolName,
132
+ ...req.callId !== undefined ? { callId: req.callId } : {},
133
+ ...req.reason !== undefined ? { reason: req.reason } : {},
134
+ });
135
+ const outcome = await this.decide(req, session);
136
+ session.append('approval/decided', { id, outcome });
137
+ return outcome;
138
+ }
139
+ /**
140
+ * The session's effective policy: its own `approval/policy` fold, else the
141
+ * configured default (the schema already defaulted an omitted policy to
142
+ * `'ask'`; the `??` only narrows the optional-input TYPE).
143
+ * @param session - the exact accepted session whose policy applies.
144
+ * @returns the policy every ask for this session resolves under right now.
145
+ */
146
+ effectivePolicy(session) {
147
+ return this.overrideOf(session) ?? this.config.policy ?? 'ask';
148
+ }
149
+ /**
150
+ * Read the session override without applying the configured default.
151
+ * @param session - session whose log supplies the override.
152
+ * @returns the last logged policy, or `undefined` without one.
153
+ */
154
+ overrideOf(session) {
155
+ for (let seq = session.seq - 1; seq >= 0; seq -= 1) {
156
+ // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred.
157
+ const event = session.eventAt(SessionSeq(seq));
158
+ if (event?.type === 'approval/policy')
159
+ return event.data.policy;
160
+ }
161
+ return undefined;
162
+ }
163
+ /**
164
+ * Dispatch the waterfall, contained and raced against the request signal.
165
+ * @param req - the borrowed public request.
166
+ * @param session - the request agent's session used for policy lookup.
167
+ * @returns the normalized closed outcome.
168
+ */
169
+ async decide(req, session) {
170
+ const signal = req.signal;
171
+ if (signal?.aborted)
172
+ return 'cancelled';
173
+ // The 'never' policy is decided HERE, before any dispatch: a listener
174
+ // registered with `prepend: true` after this service mounts would sit
175
+ // ahead of any gate LISTENER, so a listener-shaped gate cannot keep the
176
+ // documented promise that 'never' rejects deterministically regardless
177
+ // of registration order — only the service's own request path can.
178
+ if (this.effectivePolicy(session) === 'never')
179
+ return 'rejected';
180
+ // Enter the promise chain BEFORE dispatching: a listener that throws
181
+ // SYNCHRONOUSLY (before its first await) must land in the same rejection
182
+ // path as an async one — `Promise.resolve(call())` would let it escape
183
+ // the containment into the caller.
184
+ const answer = Promise.resolve().then(() => this.ctx.waterfall(scopeTarget(req.agent, req.agent), 'approval/request', req, () => Promise.resolve('unavailable'))).then(
185
+ // Normalize a rogue (non-vocabulary) answerer return to the fail-closed
186
+ // outcome instead of leaking it into callers' closed-union switches.
187
+ outcome => OUTCOMES.includes(outcome) ? outcome : 'unavailable',
188
+ // A throwing answerer must fail the QUESTION closed, not the caller's
189
+ // tool call open — the seam contains its callbacks.
190
+ () => 'unavailable');
191
+ if (signal === undefined)
192
+ return answer;
193
+ return await new Promise((resolve) => {
194
+ const onAbort = () => {
195
+ signal.removeEventListener('abort', onAbort);
196
+ resolve('cancelled');
197
+ };
198
+ signal.addEventListener('abort', onAbort, { once: true });
199
+ void answer.then((outcome) => {
200
+ signal.removeEventListener('abort', onAbort);
201
+ // After an abort won the race this resolve is a settled-promise no-op:
202
+ // the late answer is discarded by construction.
203
+ resolve(outcome);
204
+ });
205
+ });
206
+ }
207
+ }
208
+ export default ApprovalService;
209
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Wire-safe approval identifiers and outcome vocabulary, free of
3
+ * cordis/service imports so browser type chains can
4
+ * consume them without loading this package's Context augmentation.
5
+ * @module @qilin-agent/user-approval/types
6
+ */
7
+ import type { Branded } from '@qilin-agent/brand';
8
+ import type { Scoped } from '@qilin-agent/scope';
9
+ import type { Agent } from '@qilin-agent/agent/types';
10
+ import type { ToolCallId } from '@qilin-agent/llm/brand';
11
+ /**
12
+ * Pairs one `approval/asked` audit event with its `approval/decided`.
13
+ * Service-issued (one fresh id per {@link ApprovalService.request} call).
14
+ */
15
+ export type ApprovalRequestId = Branded<'ApprovalRequestId'>;
16
+ /**
17
+ * Brand a string as an {@link ApprovalRequestId}.
18
+ * @param id - the raw id string to brand.
19
+ * @returns the same string carrying the brand.
20
+ */
21
+ export declare function ApprovalRequestId(id: string): ApprovalRequestId;
22
+ /**
23
+ * Closed approval outcomes: a one-shot grant, explicit rejection, withdrawn
24
+ * request, or unavailable answerer. Callers fail closed on `unavailable`.
25
+ */
26
+ export type ApprovalOutcome = 'allowed-once' | 'rejected' | 'cancelled' | 'unavailable';
27
+ declare module '@qilin-agent/session/types' {
28
+ interface SessionEventMap {
29
+ /**
30
+ * An approval question was put to the answerer chain — log-only audit
31
+ * (like `hook/*`; NOT a surface event, carries no `surfaceOp`). `id` pairs
32
+ * it with the `approval/decided` that always follows; `toolName` is the
33
+ * tool the question is about, `callId` the exact tool call when the asker
34
+ * had one, `reason` the asker's human-readable explanation (e.g. a hook's
35
+ * permission-decision reason).
36
+ */
37
+ 'approval/asked': {
38
+ id: ApprovalRequestId;
39
+ toolName: string;
40
+ callId?: ToolCallId;
41
+ reason?: string;
42
+ };
43
+ /**
44
+ * The outcome of a prior `approval/asked` (same `id`) — log-only audit.
45
+ * Exactly one per ask, appended when the outcome is known: a decision, a
46
+ * cancellation, or the fail-closed `'unavailable'`.
47
+ */
48
+ 'approval/decided': {
49
+ id: ApprovalRequestId;
50
+ outcome: ApprovalOutcome;
51
+ };
52
+ }
53
+ }
54
+ /** Client-safe payload declared for the approval answerer waterfall. */
55
+ export interface ApprovalRequestEvent {
56
+ /** Agent identity projected to the corresponding Client Context in transit. */
57
+ readonly agent: Agent;
58
+ /** Tool whose operation requires a decision. */
59
+ readonly toolName: string;
60
+ /** Exact tool call being decided, when available. */
61
+ readonly callId?: ToolCallId;
62
+ /** Human-readable reason supplied by the asker. */
63
+ readonly reason?: string;
64
+ /** Localized presentation only; never persisted in approval audit events. */
65
+ readonly displayReason?: {
66
+ readonly en: string;
67
+ readonly [locale: string]: string;
68
+ };
69
+ /** Cancellation lifetime of the pending request. */
70
+ readonly signal?: AbortSignal;
71
+ }
72
+ declare module '@qilin-agent/kylin' {
73
+ interface Events {
74
+ /**
75
+ * Ask composed answerers for one decision. Return an outcome to claim the
76
+ * request or call `next()` to delegate. Scope-filtered dispatch
77
+ * (`@qilin-agent/scope`): agent-scoped listeners receive only that agent.
78
+ * @param req - pending approval request.
79
+ * @mode waterfall
80
+ */
81
+ 'approval/request'(this: Scoped<Agent>, req: ApprovalRequestEvent, next: () => Promise<ApprovalOutcome>): Promise<ApprovalOutcome>;
82
+ }
83
+ }
84
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Wire-safe approval identifiers and outcome vocabulary, free of
3
+ * cordis/service imports so browser type chains can
4
+ * consume them without loading this package's Context augmentation.
5
+ * @module @qilin-agent/user-approval/types
6
+ */
7
+ /**
8
+ * Brand a string as an {@link ApprovalRequestId}.
9
+ * @param id - the raw id string to brand.
10
+ * @returns the same string carrying the brand.
11
+ */
12
+ export function ApprovalRequestId(id) {
13
+ return id;
14
+ }
15
+ //# sourceMappingURL=types.js.map
package/package.json CHANGED
@@ -1,6 +1,58 @@
1
1
  {
2
2
  "name": "@qilin-agent/user-approval",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "description": "User-approval seam (ctx.approval) for the QiLin: one-shot permission decisions dispatched to composed answerers over the approval/request waterfall, fail-closed by default",
4
+ "version": "3.1.4",
5
+ "publishConfig": {
6
+ "access": "public"
7
+ },
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/kkutysllb/QiLin.git",
11
+ "directory": "packages/interaction/user-approval"
12
+ },
13
+ "engines": {
14
+ "node": "^22.19.0 || >=24.0.0"
15
+ },
16
+ "type": "module",
17
+ "main": "lib/index.js",
18
+ "types": "lib/types/index.d.ts",
19
+ "exports": {
20
+ ".": {
21
+ "types": "./lib/types/index.d.ts",
22
+ "default": "./lib/index.js"
23
+ },
24
+ "./types": {
25
+ "types": "./lib/types/types.d.ts",
26
+ "default": "./lib/types/types.js"
27
+ },
28
+ "./src/*": "./src/*",
29
+ "./package.json": "./package.json"
30
+ },
31
+ "files": [
32
+ "lib/index.js",
33
+ "lib/types/**/*.js",
34
+ "lib/types/**/*.d.ts"
35
+ ],
36
+ "license": "MIT",
37
+ "peerDependencies": {
38
+ "@qilin-agent/llm": "3.1.4",
39
+ "@qilin-agent/agent": "3.1.4",
40
+ "@qilin-agent/session": "3.1.4",
41
+ "@qilin-agent/scope": "3.1.4",
42
+ "@qilin-agent/brand": "3.1.4",
43
+ "@qilin-agent/kylin": "~4.0.3",
44
+ "@qilin-agent/system-prompt": "3.1.4"
45
+ },
46
+ "dependencies": {
47
+ "@qilin-agent/schemastery": "~3.18.3"
48
+ },
49
+ "devDependencies": {
50
+ "@qilin-agent/agent": "3.1.4",
51
+ "@qilin-agent/brand": "3.1.4",
52
+ "@qilin-agent/llm": "3.1.4",
53
+ "@qilin-agent/scope": "3.1.4",
54
+ "@qilin-agent/session": "3.1.4",
55
+ "@qilin-agent/system-prompt": "3.1.4",
56
+ "@qilin-agent/kylin": "~4.0.3"
57
+ }
6
58
  }