Merge origin/master: web permission sandbox, default pi-ai providers
This commit is contained in:
@@ -2,10 +2,11 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Behavioral guard plugins that watch the agent loop for unproductive patterns and nudge the model back on course. A single **product** package — there is no interface/implementation seam here, because a guard is a self-contained consumer of existing core seams (`tools/post-execute`, `agent/prompt-submit`, `agent/status`), not a swappable capability.
|
||||
Behavioral guard plugins that watch the agent loop and correct it — some by nudging the model back on course, some by refusing an operation outright. All are **product** packages: there is no interface/implementation seam here, because a guard is a self-contained consumer of existing core seams (`tools/pre-execute`, `tools/post-execute`, `agent/prompt-submit`, `agent/status`), not a swappable capability.
|
||||
|
||||
| Package | Role | ctx key |
|
||||
|---|---|---|
|
||||
| `repeat-tool-guard/` | Advisory reminders when an agent loops on identical tool calls | (listens on `ctx.tools`' waterfalls) |
|
||||
| `source-guard/` | Denies file edits inside a dsh staging worktree until the required skill is loaded | (listens on `ctx.tools`' waterfalls) |
|
||||
|
||||
Reminders travel as `additionalContexts` on the `tools/post-execute` decision; the agent loop appends them as logged plugin-sourced `user/message` events after the step's tool results (see [the tools package](../core/tools)), so everything a guard says to the model is reconstructable from the session log.
|
||||
An advisory guard's reminders travel as `additionalContexts` on the `tools/post-execute` decision; the agent loop appends them as logged plugin-sourced `user/message` events after the step's tool results (see [the tools package](../core/tools)), so everything such a guard says to the model is reconstructable from the session log. An enforcing guard instead decides on `tools/pre-execute`, where a `deny` becomes the call's error result and the operation never dispatches.
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/guard/source-guard/README.md
|
||||
README.md: f083ef0af53c4d4f7c4a0875837ac3a3c851c54c
|
||||
README.zh.md: 7c9efa7b8d3aec1a95b62416ef624a887fd48aa0
|
||||
@@ -0,0 +1,88 @@
|
||||
# @deepseek-ai/dsh-source-guard
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
An enforcement gate, not a model-facing tool: it never appears in the tool list and adds exactly one behavior — it denies a `write` or `edit` whose target sits inside the dsh checkout the running harness was launched from, on that checkout's own branch, until the calling session's durable log shows a successful load of the `dsh-customize` skill. That skill requires personal changes to be implemented in a task worktree and integrated under the staging lock; this plugin turns its central rule ("do not edit the personal staging checkout directly") from prompt guidance into a boundary the model cannot cross by forgetting.
|
||||
|
||||
## Config
|
||||
|
||||
```yaml
|
||||
- id: source-guard
|
||||
name: '@deepseek-ai/dsh-source-guard'
|
||||
config:
|
||||
requiredSkill: dsh-customize # default; the skill whose load lifts the denial
|
||||
tools: [write, edit] # default; the gated tool names
|
||||
protectedCheckout: /path/to/checkout # defaults to this module's own location
|
||||
```
|
||||
|
||||
Every field fails loud at plugin load: an empty `tools` list, a blank `requiredSkill`, or a relative `protectedCheckout` throws, never a silent fall-back.
|
||||
|
||||
`protectedCheckout` names a path inside the checkout to guard, and its worktree supplies BOTH protected identities: the repository and the exact branch. Its default is this module's own file, which resolves the checkout the running harness was launched from — the live deployment, whatever its branch is named. Nothing about the branch is configured or pattern-matched, so a maintainer whose staging branch follows no naming convention is protected identically. A harness running from an installed copy resolves a different repository, or none, and therefore guards nothing; the rule is meaningless outside a source checkout.
|
||||
|
||||
The shipped TUI composition (`examples/tui-agent/cordis.yml`) loads this plugin with defaults. It is inert for anyone whose workspace is not the launcher's own checkout, so an ordinary project sees no change.
|
||||
|
||||
## Which paths are protected
|
||||
|
||||
Protection is decided by git identity read from files — `.git`, its `gitdir:` pointer, and `HEAD` — never by path prefix and never by running `git`. Prefix matching would be wrong here: the task worktrees the skill prescribes live *inside* the staging tree, at `<staging>/.worktrees/...`, and are exactly where edits belong.
|
||||
|
||||
Resolution walks OUTWARD from the target and stops at the first enclosing worktree, so it reports the INNERMOST one. Denial needs that worktree to match the launcher's on BOTH identities: the same shared git directory and the same branch. A task worktree nested under the protected tree answers with its own task branch and passes; the launcher's own tree answers with the launcher's branch and is denied. Repository identity is compared on symlink-resolved paths, so two routes to one repository — a session cwd under `/var/...` and a configured path under `/private/var/...` on macOS — match rather than falling open.
|
||||
|
||||
Requiring the exact branch, not a name pattern, keeps the gate on the live deployment only. A stale sibling checkout left by an earlier install shares the repository but runs no launcher, so the workflow rule does not apply to it and it stays editable.
|
||||
|
||||
A `gitdir:` pointer may be absolute (what `git worktree add` writes) or relative, which git resolves against the worktree directory holding it; both resolve here. A relative `file_path` resolves against the calling session's workspace, exactly as the filesystem tools resolve it, so it is not an unguarded route to a protected file.
|
||||
|
||||
The gate is deliberately narrow:
|
||||
|
||||
- **`read` is never gated.** Inspecting the staging checkout violates nothing, so only mutating tools are candidates.
|
||||
- **`bash` is not gated.** Reliably classifying mutating shell commands is out of scope, so a determined model can still change staging through a shell.
|
||||
- **Calls without an agent are allowed.** A direct `ctx.tools.execute()` caller has no session to replay and no model to correct.
|
||||
- **Unresolvable git state fails OPEN.** A path outside any worktree, a detached HEAD on either side, a different repository or branch, a malformed `.git` pointer, or unreadable metadata all leave the call to the rest of the chain. A gate that blocked every write whenever git identity was unavailable would cause more harm than the violation it prevents.
|
||||
- **An unresolvable target is not judged.** An empty `file_path`, a non-string one, or a relative one in a session that names no workspace leaves the call to the tool's own validation.
|
||||
|
||||
Worktree identity is cached per target directory for the plugin's lifetime, so repeated writes in one directory read git metadata once; a mid-session branch switch is therefore not observed.
|
||||
|
||||
## How the denial lifts
|
||||
|
||||
Satisfaction is replayed from the session's durable log: a `tool/call` naming the `skill` tool whose arguments parse to `{name: <requiredSkill>}`, paired by call id with a non-error `tool/result`. Because the log is the only state, satisfaction survives a session resume — a resumed session that already loaded the skill is not asked again. A failed load, a differently-named skill, and malformed argument JSON all leave the denial in place.
|
||||
|
||||
Satisfaction is per session, so a subagent with its own session must load the skill itself.
|
||||
|
||||
## Enforcement point
|
||||
|
||||
The gate is a `tools/pre-execute` listener returning `{kind: 'deny', reason}`, so the call never dispatches and the file is never touched. It delegates via `next()` in every non-violating case. Denial — not an advisory reminder — is the point: an advisory nudge leaves the violation committed, and `ask` degrades to denial in a composition without approval support.
|
||||
|
||||
## Testing
|
||||
|
||||
Unit suites drive a real agent loop against a mock adapter over real git-metadata fixtures — a staging worktree, a task worktree nested inside it, a plain clone, a foreign repository on a staging-named branch, a detached HEAD, absolute and relative `gitdir:` pointers, a symlinked route to the same repository, and unreadable metadata — to per-file 100%. The assembled-run evidence is the Loader-composition smoke (`tests/loader-composition.e2e.ts`): it boots a real headless app over `examples/headless-agent/tests/fixtures/guard/source-guard/cordis.yml`, seeds a staging worktree in a temporary cwd, and asserts the tool result is an error carrying the exact denial while the targeted file keeps its original bytes.
|
||||
|
||||
## Model Experience
|
||||
|
||||
### Denied filesystem call
|
||||
|
||||
#### What the model sees
|
||||
|
||||
A gated call into a protected worktree without the required skill loaded returns an error result carrying exactly the text below. No prompt section, tool schema, or successful-call text is added, and an allowed call is indistinguishable from one made without this plugin.
|
||||
|
||||
##### Denial result
|
||||
|
||||
```markdown
|
||||
Error: Editing "<path>" directly is not allowed: it is inside the dsh checkout this session is running from, on branch <branch>. Load the <requiredSkill> skill first and follow it — implement in a task worktree, then integrate under the staging lock.
|
||||
```
|
||||
|
||||
#### Token effect
|
||||
|
||||
Zero tokens while no denial occurs. A denial adds its small retained error result and avoids the success payload the call would have produced.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **`bash` is ungated** — the guard is a boundary for the filesystem tools only; a shell command can still mutate a protected worktree.
|
||||
- **Worktree identity is cached per directory for the plugin's lifetime** — switching a protected worktree's branch mid-session does not change decisions until the next load, on either the target or the launcher side.
|
||||
- **Only the launcher's own checkout is protected** — a stale sibling checkout of the same repository stays editable, deliberately; run `dsh` from it to protect it.
|
||||
- **Disarmed outside a source checkout** — a harness running from an installed copy protects nothing unless `protectedCheckout` names a real checkout explicitly.
|
||||
- **Satisfaction is per session** — a subagent's session must load the skill itself; a parent's load does not carry over.
|
||||
- **Fail-open on unresolvable git state** — a broken or unreadable `.git` means no protection, chosen deliberately over blocking every edit.
|
||||
- **One skill lifts the whole gate for the session** — loading it does not verify the workflow was actually followed, only that the instructions were read.
|
||||
@@ -0,0 +1,88 @@
|
||||
# @deepseek-ai/dsh-source-guard
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
这是一道强制执行门禁,而非面向模型的工具:它不会出现在工具列表中,只增加一种行为。若 `write` 或 `edit` 的目标位于运行中 harness 启动来源的 dsh 检出目录内,并处于该检出目录自身的分支上,它会拒绝调用,直到调用方会话的持久日志表明已成功加载 `dsh-customize` skill(技能)。该 skill 要求在任务 worktree 中实现个人变更,并在 staging 锁保护下完成集成;本插件把其核心规则(「不要直接编辑个人 staging 检出目录」)从提示词指导变成一道模型无法因遗忘而越过的边界。
|
||||
|
||||
## 配置
|
||||
|
||||
```yaml
|
||||
- id: source-guard
|
||||
name: '@deepseek-ai/dsh-source-guard'
|
||||
config:
|
||||
requiredSkill: dsh-customize # default; the skill whose load lifts the denial
|
||||
tools: [write, edit] # default; the gated tool names
|
||||
protectedCheckout: /path/to/checkout # defaults to this module's own location
|
||||
```
|
||||
|
||||
插件加载时,每个字段都会对错误配置快速失败:`tools` 为空列表、`requiredSkill` 为空白字符串,或 `protectedCheckout` 使用相对路径时,都会抛出错误,绝不静默回退。
|
||||
|
||||
`protectedCheckout` 指定位于待保护检出目录内的一条路径;其 worktree 会提供两项受保护身份:仓库和确切分支。其默认值是本模块自己的文件,由此解析出运行中 harness 启动来源的检出目录——当前运行的部署,无论其分支采用什么名称。分支既无需配置,也不会通过模式匹配,因此 staging 分支不遵循任何命名约定的维护者同样会受到保护。若 harness 从已安装副本运行,则会解析到另一个仓库,或根本解析不到仓库,因此不会保护任何内容;这条规则在源码检出目录之外没有意义。
|
||||
|
||||
已交付的 TUI 组合(`examples/tui-agent/cordis.yml`)会以默认配置加载本插件。若用户的工作区并非启动器自身所在的检出目录,本插件不会生效,因此普通项目不会发生任何变化。
|
||||
|
||||
## 受保护的路径
|
||||
|
||||
保护范围根据从文件读取的 Git 身份确定,即 `.git`、其中的 `gitdir:` 指针和 `HEAD`;既不按路径前缀判断,也不运行 `git`。此处若匹配路径前缀就会出错:skill 要求使用的任务 worktree 位于 staging 树*内部*的 `<staging>/.worktrees/...`,而这正是应该进行编辑的位置。
|
||||
|
||||
解析过程从目标路径开始向外逐层查找,遇到第一个所属 worktree 就停止,因此返回最内层的 worktree。只有该 worktree 在两项身份上都与启动器的 worktree 匹配,才会拒绝:共用同一个共享 Git 目录,且分支相同。嵌套在受保护树下的任务 worktree 会返回自己的任务分支并获准;启动器自身所在的树会返回启动器的分支并被拒绝。仓库身份会按解析符号链接后的路径进行比较,因此指向同一仓库的两条路径——macOS 上位于 `/var/...` 下的会话 cwd 和位于 `/private/var/...` 下的配置路径——会相互匹配,而不会触发故障放行(fail-open)。
|
||||
|
||||
要求匹配确切分支而非名称模式,可确保门禁仅作用于当前运行的部署。先前安装留下的陈旧同级检出目录虽然共享仓库,却没有运行启动器,因此该工作流规则不适用于它,它仍可编辑。
|
||||
|
||||
`gitdir:` 指针既可以是绝对路径(`git worktree add` 写入的形式),也可以是相对路径;Git 会以包含该指针的 worktree 目录为基准解析相对路径,本插件对两者都能解析。相对 `file_path` 会像文件系统工具一样,相对于调用会话的工作区解析,因此不会成为绕过门禁访问受保护文件的路径。
|
||||
|
||||
门禁刻意保持较窄的范围:
|
||||
|
||||
- **`read` 从不受门禁限制。** 检查 staging 检出不构成违规,因此只有修改类工具是候选项。
|
||||
- **`bash` 不受门禁限制。** 可靠识别会修改内容的 shell 命令不在范围内,因此执意修改的模型仍可通过 shell 修改 staging。
|
||||
- **没有 agent(智能体)的调用会被放行。** 直接调用 `ctx.tools.execute()` 的调用方没有可供回放的会话,也没有需要纠正的模型。
|
||||
- **无法解析 Git 状态时故障放行。** 不属于任何 worktree 的路径、任一侧的 HEAD 分离状态、其他仓库或分支、格式错误的 `.git` 指针或不可读的元数据,都会把调用交给链中后续环节处理。若每逢 Git 身份不可用就阻止所有写入,这道门禁造成的危害将大于它所防止的违规。
|
||||
- **无法解析的目标不会被判断。** `file_path` 为空、不是字符串,或它是相对路径而会话未指定工作区时,调用会交给工具自身校验。
|
||||
|
||||
插件会在其整个生命周期内按目标目录缓存 worktree 身份,因此同一目录中的重复写入只读取一次 Git 元数据;由此,系统不会观察到会话中途的分支切换。
|
||||
|
||||
## 如何解除拒绝
|
||||
|
||||
是否满足解锁条件由会话的持久日志回放得出:日志中存在一条 `tool/call`,它调用名为 `skill` 的工具,参数可解析为 `{name: <requiredSkill>}`,并且有一条调用 id 相同的非错误 `tool/result` 与之配对。由于日志是唯一状态源,恢复会话时仍能保留这一结果:若恢复的会话已经加载该 skill,系统不会再次要求加载。加载失败、skill 名称不同或参数 JSON 格式错误,都会让拒绝继续生效。
|
||||
|
||||
解锁条件按会话独立满足,因此拥有独立会话的 subagent 必须自行加载该 skill。
|
||||
|
||||
## 强制执行点
|
||||
|
||||
门禁是一个 `tools/pre-execute` 监听器,返回 `{kind: 'deny', reason}`,因此调用绝不会分派执行,文件也绝不会被修改。在所有不违规的情况下,它都会通过 `next()` 委派。这里刻意采用拒绝而非建议性提醒:建议性提醒仍会让违规落地,而在不支持批准的组合中,`ask` 会退化为拒绝。
|
||||
|
||||
## 测试
|
||||
|
||||
单元测试套件基于真实 Git 元数据 fixture(测试前置数据),使用 mock 适配器驱动真实 agent loop(智能体循环):覆盖 staging worktree、嵌套其中的任务 worktree、普通克隆、位于 staging 命名分支上的其他仓库、HEAD 分离状态、绝对和相对 `gitdir:` 指针、指向同一仓库的符号链接路径以及不可读元数据,达到逐文件 100% 覆盖率。组装运行层面的证据来自 Loader 组合冒烟测试(`tests/loader-composition.e2e.ts`):它通过 `examples/headless-agent/tests/fixtures/guard/source-guard/cordis.yml` 启动一个真实的 headless 应用,在临时 cwd 中植入 staging worktree,并断言工具结果是携带精确拒绝文本的错误,同时目标文件保持原始字节不变。
|
||||
|
||||
## 模型体验
|
||||
|
||||
### 被拒绝的文件系统调用
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
如果未加载必需 skill 就对受保护 worktree 发起受门禁限制的调用,系统会返回错误结果,其中的文本与下文完全一致。系统不会添加提示词段、工具 schema 或成功调用文本;允许的调用与未启用此插件时的调用完全无法区分。
|
||||
|
||||
##### 拒绝结果
|
||||
|
||||
```markdown
|
||||
Error: Editing "<path>" directly is not allowed: it is inside the dsh checkout this session is running from, on branch <branch>. Load the <requiredSkill> skill first and follow it — implement in a task worktree, then integrate under the staging lock.
|
||||
```
|
||||
|
||||
#### Token 影响
|
||||
|
||||
未发生拒绝时为零 token。一次拒绝会添加一条会保留在历史中的短小错误结果,同时避免生成该调用原本会产生的成功载荷。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
仅追加;新出现的内容位于可复用请求前缀之后,不会使现有 KV-cache 条目失效。
|
||||
|
||||
## 已知限制与暂缓工作
|
||||
|
||||
- **`bash` 不受门禁限制**:此插件只为文件系统工具提供边界;shell 命令仍可修改受保护的 worktree。
|
||||
- **插件生命周期内按目录缓存 worktree 身份**:在会话中途切换受保护 worktree 的分支,不会改变判断结果,直至下次加载插件;目标侧和启动器侧都是如此。
|
||||
- **仅保护启动器自身的检出目录**:同一仓库中的陈旧同级检出目录会被刻意保留为可编辑状态;若要保护它,请从中运行 `dsh`。
|
||||
- **源码检出之外不启用**:从已安装副本运行的 harness 不保护任何内容,除非 `protectedCheckout` 明确指定真实检出目录。
|
||||
- **解锁条件按会话独立满足**:subagent 的会话必须自行加载该 skill;父会话的加载状态不会继承。
|
||||
- **无法解析 Git 状态时故障放行**:损坏或不可读的 `.git` 会使保护失效;这是刻意选择的结果,因为另一方案是阻止所有编辑。
|
||||
- **仅加载一个 skill 即可为会话解除整道门禁**:加载该 skill 并不能验证是否实际遵循工作流,只能证明已阅读这些指令。
|
||||
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-source-guard",
|
||||
"description": "Source-guard plugin: denies direct file edits inside a dsh staging worktree until the required customization skill is loaded",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-fs": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-sandbox": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tools": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-loop": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-loop-testkit": "workspace:^",
|
||||
"@deepseek-ai/dsh-fs": "workspace:^",
|
||||
"@deepseek-ai/dsh-fs-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-loader-smoke": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
/**
|
||||
* Denies model-driven file mutation inside a dsh staging worktree until the
|
||||
* calling session has loaded the required customization skill. Config, git
|
||||
* resolution, and satisfaction semantics live in the package README; rationale
|
||||
* lives in the source-guard Agent Note.
|
||||
* @module @deepseek-ai/dsh-source-guard
|
||||
*/
|
||||
|
||||
import { dirname, isAbsolute, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { canonicalPath } from '@deepseek-ai/dsh-sandbox'
|
||||
import type {} from '@deepseek-ai/dsh-fs'
|
||||
import type { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import type { Session } from '@deepseek-ai/dsh-session'
|
||||
import type { PreToolDecision, ToolExecution } from '@deepseek-ai/dsh-tools'
|
||||
|
||||
export const name = 'source-guard'
|
||||
|
||||
/** The `ctx.fs` provider supplies the git-metadata reads this guard resolves paths with. */
|
||||
export const inject = ['fs']
|
||||
|
||||
/**
|
||||
* Plugin config, validated by the same-named schemastery schema plus the
|
||||
* load-time checks in `apply` (misconfiguration fails loud: an empty `tools`
|
||||
* list, a blank `requiredSkill`, or a relative `protectedCheckout` throws at
|
||||
* plugin load, never a silent fall-back).
|
||||
*/
|
||||
export interface Config {
|
||||
/** Skill whose loaded presence in the session lifts the denial (default `dsh-customize`). */
|
||||
requiredSkill?: string
|
||||
/** Tool names to gate (default `['write', 'edit']`). */
|
||||
tools?: string[]
|
||||
/**
|
||||
* Absolute path inside the checkout this guard protects. Its worktree
|
||||
* supplies BOTH protected identities: the repository (targets in any other
|
||||
* repository are ignored) and the exact branch (only that branch's worktree
|
||||
* is protected). Defaults to this module's own location, which resolves the
|
||||
* checkout the running harness was launched from — the live deployment,
|
||||
* whatever its branch is named. Set it explicitly to guard a different
|
||||
* checkout, or when the harness runs from an installed copy whose own
|
||||
* location is not a checkout at all.
|
||||
*/
|
||||
protectedCheckout?: string
|
||||
}
|
||||
|
||||
export const Config: z<Config> = z.object({
|
||||
requiredSkill: z.string().default('dsh-customize'),
|
||||
tools: z.array(z.string()).default(['write', 'edit']),
|
||||
protectedCheckout: z.string().default(fileURLToPath(import.meta.url)),
|
||||
})
|
||||
|
||||
/**
|
||||
* The tool whose successful call satisfies the guard. Fixed, not configurable:
|
||||
* this is the harness's own skill-loading tool name, so a deployment that
|
||||
* renamed it has no skill to load and nothing for this guard to observe.
|
||||
*/
|
||||
const SKILL_TOOL = 'skill'
|
||||
|
||||
/**
|
||||
* The argument key every gated tool names its target with. `write` and `edit`
|
||||
* share it (`dsh-tool-fs`), and gating a tool that does not is a
|
||||
* misconfiguration the guard reports rather than silently allowing.
|
||||
*/
|
||||
const PATH_ARGUMENT = 'file_path'
|
||||
|
||||
/**
|
||||
* The absolute `file_path` a gated call targets, or `undefined` when the
|
||||
* arguments carry no usable one. Arguments arrive as the loop's parsed model
|
||||
* JSON, so this is a model-input boundary: any shape is possible.
|
||||
*
|
||||
* A relative path resolves against the calling session's workspace, exactly as
|
||||
* the filesystem tools resolve it (`dsh-tool-fs`'s `sessionCwd`). Judging only
|
||||
* absolute paths would leave `write` with a relative `file_path` as an
|
||||
* unguarded path to the same file.
|
||||
*/
|
||||
function targetPath(argumentsValue: unknown, sessionCwd: string | undefined): string | undefined {
|
||||
if (typeof argumentsValue !== 'object' || argumentsValue === null) return undefined
|
||||
const value = (argumentsValue as Record<string, unknown>)[PATH_ARGUMENT]
|
||||
if (typeof value !== 'string' || value.length === 0) return undefined
|
||||
if (isAbsolute(value)) return resolve(value)
|
||||
// Without a session cwd the tools fall back to a provider-owned default this
|
||||
// guard cannot observe, so the target is genuinely unresolvable here.
|
||||
return sessionCwd === undefined ? undefined : resolve(sessionCwd, value)
|
||||
}
|
||||
|
||||
/** One resolved worktree's identity: the branch its HEAD names, and the repository it belongs to. */
|
||||
interface Worktree {
|
||||
/** Branch name from `HEAD`, or `undefined` for a detached HEAD. */
|
||||
branch: string | undefined
|
||||
/**
|
||||
* Symlink-resolved absolute path of the shared git directory, identifying the
|
||||
* repository across worktrees. Canonical because two paths reaching one
|
||||
* repository by different symlink routes must compare equal — on macOS a
|
||||
* session cwd under `/var/...` and a configured path under `/private/var/...`
|
||||
* name the same directory, and a lexical comparison would fail open.
|
||||
*/
|
||||
commonDir: string
|
||||
}
|
||||
|
||||
/**
|
||||
* What one git-metadata path holds: a file's text, the fact that it is a
|
||||
* directory, or nothing resolvable. Every caller treats the unresolvable case
|
||||
* as "not a worktree" and lets the call proceed, so distinguishing absence
|
||||
* from a permission error would change no decision.
|
||||
*/
|
||||
type GitEntry =
|
||||
| { kind: 'file'; text: string }
|
||||
| { kind: 'directory' }
|
||||
| { kind: 'absent' }
|
||||
|
||||
/** Probe one git-metadata path, reading its text when it is a regular file. */
|
||||
async function readGitEntry(ctx: Context, path: string): Promise<GitEntry> {
|
||||
try {
|
||||
const target = await ctx.fs.resolve(path)
|
||||
const info = await ctx.fs.stat(target)
|
||||
if (info?.type === 'directory') return { kind: 'directory' }
|
||||
if (info?.type !== 'file') return { kind: 'absent' }
|
||||
return { kind: 'file', text: await ctx.fs.readText(target) }
|
||||
} catch {
|
||||
// Any resolve/stat/read failure (absent, denied, unreadable encoding)
|
||||
// yields no git identity. Nothing else can reach here: the guard performs
|
||||
// no other IO.
|
||||
return { kind: 'absent' }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Branch name from a `HEAD` file's contents. A symbolic ref names a branch; a
|
||||
* detached HEAD holds a raw object id and has no branch, which no staging
|
||||
* pattern can match.
|
||||
*/
|
||||
function branchFromHead(head: string): string | undefined {
|
||||
const trimmed = head.trim()
|
||||
const ref = 'ref: refs/heads/'
|
||||
return trimmed.startsWith(ref) ? trimmed.slice(ref.length) : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the git directory a worktree root's `.git` entry designates, plus
|
||||
* the shared common directory. A plain clone's `.git` is a directory that is
|
||||
* its own common dir; a linked worktree's `.git` is a file pointing into the
|
||||
* main repository's `worktrees/<name>`, whose common dir is two levels up.
|
||||
* A `gitdir:` pointer may be relative, which git resolves against the worktree
|
||||
* directory holding it.
|
||||
*/
|
||||
async function resolveGitDir(ctx: Context, root: string): Promise<{ gitDir: string; commonDir: string } | undefined> {
|
||||
const dotGit = resolve(root, '.git')
|
||||
const entry = await readGitEntry(ctx, dotGit)
|
||||
// A plain clone keeps a `.git` DIRECTORY, which is both the git dir and the
|
||||
// common dir; a linked worktree keeps a `.git` FILE pointing elsewhere.
|
||||
if (entry.kind === 'directory') return { gitDir: dotGit, commonDir: canonicalPath(dotGit) }
|
||||
if (entry.kind === 'absent') return undefined
|
||||
const prefix = 'gitdir:'
|
||||
const trimmed = entry.text.trim()
|
||||
if (!trimmed.startsWith(prefix)) return undefined
|
||||
const pointer = trimmed.slice(prefix.length).trim()
|
||||
if (pointer.length === 0) return undefined
|
||||
const gitDir = resolve(root, pointer)
|
||||
// `<common>/worktrees/<name>` — the shared repository is two levels up.
|
||||
return { gitDir, commonDir: canonicalPath(dirname(dirname(gitDir))) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk from a path toward the filesystem root and resolve the first enclosing
|
||||
* worktree, or `undefined` when the path is inside none.
|
||||
*/
|
||||
async function findWorktree(ctx: Context, from: string): Promise<Worktree | undefined> {
|
||||
let current = from
|
||||
for (;;) {
|
||||
const dirs = await resolveGitDir(ctx, current)
|
||||
if (dirs !== undefined) {
|
||||
const head = await readGitEntry(ctx, resolve(dirs.gitDir, 'HEAD'))
|
||||
return {
|
||||
branch: head.kind === 'file' ? branchFromHead(head.text) : undefined,
|
||||
commonDir: dirs.commonDir,
|
||||
}
|
||||
}
|
||||
const parent = dirname(current)
|
||||
if (parent === current) return undefined
|
||||
current = parent
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The skill name a `skill` call's raw argument JSON requested, or `undefined`
|
||||
* when the JSON is malformed or carries no string `name`. The log stores the
|
||||
* model's unparsed argument string, so this is a model-JSON boundary.
|
||||
*/
|
||||
function skillNameOf(rawArguments: string): string | undefined {
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(rawArguments)
|
||||
} catch {
|
||||
// The model produced argument text that is not JSON; the call cannot have
|
||||
// named a skill. Nothing else in this try can throw.
|
||||
return undefined
|
||||
}
|
||||
if (typeof parsed !== 'object' || parsed === null) return undefined
|
||||
const value = (parsed as Record<string, unknown>).name
|
||||
return typeof value === 'string' ? value : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the session's durable log records a successful load of
|
||||
* `requiredSkill`. Replayed from `tool/call` + `tool/result` pairs, so
|
||||
* satisfaction survives a session resume: the log is the only state.
|
||||
*/
|
||||
function skillLoaded(session: Session, requiredSkill: string): boolean {
|
||||
const requested = new Map<CallId, string>()
|
||||
for (const event of session.events) {
|
||||
if (event.type === 'tool/call') {
|
||||
if (event.data.name === SKILL_TOOL) requested.set(event.data.callId, event.data.arguments)
|
||||
continue
|
||||
}
|
||||
const block = event.type === 'tool/result' ? event.data.message.content[0] : undefined
|
||||
if (block === undefined || block.isError === true) continue
|
||||
const rawArguments = requested.get(block.toolCallId)
|
||||
if (rawArguments !== undefined && skillNameOf(rawArguments) === requiredSkill) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** The denial text a blocked call reports to the model. */
|
||||
function denialReason(path: string, branch: string, requiredSkill: string): string {
|
||||
return `Editing "${path}" directly is not allowed: it is inside the dsh checkout this session is running from, on branch ${branch}. `
|
||||
+ `Load the ${requiredSkill} skill first and follow it — implement in a task worktree, then integrate under the staging lock.`
|
||||
}
|
||||
|
||||
/**
|
||||
* Install the guard's listener.
|
||||
* @param ctx - plugin context; the listener is scoped to it and disposed with it.
|
||||
* @param config - validated {@link Config}; re-checked fail-loud here.
|
||||
*/
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
// schemastery's .default() guarantees the fields are set after validation.
|
||||
const requiredSkill = config.requiredSkill as string
|
||||
const tools = config.tools as string[]
|
||||
if (tools.length === 0) {
|
||||
throw new Error('source-guard: `tools` must not be empty')
|
||||
}
|
||||
if (requiredSkill.trim().length === 0) {
|
||||
throw new Error('source-guard: `requiredSkill` must not be blank')
|
||||
}
|
||||
const gated = new Set(tools)
|
||||
|
||||
const protectedCheckout = config.protectedCheckout as string
|
||||
if (!isAbsolute(protectedCheckout)) {
|
||||
throw new Error(`source-guard: \`protectedCheckout\` must be an absolute path, got "${protectedCheckout}"`)
|
||||
}
|
||||
// Resolved once per plugin lifetime: the worktree this guard arms for, which
|
||||
// supplies both the protected repository and the protected branch. A harness
|
||||
// running from an installed copy resolves a different repository (or none)
|
||||
// and therefore guards nothing, which is correct — the rule is meaningless
|
||||
// outside a source checkout.
|
||||
let protectedRepository: Promise<Worktree | undefined> | undefined
|
||||
|
||||
/** The repository containing {@link Config.protectedCheckout}. */
|
||||
function repository(): Promise<Worktree | undefined> {
|
||||
protectedRepository ??= findWorktree(ctx, dirname(protectedCheckout))
|
||||
return protectedRepository
|
||||
}
|
||||
|
||||
// Worktree identity per directory, cached for the plugin's lifetime: a
|
||||
// directory's repository and branch are stable in practice, and re-reading
|
||||
// git metadata on every write would repeat identical IO. A mid-session
|
||||
// branch switch is therefore not observed (see the README).
|
||||
const worktrees = new Map<string, Promise<Worktree | undefined>>()
|
||||
|
||||
/** Resolve (and memoize) the worktree enclosing a target path's directory. */
|
||||
function worktreeOf(path: string): Promise<Worktree | undefined> {
|
||||
const directory = dirname(path)
|
||||
let pending = worktrees.get(directory)
|
||||
if (pending === undefined) {
|
||||
pending = findWorktree(ctx, directory)
|
||||
worktrees.set(directory, pending)
|
||||
}
|
||||
return pending
|
||||
}
|
||||
|
||||
/**
|
||||
* The target path and the staging branch protecting it, or `undefined` when
|
||||
* the call may proceed. Fails open on every unresolvable case: a path outside
|
||||
* any worktree, a detached HEAD, a different repository, or unreadable git
|
||||
* metadata leaves the call to the rest of the chain, because a guard that
|
||||
* blocked writes whenever git identity was unavailable would be worse than
|
||||
* the violation it prevents.
|
||||
*/
|
||||
async function protectedTarget(exec: ToolExecution, session: Session): Promise<{ path: string; branch: string } | undefined> {
|
||||
if (!gated.has(exec.name)) return undefined
|
||||
const path = targetPath(exec.arguments, session.header.cwd)
|
||||
if (path === undefined) return undefined
|
||||
const launcher = await repository()
|
||||
// A detached launcher checkout names no branch to protect, so nothing is.
|
||||
if (launcher?.branch === undefined) return undefined
|
||||
// Resolution walks OUTWARD from the target, so it reports the INNERMOST
|
||||
// enclosing worktree: a task worktree nested under the protected tree
|
||||
// answers with its own task branch, which is not the launcher's. That is
|
||||
// what keeps the prescribed workflow unblocked.
|
||||
const worktree = await worktreeOf(path)
|
||||
if (worktree === undefined || worktree.commonDir !== launcher.commonDir) return undefined
|
||||
// Only the branch the launcher itself runs from is protected: a stale
|
||||
// sibling checkout of the same repository is not the live deployment.
|
||||
if (worktree.branch !== launcher.branch) return undefined
|
||||
return { path, branch: launcher.branch }
|
||||
}
|
||||
|
||||
ctx.on('tools/pre-execute', async (exec, next): Promise<PreToolDecision> => {
|
||||
// A direct `ctx.tools.execute()` caller has no session to replay and no
|
||||
// model to correct; only agent-loop calls are gated.
|
||||
if (exec.agent === undefined) return next()
|
||||
const { session } = exec.agent
|
||||
const target = await protectedTarget(exec, session)
|
||||
if (target === undefined) return next()
|
||||
if (skillLoaded(session, requiredSkill)) return next()
|
||||
return { kind: 'deny', reason: denialReason(target.path, target.branch, requiredSkill) }
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-source-guard`.
|
||||
* @module @deepseek-ai/dsh-source-guard/invariant
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-source-guard'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'source-guard-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* The durable shape of this guard's refusal. The denial is the package's only
|
||||
* model-visible output, and it is actionable only when it names all three of
|
||||
* the offending path, the branch that protects it, and the skill that lifts
|
||||
* the denial — a refusal missing any of them tells the model to stop without
|
||||
* telling it how to proceed.
|
||||
*/
|
||||
const DENIAL = new RegExp(
|
||||
'^Error: Editing "(?<path>.+)" directly is not allowed: '
|
||||
+ 'it is inside the dsh checkout this session is running from, on branch (?<branch>\\S+)\\. '
|
||||
+ 'Load the (?<skill>\\S+) skill first and follow it '
|
||||
+ '— implement in a task worktree, then integrate under the staging lock\\.$',
|
||||
)
|
||||
|
||||
/** The denial prefix identifying a result this package produced, before its full shape is validated. */
|
||||
const DENIAL_PREFIX = 'Error: Editing "'
|
||||
|
||||
/** Validate one guard-produced denial result's model-facing text. */
|
||||
function validateDenial(text: string, fail: InvariantFailure): void {
|
||||
const match = DENIAL.exec(text)
|
||||
if (match === null) {
|
||||
fail('source-guard denial must name the path, the protecting branch, and the skill that lifts it')
|
||||
}
|
||||
// The pattern's `\S+` groups already establish a non-empty branch and skill;
|
||||
// only path absoluteness remains to check.
|
||||
const { path } = match.groups as { path: string }
|
||||
if (!path.startsWith('/') && !/^[A-Za-z]:[\\/]/.test(path)) {
|
||||
fail(`source-guard denial must name an absolute path, got ${JSON.stringify(path)}`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Validate every guard denial carried by one session's durable log. */
|
||||
function validateSession(session: Session, fail: InvariantFailure): void {
|
||||
for (const event of session.events) {
|
||||
if (event.type !== 'tool/result') continue
|
||||
validateEvent(event, fail)
|
||||
}
|
||||
}
|
||||
|
||||
/** Validate one durable tool result, when it carries this package's denial. */
|
||||
function validateEvent(event: SessionEvent<'tool/result'>, fail: InvariantFailure): void {
|
||||
const result = event.data.message.content[0]
|
||||
if (result.isError !== true) return
|
||||
for (const block of result.content) {
|
||||
if (block.type !== 'text' || !block.text.startsWith(DENIAL_PREFIX)) continue
|
||||
validateDenial(block.text, fail)
|
||||
}
|
||||
}
|
||||
|
||||
/* jscpd:ignore-start -- package companions share replay and dispatch plumbing */
|
||||
/** Install validation for loaded and newly appended denial results. */
|
||||
const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => {
|
||||
for (const session of ctx.sessions.list()) validateSession(session, fail)
|
||||
ctx.on('internal/dispatch', (_mode, eventName, args) => {
|
||||
if (eventName !== 'session/event') return
|
||||
const [, event] = args as [Session, SessionEvent]
|
||||
if (event.type !== 'tool/result') return
|
||||
validateEvent(event, fail)
|
||||
}, { global: true })
|
||||
}, { inject: ['sessions'] })
|
||||
/* jscpd:ignore-end */
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
@@ -0,0 +1,134 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { CallId, createToolResultMessage, createUserMessage, type ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import InvariantService from '@deepseek-ai/dsh-invariants'
|
||||
import * as SourceGuardInvariant from '@deepseek-ai/dsh-source-guard/invariant'
|
||||
|
||||
/**
|
||||
* The companion validates the durable shape of this package's only
|
||||
* model-visible output: its refusal must name the offending path, the branch
|
||||
* that protects it, and the skill that lifts it, so the model can act on the
|
||||
* denial instead of merely stopping.
|
||||
*/
|
||||
|
||||
const PATH = '/repo/staging/file.ts'
|
||||
|
||||
/** A well-formed denial for `path`, as the guard materializes it into a tool result. */
|
||||
function denial(path = PATH, branch = 'dsh-staging/20260101T000000Z', skill = 'dsh-customize'): string {
|
||||
return `Error: Editing "${path}" directly is not allowed: it is inside the dsh checkout this session is running from, `
|
||||
+ `on branch ${branch}. Load the ${skill} skill first and follow it `
|
||||
+ '— implement in a task worktree, then integrate under the staging lock.'
|
||||
}
|
||||
|
||||
async function setup(): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(InvariantService, { enabled: true })
|
||||
await ctx.plugin(SourceGuardInvariant)
|
||||
return ctx
|
||||
}
|
||||
|
||||
/** One durable tool result carrying `content`, error-flagged unless told otherwise. */
|
||||
function result(content: unknown[], isError = true): SessionEvent {
|
||||
return {
|
||||
type: 'tool/result',
|
||||
seq: 0,
|
||||
time: 1,
|
||||
surfaceOp: 'append',
|
||||
sourceEventSeqs: [0],
|
||||
data: {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
message: createToolResultMessage({
|
||||
callId: CallId('c0'),
|
||||
content: content as ContentBlock[],
|
||||
isError,
|
||||
}),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('source-guard invariants', () => {
|
||||
it('accepts a denial naming the path, branch, and skill', async () => {
|
||||
const ctx = await setup()
|
||||
const session = ctx.sessions.create(SessionId('accept'))
|
||||
expect(() => { ctx.emit('session/event', session, result([{ type: 'text', text: denial() }])) }).not.toThrow()
|
||||
})
|
||||
|
||||
it('accepts a Windows-style absolute path', async () => {
|
||||
const ctx = await setup()
|
||||
const session = ctx.sessions.create(SessionId('accept-windows'))
|
||||
const event = result([{ type: 'text', text: denial(String.raw`C:\repo\staging\file.ts`) }])
|
||||
expect(() => { ctx.emit('session/event', session, event) }).not.toThrow()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['a successful result that merely quotes the prefix', false],
|
||||
])('ignores %s', async (_label, isError) => {
|
||||
const ctx = await setup()
|
||||
const session = ctx.sessions.create(SessionId('ignore-success'))
|
||||
const event = result([{ type: 'text', text: 'Error: Editing "x" was fine' }], isError)
|
||||
expect(() => { ctx.emit('session/event', session, event) }).not.toThrow()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['a non-text block', [{ type: 'image', data: 'x', mimeType: 'image/png' }]],
|
||||
['text that is not this package\'s denial', [{ type: 'text', text: 'Error: something else' }]],
|
||||
])('ignores %s', async (_label, content) => {
|
||||
const ctx = await setup()
|
||||
const session = ctx.sessions.create(SessionId('ignore-other'))
|
||||
expect(() => { ctx.emit('session/event', session, result(content)) }).not.toThrow()
|
||||
})
|
||||
|
||||
it('ignores an event that is not a tool result', async () => {
|
||||
const ctx = await setup()
|
||||
const session = ctx.sessions.create(SessionId('ignore-kind'))
|
||||
const event: SessionEvent = {
|
||||
type: 'user/message',
|
||||
seq: 0,
|
||||
time: 1,
|
||||
surfaceOp: 'append',
|
||||
data: createUserMessage({ content: [{ type: 'text', text: denial() }], source: { kind: 'user' } }),
|
||||
}
|
||||
expect(() => { ctx.emit('session/event', session, event) }).not.toThrow()
|
||||
})
|
||||
|
||||
it.each([
|
||||
[
|
||||
'omits the skill that lifts it',
|
||||
`Error: Editing "${PATH}" directly is not allowed: it is inside the dsh checkout this session is running from, on branch main.`,
|
||||
],
|
||||
[
|
||||
'names a relative path',
|
||||
denial('relative/file.ts'),
|
||||
],
|
||||
])('rejects a denial that %s', async (_label, text) => {
|
||||
const ctx = await setup()
|
||||
const session = ctx.sessions.create(SessionId('reject'))
|
||||
expect(() => { ctx.emit('session/event', session, result([{ type: 'text', text }])) }).toThrow(/source-guard denial/)
|
||||
})
|
||||
|
||||
it('rejects an invalid denial already present on late registration', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const session = ctx.sessions.create(SessionId('late'))
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('step/start', { turn: 1, step: 1 })
|
||||
const call = session.append('tool/call', {
|
||||
turn: 1, step: 1, callId: CallId('c0'), name: 'write', arguments: '{}',
|
||||
})
|
||||
session.append('tool/result', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
message: createToolResultMessage({
|
||||
callId: CallId('c0'),
|
||||
content: [{ type: 'text', text: denial('relative/file.ts') }],
|
||||
isError: true,
|
||||
}),
|
||||
}, { surfaceOp: 'append', sourceEventSeqs: [call.seq] })
|
||||
|
||||
await ctx.plugin(InvariantService, { enabled: true })
|
||||
await expect(ctx.plugin(SourceGuardInvariant).then(() => undefined)).rejects.toThrow(/source-guard denial/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,93 @@
|
||||
import { mkdir, readdir, readFile, realpath, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { type SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke'
|
||||
|
||||
// The Loader config lives under examples so both launch modes exercise the same
|
||||
// deployable topology: a local fixture adapter plus bare workspace plugins.
|
||||
const configPath = fileURLToPath(new URL(
|
||||
'../../../../examples/headless-agent/tests/fixtures/guard/source-guard/cordis.yml',
|
||||
import.meta.url,
|
||||
))
|
||||
const binScript = fileURLToPath(new URL('../../../examples/cli-demo/src/bin.ts', import.meta.url))
|
||||
const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url))
|
||||
|
||||
/** Every `.jsonl` session log under `dir`. */
|
||||
async function jsonlFiles(dir: string): Promise<string[]> {
|
||||
const entries = await readdir(dir, { withFileTypes: true })
|
||||
const paths = await Promise.all(entries.map(async (entry) => {
|
||||
const path = join(dir, entry.name)
|
||||
if (entry.isDirectory()) return jsonlFiles(path)
|
||||
return entry.isFile() && entry.name.endsWith('.jsonl') ? [path] : []
|
||||
}))
|
||||
return paths.flat()
|
||||
}
|
||||
|
||||
/**
|
||||
* Write git metadata mirroring the installer layout — a master clone owning the
|
||||
* shared git directory and one linked worktree on a staging branch — and return
|
||||
* the worktree file the model will try to write.
|
||||
*/
|
||||
async function stagingFixture(cwd: string): Promise<{ checkout: string; target: string }> {
|
||||
const gitDir = join(cwd, 'master', '.git')
|
||||
const worktreeGitDir = join(gitDir, 'worktrees', 'staging')
|
||||
await mkdir(worktreeGitDir, { recursive: true })
|
||||
await writeFile(join(gitDir, 'HEAD'), 'ref: refs/heads/master\n')
|
||||
await writeFile(join(worktreeGitDir, 'HEAD'), 'ref: refs/heads/dsh-staging/20260101T000000Z\n')
|
||||
const checkout = join(cwd, 'staging')
|
||||
await mkdir(checkout, { recursive: true })
|
||||
await writeFile(join(checkout, '.git'), `gitdir: ${worktreeGitDir}\n`)
|
||||
const target = join(checkout, 'guarded.ts')
|
||||
await writeFile(target, 'original\n')
|
||||
return { checkout, target }
|
||||
}
|
||||
|
||||
describe('source-guard through a real headless cordis.yml', () => {
|
||||
it('denies the model-requested write and leaves the staged file untouched', async () => {
|
||||
let events: SessionEvent[] = []
|
||||
let contents = ''
|
||||
let target = ''
|
||||
const { stderr } = await runLoaderSmoke({
|
||||
label: 'source-guard headless smoke',
|
||||
tempDirPrefix: 'source-guard-e2e-',
|
||||
binScript,
|
||||
configPath,
|
||||
tsconfigPath: repoTsconfig,
|
||||
binArgs: ['--config', configPath, 'edit the guarded file'],
|
||||
// The isolated cwd is not known when these options are built, so the
|
||||
// config and adapter resolve their fixture paths against the child's own
|
||||
// cwd, which is that directory.
|
||||
prepare: async (cwd) => {
|
||||
// macOS puts the temp directory behind the /var -> /private/var
|
||||
// symlink; the child resolves its cwd, so compare against the same
|
||||
// real path rather than the symlinked one this process was handed.
|
||||
target = (await stagingFixture(await realpath(cwd))).target
|
||||
},
|
||||
inspect: async (cwd) => {
|
||||
const logs = await jsonlFiles(join(cwd, '.sessions'))
|
||||
expect(logs).toHaveLength(1)
|
||||
const lines = (await readFile(logs[0] as string, 'utf8')).trimEnd().split('\n')
|
||||
events = lines.slice(1).map(line => JSON.parse(line) as SessionEvent)
|
||||
contents = await readFile(target, 'utf8')
|
||||
},
|
||||
})
|
||||
expect(stderr).not.toContain('UNHANDLED')
|
||||
|
||||
const results = events.filter(
|
||||
(event): event is SessionEvent<'tool/result'> => event.type === 'tool/result')
|
||||
expect(results).toHaveLength(1)
|
||||
const result = results[0]?.data.message.content[0]
|
||||
expect(result?.isError).toBe(true)
|
||||
const text = result?.content.map(block => block.type === 'text' ? block.text : '').join('')
|
||||
expect(text).toBe(
|
||||
`Error: Editing "${target}" directly is not allowed: it is inside the dsh checkout this session is running from, `
|
||||
+ 'on branch dsh-staging/20260101T000000Z. Load the dsh-customize skill first and follow it '
|
||||
+ '— implement in a task worktree, then integrate under the staging lock.',
|
||||
)
|
||||
// Enforcement, not advice: the guard denies before dispatch, so the file
|
||||
// the model targeted still holds its original bytes.
|
||||
expect(contents).toBe('original\n')
|
||||
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
|
||||
})
|
||||
@@ -0,0 +1,581 @@
|
||||
import { chmod, mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
|
||||
import LocalFileSystem from '@deepseek-ai/dsh-fs-local'
|
||||
import { CallId, createToolResultMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import * as SourceGuard from '@deepseek-ai/dsh-source-guard'
|
||||
import type { Config } from '@deepseek-ai/dsh-source-guard'
|
||||
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
|
||||
|
||||
/**
|
||||
* Behavior suite for the staging-source guard: worktree resolution over REAL
|
||||
* git metadata fixtures (a staging worktree, a nested task worktree, a plain
|
||||
* clone, an unrelated repository, a detached HEAD), skill satisfaction replayed
|
||||
* from the durable session log, and fail-loud config validation — all driven
|
||||
* through a real agent loop against a scripted mock adapter (no network).
|
||||
*/
|
||||
|
||||
const roots: string[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
/**
|
||||
* Build a git-metadata fixture tree that mirrors the real installer layout: a
|
||||
* `master` clone holding the shared git directory, linked worktrees registered
|
||||
* under `master/.git/worktrees/<name>`, and one file per worktree to target.
|
||||
*/
|
||||
async function fixture(): Promise<{
|
||||
/** Absolute path of the fixture container. */
|
||||
root: string
|
||||
/** A file inside the staging worktree — the protected target. */
|
||||
stagingFile: string
|
||||
/** A file inside a task worktree NESTED under the staging tree. */
|
||||
taskFile: string
|
||||
/** A file inside a SIBLING staging worktree of the same repository, on another branch. */
|
||||
siblingFile: string
|
||||
/** A file inside the plain master clone. */
|
||||
masterFile: string
|
||||
/** A file inside a worktree whose HEAD is detached. */
|
||||
detachedFile: string
|
||||
/** A file inside an unrelated repository sharing no git directory. */
|
||||
outsideFile: string
|
||||
/** A file under no repository at all. */
|
||||
looseFile: string
|
||||
}> {
|
||||
const root = await mkdtemp(join(tmpdir(), 'source-guard-'))
|
||||
roots.push(root)
|
||||
const master = join(root, 'master')
|
||||
const gitDir = join(master, '.git')
|
||||
await mkdir(join(gitDir, 'worktrees'), { recursive: true })
|
||||
await writeFile(join(gitDir, 'HEAD'), 'ref: refs/heads/master\n')
|
||||
await writeFile(join(master, 'file.ts'), 'master\n')
|
||||
|
||||
/** Register one linked worktree at `path` whose HEAD file holds `head`. */
|
||||
async function linked(path: string, name: string, head: string): Promise<string> {
|
||||
const worktreeGitDir = join(gitDir, 'worktrees', name)
|
||||
await mkdir(worktreeGitDir, { recursive: true })
|
||||
await writeFile(join(worktreeGitDir, 'HEAD'), head)
|
||||
await mkdir(path, { recursive: true })
|
||||
await writeFile(join(path, '.git'), `gitdir: ${worktreeGitDir}\n`)
|
||||
const file = join(path, 'file.ts')
|
||||
await writeFile(file, 'content\n')
|
||||
return file
|
||||
}
|
||||
|
||||
const staging = join(root, 'staging-20260728T022827Z')
|
||||
const stagingFile = await linked(staging, 'staging-20260728T022827Z', 'ref: refs/heads/dsh-staging/20260728T022827Z\n')
|
||||
// The prescribed workflow's task worktree lives INSIDE the staging tree.
|
||||
const taskFile = await linked(join(staging, '.worktrees', 'task', 'x'), 'task-x', 'ref: refs/heads/task/x\n')
|
||||
// A stale staging worktree from an earlier install: same repository, different branch.
|
||||
const siblingFile = await linked(
|
||||
join(root, 'staging-20260727T045831Z'),
|
||||
'staging-20260727T045831Z',
|
||||
'ref: refs/heads/dsh-staging/20260727T045831Z\n',
|
||||
)
|
||||
const detachedFile = await linked(join(root, 'detached'), 'detached', '0123456789abcdef0123456789abcdef01234567\n')
|
||||
|
||||
const outside = join(root, 'outside')
|
||||
await mkdir(join(outside, '.git'), { recursive: true })
|
||||
await writeFile(join(outside, '.git', 'HEAD'), 'ref: refs/heads/dsh-staging/20260728T022827Z\n')
|
||||
const outsideFile = join(outside, 'file.ts')
|
||||
await writeFile(outsideFile, 'outside\n')
|
||||
|
||||
const loose = join(root, 'loose')
|
||||
await mkdir(loose, { recursive: true })
|
||||
const looseFile = join(loose, 'file.ts')
|
||||
await writeFile(looseFile, 'loose\n')
|
||||
|
||||
return {
|
||||
root, stagingFile, taskFile, siblingFile, masterFile: join(master, 'file.ts'), detachedFile, outsideFile, looseFile,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Boot the core spine, a real local filesystem, and the guard, pointing
|
||||
* `protectedCheckout` at a fixture path so the guard arms for the fixture
|
||||
* repository instead of the checkout these tests actually run in.
|
||||
*/
|
||||
async function harness(protectedCheckout: string, config: Partial<Config> = {}): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await mountAgentLoopTestDependencies(ctx)
|
||||
await ctx.plugin(LocalFileSystem, {})
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(SourceGuard, { ...config, protectedCheckout })
|
||||
for (const name of ['write', 'edit', 'read', 'skill']) {
|
||||
ctx.tools.register(defineContentToolFixture({
|
||||
name,
|
||||
description: name,
|
||||
parameters: { file_path: { type: 'string' }, name: { type: 'string' } },
|
||||
async execute() { return [{ type: 'text', text: 'ok' }] },
|
||||
}))
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const dispose = ctx.on('agent/status', (subject, status) => {
|
||||
if (subject === agent && status === 'idle') { dispose(); resolve() }
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/** Every tool result in the agent's log as `{ isError, text }`, in log order. */
|
||||
function results(agent: Agent): { isError: boolean; text: string }[] {
|
||||
return [...agent.session.events]
|
||||
.filter((event): event is SessionEvent<'tool/result'> => event.type === 'tool/result')
|
||||
.map(event => event.data.message.content[0])
|
||||
.map(result => ({
|
||||
isError: result.isError === true,
|
||||
text: result.content.map(block => block.type === 'text' ? block.text : '').join(''),
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Durable events recording completed `skill` calls, as a RESUMED session's seed:
|
||||
* the guard's satisfaction check then has nothing but the log to read, with no
|
||||
* in-memory state from an original run to fall back on.
|
||||
*/
|
||||
function priorSkillCalls(calls: { arguments: string; isError?: boolean }[]): SessionEvent[] {
|
||||
const events: SessionEvent[] = [
|
||||
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
|
||||
{ type: 'step/start', seq: 1, time: 2, data: { turn: 1, step: 1 } },
|
||||
]
|
||||
for (const [index, call] of calls.entries()) {
|
||||
const callId = CallId(`prior${index}`)
|
||||
const seq = events.length
|
||||
events.push({
|
||||
type: 'tool/call',
|
||||
seq,
|
||||
time: seq + 1,
|
||||
data: { turn: 1, step: 1, callId, name: 'skill', arguments: call.arguments },
|
||||
})
|
||||
events.push({
|
||||
type: 'tool/result',
|
||||
seq: seq + 1,
|
||||
time: seq + 2,
|
||||
surfaceOp: 'append',
|
||||
sourceEventSeqs: [seq],
|
||||
data: {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
message: createToolResultMessage({
|
||||
callId,
|
||||
content: [{ type: 'text', text: 'loaded' }],
|
||||
isError: call.isError ?? false,
|
||||
}),
|
||||
},
|
||||
})
|
||||
}
|
||||
const tail = events.length
|
||||
events.push({ type: 'step/end', seq: tail, time: tail + 1, data: { turn: 1, step: 1 } })
|
||||
events.push({ type: 'turn/end', seq: tail + 1, time: tail + 2, data: { turn: 1, reason: { kind: 'completed' } } })
|
||||
return events
|
||||
}
|
||||
|
||||
/** Resume a session from durable seed events and let the model attempt one write at `path`. */
|
||||
async function resume(ctx: Context, id: string, seed: SessionEvent[], path: string): Promise<Agent> {
|
||||
const adapter = new MockAdapter([
|
||||
toolCallResponse(CallId('c0'), 'write', { file_path: path }),
|
||||
textResponse('done'),
|
||||
])
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
const { agent } = await ctx.agentLoop.createAgent(ctx, {
|
||||
sessionId: SessionId(id),
|
||||
seed,
|
||||
agentOptions: { provider: 'mock', model: 'mock' },
|
||||
})
|
||||
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
|
||||
await waitForIdle(ctx, agent)
|
||||
return agent
|
||||
}
|
||||
|
||||
/** Drive one turn whose scripted model output is the given tool calls, then a closing text. */
|
||||
async function run(
|
||||
ctx: Context,
|
||||
calls: { name: string; args: Record<string, unknown> }[],
|
||||
cwd?: string,
|
||||
): Promise<Agent> {
|
||||
const adapter = new MockAdapter([
|
||||
...calls.map((call, index) => toolCallResponse(CallId(`c${index}`), call.name, call.args)),
|
||||
textResponse('done'),
|
||||
])
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
const agent = ctx.agentLoop.create(
|
||||
SessionId('s1'),
|
||||
{ provider: 'mock', model: 'mock' },
|
||||
cwd === undefined ? {} : { cwd },
|
||||
)
|
||||
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
|
||||
await waitForIdle(ctx, agent)
|
||||
return agent
|
||||
}
|
||||
|
||||
describe('staging protection', () => {
|
||||
it('denies a write inside the staging worktree and names the path, branch, and skill', async () => {
|
||||
const paths = await fixture()
|
||||
const ctx = await harness(paths.stagingFile)
|
||||
const agent = await run(ctx, [{ name: 'write', args: { file_path: paths.stagingFile } }])
|
||||
const [result] = results(agent)
|
||||
expect(result?.isError).toBe(true)
|
||||
expect(result?.text).toBe(
|
||||
`Error: Editing "${paths.stagingFile}" directly is not allowed: it is inside the dsh checkout this session is running from, `
|
||||
+ 'on branch dsh-staging/20260728T022827Z. Load the dsh-customize skill first and follow it '
|
||||
+ '— implement in a task worktree, then integrate under the staging lock.',
|
||||
)
|
||||
})
|
||||
|
||||
it('denies an edit inside the staging worktree', async () => {
|
||||
const paths = await fixture()
|
||||
const ctx = await harness(paths.stagingFile)
|
||||
const agent = await run(ctx, [{ name: 'edit', args: { file_path: paths.stagingFile } }])
|
||||
expect(results(agent)[0]?.isError).toBe(true)
|
||||
})
|
||||
|
||||
it('allows a read inside the staging worktree, since inspection never violates the skill', async () => {
|
||||
const paths = await fixture()
|
||||
const ctx = await harness(paths.stagingFile)
|
||||
const agent = await run(ctx, [{ name: 'read', args: { file_path: paths.stagingFile } }])
|
||||
expect(results(agent)).toEqual([{ isError: false, text: 'ok' }])
|
||||
})
|
||||
|
||||
it('allows a write inside a task worktree nested under the staging tree', async () => {
|
||||
const paths = await fixture()
|
||||
const ctx = await harness(paths.stagingFile)
|
||||
const agent = await run(ctx, [{ name: 'write', args: { file_path: paths.taskFile } }])
|
||||
expect(results(agent)).toEqual([{ isError: false, text: 'ok' }])
|
||||
})
|
||||
|
||||
it('allows a write in the plain clone that owns the shared git directory', async () => {
|
||||
const paths = await fixture()
|
||||
const ctx = await harness(paths.stagingFile)
|
||||
const agent = await run(ctx, [{ name: 'write', args: { file_path: paths.masterFile } }])
|
||||
expect(results(agent)).toEqual([{ isError: false, text: 'ok' }])
|
||||
})
|
||||
|
||||
it('allows a write on a staging-named branch in an unrelated repository', async () => {
|
||||
const paths = await fixture()
|
||||
const ctx = await harness(paths.stagingFile)
|
||||
const agent = await run(ctx, [{ name: 'write', args: { file_path: paths.outsideFile } }])
|
||||
expect(results(agent)).toEqual([{ isError: false, text: 'ok' }])
|
||||
})
|
||||
|
||||
it('allows a write under a detached HEAD, which names no branch to match', async () => {
|
||||
const paths = await fixture()
|
||||
const ctx = await harness(paths.stagingFile)
|
||||
const agent = await run(ctx, [{ name: 'write', args: { file_path: paths.detachedFile } }])
|
||||
expect(results(agent)).toEqual([{ isError: false, text: 'ok' }])
|
||||
})
|
||||
|
||||
it('allows a write when the git metadata exists but cannot be read', async () => {
|
||||
const paths = await fixture()
|
||||
// A `.git` pointer that stats as a file yet fails to read leaves the guard
|
||||
// with no branch to judge; failing open beats blocking every edit.
|
||||
const unreadable = join(paths.root, 'unreadable')
|
||||
await mkdir(unreadable, { recursive: true })
|
||||
await writeFile(join(unreadable, '.git'), `gitdir: ${join(paths.root, 'master', '.git')}\n`)
|
||||
await chmod(join(unreadable, '.git'), 0o000)
|
||||
const file = join(unreadable, 'file.ts')
|
||||
await writeFile(file, 'content\n')
|
||||
const ctx = await harness(paths.stagingFile)
|
||||
const agent = await run(ctx, [{ name: 'write', args: { file_path: file } }])
|
||||
expect(results(agent)).toEqual([{ isError: false, text: 'ok' }])
|
||||
})
|
||||
|
||||
it('allows a write under no repository at all', async () => {
|
||||
const paths = await fixture()
|
||||
const ctx = await harness(paths.stagingFile)
|
||||
const agent = await run(ctx, [{ name: 'write', args: { file_path: paths.looseFile } }])
|
||||
expect(results(agent)).toEqual([{ isError: false, text: 'ok' }])
|
||||
})
|
||||
|
||||
it('arms for nothing when its own location is inside no repository', async () => {
|
||||
const paths = await fixture()
|
||||
const ctx = await harness(paths.looseFile)
|
||||
const agent = await run(ctx, [{ name: 'write', args: { file_path: paths.stagingFile } }])
|
||||
expect(results(agent)).toEqual([{ isError: false, text: 'ok' }])
|
||||
})
|
||||
|
||||
it('arms for nothing when the launcher checkout has a detached HEAD', async () => {
|
||||
const paths = await fixture()
|
||||
// A detached launcher names no branch, so there is no branch to protect.
|
||||
const ctx = await harness(paths.detachedFile)
|
||||
const agent = await run(ctx, [{ name: 'write', args: { file_path: paths.stagingFile } }])
|
||||
expect(results(agent)).toEqual([{ isError: false, text: 'ok' }])
|
||||
})
|
||||
|
||||
it('denies when the target and the protected checkout reach one repository through different symlinks', async () => {
|
||||
const paths = await fixture()
|
||||
// macOS reaches the temp directory through both `/var/...` and
|
||||
// `/private/var/...`; a lexical repository comparison would treat the two
|
||||
// routes as different repositories and fail open on every write.
|
||||
const link = join(paths.root, 'link')
|
||||
await symlink(dirname(paths.stagingFile), link, 'dir')
|
||||
const ctx = await harness(paths.stagingFile)
|
||||
const agent = await run(ctx, [{ name: 'write', args: { file_path: join(link, 'file.ts') } }])
|
||||
expect(results(agent)[0]?.text).toContain('on branch dsh-staging/20260728T022827Z')
|
||||
})
|
||||
|
||||
it('denies a RELATIVE target path resolved against the session workspace', async () => {
|
||||
const paths = await fixture()
|
||||
const ctx = await harness(paths.stagingFile)
|
||||
// The filesystem tools resolve a relative `file_path` against the session
|
||||
// cwd, so judging only absolute paths would leave this as an unguarded
|
||||
// route to the same file.
|
||||
const agent = await run(ctx, [{ name: 'write', args: { file_path: 'file.ts' } }], dirname(paths.stagingFile))
|
||||
expect(results(agent)[0]?.text).toContain('directly is not allowed')
|
||||
})
|
||||
|
||||
it('ignores a relative target path when the session names no workspace', async () => {
|
||||
const paths = await fixture()
|
||||
const ctx = await harness(paths.stagingFile)
|
||||
const agent = await run(ctx, [{ name: 'write', args: { file_path: 'file.ts' } }])
|
||||
expect(results(agent)).toEqual([{ isError: false, text: 'ok' }])
|
||||
})
|
||||
|
||||
it('ignores a call whose target path is an empty string', async () => {
|
||||
const paths = await fixture()
|
||||
const ctx = await harness(paths.stagingFile)
|
||||
const agent = await run(ctx, [{ name: 'write', args: { file_path: '' } }])
|
||||
expect(results(agent)).toEqual([{ isError: false, text: 'ok' }])
|
||||
})
|
||||
|
||||
it.each([
|
||||
['a non-string file_path', { file_path: 7 }],
|
||||
['no file_path at all', { other: 'x' }],
|
||||
])('ignores a gated call carrying %s', async (_label, args) => {
|
||||
const paths = await fixture()
|
||||
const ctx = await harness(paths.stagingFile)
|
||||
const agent = await run(ctx, [{ name: 'write', args }])
|
||||
expect(results(agent)[0]?.text).not.toContain('directly is not allowed')
|
||||
})
|
||||
|
||||
it('ignores a gated call whose arguments are not JSON, which the loop keeps as raw text', async () => {
|
||||
const paths = await fixture()
|
||||
const ctx = await harness(paths.stagingFile)
|
||||
const callId = CallId('raw')
|
||||
const adapter = new MockAdapter([
|
||||
[
|
||||
{ type: 'block-start', index: 0, blockType: 'tool-call' },
|
||||
{ type: 'tool-call-delta', index: 0, id: callId, name: 'write', argumentsDelta: 'not json' },
|
||||
{ type: 'block-end', index: 0, block: { type: 'tool-call', id: callId, name: 'write', arguments: 'not json' } },
|
||||
{ type: 'finish', reason: { kind: 'tool-calls' } },
|
||||
],
|
||||
textResponse('done'),
|
||||
])
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('raw'), { provider: 'mock', model: 'mock' })
|
||||
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
|
||||
await waitForIdle(ctx, agent)
|
||||
expect(results(agent)[0]?.text).not.toContain('directly is not allowed')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['a `.git` pointer that names no git directory', 'not a gitdir pointer\n'],
|
||||
['an empty `.git` pointer', 'gitdir:\n'],
|
||||
['a `.git` pointer into a nonexistent git directory', 'gitdir: /nonexistent/worktrees/x\n'],
|
||||
])('allows a write behind %s', async (_label, pointer) => {
|
||||
const paths = await fixture()
|
||||
const broken = join(paths.root, 'broken')
|
||||
await mkdir(broken, { recursive: true })
|
||||
await writeFile(join(broken, '.git'), pointer)
|
||||
const file = join(broken, 'file.ts')
|
||||
await writeFile(file, 'content\n')
|
||||
const ctx = await harness(paths.stagingFile)
|
||||
const agent = await run(ctx, [{ name: 'write', args: { file_path: file } }])
|
||||
expect(results(agent)).toEqual([{ isError: false, text: 'ok' }])
|
||||
})
|
||||
|
||||
it('denies behind a RELATIVE `.git` pointer, which git resolves against the worktree', async () => {
|
||||
const paths = await fixture()
|
||||
// `git worktree add` writes an absolute pointer, but a relocated or
|
||||
// hand-written one may be relative; git accepts both, so the guard must
|
||||
// resolve both or it would fail open on a real repository layout.
|
||||
const relative = join(paths.root, 'relative-pointer')
|
||||
await mkdir(relative, { recursive: true })
|
||||
await writeFile(join(relative, '.git'), 'gitdir: ../master/.git/worktrees/staging-20260728T022827Z\n')
|
||||
const file = join(relative, 'file.ts')
|
||||
await writeFile(file, 'content\n')
|
||||
const ctx = await harness(paths.stagingFile)
|
||||
const agent = await run(ctx, [{ name: 'write', args: { file_path: file } }])
|
||||
expect(results(agent)[0]?.text).toContain('on branch dsh-staging/20260728T022827Z')
|
||||
})
|
||||
|
||||
it('allows a write when the worktree resolves but its HEAD is missing', async () => {
|
||||
const paths = await fixture()
|
||||
const gitDir = join(paths.root, 'master', '.git', 'worktrees', 'headless')
|
||||
await mkdir(gitDir, { recursive: true })
|
||||
const headless = join(paths.root, 'headless')
|
||||
await mkdir(headless, { recursive: true })
|
||||
await writeFile(join(headless, '.git'), `gitdir: ${gitDir}\n`)
|
||||
const file = join(headless, 'file.ts')
|
||||
await writeFile(file, 'content\n')
|
||||
const ctx = await harness(paths.stagingFile)
|
||||
const agent = await run(ctx, [{ name: 'write', args: { file_path: file } }])
|
||||
expect(results(agent)).toEqual([{ isError: false, text: 'ok' }])
|
||||
})
|
||||
|
||||
it('reuses one resolution for sibling targets in the same directory', async () => {
|
||||
const paths = await fixture()
|
||||
const sibling = join(dirname(paths.stagingFile), 'other.ts')
|
||||
await writeFile(sibling, 'content\n')
|
||||
const ctx = await harness(paths.stagingFile)
|
||||
const agent = await run(ctx, [
|
||||
{ name: 'write', args: { file_path: paths.stagingFile } },
|
||||
{ name: 'write', args: { file_path: sibling } },
|
||||
])
|
||||
expect(results(agent).map(result => result.isError)).toEqual([true, true])
|
||||
})
|
||||
|
||||
it('protects whichever branch the launcher checkout is on, whatever its name', async () => {
|
||||
const paths = await fixture()
|
||||
// The protected branch is read from `protectedCheckout`'s own worktree, so
|
||||
// a checkout on an unconventional branch name is still protected — a
|
||||
// hardcoded name pattern would have silently guarded nothing.
|
||||
const ctx = await harness(paths.taskFile)
|
||||
const agent = await run(ctx, [{ name: 'write', args: { file_path: paths.taskFile } }])
|
||||
expect(results(agent)[0]?.text).toContain('on branch task/x')
|
||||
})
|
||||
|
||||
it('allows a write in a SIBLING checkout of the same repository on another branch', async () => {
|
||||
const paths = await fixture()
|
||||
// A stale staging worktree left by an earlier install shares the
|
||||
// repository but is not the live deployment, so the workflow rule the
|
||||
// guard enforces does not apply to it.
|
||||
const ctx = await harness(paths.siblingFile)
|
||||
const agent = await run(ctx, [{ name: 'write', args: { file_path: paths.stagingFile } }])
|
||||
expect(results(agent)).toEqual([{ isError: false, text: 'ok' }])
|
||||
})
|
||||
|
||||
it('gates only the configured tools', async () => {
|
||||
const paths = await fixture()
|
||||
const ctx = await harness(paths.stagingFile, { tools: ['edit'] })
|
||||
const agent = await run(ctx, [
|
||||
{ name: 'write', args: { file_path: paths.stagingFile } },
|
||||
{ name: 'edit', args: { file_path: paths.stagingFile } },
|
||||
])
|
||||
expect(results(agent).map(result => result.isError)).toEqual([false, true])
|
||||
})
|
||||
})
|
||||
|
||||
describe('skill satisfaction', () => {
|
||||
it('allows the write after a successful load of the required skill in the same turn', async () => {
|
||||
const paths = await fixture()
|
||||
const ctx = await harness(paths.stagingFile)
|
||||
const agent = await run(ctx, [
|
||||
{ name: 'skill', args: { name: 'dsh-customize' } },
|
||||
{ name: 'write', args: { file_path: paths.stagingFile } },
|
||||
])
|
||||
expect(results(agent)).toEqual([
|
||||
{ isError: false, text: 'ok' },
|
||||
{ isError: false, text: 'ok' },
|
||||
])
|
||||
})
|
||||
|
||||
it('allows the write when the skill load is only in the REPLAYED log, so resume keeps satisfaction', async () => {
|
||||
const paths = await fixture()
|
||||
const ctx = await harness(paths.stagingFile)
|
||||
const seed = priorSkillCalls([{ arguments: JSON.stringify({ name: 'dsh-customize' }) }])
|
||||
const agent = await resume(ctx, 'resumed', seed, paths.stagingFile)
|
||||
expect(results(agent).at(-1)).toEqual({ isError: false, text: 'ok' })
|
||||
})
|
||||
|
||||
it('does not accept a failed skill load', async () => {
|
||||
const paths = await fixture()
|
||||
const ctx = await harness(paths.stagingFile)
|
||||
const seed = priorSkillCalls([{ arguments: JSON.stringify({ name: 'dsh-customize' }), isError: true }])
|
||||
const agent = await resume(ctx, 'failed', seed, paths.stagingFile)
|
||||
expect(results(agent).at(-1)?.isError).toBe(true)
|
||||
})
|
||||
|
||||
it('does not accept a different skill', async () => {
|
||||
const paths = await fixture()
|
||||
const ctx = await harness(paths.stagingFile)
|
||||
const agent = await run(ctx, [
|
||||
{ name: 'skill', args: { name: 'dsh-upgrade' } },
|
||||
{ name: 'write', args: { file_path: paths.stagingFile } },
|
||||
])
|
||||
expect(results(agent).map(result => result.isError)).toEqual([false, true])
|
||||
})
|
||||
|
||||
it('does not accept a skill call whose arguments are not a JSON object naming a string', async () => {
|
||||
const paths = await fixture()
|
||||
const ctx = await harness(paths.stagingFile)
|
||||
const seed = priorSkillCalls([
|
||||
{ arguments: 'not json' },
|
||||
{ arguments: '[]' },
|
||||
{ arguments: '{"name":7}' },
|
||||
{ arguments: 'null' },
|
||||
])
|
||||
const agent = await resume(ctx, 'malformed', seed, paths.stagingFile)
|
||||
expect(results(agent).at(-1)?.isError).toBe(true)
|
||||
})
|
||||
|
||||
it('honours a configured skill name other than the default', async () => {
|
||||
const paths = await fixture()
|
||||
const ctx = await harness(paths.stagingFile, { requiredSkill: 'other-skill' })
|
||||
const agent = await run(ctx, [
|
||||
{ name: 'skill', args: { name: 'other-skill' } },
|
||||
{ name: 'write', args: { file_path: paths.stagingFile } },
|
||||
])
|
||||
expect(results(agent).map(result => result.isError)).toEqual([false, false])
|
||||
})
|
||||
})
|
||||
|
||||
describe('non-agent callers', () => {
|
||||
it('leaves a direct registry call ungated, having no session to replay', async () => {
|
||||
const paths = await fixture()
|
||||
const ctx = await harness(paths.stagingFile)
|
||||
const result = await ctx.tools.execute({
|
||||
callId: CallId('direct'),
|
||||
name: 'write',
|
||||
arguments: { file_path: paths.stagingFile },
|
||||
signal: new AbortController().signal,
|
||||
})
|
||||
expect(result.isError).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('config validation', () => {
|
||||
it.each([
|
||||
['tools', { tools: [] }, '`tools` must not be empty'],
|
||||
['requiredSkill', { requiredSkill: ' ' }, '`requiredSkill` must not be blank'],
|
||||
['protectedCheckout', { protectedCheckout: 'relative/path' }, '`protectedCheckout` must be an absolute path'],
|
||||
])('rejects an invalid %s at load', async (_field, config, message) => {
|
||||
const ctx = new Context()
|
||||
await mountAgentLoopTestDependencies(ctx)
|
||||
await ctx.plugin(LocalFileSystem, {})
|
||||
await expect(ctx.plugin(SourceGuard, config as Config)).rejects.toThrow(message)
|
||||
})
|
||||
})
|
||||
|
||||
describe('disposal', () => {
|
||||
it('stops gating once the plugin fiber is disposed', async () => {
|
||||
const paths = await fixture()
|
||||
const ctx = new Context()
|
||||
await mountAgentLoopTestDependencies(ctx)
|
||||
await ctx.plugin(LocalFileSystem, {})
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
const fiber = await ctx.plugin(SourceGuard, { protectedCheckout: paths.stagingFile })
|
||||
for (const name of ['write', 'skill']) {
|
||||
ctx.tools.register(defineContentToolFixture({
|
||||
name,
|
||||
description: name,
|
||||
parameters: { file_path: { type: 'string' }, name: { type: 'string' } },
|
||||
async execute() { return [{ type: 'text', text: 'ok' }] },
|
||||
}))
|
||||
}
|
||||
await fiber.dispose()
|
||||
const agent = await run(ctx, [{ name: 'write', args: { file_path: paths.stagingFile } }])
|
||||
expect(results(agent)).toEqual([{ isError: false, text: 'ok' }])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/schemastery"
|
||||
},
|
||||
{
|
||||
"path": "../../core/tools"
|
||||
},
|
||||
{
|
||||
"path": "../../core/agent"
|
||||
},
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../../fs/fs"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
{
|
||||
"path": "../../sandbox/sandbox"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user