refactor(cli): exclude tmux context and source guard

This commit is contained in:
Turtle
2026-07-29 15:43:54 +08:00
parent c1324ee896
commit 9e2c3d3093
47 changed files with 97 additions and 3153 deletions

View File

@@ -2,5 +2,5 @@
# 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/context/README.md
README.md: a5244dfe99a714605744b57d33f97359d4d6fa4e
README.zh.md: 6036d58c6937d025adc36d9b2d12f51e396ee3d0
README.md: bc3237b98732c23e6a2b120e055f7713b91f9b7c
README.zh.md: b195a4c0b96b1f6f0b66bc6efa99a4a66b3c2fa2

View File

@@ -2,13 +2,12 @@
English | [中文](README.zh.md)
Product plugins that add model-visible request context without defining a tool. `workspace-context` is included by the default `dsh-agent-spine-demo` bundle and can be disabled through bundle config; `time-context` and `tmux-context` are opt-in, while the standard TUI bundle composes `session-reference` explicitly.
Product plugins that add model-visible request context without defining a tool. `workspace-context` is included by the default `dsh-agent-spine-demo` bundle and can be disabled through bundle config; `time-context` is opt-in, while the standard TUI bundle composes `session-reference` explicitly.
| Package | Role | ctx key |
|---|---|---|
| `session-reference/` | Bounded current-surface snapshots of other sessions | `ctx.sessionReferences` |
| `time-context/` | Durable per-step current time and elapsed-time context | (none) |
| `tmux-context/` | Durable per-turn context with this agent's tmux pane/window location | (listens on `agent/pre-step`, reads `ctx.bash`) |
| `workspace-context/` | `AGENTS.md`/`CLAUDE.md` workspace context loader | (listens on `agent/session-prefix` + `tools/post-execute`) |
The [`workspace-context` decision record](../../.agents/notes/implemented/feature/2026-06-24-workspace-context.md) explains its per-agent/session isolation and lifecycle split.

View File

