dsh-lark-bot 0.16.1 → 0.17.2

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/README.md CHANGED
@@ -40,7 +40,7 @@
40
40
 
41
41
  **你的 DeepSeek Harness 只能“贴身”用?** dsh 跑在本机,每次看进度、改任务都得回到电脑前;离开工位后任务卡住、跑偏甚至 dsh 崩了,你都收不到任何消息——回来才发现白等半天。
42
42
 
43
- **dsh-lark-bot 把遥控器装进你的飞书**:在私聊、群聊、话题里直接指挥本机 dsh coding agent,流式卡片的飞书原生折叠面板实时展示思考与工具调用,最终回答单独成消息;任务完成还能主动推送到你所在的任何群并 @ 你;即使 dsh 崩溃下线,飞书里依然叫得应——发 `/safemode` 进入仅核心安全模式,直接在聊天里定位问题、重启引擎。**这是唯一“dsh 挂了你不会失联”的桥接方案。**
43
+ **dsh-lark-bot 把遥控器装进你的飞书**:在私聊、群聊、话题里直接指挥本机 dsh coding agent,流式卡片的飞书原生折叠面板实时展示阶段、耗时以及工具名称与状态,最终回答单独成消息;任务完成还能主动推送到你所在的任何群并 @ 你;即使 dsh 崩溃下线,飞书里依然叫得应——发 `/safemode` 进入仅核心安全模式,直接在聊天里定位问题、重启引擎。**这是唯一“dsh 挂了你不会失联”的桥接方案。**
44
44
 
45
45
  **适合谁**:在飞书 / Lark(私聊、群聊、话题)里指挥本机 dsh coding agent 的开发者与团队,尤其是需要多项目隔离、角色分工、并行任务与会话归档的协作场景。
46
46
 
@@ -49,7 +49,7 @@
49
49
  **基础能力**:
50
50
 
51
51
  - 私聊、群聊、话题(thread)里指挥本机 dsh coding agent,图片 / 文本文件直接发给 bot 即可;
52
- - 流式过程卡以飞书原生折叠面板实时展示思考、工具调用与结果,完成后最终回答单独发送,支持交互按钮(停止 / 计划门禁 / 审批 / 问答卡);
52
+ - 流式过程卡以飞书原生折叠面板实时展示阶段、耗时以及工具名称与状态,完成后最终回答单独发送,支持交互按钮(停止 / 计划门禁 / 审批 / 问答卡);原始推理、工具输入输出与底层错误不会进入卡片;卡片更新失败会有限重试并降级为普通提示,Agent 与最终回答继续,不会拖垮 bridge 进程;
53
53
  - Git 仓库内为每个会话自动创建隔离 worktree 项目工作区,多项目互不干扰。
54
54
 
55
55
  **十一项全网独有组合**:
@@ -104,8 +104,8 @@ dsh --profile dsh-lark
104
104
 
105
105
  bot 自带的命令帮助、状态、错误提示与交互卡片均提供中文 / English。Card JSON 2.0 在各文本组件使用飞书原生
106
106
  `i18n_content`,同一张群卡会按每位读者的客户端语言显示;无法取得读者语言的普通
107
- Markdown、toast 与旧客户端降级路径同时显示中英文。agent 生成的回答、推理、工具输入输出和用户原文
108
- 保持原样,不自动翻译。
107
+ Markdown、toast 与旧客户端降级路径同时显示中英文。agent 最终回答和用户原文保持原样,不自动翻译;
108
+ 原始推理、工具输入输出和底层错误只保留在本机运行边界内,不进入飞书过程卡。
109
109
 
110
110
  | 命令 | 作用 |
111
111
  | --- | --- |
@@ -165,7 +165,7 @@ transcript 卡并持久绑定。私聊允许已授权用户;member scope 仅
165
165
 
166
166
  **`/newg <群名>`**:自动新建私密群、拉发送者入群并回复群链接——新群即新 scope / 新会话,当前会话不受影响。需应用具备 `im:chat` 与 `im:chat.members:write_only` 权限。
167
167
 
168
- 同一 scope(私聊 / 群聊 / 话题)默认 **2 个任务并行**(`DSH_LARK_SCOPE_CONCURRENCY` 或 `/concurrency` 调整):多条消息以独立 run 并行推进,每个 run 使用独立 dsh session 与 runId;`/status` 查看当前 workspace 的 run,`/new` 只停止当前 workspace,`/stop` 一次性终止 scope 内全部运行。
168
+ 同一 scope(私聊 / 群聊 / 话题)默认 **2 个任务并行**(`DSH_LARK_SCOPE_CONCURRENCY` 或 `/concurrency` 调整):多条消息以独立 run 并行推进,每个 run 使用独立 dsh session 与 runId;SDK runtime 以 `scope + workspace` 为取消域,同一 scope 的并发 session 也会切到独立 runtime,因此卡片停止只影响该 run,`/stop` 只终止当前 scope 的全部运行,不会误停其他群。`/status` 查看当前 workspace 的 run,`/new` 只停止当前 workspace
169
169
 
170
170
  **会话状态卡**:`/status` 展示工作区、有效模型、session、显式投影绑定/cursor、active runs、版本、上下文占用、
171
171
  累计 input / output / cache token,以及待审批 / 待提问 / 待批准计划;点击“刷新”会原位更新同一张卡。
@@ -233,10 +233,14 @@ guardian 仍只救援其配置的主实例。
233
233
 
234
234
  **关键任务计划门禁**:SDK / ACP / Web agent 在修改文件、运行脚本等较大或高风险动作前使用
235
235
  `lark_request_plan_approval`;同一 turn 未获批准时,runtime pre-execute 策略会拒绝写入、删除、
236
- 移动、命令执行与 `run_code`。bridge 先把完整 Markdown 计划作为普通消息发出,再弹出“批准,开始执行 /
236
+ 移动、非只读 shell 命令与 `run_code`。一次计划批准只放行随后一次高风险调用,计划外的后续调用必须
237
+ 重新确认。`date`、`pwd`、`ls`、`find`、`rg`、`git status/log/diff` 等单条
238
+ 只读检查直接放行;包含串联、重定向、命令替换或未知程序的 shell 调用仍保守地走计划门禁。
239
+ bridge 先把完整 Markdown 计划作为普通消息发出,再弹出“批准,开始执行 /
237
240
  继续规划”决策卡;卡内可填写修改意见。工具在等待期间阻塞且暂停空闲超时,批准后原任务自动继续;
238
241
  继续规划时 agent 会收到意见、修订计划并再次请求确认。门禁无固定十分钟截止,跟随所属 run 的取消
239
- 信号;停止任务会精确取消该 session 的 pending 卡并撤回。legacy headless adapter 不具备工具回调能力。
242
+ 信号;停止任务会精确取消该 session 的 pending 卡并撤回。可信部署可设置
243
+ `DSH_LARK_PLAN_GATE=off` 关闭这层独立门禁(逐工具审批仍按原策略执行);legacy headless adapter 不具备工具回调能力。
240
244
 