@@ -2,12 +2,12 @@
[English](README.md) | 中文
这些产品插件无需定义工具,即可增加模型可见的请求上下文。`workspace-context` 包含在默认的 `dsh-agent-spine-demo` 组合包中,且可通过组合包配置将其禁用;`time-context`显式启用,标准 TUI 组合包则会显式组合 `session-reference`
这些产品插件无需定义工具,即可增加模型可见的请求上下文。`workspace-context` 包含在默认的 `dsh-agent-spine-demo` 组合包中,且可通过组合包配置将其禁用;`time-context`要选择启用,标准 TUI 组合包则会显式组合 `session-reference`
| 包 | 职责 | ctx key |
|---|---|---|
| `session-reference/` | 其他会话当前表层的有界快照 | `ctx.sessionReferences` |
| `time-context/` | 持久的逐步骤当前时间与已用时上下文 | (无) |
| `workspace-context/` | `AGENTS.md``CLAUDE.md` 工作区上下文 loader | (监听 `agent/step` + `tools/post-execute` |
| `time-context/` | 持久的逐步骤当前时间与时上下文 | (无) |
| `workspace-context/` | `AGENTS.md``CLAUDE.md` 工作区上下文 loader | (监听 `agent/session-prefix` + `tools/post-execute` |
[`workspace-context` 决策记录](../../.agents/notes/implemented/feature/2026-06-24-workspace-context.md)解释了每个 agent(智能体)和会话各自隔离的方式,以及相应的生命周期拆分。
[`workspace-context` 决策记录](../../.agents/notes/implemented/feature/2026-06-24-workspace-context.md)解释了它的逐 agent/会话隔离与生命周期拆分。

View File

@@ -1,6 +0,0 @@
# 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/context/tmux-context/README.md
README.md: 5ea36948d6d83135c5aa97650c0d77e942adbbaa
README.zh.md: 914d8d7c99c37de2c64541bcf4968996d819077d

View File

@@ -1,68 +0,0 @@
# @deepseek-ai/dsh-tmux-context
English | [中文](README.zh.md)
Opt-in durable context naming the tmux session, window, and pane this agent process runs in, plus the window's pane-tree layout. Sampled once per turn during model-request preparation. `dsh-agent-spine-demo` and shipped examples do not mount it. Decision record: [the tmux-context Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-tmux-location-context.md).
## Config
```yaml
- id: tmux-context
name: '@deepseek-ai/dsh-tmux-context'
config:
refreshIntervalMs: 60000 # optional; omit or set to 0 to inject on every changed turn
```
`refreshIntervalMs` must be a non-negative safe integer. Omission or `0` injects whenever the tmux state changed since the last injection. A positive value additionally suppresses injections that fall within that many milliseconds of the latest one.
## How it reads tmux
The plugin prepends an `agent/step` listener that runs only on the first step of each turn. When due, it runs one read-only command through the `ctx.bash` executor seam:
```sh
[ -n "$TMUX_PANE" ] || exit 1
self_tty=$(ps -o tty= -p <pid> | tr -d ' ')
pane_tty=$(tmux display-message -t "$TMUX_PANE" -p '#{pane_tty}') || exit 1
[ "$pane_tty" = "/dev/$self_tty" ] || exit 1
exec tmux display-message -t "$TMUX_PANE" -p '<format>'
```
`$TMUX_PANE` alone is insufficient: a terminal launched from a tmux shell (a VS Code integrated terminal, a desktop launcher) **inherits** `$TMUX` and `$TMUX_PANE` from that ancestor, so the variables are present even though the process does not live in that pane. The command therefore also compares the pane's `#{pane_tty}` against this process's own controlling terminal (`ps -o tty=` for its pid): a genuine pane owns this process's tty, while an inherited environment names some other pane's tty. Running through `ctx.bash` applies the deployment's sandbox and policy; the plugin owns no subprocess code. When `ctx.bash` is absent, the process is not in a real tmux pane (`$TMUX_PANE` unset, or the tty does not match ⇒ nonzero exit), or the reading is malformed, the attempt is a no-op, never an error.
State is pulled on every eligible turn — a moved, renamed, or re-laid-out pane is picked up without any tmux hook or background process. The plugin re-injects only when the rendered tmux state differs from its last injection, so an unchanged location adds nothing.
## Timing semantics
When an injection is due, the plugin appends one injected `user/message` through `agent.inject()` before `step/start`, with source `{ kind: 'plugin', plugin: 'tmux-context' }`. Change suppression and interval scheduling scan the raw durable session events for the latest injection of this source, so the schedule survives compaction and resumed processes without process-local cache state; sessions schedule independently. The reading records a request-preparation attempt, not a committed step; because the listener runs first, its append may remain when a later pre-step listener cancels or fails the attempt (the log is append-only and the plugin performs no rollback).
## Model Experience
### Preparation-time tmux location
#### What the model sees
On each turn whose tmux state changed, one source-tagged context message with the three lines below. `<window-layout>` is tmux's compact pane-tree description; pane and window pixel sizes are intentionally excluded, and the contents of sibling panes are never captured.
##### Changed-turn reading
```markdown
tmux location (turn <turn>):
session <session>, window <index> "<name>", pane <index> <pane-id>
window active=<0|1>, pane active=<0|1>, layout <window-layout>
```
#### Token effect
Each two-line reading accumulates until compaction shadows it. Unchanged locations and interval suppression add nothing.
#### 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
- **First step only** — a pane moved or resized mid-turn is reflected on the next turn, not between steps.
- **Own location only** — the plugin never captures the visible text of sibling panes.
- **Layout, not size** — pane/window pixel dimensions are omitted; only the layout tree and active flags are reported.
- **Tab-delimited fields** — a tmux window name containing the literal two-character sequence `\t` would mis-split the reading and be skipped as malformed; ordinary names are unaffected.
- **tty-based pane detection** — the process is considered "in tmux" only when its controlling terminal matches `$TMUX_PANE`'s `#{pane_tty}`. This deliberately excludes terminals that inherited `$TMUX`/`$TMUX_PANE` from a tmux ancestor (e.g. a VS Code integrated terminal). `ps -o tty=` is POSIX; the check is a no-op wherever it or `#{pane_tty}` is unavailable.

View File

@@ -1,68 +0,0 @@
# @deepseek-ai/dsh-tmux-context
[English](README.md) | 中文
可选启用的持久上下文,记录本 agent 进程所在的 tmux session、window、pane以及该 window 的 pane 树布局。在准备模型请求时每轮采样一次。`dsh-agent-spine-demo` 与随附示例均不挂载它。决策记录见:[tmux-context Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-tmux-location-context.md)。
## 配置
```yaml
- id: tmux-context
name: '@deepseek-ai/dsh-tmux-context'
config:
refreshIntervalMs: 60000 # optional; omit or set to 0 to inject on every changed turn
```
`refreshIntervalMs` 必须是非负安全整数。省略或 `0` 表示只要 tmux 状态自上次注入以来发生变化就注入。正值会额外抑制距最近一次注入不足该毫秒数的注入。
## 如何读取 tmux
插件前置注册一个 `agent/step` 监听器,仅在每轮的第一个 step 运行。当需要注入时,它通过 `ctx.bash` 执行器 seam 运行一条只读命令:
```sh
[ -n "$TMUX_PANE" ] || exit 1
self_tty=$(ps -o tty= -p <pid> | tr -d ' ')
pane_tty=$(tmux display-message -t "$TMUX_PANE" -p '#{pane_tty}') || exit 1
[ "$pane_tty" = "/dev/$self_tty" ] || exit 1
exec tmux display-message -t "$TMUX_PANE" -p '<format>'
```
仅凭 `$TMUX_PANE` 并不足够:从 tmux shell 启动的终端VS Code 集成终端、桌面启动器)会从该祖先进程**继承** `$TMUX``$TMUX_PANE`,因此即使进程并不位于那个 pane 中,这些变量依然存在。为此该命令还会把 pane 的 `#{pane_tty}` 与本进程自己的控制终端(对其 pid 执行 `ps -o tty=`)作比较:真正的 pane 拥有本进程的 tty而继承而来的环境指向的是另一个 pane 的 tty。通过 `ctx.bash` 运行会应用部署方的沙箱与策略;插件不拥有任何子进程代码。当 `ctx.bash` 缺失、进程不在真实的 tmux pane 内(`$TMUX_PANE` 未设置,或 tty 不匹配 ⇒ 非零退出)或读取结果格式非法时,本次尝试为空操作,绝不报错。
状态在每个符合条件的轮次拉取——pane 被移动、改名或重新布局都会被感知,无需任何 tmux hook 或后台进程。插件仅在渲染出的 tmux 状态与上次注入不同时才重新注入,因此位置不变时不会新增任何内容。
## 时序语义
当需要注入时,插件在 `step/start` 之前通过 `agent.inject()` 追加一条注入的 `user/message`,来源为 `{ kind: 'plugin', plugin: 'tmux-context' }`。变化抑制与间隔调度会扫描原始持久会话事件中该来源的最近一次注入,因此调度可跨压缩与恢复的进程存续,无需进程内缓存状态;各会话独立调度。该读数记录的是一次请求准备尝试,而非已提交的 step由于监听器最先运行当后续 pre-step 监听器取消或失败时,它的追加可能仍会保留(日志只追加,插件不做回滚)。
## 模型体验
### 准备期 tmux 位置
#### 模型看到的内容
在 tmux 状态发生变化的每一轮,注入一条带来源标记、含以下三行的上下文消息。`<window-layout>` 是 tmux 紧凑的 pane 树描述pane 与 window 的像素尺寸有意省略,相邻 pane 的内容从不采集。
##### 变化轮次读数
```markdown
tmux location (turn <turn>):
session <session>, window <index> "<name>", pane <index> <pane-id>
window active=<0|1>, pane active=<0|1>, layout <window-layout>
```
#### Token 影响
每条两行读数会累积,直到压缩将其遮蔽。位置未变化以及间隔抑制不会新增内容。
#### KV 缓存影响
只追加;新增可见内容位于可复用的请求前缀之后,不会使已有 KV 缓存条目失效。
## 已知限制与后续工作
- **仅第一个 step**——轮次中途移动或缩放的 pane 会在下一轮反映,而非在 step 之间。
- **仅自身位置**——插件从不采集相邻 pane 的可见文本。
- **只有布局,没有尺寸**——省略 pane/window 像素尺寸;仅报告布局树与活动标志。
- **制表符分隔字段**——若 tmux window 名称包含字面两字符序列 `\t`,会使读数分割错误并作为非法读数跳过;常规名称不受影响。
- **基于 tty 的 pane 判定**——只有当进程的控制终端与 `$TMUX_PANE``#{pane_tty}` 一致时,才视为“位于 tmux 中”。这会有意排除从 tmux 祖先进程继承 `$TMUX``$TMUX_PANE` 的终端(如 VS Code 集成终端)。`ps -o tty=` 属于 POSIX在其或 `#{pane_tty}` 不可用的环境中,该检查即为空操作。

View File

@@ -1,49 +0,0 @@
{
"name": "@deepseek-ai/dsh-tmux-context",
"description": "Opt-in durable per-step context with this agent's tmux pane and window location",
"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-bash": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-bash": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-loader-smoke": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -1,227 +0,0 @@
/**
* Opt-in request-preparation tmux-location context. Eligible step attempts
* append durable, source-attributed context naming the tmux session, window,
* and pane this agent process runs in, plus the window's pane-tree layout.
*
* The plugin pulls state once per turn, on the first step (`step === 1`), by
* running one `tmux display-message` through the `ctx.bash` executor seam. It
* confirms this process genuinely runs inside the pane `$TMUX_PANE` names by
* matching the pane's `#{pane_tty}` against this process's controlling terminal,
* so a terminal that merely inherited `$TMUX`/`$TMUX_PANE` from a tmux ancestor
* (e.g. a VS Code integrated terminal) reads as "not in tmux". It re-injects
* only when the rendered tmux state changes since the last injection (a moved,
* renamed, or re-laid-out pane), with an optional `refreshIntervalMs` floor
* between injections. Absent tmux environment, an inherited-only environment,
* absent `ctx.bash`, or a failed query is a no-op, never an error.
*
* @module @deepseek-ai/dsh-tmux-context
*/
import type { Context } from 'cordis'
import z from 'schemastery'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type { BashExecutor } from '@deepseek-ai/dsh-bash'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
/** Cordis plugin name used by loader diagnostics. */
export const name = 'tmux-context'
/** The agent registry that owns the `agent/step` lifecycle seam. */
export const inject = ['agents']
/** Per-turn tmux-location scheduling. Invalid values fail plugin load. */
export interface Config {
/** Minimum milliseconds between durable injections in one session. Omit or set to 0 to inject on every eligible change. */
refreshIntervalMs?: number
}
/** Schemastery validation for {@link Config}. */
export const Config: z<Config> = z.object({
refreshIntervalMs: z.number(),
})
/**
* Tab-separated tmux format fields, in query order. Layout (`window_layout`)
* is the pane-tree description; pane/window pixel sizes are intentionally
* excluded (own location and layout only, per the package scope).
*/
const TMUX_FIELDS = [
'#{session_name}',
'#{window_index}',
'#{window_name}',
'#{pane_index}',
'#{pane_id}',
'#{window_active}',
'#{pane_active}',
'#{window_layout}',
] as const
/** Structured tmux location parsed from one `display-message` reading. */
interface TmuxLocation {
sessionName: string
windowIndex: string
windowName: string
paneIndex: string
paneId: string
windowActive: string
paneActive: string
windowLayout: string
}
/** Prefix marking the volatile turn/step preamble line of a rendered reading. */
const READING_PREFIX = 'tmux location (turn '
/**
* Field separator between tmux format fields. tmux does not interpret C escapes
* in a format, so the literal two-character sequence `\t` is emitted verbatim
* and split back out here; this avoids embedding raw whitespace in the command.
*/
const FIELD_SEP = '\\t'
/**
* Read this process's tmux location through the bash seam, or `undefined` when
* this process is not genuinely running inside a tmux pane or the query fails.
*
* `$TMUX_PANE` alone is insufficient: a terminal launched from a tmux shell
* (e.g. VS Code's integrated terminal, a desktop launcher) inherits `$TMUX` and
* `$TMUX_PANE` from that ancestor, so the variables are present even though this
* process does not live in that pane. The command therefore also compares the
* pane's `#{pane_tty}` against this process's own controlling terminal
* (`ps -o tty=` for {@link processId}); a genuine pane owns this process's tty,
* an inherited environment names some other pane's tty. Fields are emitted only
* on a match, so an inherited environment reads as "not in tmux" and injects
* nothing.
*
* @param bash - the executor seam used to run the read-only tmux/ps commands.
* @param processId - this agent process's pid, whose controlling tty must match the pane.
* @param signal - abort signal forwarded to the executor.
* @returns the parsed location, or `undefined` when not in a real pane or on any failure.
*/
async function queryTmuxLocation(
bash: BashExecutor,
processId: number,
signal: AbortSignal,
): Promise<TmuxLocation | undefined> {
const format = TMUX_FIELDS.join(FIELD_SEP)
const command = [
'[ -n "$TMUX_PANE" ] || exit 1',
`self_tty=$(ps -o tty= -p ${processId} | tr -d ' ')`,
'[ -n "$self_tty" ] || exit 1',
'pane_tty=$(tmux display-message -t "$TMUX_PANE" -p \'#{pane_tty}\') || exit 1',
'[ "$pane_tty" = "/dev/$self_tty" ] || exit 1',
`exec tmux display-message -t "$TMUX_PANE" -p '${format}'`,
].join('\n')
const spec = bash.resolve({ command, signal })
const result = await bash.run(spec)
if (result.exitCode !== 0) return undefined
const line = result.stdout.text.split('\n', 1)[0] as string
const parts = line.split(FIELD_SEP)
if (parts.length !== TMUX_FIELDS.length) return undefined
const [
sessionName,
windowIndex,
windowName,
paneIndex,
paneId,
windowActive,
paneActive,
windowLayout,
] = parts as [string, string, string, string, string, string, string, string]
if (paneId.length === 0) return undefined
return {
sessionName,
windowIndex,
windowName,
paneIndex,
paneId,
windowActive,
paneActive,
windowLayout,
}
}
/**
* Render the stable tmux state block: the part of a reading compared for
* change suppression. It excludes the turn preamble so re-injection is driven
* only by tmux state, not by loop position.
*/
function renderState(location: TmuxLocation): string {
return `session ${location.sessionName}, `
+ `window ${location.windowIndex} ${JSON.stringify(location.windowName)}, `
+ `pane ${location.paneIndex} ${location.paneId}\n`
+ `window active=${location.windowActive}, pane active=${location.paneActive}, `
+ `layout ${location.windowLayout}`
}
/** Render the full durable reading, including the volatile turn preamble. */
function renderReading(location: TmuxLocation, turn: number): string {
return `${READING_PREFIX}${turn}):\n${renderState(location)}`
}
/**
* The stable state block of this plugin's latest durable injection, or
* `undefined` when the session has none. Scans raw durable events so the
* schedule survives compaction and resumed processes without process-local
* cache state.
*/
function latestInjectedState(agent: Agent): { state: string; time: number } | undefined {
for (const event of [...agent.session.events].reverse()) {
if (event.type === 'user/message'
&& event.data.source.kind === 'plugin'
&& event.data.source.plugin === name) {
const [block] = event.data.content
if (block?.type !== 'text') return undefined
const newline = block.text.indexOf('\n')
const state = newline === -1 ? '' : block.text.slice(newline + 1)
return { state, time: event.time }
}
}
return undefined
}
/** Reject refresh intervals that cannot represent an exact elapsed-millisecond threshold. */
function validateRefreshInterval(refreshIntervalMs: number | undefined): void {
if (refreshIntervalMs !== undefined && (
!Number.isSafeInteger(refreshIntervalMs)
|| refreshIntervalMs < 0
)) {
throw new TypeError(
`tmux-context: refreshIntervalMs must be a non-negative safe integer, got ${String(refreshIntervalMs)}`,
)
}
}
/**
* Register a prepended `agent/step` listener for the lifetime of `ctx`.
* @param ctx - plugin context; the listener is disposed with it.
* @param config - durable refresh scheduling configuration.
* @throws when the refresh interval is invalid.
*/
export function apply(ctx: Context, config: Config): void {
const refreshIntervalMs = config.refreshIntervalMs
validateRefreshInterval(refreshIntervalMs)
ctx.on('agent/step', async (
agent: Agent,
turn: number,
step: number,
signal: AbortSignal,
): Promise<void> => {
if (signal.aborted || step !== 1) return
const bash = ctx.get('bash')
if (bash === undefined) return
const previous = latestInjectedState(agent)
if (refreshIntervalMs !== undefined && refreshIntervalMs > 0 && previous !== undefined) {
const now = Date.now()
if (now >= previous.time && now - previous.time < refreshIntervalMs) return
}
const location = await queryTmuxLocation(bash, process.pid, signal)
if (location === undefined) return
const state = renderState(location)
if (previous !== undefined && previous.state === state) return
agent.inject(createUserMessage({
content: [{ type: 'text', text: renderReading(location, turn) }],
source: { kind: 'plugin', plugin: name },
}))
}, { prepend: true })
}

View File

@@ -1,30 +0,0 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-tmux-context`.
* @module @deepseek-ai/dsh-tmux-context/invariant
*/
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-tmux-context'
/** Cordis companion plugin name. */
export const name = 'tmux-context-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: a reading is a per-turn snapshot of external tmux state, so the session
* holds no cross-event relation to check; scheduling and format are owned by pipeline tests.
*/
const install: InvariantInstaller = () => {}
/**
* 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))
/* jscpd:ignore-end */

View File

@@ -1,77 +0,0 @@
import { readFile, readdir } 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'
// Keep the Loader config under examples so both modes exercise the same deployable
// topology: local fixture source plus bare plugins owned by the examples workspace.
const driver = fileURLToPath(new URL(
'../../../../examples/headless-agent/tests/fixtures/tmux-context-driver.ts',
import.meta.url,
))
const configPath = fileURLToPath(new URL(
'../../../../examples/headless-agent/tests/fixtures/tmux-context.cordis.yml',
import.meta.url,
))
const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url))
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()
}
describe('tmux-context through a real headless cordis.yml', () => {
it('injects one ordered tmux-location event on the first turn and suppresses the unchanged second', async () => {
let events: SessionEvent[] = []
const { stderr } = await runLoaderSmoke({
label: 'tmux-context headless smoke',
tempDirPrefix: 'tmux-context-e2e-',
binScript: driver,
libBinScript: driver,
configPath,
tsconfigPath: repoTsconfig,
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)
},
})
expect(stderr).not.toContain('UNHANDLED')
expect(events.filter(event => event.type === 'turn/end')).toHaveLength(2)
const contexts = events.filter(
(event): event is SessionEvent<'user/message'> =>
event.type === 'user/message'
&& event.data.source.kind === 'plugin'
&& event.data.source.plugin === 'tmux-context')
// Two identical-state turns: the location injects once and is suppressed after.
expect(contexts).toHaveLength(1)
const [reading] = contexts
if (reading === undefined) throw new Error('missing tmux-context reading')
const starts = events.filter(event => event.type === 'step/start')
expect(reading.seq).toBeLessThan(starts[0]!.seq)
expect(reading.surfaceOp).toBe('append')
const text = reading.data.content
.filter(block => block.type === 'text')
.map(block => block.text)
.join('\n')
expect(text).toBe(
'tmux location (turn 1):\n'
+ 'session work, window 0 "editor", pane 1 %3\n'
+ 'window active=1, pane active=1, layout a1b2,80x24,0,0,4',
)
const headers = events.filter(event => event.type === 'request/header')
expect(JSON.stringify(headers)).not.toContain('tmux location (turn')
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
})

View File

@@ -1,367 +0,0 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import AgentRegistry, { agentEvents, type Agent } from '@deepseek-ai/dsh-agent'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import { BashExecutor } from '@deepseek-ai/dsh-bash'
import type { BashExecRequest, BashExecSpec, BashProcess, BashRunResult } from '@deepseek-ai/dsh-bash'
import * as tmuxContext from '@deepseek-ai/dsh-tmux-context'
import type { Config } from '@deepseek-ai/dsh-tmux-context'
const SIGNAL = new AbortController().signal
/** One `#{...}`-joined tmux reading line for the eight queried fields. */
function tmuxLine(fields: {
sessionName?: string
windowIndex?: string
windowName?: string
paneIndex?: string
paneId?: string
windowActive?: string
paneActive?: string
windowLayout?: string
} = {}): string {
return [
fields.sessionName ?? '0',
fields.windowIndex ?? '1',
fields.windowName ?? 'node',
fields.paneIndex ?? '2',
fields.paneId ?? '%90',
fields.windowActive ?? '1',
fields.paneActive ?? '0',
fields.windowLayout ?? 'd517,270x71,0,0{135x71,0,0,87,134x71,136,0[134x35,136,0,90,134x35,136,36,93]}',
].join('\\t')
}
function runResult(stdout: string, overrides: Partial<BashRunResult> = {}): BashRunResult {
return {
exitCode: 0,
signal: null,
timedOut: false,
aborted: false,
timeoutMs: 60_000,
stdout: { text: stdout, truncated: false },
stderr: { text: '', truncated: false },
...overrides,
}
}
/** A scriptable fake `ctx.bash` recording the command it was asked to run. */
class FakeBash extends BashExecutor {
commands: string[] = []
result: BashRunResult = runResult(`${tmuxLine()}\n`)
runError?: Error
override resolve(request: BashExecRequest): BashExecSpec {
return {
command: request.command,
workdir: request.workdir ?? '/work',
timeoutMs: request.timeoutMs ?? 60_000,
stdoutMaxBytes: request.stdoutMaxBytes ?? 64_000,
signal: request.signal,
sandboxPolicy: request.sandboxPolicy,
}
}
override async run(spec: BashExecSpec): Promise<BashRunResult> {
this.commands.push(spec.command)
if (this.runError) throw this.runError
return this.result
}
override start(): BashProcess {
throw new Error('tmux-context must never start a background task')
}
}
async function mount(config: Config, withBash: true): Promise<{ ctx: Context; bash: FakeBash }>
async function mount(config?: Config, withBash?: boolean): Promise<{ ctx: Context; bash: FakeBash | undefined }>
async function mount(
config: Config = {},
withBash = false,
): Promise<{ ctx: Context; bash: FakeBash | undefined }> {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
let bash: FakeBash | undefined
if (withBash) {
await ctx.plugin(FakeBash)
bash = ctx.bash as FakeBash
}
await ctx.plugin(tmuxContext, config)
return { ctx, bash }
}
function sessionAgent(session: Session, id = 'agent'): Agent {
return {
id: SessionId(id),
options: {},
session,
status: 'running',
acceptsNextStep: true,
ctx: new Context(),
followup: () => {},
steer: () => {},
inject(input) {
session.append('user/message', input, { surfaceOp: 'append' })
},
send: () => {},
cancel() {},
whenIdle: () => Promise.resolve(),
}
}
function openMessageTurn(session: Session, turn: number): void {
session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: `turn ${turn}` }],
source: { kind: 'user' },
}), { surfaceOp: 'append' })
}
function contextTexts(session: Session): string[] {
const texts: string[] = []
for (const event of session.events) {
if (event.type === 'user/message'
&& event.data.source.kind === 'plugin'
&& event.data.source.plugin === 'tmux-context') {
texts.push(event.data.content.find(block => block.type === 'text')?.text ?? '')
}
}
return texts
}
async function fire(
ctx: Context,
agent: Agent,
turn: number,
step: number,
signal: AbortSignal = SIGNAL,
): Promise<void> {
await agentEvents(ctx, agent).serial('agent/step', turn, step, signal)
}
afterEach(() => {
vi.restoreAllMocks()
vi.useRealTimers()
})
describe('tmux-context injection', () => {
it('injects the tmux location on the first step of a turn', async () => {
const { ctx } = await mount({}, true)
const session = new Session(SessionId('first'))
openMessageTurn(session, 1)
await fire(ctx, sessionAgent(session), 1, 1)
expect(contextTexts(session)).toEqual([
'tmux location (turn 1):\n'
+ 'session 0, window 1 "node", pane 2 %90\n'
+ 'window active=1, pane active=0, '
+ 'layout d517,270x71,0,0{135x71,0,0,87,134x71,136,0[134x35,136,0,90,134x35,136,36,93]}',
])
const event = session.events.at(-1)
if (event?.type !== 'user/message') throw new Error('missing tmux context')
expect(event.data.source).toEqual({ kind: 'plugin', plugin: 'tmux-context' })
expect(event.surfaceOp).toBe('append')
})
it('queries the pane this process runs in and matches its controlling tty', async () => {
const { ctx, bash } = await mount({}, true)
const session = new Session(SessionId('command'))
openMessageTurn(session, 1)
await fire(ctx, sessionAgent(session), 1, 1)
expect(bash.commands).toHaveLength(1)
const command = bash.commands[0]!
expect(command).toContain('[ -n "$TMUX_PANE" ]')
expect(command).toContain('tmux display-message -t "$TMUX_PANE" -p')
// Guards against an inherited $TMUX_PANE: the pane's tty must equal this
// process's controlling tty (resolved for this exact pid).
expect(command).toContain(`ps -o tty= -p ${process.pid}`)
// The exact fragment matters: unquoted, `#` starts a shell comment and the
// substitution silently breaks while a substring check still passes.
expect(command).toContain('pane_tty=$(tmux display-message -t "$TMUX_PANE" -p \'#{pane_tty}\') || exit 1')
expect(command).toContain('[ "$pane_tty" = "/dev/$self_tty" ]')
})
it('does not run on later steps of a turn', async () => {
const { ctx, bash } = await mount({}, true)
const session = new Session(SessionId('later-step'))
openMessageTurn(session, 1)
await fire(ctx, sessionAgent(session), 1, 2)
expect(bash.commands).toHaveLength(0)
expect(contextTexts(session)).toHaveLength(0)
})
it('re-injects a new turn only when tmux state changed', async () => {
const { ctx, bash } = await mount({}, true)
const session = new Session(SessionId('change'))
const agent = sessionAgent(session)
openMessageTurn(session, 1)
await fire(ctx, agent, 1, 1)
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
// Same state on turn 2: suppressed.
openMessageTurn(session, 2)
await fire(ctx, agent, 2, 1)
session.append('turn/end', { turn: 2, reason: { kind: 'completed' } })
expect(contextTexts(session)).toHaveLength(1)
// Moved pane on turn 3: re-injected.
bash.result = runResult(`${tmuxLine({ windowName: 'shell', paneId: '%12' })}\n`)
openMessageTurn(session, 3)
await fire(ctx, agent, 3, 1)
const texts = contextTexts(session)
expect(texts).toHaveLength(2)
expect(texts[1]).toContain('tmux location (turn 3):')
expect(texts[1]).toContain('window 1 "shell", pane 2 %12')
})
it('honors a positive refresh interval between injections', async () => {
vi.useFakeTimers()
vi.setSystemTime(1_000)
const { ctx, bash } = await mount({ refreshIntervalMs: 10_000 }, true)
const session = new Session(SessionId('interval'))
const agent = sessionAgent(session)
openMessageTurn(session, 1)
await fire(ctx, agent, 1, 1)
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
// Changed state but inside the interval: suppressed, and never queried.
bash.result = runResult(`${tmuxLine({ paneId: '%99' })}\n`)
vi.setSystemTime(5_000)
openMessageTurn(session, 2)
await fire(ctx, agent, 2, 1)
expect(contextTexts(session)).toHaveLength(1)
expect(bash.commands).toHaveLength(1)
// Past the interval: queried and re-injected.
vi.setSystemTime(12_000)
openMessageTurn(session, 3)
await fire(ctx, agent, 3, 1)
expect(contextTexts(session)).toHaveLength(2)
expect(bash.commands).toHaveLength(2)
})
})
describe('tmux-context prior-reading resilience', () => {
it('treats a prior non-text plugin reading as absent and injects afresh', async () => {
const { ctx, bash } = await mount({}, true)
const session = new Session(SessionId('prior-non-text'))
const agent = sessionAgent(session)
openMessageTurn(session, 1)
session.append('user/message', createUserMessage({
content: [{ type: 'reasoning', text: 'not a location' }],
source: { kind: 'plugin', plugin: 'tmux-context' },
}), { surfaceOp: 'append' })
await fire(ctx, agent, 1, 1)
expect(bash.commands).toHaveLength(1)
expect(contextTexts(session).at(-1)).toContain('tmux location (turn 1):')
})
it('treats a prior single-line plugin reading (no newline) as empty state', async () => {
const { ctx, bash } = await mount({}, true)
const session = new Session(SessionId('prior-single-line'))
const agent = sessionAgent(session)
openMessageTurn(session, 1)
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'single line, no newline' }],
source: { kind: 'plugin', plugin: 'tmux-context' },
}), { surfaceOp: 'append' })
await fire(ctx, agent, 1, 1)
// Empty prior state never equals the multi-line reading, so it re-injects.
expect(bash.commands).toHaveLength(1)
expect(contextTexts(session).at(-1)).toContain('tmux location (turn 1):')
})
})
describe('tmux-context no-op paths', () => {
it('is a no-op when no bash executor is mounted', async () => {
const { ctx } = await mount()
const session = new Session(SessionId('no-bash'))
openMessageTurn(session, 1)
await fire(ctx, sessionAgent(session), 1, 1)
expect(contextTexts(session)).toHaveLength(0)
})
it('is a no-op when the tmux query exits nonzero (outside tmux, or an inherited env whose tty does not match the pane)', async () => {
const { ctx, bash } = await mount({}, true)
bash.result = runResult('', { exitCode: 1 })
const session = new Session(SessionId('outside-tmux'))
openMessageTurn(session, 1)
await fire(ctx, sessionAgent(session), 1, 1)
expect(contextTexts(session)).toHaveLength(0)
})
it('is a no-op when the reading has the wrong field count', async () => {
const { ctx, bash } = await mount({}, true)
bash.result = runResult('0\\t1\\tnode\n')
const session = new Session(SessionId('malformed'))
openMessageTurn(session, 1)
await fire(ctx, sessionAgent(session), 1, 1)
expect(contextTexts(session)).toHaveLength(0)
})
it('is a no-op when the pane id is empty', async () => {
const { ctx, bash } = await mount({}, true)
bash.result = runResult(`${tmuxLine({ paneId: '' })}\n`)
const session = new Session(SessionId('empty-pane'))
openMessageTurn(session, 1)
await fire(ctx, sessionAgent(session), 1, 1)
expect(contextTexts(session)).toHaveLength(0)
})
it('skips an already-aborted step and runs before ordinary agent/step listeners', async () => {
const { ctx } = await mount({}, true)
const session = new Session(SessionId('ordering'))
const agent = sessionAgent(session)
openMessageTurn(session, 1)
let ordinarySawContext = false
ctx.on('agent/step', (subject) => {
ordinarySawContext = subject.session.events.some(
event => event.type === 'user/message'
&& event.data.source.kind === 'plugin'
&& event.data.source.plugin === 'tmux-context',
)
})
const abort = new AbortController()
abort.abort()
await fire(ctx, agent, 1, 1, abort.signal)
expect(contextTexts(session)).toHaveLength(0)
await fire(ctx, agent, 1, 1)
expect(ordinarySawContext).toBe(true)
expect(contextTexts(session)).toHaveLength(1)
})
})
describe('tmux-context configuration', () => {
it('rejects a negative refresh interval at plugin load', async () => {
await expect(mount({ refreshIntervalMs: -1 })).rejects.toThrow(
/refreshIntervalMs must be a non-negative safe integer/,
)
})
it('rejects a non-integer refresh interval at plugin load', async () => {
await expect(mount({ refreshIntervalMs: 1.5 })).rejects.toThrow(
/refreshIntervalMs must be a non-negative safe integer/,
)
})
})

View File

@@ -1,40 +0,0 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": ["src"],
"references": [
{
"path": "../../../vendor/cosmokit"
},
{
"path": "../../../vendor/cordis"
},
{
"path": "../../../vendor/schemastery"
},
{
"path": "../../llm/llm"
},
{
"path": "../../bash/bash"
},
{
"path": "../../core/agent"
},
{
"path": "../../core/system-prompt"
},
{
"path": "../../support/loader-smoke"
},
{
"path": "../../support/invariants"
},
{
"path": "../../core/session"
}
]
}

View File

@@ -2,5 +2,5 @@
# 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/README.md
README.md: b7375fd2bb12ae0cec94b13e6a1012c6f143bdad
README.zh.md: bba5c144d0663266e4327e388b9915cde176ce08
README.md: 59ab2fcea91bbbb9f6628523f3c6f13497d6f742
README.zh.md: caec5618f9ddc27825ad68cd4145f6197c7b0517

View File

@@ -7,6 +7,5 @@ Behavioral guard plugins that watch the agent loop and correct it — some by nu
| 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) |
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.

View File

@@ -2,10 +2,10 @@
[English](README.md) | 中文
这组行为 guard 插件会监视 agent loop(智能体循环)中的低效模式,并提醒模型调整方向。这里只有一个**产品**包package,不设接口/实现 seamguard 是现有核心 seam`tools/post-execute``agent/prompt-submit``agent/status`)的自包含消费方,并非可替换能力。
这组行为 guard 插件会监视 agent智能体循环并加以纠正:一部分提醒模型调整方向,另一部分则直接拒绝某个操作。它们都是**产品**包,不设接口/实现 seamguard 是现有核心 seam`tools/pre-execute``tools/post-execute``agent/prompt-submit``agent/status`)的自包含消费方,并非可替换能力。
| 包 | 职责 | ctx 键 |
|---|---|---|
| `repeat-tool-guard/` | 当 agent 对完全相同的工具调用反复循环时给出提示 | (监听 `ctx.tools` 的 waterfall,即瀑布式事件) |
| `repeat-tool-guard/` | 当 agent 对完全相同的工具调用反复循环时给出提示 | (监听 `ctx.tools` 的 waterfall瀑布式事件) |
提示以 `additionalContexts` 形式附在 `tools/post-execute` 决策中传递agent loop 会在该步骤的工具结果之后,将其追加为有日志记录、来源为插件的 `user/message` 事件(参见[工具包](../core/tools)。因此guard 告诉模型的所有内容都能从会话日志中重建。
建议型 guard 的提示以 `additionalContexts` 形式附在 `tools/post-execute` 决策中传递agent loop 会在该步骤的工具结果之后,将其追加为有日志记录、来源为插件的 `user/message` 事件(参见[工具包](../core/tools))。因此,此类 guard 告诉模型的所有内容都能从会话日志中重建。强制型 guard 则在 `tools/pre-execute` 上做出决策,其 `deny` 会成为该调用的错误结果,操作绝不会分派执行。

View File

@@ -1,6 +0,0 @@
# 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: a7406d71c6591f78d12b6c02ec22bc4b0b3d517f
README.zh.md: 916d0513b77e17989730fdf27ef50d21013f4e58

View File

@@ -1,88 +0,0 @@
# @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 (`apps/cli/base.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.

View File

@@ -1,88 +0,0 @@
# @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 组合(`apps/cli/base.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 并不能验证是否实际遵循工作流,只能证明已阅读这些指令。

View File

@@ -1,56 +0,0 @@
{
"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"
}
}

View File

@@ -1,319 +0,0 @@
/**
* 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) }
})
}

View File

@@ -1,85 +0,0 @@
/**
* 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))

View File

@@ -1,134 +0,0 @@
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/)
})
})

View File

@@ -1,93 +0,0 @@
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)
})

View File

@@ -1,581 +0,0 @@
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' }])
})
})

View File

@@ -1,42 +0,0 @@
{
"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"
}
]
}