241
245
  **任务中向你提问(问答卡)**:agent 需要你拍板、确认或补充信息时,通过 `lark_ask_user` 工具弹**问答卡**(单选 / 多选 / 自由文本)。可提交卡片,也可直接回复该卡片输入任意文字;单选/多选没有合适项时,回复文字就是补充答案。系统按被回复的 card messageId 精确匹配 pending 问题,回答后任务自动继续,等待期间运行超时看门狗暂停。(与 `/ask` 的“你主动提问”方向相反。)
242
246
 
@@ -391,7 +395,7 @@ dsh plugin --profile dsh-lark remove dsh-lark-bot
391
395
 
392
396
  ## 兼容性
393
397
 
394
- - **DeepSeek Harness(`dsh`)**:已验证 **dsh 0.1.0-rc.8**(最后验证 2026-08-20:临时安装 + SDK JSON-RPC / ACP runtime initialize、工具/审批、live session 续接与 restart collision 探针),通过官方 `@deepseek-ai/dsh-sdk-client` / `@deepseek-ai/dsh-acp` 接入;
398
+ - **DeepSeek Harness(`dsh`)**:已验证 **dsh 0.1.0-rc.8**(最后验证 2026-08-22:临时安装 + SDK JSON-RPC / ACP runtime initialize、工具/审批、live session 续接与 restart collision 探针),通过官方 `@deepseek-ai/dsh-sdk-client` / `@deepseek-ai/dsh-acp` 接入;
395
399
  具体锁定版本、升级政策与自动化探测见 [`docs/COMPATIBILITY.md`](docs/COMPATIBILITY.md),
396
400
  adapter 接入细节见 [`docs/adapter-notes.md`](docs/adapter-notes.md),rc.8 差异、已知风险和
397
401
  自动/人工验证边界见 [`docs/DSH_RC8_AUDIT.md`](docs/DSH_RC8_AUDIT.md)。
@@ -405,10 +409,12 @@ dsh plugin --profile dsh-lark remove dsh-lark-bot
405
409
 
406
410
  ## 已知限制
407
411
 
408
- - ACP 模式会话每次全新(上游限制,无续跑);SDK 协议暂无 mid-turn cancel,`/stop` 会关闭
409
- 对应 runtime 并自动重建。
412
+ - ACP 模式会话每次全新(上游限制,无续跑);SDK 协议暂无 mid-turn cancel,停止操作会关闭
413
+ run 所属的隔离 runtime 并自动重建,不会关闭其他 scope 或并发 run 的 runtime。SDK 只在当前
414
+ bridge 进程仍持有同一个 live runtime 时原生续接 session;进程重启、停止或模型切换后会主动
415
+ 新建 session 并回放 bridge transcript,避免把旧 ID 交给 rc.8 新 runtime 触发 `id collision`。
410
416
  - 桥接引擎作为 dsh 插件在 dsh 进程内运行,agent 执行使用官方 dsh SDK runtime 子进程
411
- (嵌套 runtime 是有意取舍,用于按工作区隔离的 runtime 池与 scope 内并行 run)。
417
+ (嵌套 runtime 是有意取舍,用于按 scope + workspace 隔离取消域与并行 run)。
412
418
  唯一的进程级例外是默认安装的「安全网守护」——它独立于 dsh / Cordis 常驻,仅在 dsh
413
419
  下线后接管飞书通道,正常运行时保持静默。
414
420
  - 飞书文档评论、富文本回复为规划中能力,尚未实现。
@@ -526,7 +532,9 @@ SDK 模式下 dsh 原生 session 续跑,headless 模式则把历史注入下
526
532
  后续问答卡作为 reply 正确发回原话题。
527
533
  - **本地回调**:运行 `lark_notify`、`lark_send_file`、`lark_ask_user`、`lark_request_plan_approval` 或逐工具审批时,dsh
528
534
  runtime 子进程通过 `127.0.0.1` 随机端口 + 每启动随机 token 回调 bridge 进程(仅本机回环,
529
- 不监听公网);计划内容、待执行工具的理由/参数与决策卡会发送到当前飞书 / Lark 会话。群聊中的审批内容对群成员可见。
535
+ 不监听公网);等待人工回答的回调会立即发送响应头,并用 JSON 空白心跳维持连接,避免 Node 默认的
536
+ 5 分钟 HTTP 空闲边界中断审批。计划内容、待执行工具的理由/参数与决策卡会发送到当前飞书 / Lark 会话。
537
+ 群聊中的审批内容对群成员可见。
530
538
  - **进程**:spawn 本机 `dsh` runtime 子进程(`dsh-sdk-jsonrpc-server` / `dsh-acp` profile)执行 agent 任务。
531
539
  - **dsh 配置**:`/model` `/providers` `/provider` `/key` 命令按 dsh 官方存储协议读写
532
540
  `~/.dsh/settings.yaml` 与 `~/.dsh/.credentials.yaml`(仅管理员可写;settings 只存 `apiKeyEnv`
package/README_EN.md CHANGED
@@ -40,18 +40,18 @@ Turn **DeepSeek Harness (`dsh`)** into a member of your Feishu / Lark workspace
40
40
 
41
41
  Tired of being chained to your desk to drive DeepSeek Harness? dsh runs on your local machine, so checking progress and adjusting tasks means going back to your computer; once you leave your desk, a run can stall, drift, or dsh itself can crash without you ever hearing about it — until you come back and find you wasted hours.
42
42
 
43
- dsh-lark-bot puts the remote control in your Feishu: drive your local dsh coding agent from DMs, group chats and topics, with a native collapsible panel showing reasoning and tool calls in real time and the final answer delivered as a separate message; get proactive notifications pushed to any chat you're in with @mentions when tasks finish; and even when dsh crashes, Feishu still answers — send `/safemode` to enter core-only safe mode and locate the problem and restart the engine right from the chat. **It is the only bridge where you never lose contact when dsh goes down.**
43
+ dsh-lark-bot puts the remote control in your Feishu: drive your local dsh coding agent from DMs, group chats and topics, with a native collapsible panel showing phase, elapsed time, and tool names/statuses in real time and the final answer delivered as a separate message; get proactive notifications pushed to any chat you're in with @mentions when tasks finish; and even when dsh crashes, Feishu still answers — send `/safemode` to enter core-only safe mode and locate the problem and restart the engine right from the chat. **It is the only bridge where you never lose contact when dsh goes down.**
44
44
 
45
45
  **Who it is for**: developers and teams who drive a local dsh coding agent from Feishu / Lark (DMs, groups, topics) — especially those needing multi-project isolation, role-based collaboration, parallel tasks and session archival.
46
46
 
47
- Bot-owned command help, status/error messages and interactive cards are available in Chinese and English. Card JSON 2.0 uses native component-level `i18n_content`, so members of the same group see one shared card in their own client language. Plain Markdown, toast messages and legacy fallbacks cannot detect each viewer's locale and therefore show both languages. Agent answers, reasoning, tool input/output and user-authored text are never translated.
47
+ Bot-owned command help, status/error messages and interactive cards are available in Chinese and English. Card JSON 2.0 uses native component-level `i18n_content`, so members of the same group see one shared card in their own client language. Plain Markdown, toast messages and legacy fallbacks cannot detect each viewer's locale and therefore show both languages. Agent answers and user-authored text are never translated; raw reasoning and tool payloads remain local and are not rendered into process cards.
48
48
 
49
49
  ## What you get
50
50
 
51
51
  **Core**:
52
52
 
53
53
  - Drive your local dsh coding agent from private chats, group chats and threads; images / text files can be sent straight to the bot;
54
- - A streaming process card with a native collapsible panel for reasoning, tool calls and results; the final answer arrives separately, with interactive buttons for stop / plan gate / approval / questions;
54
+ - A streaming process card with a native collapsible panel for phase, elapsed time, and tool names/statuses; raw reasoning, tool payloads, and underlying errors stay out of the card. The final answer arrives separately, with interactive buttons for stop / plan gate / approval / questions. Failed card patches are retried finitely and degrade to a plain notice—the agent and final reply continue instead of taking down the bridge;
55
55
  - Automatic session archival and retention policies; per-session isolated git worktrees inside Git repositories, so multiple projects never interfere with each other.
56
56
 
57
57
  **Eleven exclusive capabilities**:
@@ -165,7 +165,7 @@ message mappings, plus only the unfinished card body needed to resume that card
165
165
 
166
166
  **`/newg <group name>`**: auto-creates a private group, invites the sender and replies with a group link — chatting in the new group starts a fresh scope/session while the current session is untouched. Requires the `im:chat` and `im:chat.members:write_only` scopes.
167
167
 
168
- Each scope (DM / group / topic) runs up to **2 tasks in parallel** by default (adjust with `DSH_LARK_SCOPE_CONCURRENCY` or `/concurrency`): successive messages become independent runs, each with its own dsh session and run id. `/status` lists runs for the current workspace; `/new` stops only that workspace, while `/stop` interrupts all runs in the scope.
168
+ Each scope (DM / group / topic) runs up to **2 tasks in parallel** by default (adjust with `DSH_LARK_SCOPE_CONCURRENCY` or `/concurrency`): successive messages become independent runs, each with its own dsh session and run id. SDK runtimes use `scope + workspace` as their cancellation domain, and concurrent sessions in one scope are split into separate runtimes, so a card stop affects only that run and `/stop` affects only the current scope—never another group. `/status` lists runs for the current workspace; `/new` stops only that workspace.
169
169
 
170
170
  **Session status card**: `/status` shows the workspace, effective model, session, explicit projection binding/cursor, active runs, version,
171
171
  context occupancy, cumulative input/output/cache tokens, and pending approvals/questions/plans. **Refresh**
@@ -237,11 +237,15 @@ reject `web`, because a shared Web agent broadcast stream cannot isolate session
237
237
 
238
238
  **Plan gate for substantial tasks**: SDK / ACP / Web agents use `lark_request_plan_approval` before file
239
239
  changes, scripts, or other substantial/high-risk actions. A runtime pre-execute policy denies writes, deletes,
240
- moves, command execution and `run_code` in that turn until a plan is approved. The bridge sends the complete Markdown plan as a normal
240
+ moves, non-read-only shell commands and `run_code` until a plan is approved. Each approval grants only the next
241
+ high-risk call; later unplanned calls require approval again. Single read-only inspections such as `date`, `pwd`,
242
+ `ls`, `find`, `rg`, and `git status/log/diff` run directly; shell chaining,
243
+ redirection, command substitution, and unknown executables remain behind the conservative plan gate. The bridge sends the complete Markdown plan as a normal
241
244
  message, then a card with **Approve and execute** / **Continue planning** plus optional feedback. The tool blocks
242
245
  and pauses the idle watchdog; approval resumes the original turn, while revision returns the feedback and requires
243
246
  another plan. There is no fixed ten-minute deadline: the gate follows the owning run's cancellation signal, and
244
- stopping it cancels and recalls only that session's pending card. The legacy headless adapter cannot use callback tools.
247
+ stopping it cancels and recalls only that session's pending card. Trusted deployments may set
248
+ `DSH_LARK_PLAN_GATE=off` to disable this separate gate (ordinary per-tool approval still applies). The legacy headless adapter cannot use callback tools.
245
249
 
246
250
  **Mid-task questions (question cards)**: when the agent needs a decision, confirmation, or missing information, it sends a **question card** via the `lark_ask_user` tool (single choice / multi choice / free text). Submit the form or reply directly to that card with any text—even when none of the listed choices fits. The replied card message id selects the exact pending question, the agent resumes automatically, and the run-timeout watchdog pauses while it waits. (The opposite direction of `/ask`, where you ask the agent.)
247
251
 
@@ -387,15 +391,15 @@ See [`docs/QUICK_START.md`](docs/QUICK_START.md) for installation details, state
387
391
 
388
392
  ## Compatibility
389
393
 
390
- - **DeepSeek Harness (`dsh`)**: verified against **dsh 0.1.0-rc.8** (last verified 2026-08-20: clean temporary install, SDK JSON-RPC / ACP initialize, tool/approval, live-session resume, and restart-collision probes), connected through the official `@deepseek-ai/dsh-sdk-client` / `@deepseek-ai/dsh-acp`; see [`docs/COMPATIBILITY.md`](docs/COMPATIBILITY.md) for exact pins and probing, [`docs/adapter-notes.md`](docs/adapter-notes.md) for adapter details, and [`docs/DSH_RC8_AUDIT.md`](docs/DSH_RC8_AUDIT.md) for rc.8 risks and verification boundaries.
394
+ - **DeepSeek Harness (`dsh`)**: verified against **dsh 0.1.0-rc.8** (last verified 2026-08-22: clean temporary install, SDK JSON-RPC / ACP initialize, tool/approval, live-session resume, and restart-collision probes), connected through the official `@deepseek-ai/dsh-sdk-client` / `@deepseek-ai/dsh-acp`; see [`docs/COMPATIBILITY.md`](docs/COMPATIBILITY.md) for exact pins and probing, [`docs/adapter-notes.md`](docs/adapter-notes.md) for adapter details, and [`docs/DSH_RC8_AUDIT.md`](docs/DSH_RC8_AUDIT.md) for rc.8 risks and verification boundaries.
391
395
  - **Runtime**: Node.js ≥ 22.19 (see `engines` in `package.json`).
392
396
  - **Platform**: Linux / macOS / Windows (Feishu outbound WebSocket long connection; no public server, domain or tunneling required).
393
397
  - The default adapter is the official **`@deepseek-ai/dsh-sdk-client`** (SDK JSON-RPC runtime with native continuation, streaming events, and the rc.8 approval answerer); `DSH_LARK_ADAPTER=acp` switches to the official **ACP server** with protocol-native approval; `headless` keeps the legacy subprocess fallback; `DSH_LARK_ADAPTER=web` drives the **local dsh web agent** (`session.prompt` + `/api/events.mux` — the web agent becomes the single writer, eliminating multi-writer session-log corruption at the root). On first start the bot creates the runtime profile at `~/.dsh/profiles/dsh-lark-sdk` (or `dsh-lark-acp`).
394
398
 
395
399
  ## Known limitations
396
400
 
397
- - ACP sessions are always fresh (an upstream limit); the SDK protocol has no mid-turn cancel, so `/stop` closes and recreates the runtime.
398
- - The engine runs in-process as a dsh plugin; agent execution uses the official dsh SDK runtime subprocess — a deliberate nested-runtime design for per-workspace runtime pools and parallel runs. The one process-level exception is the safety-net guardian installed by default — a minimal resident process independent of dsh / Cordis that only takes over the Feishu channel after dsh goes down and stays silent otherwise.
401
+ - ACP sessions are always fresh (an upstream limit); the SDK protocol has no mid-turn cancel, so stopping a run closes and recreates only that run's isolated runtime. Other scopes and concurrent runs keep their own runtimes. Native SDK continuation is used only while this bridge process still owns the same live runtime; after restart, stop, or model switch the bridge creates a fresh session and replays its transcript instead of handing rc.8 a stale ID that would trigger `id collision`.
402
+ - The engine runs in-process as a dsh plugin; agent execution uses the official dsh SDK runtime subprocess — a deliberate nested-runtime design for scope/workspace cancellation domains and parallel runs. The one process-level exception is the safety-net guardian installed by default — a minimal resident process independent of dsh / Cordis that only takes over the Feishu channel after dsh goes down and stays silent otherwise.
399
403
  - Feishu doc comments and rich-text replies are planned, not yet implemented.
400
404
  - pnpm ≥ 10 build policy is handled by `setup`; when installing manually and `ERR_PNPM_IGNORED_BUILDS` appears, add `allowBuilds: { protobufjs: true }` to the profile's `pnpm-workspace.yaml` and retry.
401
405
 
@@ -501,7 +505,9 @@ This tool runs **locally**; before installing, be aware that it accesses:
501
505
  - **Scope routing**: `scopes.json` stores the chat/thread and latest inbound message id; that id is used only as
502
506
  the reply anchor that places later agent question cards back in the original topic.
503
507
  - **Local callback**: `lark_notify`, `lark_send_file`, `lark_ask_user`, `lark_request_plan_approval`, and per-tool approval call the bridge over a
504
- random 127.0.0.1 port with a per-boot token (loopback only); plan text and its decision card are sent to the
508
+ random 127.0.0.1 port with a per-boot token (loopback only). Human-wait callbacks send response headers immediately
509
+ and JSON-whitespace heartbeats while pending, so Node's default five-minute HTTP idle boundary cannot cancel a
510
+ legitimate approval wait. Plan text and its decision card are sent to the
505
511
  current Feishu / Lark conversation. Approval reasons/arguments are visible to members of a shared group.
506
512
  - **Processes**: spawns local `dsh` runtime subprocesses (`dsh-sdk-jsonrpc-server` / `dsh-acp` profiles) to run agent tasks.
507
513
  - **dsh configuration**: `/model` `/providers` `/provider` `/key` read / write `~/.dsh/settings.yaml` and `~/.dsh/.credentials.yaml` using the official dsh storage protocol (admin-only writes; settings keep only `apiKeyEnv` references; credentials file mode 0600, directory 0700; literal keys never enter settings or chat history).
package/cordis.patch.yml CHANGED
@@ -28,7 +28,7 @@
28
28
  workspace: !!js process.env.DSH_LARK_WORKSPACE
29
29
  adapter: !!js process.env.DSH_LARK_ADAPTER
30
30
  model: !!js process.env.DSH_LARK_MODEL
31
- scopeConcurrency: !!js process.env.DSH_LARK_SCOPE_CONCURRENCY ? Number(process.env.DSH_LARK_SCOPE_CONCURRENCY) : undefined
31
+ scopeConcurrency: !!js 'process.env.DSH_LARK_SCOPE_CONCURRENCY ? Number(process.env.DSH_LARK_SCOPE_CONCURRENCY) : undefined'
32
32
  notificationDefault: !!js process.env.DSH_LARK_NOTIFICATION_DEFAULT
33
33
  disabled: !!js process.env.DSH_LARK_DISABLED === '1'
34
34
 
@@ -49,6 +49,7 @@
49
49
  config:
50
50
  endpoint: !!js process.env.DSH_LARK_PLAN_URL
51
51
  token: !!js process.env.DSH_LARK_NOTIFY_TOKEN
52
+ mode: !!js process.env.DSH_LARK_PLAN_GATE
52
53
 
53
54
  - id: lark-approval-answerer
54
55
  name: 'dsh-lark-bot/approval'
package/dist/approval.js CHANGED
@@ -1,7 +1,48 @@
1
1
  // src/notify/plan-tool.ts
2
+ var READ_ONLY_SHELL_TOOLS = /* @__PURE__ */ new Set(["bash", "shell"]);
3
+ var READ_ONLY_COMMANDS = /* @__PURE__ */ new Set([
4
+ "basename",
5
+ "cat",
6
+ "date",
7
+ "df",
8
+ "dirname",
9
+ "du",
10
+ "find",
11
+ "grep",
12
+ "head",
13
+ "id",
14
+ "jq",
15
+ "ls",
16
+ "pgrep",
17
+ "ps",
18
+ "pwd",
19
+ "readlink",
20
+ "realpath",
21
+ "rg",
22
+ "stat",
23
+ "tail",
24
+ "uname",
25
+ "wc",
26
+ "whoami"
27
+ ]);
28
+ var READ_ONLY_GIT_SUBCOMMANDS = /* @__PURE__ */ new Set([
29
+ "diff",
30
+ "ls-files",
31
+ "ls-tree",
32
+ "log",
33
+ "merge-base",
34
+ "rev-parse",
35
+ "show",
36
+ "status"
37
+ ]);
38
+ var SHELL_CONTROL_SYNTAX = /[\n\r;&|<>`]|\$\(|\$\{/u;
2
39
  function isHighRiskTool(ctx, execution) {
3
40
  if (execution.name === "lark_request_plan_approval") return false;
4
41
  if (execution.name === "run_code") return true;
42
+ const normalized = execution.name.toLowerCase().replaceAll("-", "_");
43
+ if (READ_ONLY_SHELL_TOOLS.has(normalized)) {
44
+ return !isSimpleReadOnlyShellCommand(execution.arguments);
45
+ }
5
46
  try {
6
47
  const view = ctx.tools.get?.(execution.name, execution.agent)?.presentCall?.(
7
48
  execution.arguments
@@ -11,9 +52,76 @@ function isHighRiskTool(ctx, execution) {
11
52
  if (kind && ["edit", "delete", "move", "execute"].includes(kind)) return true;
12
53
  } catch {
13
54
  }
14
- const normalized = execution.name.toLowerCase().replaceAll("-", "_");
15
55
  return /(^|_)(bash|shell|exec|execute|run|write|edit|patch|delete|remove|move|rename)(_|$)/u.test(normalized);
16
56
  }
57
+ function isSimpleReadOnlyShellCommand(rawArguments) {
58
+ const command = shellCommand(rawArguments)?.trim();
59
+ if (!command || SHELL_CONTROL_SYNTAX.test(command)) return false;
60
+ const words = command.split(/\s+/u);
61
+ const executablePath = words[0];
62
+ if (!executablePath || executablePath.includes("/") && !executablePath.startsWith("/bin/") && !executablePath.startsWith("/usr/bin/")) return false;
63
+ const executable = executablePath.split("/").at(-1);
64
+ if (!executable) return false;
65
+ if (executable === "git") return isReadOnlyGitCommand(words.slice(1));
66
+ if (!READ_ONLY_COMMANDS.has(executable)) return false;
67
+ if (executable === "date") {
68
+ return words.slice(1).every(
69
+ (word) => word === "-u" || word === "--utc" || word === "--universal" || word.startsWith("+")
70
+ );
71
+ }
72
+ if (executable === "rg") {
73
+ return !words.slice(1).some(
74
+ (word) => word === "--pre" || word.startsWith("--pre=") || word === "--hostname-bin" || word.startsWith("--hostname-bin=")
75
+ );
76
+ }
77
+ if (executable === "find") {
78
+ return !words.slice(1).some(
79
+ (word) => ["-delete", "-exec", "-execdir", "-ok", "-okdir", "-fprint", "-fprint0", "-fprintf"].includes(word)
80
+ );
81
+ }
82
+ if (executable === "tail") {
83
+ return !words.slice(1).some(
84
+ (word) => word === "-f" || word === "-F" || word === "--follow" || word.startsWith("--follow=")
85
+ );
86
+ }
87
+ return true;
88
+ }
89
+ function shellCommand(rawArguments) {
90
+ if (typeof rawArguments === "object" && rawArguments !== null && !Array.isArray(rawArguments)) {
91
+ const entries = Object.entries(rawArguments);
92
+ if (entries.length !== 1 || entries[0]?.[0] !== "command") return void 0;
93
+ const command = entries[0][1];
94
+ return typeof command === "string" ? command : void 0;
95
+ }
96
+ if (typeof rawArguments !== "string") return void 0;
97
+ try {
98
+ return shellCommand(JSON.parse(rawArguments));
99
+ } catch {
100
+ return rawArguments;
101
+ }
102
+ }
103
+ function isReadOnlyGitCommand(words) {
104
+ let index = 0;
105
+ while (words[index] === "-C") {
106
+ if (!words[index + 1]) return false;
107
+ index += 2;
108
+ }
109
+ const subcommand = words[index];
110
+ if (subcommand === "branch") {
111
+ const flags = words.slice(index + 1);
112
+ return flags.length === 0 || flags.every(
113
+ (word) => ["--show-current", "--list", "--all", "-a", "--remotes", "-r", "-v", "-vv"].includes(word)
114
+ );
115
+ }
116
+ if (subcommand === "remote") {
117
+ const args = words.slice(index + 1);
118
+ return args.length === 0 || args.every((word) => word === "-v" || word === "--verbose") || args[0] === "get-url";
119
+ }
120
+ if (!subcommand || !READ_ONLY_GIT_SUBCOMMANDS.has(subcommand)) return false;
121
+ return !words.slice(index + 1).some(
122
+ (word) => word === "-o" || word === "--output" || word.startsWith("--output=") || word === "--ext-diff" || word === "--textconv"
123
+ );
124
+ }
17
125
 
18
126
  // src/notify/approval-answerer.ts
19
127
  var name = "lark-approval-answerer";
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/notify/plan-tool.ts","../src/notify/approval-answerer.ts"],"sourcesContent":["import type { Context } from '@deepseek-ai/cordis';\nimport {\n objectArgs,\n requiredString,\n type RawToolExecution,\n type ToolPluginContext,\n} from './raw-tool.js';\n\nexport const name = 'lark-plan-approval';\nexport const inject = ['tools'];\n\nexport interface Config {\n endpoint?: string;\n token?: string;\n}\n\nexport interface PlanPolicyExecution {\n name: string;\n arguments: unknown;\n agent?: object;\n}\n\ntype PlanPolicyContext = ToolPluginContext & {\n on(\n event: 'agent/pre-step',\n listener: (\n payload: { agent: object; turn: number },\n next: () => Promise<unknown>,\n ) => Promise<unknown>,\n ): unknown;\n on(\n event: 'tools/pre-execute',\n listener: (\n execution: PlanPolicyExecution,\n next: () => Promise<unknown>,\n ) => Promise<unknown>,\n ): unknown;\n};\n\n/** Human plan gate for substantial or high-risk repository actions. */\nexport function apply(ctx: Context, config: Config = {}) {\n const policyCtx = ctx as PlanPolicyContext;\n const currentTurns = new WeakMap<object, number>();\n const approvedTurns = new WeakMap<object, number>();\n\n policyCtx.on('agent/pre-step', async (payload, next) => {\n currentTurns.set(payload.agent, payload.turn);\n return next();\n });\n policyCtx.on('tools/pre-execute', async (execution, next) => {\n if (!isHighRiskTool(policyCtx, execution)) return next();\n const agent = execution.agent;\n const turn = agent ? currentTurns.get(agent) : undefined;\n if (agent && turn !== undefined && approvedTurns.get(agent) === turn) return next();\n return {\n kind: 'deny',\n reason:\n 'This action is blocked until the current turn calls lark_request_plan_approval and the user approves the plan.',\n };\n });\n\n policyCtx.tools.register({\n name: 'lark_request_plan_approval',\n description:\n 'Before modifying files, running scripts, or taking another substantial/high-risk action, draft a complete plan and call this tool before execution. It sends the full plan as a normal Feishu/Lark message, then waits for Approve or Continue planning plus optional feedback. Do not execute until approved. If revision is requested, revise the plan and call this tool again.',\n parameters: {\n type: 'object',\n additionalProperties: false,\n required: ['plan'],\n properties: {\n plan: { type: 'string', minLength: 1, description: 'Complete readable plan in Markdown.' },\n },\n },\n output: {\n schema: {\n type: 'object',\n additionalProperties: false,\n required: ['resolved'],\n properties: {\n resolved: { type: 'boolean' },\n decision: { type: 'string', enum: ['approved', 'revise'] },\n feedback: { type: 'string' },\n error: { type: 'string' },\n },\n },\n render: (_args, rawValue) => {\n const value = rawValue as {\n resolved: boolean;\n decision?: string;\n feedback?: string;\n error?: string;\n };\n return [{\n type: 'text',\n text: value.resolved\n ? `Plan decision: ${value.decision}${value.feedback ? `; feedback: ${value.feedback}` : ''}`\n : `Plan approval failed: ${value.error ?? 'cancelled'}`,\n }];\n },\n },\n async execute(rawArgs, exec: RawToolExecution | undefined) {\n const args = objectArgs(rawArgs, 'lark_request_plan_approval');\n const plan = requiredString(args, 'plan', 'lark_request_plan_approval');\n const endpoint = config.endpoint ?? process.env.DSH_LARK_PLAN_URL;\n const token = config.token ?? process.env.DSH_LARK_NOTIFY_TOKEN;\n if (!endpoint || !token) {\n throw new Error('lark_request_plan_approval is not configured (endpoint/token missing)');\n }\n const sessionId = exec?.agent?.session === undefined\n ? undefined\n : String(exec.agent.session.id);\n if (!sessionId) throw new Error('lark_request_plan_approval needs an active session');\n const response = await fetch(endpoint, {\n method: 'POST',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify({ token, sessionId, plan }),\n ...(exec?.signal === undefined ? {} : { signal: exec.signal }),\n });\n const body = await response.json() as {\n ok?: boolean;\n decision?: 'approved' | 'revise';\n feedback?: string;\n error?: string;\n };\n if (!response.ok || body.ok !== true || !body.decision) {\n return { resolved: false, ...(body.error ? { error: body.error } : {}) };\n }\n if (body.decision === 'approved' && exec?.agent) {\n const turn = currentTurns.get(exec.agent);\n if (turn !== undefined) approvedTurns.set(exec.agent, turn);\n }\n return {\n resolved: true,\n decision: body.decision,\n ...(body.feedback ? { feedback: body.feedback } : {}),\n };\n },\n });\n}\n\nexport function isHighRiskTool(ctx: ToolPluginContext, execution: PlanPolicyExecution): boolean {\n if (execution.name === 'lark_request_plan_approval') return false;\n if (execution.name === 'run_code') return true;\n try {\n const view = ctx.tools.get?.(execution.name, execution.agent)?.presentCall?.(\n execution.arguments,\n ) as { card?: string; kind?: string } | undefined;\n if (view?.card === 'terminal' || view?.card === 'diff') return true;\n const kind = view?.kind;\n if (kind && ['edit', 'delete', 'move', 'execute'].includes(kind)) return true;\n } catch {\n // Fall through to the conservative name classifier.\n }\n const normalized = execution.name.toLowerCase().replaceAll('-', '_');\n return /(^|_)(bash|shell|exec|execute|run|write|edit|patch|delete|remove|move|rename)(_|$)/u\n .test(normalized);\n}\n","import type { Context } from '@deepseek-ai/cordis';\nimport { isHighRiskTool, type PlanPolicyExecution } from './plan-tool.js';\nimport type { ToolPluginContext } from './raw-tool.js';\n\nexport const name = 'lark-approval-answerer';\nexport const inject = ['approval', 'tools'];\n\nexport interface Config {\n endpoint?: string;\n token?: string;\n}\n\ntype Outcome = 'allowed-once' | 'rejected' | 'cancelled' | 'unavailable';\n\ninterface RawApprovalRequest {\n agent?: { session?: { id?: unknown } };\n toolName?: unknown;\n callId?: unknown;\n reason?: unknown;\n toolInput?: unknown;\n signal?: AbortSignal;\n}\n\ntype ApprovalContext = Context & {\n on(\n event: 'approval/request',\n listener: (request: RawApprovalRequest, next: () => Promise<Outcome>) => Promise<Outcome>,\n ): unknown;\n on(\n event: 'tools/pre-execute',\n listener: (\n execution: PlanPolicyExecution,\n next: () => Promise<unknown>,\n ) => Promise<unknown>,\n ): unknown;\n};\n\n/** Terminal rc.8 approval answerer for the nested SDK and host runtimes. */\nexport function apply(ctx: Context, config: Config = {}): void {\n const approvalCtx = ctx as ApprovalContext & ToolPluginContext;\n const inFlightGrant = new WeakMap<object, string>();\n\n approvalCtx.on('approval/request', async (request, next) => {\n if (request.agent && inFlightGrant.get(request.agent) === request.toolName) {\n inFlightGrant.delete(request.agent);\n return 'allowed-once';\n }\n return requestBridgeApproval(config, request, next);\n });\n\n approvalCtx.on('tools/pre-execute', async (execution, next) => {\n if (!isHighRiskTool(approvalCtx, execution)) return next();\n const request: RawApprovalRequest = {\n ...(execution.agent === undefined\n ? {}\n : { agent: execution.agent as NonNullable<RawApprovalRequest['agent']> }),\n toolName: execution.name,\n reason: approvalReason(execution),\n toolInput: execution.arguments,\n };\n const outcome = await requestBridgeApproval(config, request, async () => 'unavailable');\n if (outcome !== 'allowed-once') {\n return {\n kind: 'deny',\n reason: outcome === 'rejected'\n ? 'The user rejected this one-shot tool execution. Continue with a safer alternative.'\n : 'This tool execution was not approved and remains blocked.',\n };\n }\n if (execution.agent) inFlightGrant.set(execution.agent, execution.name);\n try {\n return await next();\n } finally {\n if (execution.agent) inFlightGrant.delete(execution.agent);\n }\n });\n}\n\nasync function requestBridgeApproval(\n config: Config,\n request: RawApprovalRequest,\n next: () => Promise<Outcome>,\n): Promise<Outcome> {\n const endpoint = config.endpoint ?? process.env.DSH_LARK_APPROVAL_URL;\n const token = config.token ?? process.env.DSH_LARK_NOTIFY_TOKEN;\n if (!endpoint || !token) return next();\n const sessionId = request.agent?.session?.id;\n if (sessionId === undefined || typeof request.toolName !== 'string') return 'unavailable';\n try {\n const response = await fetch(endpoint, {\n method: 'POST',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify({\n token,\n sessionId: String(sessionId),\n toolName: request.toolName,\n ...(request.callId === undefined ? {} : { callId: String(request.callId) }),\n ...(typeof request.reason === 'string' ? { reason: request.reason } : {}),\n ...(request.toolInput === undefined ? {} : { toolInput: request.toolInput }),\n }),\n ...(request.signal === undefined ? {} : { signal: request.signal }),\n });\n const body = await response.json() as { ok?: boolean; outcome?: unknown };\n return response.ok && body.ok === true && isOutcome(body.outcome)\n ? body.outcome\n : 'unavailable';\n } catch {\n return request.signal?.aborted ? 'cancelled' : 'unavailable';\n }\n}\n\nfunction approvalReason(execution: PlanPolicyExecution): string {\n if (\n typeof execution.arguments === 'object' && execution.arguments !== null &&\n 'description' in execution.arguments &&\n typeof (execution.arguments as { description?: unknown }).description === 'string'\n ) {\n return (execution.arguments as { description: string }).description;\n }\n return `Execute high-risk tool ${execution.name}`;\n}\n\nfunction isOutcome(value: unknown): value is Outcome {\n return value === 'allowed-once' || value === 'rejected' ||\n value === 'cancelled' || value === 'unavailable';\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../src/notify/plan-tool.ts","../src/notify/approval-answerer.ts"],"sourcesContent":["import type { Context } from '@deepseek-ai/cordis';\nimport {\n objectArgs,\n requiredString,\n type RawToolExecution,\n type ToolPluginContext,\n} from './raw-tool.js';\n\nexport const name = 'lark-plan-approval';\nexport const inject = ['tools'];\n\nexport interface Config {\n endpoint?: string;\n token?: string;\n mode?: 'strict' | 'off';\n}\n\nexport interface PlanPolicyExecution {\n name: string;\n arguments: unknown;\n agent?: object;\n}\n\nconst READ_ONLY_SHELL_TOOLS = new Set(['bash', 'shell']);\nconst READ_ONLY_COMMANDS = new Set([\n 'basename',\n 'cat',\n 'date',\n 'df',\n 'dirname',\n 'du',\n 'find',\n 'grep',\n 'head',\n 'id',\n 'jq',\n 'ls',\n 'pgrep',\n 'ps',\n 'pwd',\n 'readlink',\n 'realpath',\n 'rg',\n 'stat',\n 'tail',\n 'uname',\n 'wc',\n 'whoami',\n]);\nconst READ_ONLY_GIT_SUBCOMMANDS = new Set([\n 'diff',\n 'ls-files',\n 'ls-tree',\n 'log',\n 'merge-base',\n 'rev-parse',\n 'show',\n 'status',\n]);\nconst SHELL_CONTROL_SYNTAX = /[\\n\\r;&|<>`]|\\$\\(|\\$\\{/u;\n\ntype PlanPolicyContext = ToolPluginContext & {\n on(\n event: 'agent/pre-step',\n listener: (\n payload: { agent: object; turn: number },\n next: () => Promise<unknown>,\n ) => Promise<unknown>,\n ): unknown;\n on(\n event: 'tools/pre-execute',\n listener: (\n execution: PlanPolicyExecution,\n next: () => Promise<unknown>,\n ) => Promise<unknown>,\n ): unknown;\n};\n\n/** Human plan gate for substantial or high-risk repository actions. */\nexport function apply(ctx: Context, config: Config = {}) {\n const policyCtx = ctx as PlanPolicyContext;\n const currentTurns = new WeakMap<object, number>();\n const approvedCalls = new WeakMap<object, number>();\n const gateDisabled = (config.mode ?? process.env.DSH_LARK_PLAN_GATE) === 'off';\n\n policyCtx.on('agent/pre-step', async (payload, next) => {\n currentTurns.set(payload.agent, payload.turn);\n return next();\n });\n policyCtx.on('tools/pre-execute', async (execution, next) => {\n if (gateDisabled) return next();\n if (!isHighRiskTool(policyCtx, execution)) return next();\n const agent = execution.agent;\n const turn = agent ? currentTurns.get(agent) : undefined;\n if (agent && turn !== undefined && approvedCalls.get(agent) === turn) {\n approvedCalls.delete(agent);\n return next();\n }\n return {\n kind: 'deny',\n reason:\n 'This action is blocked until the current turn calls lark_request_plan_approval and the user approves the plan.',\n };\n });\n\n policyCtx.tools.register({\n name: 'lark_request_plan_approval',\n description:\n 'Before modifying files, running scripts, or taking another substantial/high-risk action, draft a complete plan and call this tool before execution. It sends the full plan as a normal Feishu/Lark message, then waits for Approve or Continue planning plus optional feedback. Do not execute until approved. If revision is requested, revise the plan and call this tool again.',\n parameters: {\n type: 'object',\n additionalProperties: false,\n required: ['plan'],\n properties: {\n plan: { type: 'string', minLength: 1, description: 'Complete readable plan in Markdown.' },\n },\n },\n output: {\n schema: {\n type: 'object',\n additionalProperties: false,\n required: ['resolved'],\n properties: {\n resolved: { type: 'boolean' },\n decision: { type: 'string', enum: ['approved', 'revise'] },\n feedback: { type: 'string' },\n error: { type: 'string' },\n },\n },\n render: (_args, rawValue) => {\n const value = rawValue as {\n resolved: boolean;\n decision?: string;\n feedback?: string;\n error?: string;\n };\n return [{\n type: 'text',\n text: value.resolved\n ? `Plan decision: ${value.decision}${value.feedback ? `; feedback: ${value.feedback}` : ''}`\n : `Plan approval failed: ${value.error ?? 'cancelled'}`,\n }];\n },\n },\n async execute(rawArgs, exec: RawToolExecution | undefined) {\n const args = objectArgs(rawArgs, 'lark_request_plan_approval');\n const plan = requiredString(args, 'plan', 'lark_request_plan_approval');\n const endpoint = config.endpoint ?? process.env.DSH_LARK_PLAN_URL;\n const token = config.token ?? process.env.DSH_LARK_NOTIFY_TOKEN;\n if (!endpoint || !token) {\n throw new Error('lark_request_plan_approval is not configured (endpoint/token missing)');\n }\n const sessionId = exec?.agent?.session === undefined\n ? undefined\n : String(exec.agent.session.id);\n if (!sessionId) throw new Error('lark_request_plan_approval needs an active session');\n let response: Response;\n try {\n response = await fetch(endpoint, {\n method: 'POST',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify({ token, sessionId, plan }),\n ...(exec?.signal === undefined ? {} : { signal: exec.signal }),\n });\n } catch (error) {\n if (exec?.signal?.aborted) return { resolved: false, error: 'cancelled' };\n throw error;\n }\n const body = await response.json() as {\n ok?: boolean;\n decision?: 'approved' | 'revise';\n feedback?: string;\n error?: string;\n };\n if (!response.ok || body.ok !== true || !body.decision) {\n if (body.error?.toLowerCase().includes('cancel')) {\n return { resolved: false, error: body.error };\n }\n throw new Error(body.error ?? `plan approval delivery failed (${response.status})`);\n }\n if (body.decision === 'approved' && exec?.agent) {\n const turn = currentTurns.get(exec.agent);\n if (turn !== undefined) approvedCalls.set(exec.agent, turn);\n }\n return {\n resolved: true,\n decision: body.decision,\n ...(body.feedback ? { feedback: body.feedback } : {}),\n };\n },\n });\n}\n\nexport function isHighRiskTool(ctx: ToolPluginContext, execution: PlanPolicyExecution): boolean {\n if (execution.name === 'lark_request_plan_approval') return false;\n if (execution.name === 'run_code') return true;\n const normalized = execution.name.toLowerCase().replaceAll('-', '_');\n if (READ_ONLY_SHELL_TOOLS.has(normalized)) {\n return !isSimpleReadOnlyShellCommand(execution.arguments);\n }\n try {\n const view = ctx.tools.get?.(execution.name, execution.agent)?.presentCall?.(\n execution.arguments,\n ) as { card?: string; kind?: string } | undefined;\n if (view?.card === 'terminal' || view?.card === 'diff') return true;\n const kind = view?.kind;\n if (kind && ['edit', 'delete', 'move', 'execute'].includes(kind)) return true;\n } catch {\n // Fall through to the conservative name classifier.\n }\n return /(^|_)(bash|shell|exec|execute|run|write|edit|patch|delete|remove|move|rename)(_|$)/u\n .test(normalized);\n}\n\n/**\n * Read-only shell calls bypass both the plan gate and one-shot approval.\n * Keep this deliberately narrow: one command only, no shell composition, and\n * an allowlisted executable/subcommand. Unknown syntax remains high risk.\n */\nfunction isSimpleReadOnlyShellCommand(rawArguments: unknown): boolean {\n const command = shellCommand(rawArguments)?.trim();\n if (!command || SHELL_CONTROL_SYNTAX.test(command)) return false;\n const words = command.split(/\\s+/u);\n const executablePath = words[0];\n if (\n !executablePath ||\n (executablePath.includes('/') &&\n !executablePath.startsWith('/bin/') &&\n !executablePath.startsWith('/usr/bin/'))\n ) return false;\n const executable = executablePath.split('/').at(-1);\n if (!executable) return false;\n if (executable === 'git') return isReadOnlyGitCommand(words.slice(1));\n if (!READ_ONLY_COMMANDS.has(executable)) return false;\n if (executable === 'date') {\n return words.slice(1).every((word) =>\n word === '-u' || word === '--utc' || word === '--universal' || word.startsWith('+')\n );\n }\n if (executable === 'rg') {\n return !words.slice(1).some((word) =>\n word === '--pre' ||\n word.startsWith('--pre=') ||\n word === '--hostname-bin' ||\n word.startsWith('--hostname-bin=')\n );\n }\n if (executable === 'find') {\n return !words.slice(1).some((word) =>\n ['-delete', '-exec', '-execdir', '-ok', '-okdir', '-fprint', '-fprint0', '-fprintf'].includes(word)\n );\n }\n if (executable === 'tail') {\n return !words.slice(1).some((word) =>\n word === '-f' || word === '-F' || word === '--follow' || word.startsWith('--follow=')\n );\n }\n return true;\n}\n\nfunction shellCommand(rawArguments: unknown): string | undefined {\n if (typeof rawArguments === 'object' && rawArguments !== null && !Array.isArray(rawArguments)) {\n const entries = Object.entries(rawArguments);\n if (entries.length !== 1 || entries[0]?.[0] !== 'command') return undefined;\n const command = entries[0][1];\n return typeof command === 'string' ? command : undefined;\n }\n if (typeof rawArguments !== 'string') return undefined;\n try {\n return shellCommand(JSON.parse(rawArguments));\n } catch {\n return rawArguments;\n }\n}\n\nfunction isReadOnlyGitCommand(words: readonly string[]): boolean {\n let index = 0;\n while (words[index] === '-C') {\n if (!words[index + 1]) return false;\n index += 2;\n }\n const subcommand = words[index];\n if (subcommand === 'branch') {\n const flags = words.slice(index + 1);\n return flags.length === 0 || flags.every((word) =>\n ['--show-current', '--list', '--all', '-a', '--remotes', '-r', '-v', '-vv'].includes(word)\n );\n }\n if (subcommand === 'remote') {\n const args = words.slice(index + 1);\n return args.length === 0 ||\n args.every((word) => word === '-v' || word === '--verbose') ||\n args[0] === 'get-url';\n }\n if (!subcommand || !READ_ONLY_GIT_SUBCOMMANDS.has(subcommand)) return false;\n return !words.slice(index + 1).some((word) =>\n word === '-o' ||\n word === '--output' ||\n word.startsWith('--output=') ||\n word === '--ext-diff' ||\n word === '--textconv'\n );\n}\n","import type { Context } from '@deepseek-ai/cordis';\nimport { isHighRiskTool, type PlanPolicyExecution } from './plan-tool.js';\nimport type { ToolPluginContext } from './raw-tool.js';\n\nexport const name = 'lark-approval-answerer';\nexport const inject = ['approval', 'tools'];\n\nexport interface Config {\n endpoint?: string;\n token?: string;\n}\n\ntype Outcome = 'allowed-once' | 'rejected' | 'cancelled' | 'unavailable';\n\ninterface RawApprovalRequest {\n agent?: { session?: { id?: unknown } };\n toolName?: unknown;\n callId?: unknown;\n reason?: unknown;\n toolInput?: unknown;\n signal?: AbortSignal;\n}\n\ntype ApprovalContext = Context & {\n on(\n event: 'approval/request',\n listener: (request: RawApprovalRequest, next: () => Promise<Outcome>) => Promise<Outcome>,\n ): unknown;\n on(\n event: 'tools/pre-execute',\n listener: (\n execution: PlanPolicyExecution,\n next: () => Promise<unknown>,\n ) => Promise<unknown>,\n ): unknown;\n};\n\n/** Terminal rc.8 approval answerer for the nested SDK and host runtimes. */\nexport function apply(ctx: Context, config: Config = {}): void {\n const approvalCtx = ctx as ApprovalContext & ToolPluginContext;\n const inFlightGrant = new WeakMap<object, string>();\n\n approvalCtx.on('approval/request', async (request, next) => {\n if (request.agent && inFlightGrant.get(request.agent) === request.toolName) {\n inFlightGrant.delete(request.agent);\n return 'allowed-once';\n }\n return requestBridgeApproval(config, request, next);\n });\n\n approvalCtx.on('tools/pre-execute', async (execution, next) => {\n if (!isHighRiskTool(approvalCtx, execution)) return next();\n const request: RawApprovalRequest = {\n ...(execution.agent === undefined\n ? {}\n : { agent: execution.agent as NonNullable<RawApprovalRequest['agent']> }),\n toolName: execution.name,\n reason: approvalReason(execution),\n toolInput: execution.arguments,\n };\n const outcome = await requestBridgeApproval(config, request, async () => 'unavailable');\n if (outcome !== 'allowed-once') {\n return {\n kind: 'deny',\n reason: outcome === 'rejected'\n ? 'The user rejected this one-shot tool execution. Continue with a safer alternative.'\n : 'This tool execution was not approved and remains blocked.',\n };\n }\n if (execution.agent) inFlightGrant.set(execution.agent, execution.name);\n try {\n return await next();\n } finally {\n if (execution.agent) inFlightGrant.delete(execution.agent);\n }\n });\n}\n\nasync function requestBridgeApproval(\n config: Config,\n request: RawApprovalRequest,\n next: () => Promise<Outcome>,\n): Promise<Outcome> {\n const endpoint = config.endpoint ?? process.env.DSH_LARK_APPROVAL_URL;\n const token = config.token ?? process.env.DSH_LARK_NOTIFY_TOKEN;\n if (!endpoint || !token) return next();\n const sessionId = request.agent?.session?.id;\n if (sessionId === undefined || typeof request.toolName !== 'string') return 'unavailable';\n try {\n const response = await fetch(endpoint, {\n method: 'POST',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify({\n token,\n sessionId: String(sessionId),\n toolName: request.toolName,\n ...(request.callId === undefined ? {} : { callId: String(request.callId) }),\n ...(typeof request.reason === 'string' ? { reason: request.reason } : {}),\n ...(request.toolInput === undefined ? {} : { toolInput: request.toolInput }),\n }),\n ...(request.signal === undefined ? {} : { signal: request.signal }),\n });\n const body = await response.json() as { ok?: boolean; outcome?: unknown };\n return response.ok && body.ok === true && isOutcome(body.outcome)\n ? body.outcome\n : 'unavailable';\n } catch {\n return request.signal?.aborted ? 'cancelled' : 'unavailable';\n }\n}\n\nfunction approvalReason(execution: PlanPolicyExecution): string {\n if (\n typeof execution.arguments === 'object' && execution.arguments !== null &&\n 'description' in execution.arguments &&\n typeof (execution.arguments as { description?: unknown }).description === 'string'\n ) {\n return (execution.arguments as { description: string }).description;\n }\n return `Execute high-risk tool ${execution.name}`;\n}\n\nfunction isOutcome(value: unknown): value is Outcome {\n return value === 'allowed-once' || value === 'rejected' ||\n value === 'cancelled' || value === 'unavailable';\n}\n"],"mappings":"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","names":[]}