跳到主要內容

【2026 最新】DeepSeek Harness 安全 Plugin 實戰:權限、取消、Config-only HMR 與 Crash Recovery

最後更新: ·
DeepSeek Harness 安全 Plugin 實戰:canonical allowlist、取消與 event projection

DeepSeek Harness 安全 Plugin 真正要驗收的,不是「工具有沒有跑」,而是「路徑繞出去、Plugin 被卸載、程序突然 crash 時,下一個副作用還會不會發生」。這篇是進階故障注入實驗;如果你還沒跑過 Web、四種 Mode 與第一個 Plugin,先完成《DeepSeek Harness 完整教學》。本文不重複安裝導覽,而是用固定官方 source 的 acceptance protocol,拆解 permission boundary、cooperative cancellation、config replacement 與 event projection;文中的程式碼是可移植的關鍵 excerpt,481 項測試則是 AlphaLab 對固定 checkout 的實測結果,不是可下載的完整 lab。

截至 2026 年 8 月 19 日,官方 master 與最新 prerelease 都是 dsh-v0.1.0-rc.7、commit 99f6f02fecdb7dff40c3fbc9470f5907c29f74ca;本文以這個 snapshot 實測。source license 是 MIT,LICENSE SHA-256 為 ebb4f099…6be。官方 package.json 宣告 pnpm@11.7.0 與 Node ^22.19.0 || >=24.0.0;本次 shell 另行核對後,實際使用 pnpm 11.7.0、Node v24.15.0。驗收只涵蓋這份官方 Git checkout 與這個環境,沒有宣稱任何 npm tarball 與它 byte-for-byte 相同。官方仍標示 developer preview,並明示會有 compatibility-breaking changes;未來版本請先重跑本文測試,不要只改版本字串。

MIT 是 DeepSeek Harness 本身的 license,不代表所有 dependency 都是 MIT。這個 commit 的 THIRD_PARTY_NOTICES.md 分列 vendored source、runtime npm dependencies 與其他 disclosed payload;各專案仍受自己的 license 約束,完整 transitive closure 另由 pnpm-lock.yamlpython/sdk/uv.lock 固定。部署或再散布前要一併審查,不能只看根目錄 LICENSE

DeepSeek Harness 安全 Plugin 先說結論:安全是三道閘門

🐋 記憶把手:安全 Plugin = canonical allowlist(只能寫哪裡)+ cooperative cancellation(何時停止)+ append-only log projection(出事後辨認未知結果)。
少任何一層,都可能出現「畫面顯示取消,第二個檔案卻照樣寫完」或「重啟後把未知結果的操作再做一次」。

  1. 空間閘門:把 model-controlled path 解析成 canonical target,再要求它位於 Plugin 專用 root 且命中 exact allowlist。
  2. 時間閘門:把每次 tool call 的 exec.signal 與 Plugin lifetime signal 合併,並在每個副作用前再次檢查。
  3. 復原閘門:從 append-only event log 重建 model-visible history;遇到已記錄 call、沒有 result 的 crash window,先查外部狀態,不盲目重跑。
DeepSeek Harness 安全 Plugin 的 canonical allowlist、lifetime cancellation 與 event projection 三層閘門
同一個 tool call 依序通過路徑、生命週期與復原閘門;event projection 只重建模型可見歷史,不會替你回滾或重跑外部副作用。

為什麼「workspace-write 已開」仍不夠?

官方 filesystem sandbox 會 canonicalize target,workspace-write 允許 workspace 與平台 temp 下的寫入;讀取、網路與程序可見性不在這個 mode 的限制範圍。更容易踩雷的是:bare fs-localcwd 只負責相對路徑解析,並不是 containment boundary。

所以本文不是取代官方 dsh-fs-sandbox,而是在它上面再縮一層:整個 Agent 可以寫 workspace,但這支 Plugin 只准碰 .dsh-safe-output/phase-1.txtphase-2.txt。這是 trusted-code policy fence,不是 kernel boundary;若要執行不受信任程式碼,外層仍應使用隔離帳號、VM/container。可對照《把 AI coding agent 關進 microVM》理解兩層責任差異。

DeepSeek Harness 安全 Plugin 實作:先固定版本與測試世界

先在拋棄式目錄準備官方 source checkout。packageManager 欄位只是期望值,不保證你目前 shell 叫到的 pnpm 正確,因此安裝前要逐項核對 commit、tag、pnpm、Node 與 license hash;不要在含生產 API key 的日常 workspace 做故障注入。

git clone https://github.com/deepseek-ai/deepseek-harness.git
cd deepseek-harness
git checkout --detach dsh-v0.1.0-rc.7

test "$(git rev-parse HEAD)" = "99f6f02fecdb7dff40c3fbc9470f5907c29f74ca"
test "$(git describe --exact-match --tags HEAD)" = "dsh-v0.1.0-rc.7"
node --version        # 必須符合 ^22.19.0 || >=24.0.0
pnpm --version        # 本文驗收值:11.7.0
shasum -a 256 LICENSE # ebb4f09972...55c240dec2ecfa16ea6be

pnpm install --frozen-lockfile
mkdir -p tmp/alphalab-safety-lab

核心 Plugin 如下。除了 canonical identity 與合併 signal,還有三個不能省的細節:必須注入並逐 call 解析 sandboxPolicy;用 createIfAbsent 防止盲目覆寫;tool registration 與 abort/drain disposer 必須放在同一個 composite effect,利用反向 disposal 先停止、等待 in-flight 工作收斂,最後才 unregister。分成兩個頂層 effect 會並行卸載,不能證明這個順序。

import { setTimeout as delay } from 'node:timers/promises'
import type { Context } from '@deepseek-ai/cordis'
import Schema from '@deepseek-ai/schemastery'
import { defineTool } from '@deepseek-ai/dsh-tools'
import type { ToolRunContext } from '@deepseek-ai/dsh-tools'
import type { FsTarget } from '@deepseek-ai/dsh-fs'
import type {} from '@deepseek-ai/dsh-fs'
import type { SandboxExecutionPolicy } from '@deepseek-ai/dsh-sandbox'
import type {} from '@deepseek-ai/dsh-sandbox-policy'

export const name = 'alphalab-safe-writer'
export const inject = ['tools', 'fs', 'sandboxPolicy']

declare module '@deepseek-ai/cordis' {
  interface Events {
    /** Test/diagnostic boundary only: emitted after dispatch, before any filesystem operation. */
    'alphalab-safe-writer/body-start'(callId: string): void
    /** Test/diagnostic boundary only: emitted after an atomic phase write commits. */
    'alphalab-safe-writer/phase-committed'(callId: string, phase: 1 | 2): void
  }
}

export interface Config {
  outputRoot: string
  allowedFiles: string[]
}

export const Config: Schema<Config> = Schema.object({
  outputRoot: Schema.string().required(),
  allowedFiles: Schema.array(String).required(),
})

function sameTarget(ctx: Context, a: FsTarget, b: FsTarget): boolean {
  return ctx.fs.contains(a, b) && ctx.fs.contains(b, a)
}

function policyRequest(exec: ToolRunContext): Parameters<Context['sandboxPolicy']['resolve']>[0] {
  return exec.agent === undefined ? {} : { session: exec.agent.session }
}

/**
 * A deliberately narrow demo tool. Its allowlist constrains model-controlled
 * names; the injected filesystem and per-call policy remain the enforcing
 * capability seam. This is trusted-process containment, not a kernel sandbox.
 */
export async function apply(ctx: Context, config: Config): Promise<void> {
  if (ctx.fs.sandboxMode === undefined) {
    throw new Error('alphalab-safe-writer requires a confining ctx.fs backend')
  }
  if (config.allowedFiles.length === 0) {
    throw new Error('alphalab-safe-writer requires at least one allowed file')
  }

  const root = await ctx.fs.resolve(config.outputRoot)
  const slots = await Promise.all(config.allowedFiles.map(
    path => ctx.fs.resolve(path, { cwd: config.outputRoot }),
  ))
  for (let index = 0; index < slots.length; index += 1) {
    const slot = slots[index]!
    if (sameTarget(ctx, root, slot) || !ctx.fs.contains(root, slot)) {
      throw new Error(`allowed file is not a child of outputRoot: ${config.allowedFiles[index]}`)
    }
    if (slots.slice(0, index).some(prior => sameTarget(ctx, prior, slot))) {
      throw new Error(`duplicate canonical allowlist target: ${config.allowedFiles[index]}`)
    }
  }

  const lifetime = new AbortController()
  const inFlight = new Set<Promise<unknown>>()
  let closing = false

  const confinedPolicy = async (exec: ToolRunContext, signal: AbortSignal): Promise<SandboxExecutionPolicy> => {
    const standing = ctx.sandboxPolicy.resolve(policyRequest(exec))
    if (standing.mode === 'read-only') {
      throw new Error('safe_write_pair denied by the calling session read-only policy')
    }
    if (standing.mode === 'workspace-write') {
      const standingRoot = await ctx.fs.resolve(standing.workspaceRoot, { signal })
      if (!ctx.fs.contains(standingRoot, root)) {
        throw new Error('safe_write_pair outputRoot is outside the calling session workspace')
      }
    }
    // Narrow danger-full-access or a broader workspace to this plugin root.
    // fs-sandbox still documents platform-temp allowance and a residual
    // canonicalize-to-syscall race; the exact canonical slot check below is a
    // second trusted-code fence, not a claim of kernel isolation.
    return {
      ...standing,
      mode: 'workspace-write',
      workspaceRoot: ctx.fs.processPath(root),
    }
  }

  const run = async (args: { first_path: string; second_path: string; pause_ms: number }, exec: ToolRunContext): Promise<string[]> => {
    const signal = AbortSignal.any([exec.signal, lifetime.signal])
    ctx.emit('alphalab-safe-writer/body-start', String(exec.callId))
    signal.throwIfAborted()
    const sandboxPolicy = await confinedPolicy(exec, signal)

    const write = async (path: string, content: string, phase: 1 | 2): Promise<string> => {
      signal.throwIfAborted()
      const target = await ctx.fs.resolve(path, { cwd: config.outputRoot, signal })
      if (!ctx.fs.contains(root, target) || !slots.some(slot => sameTarget(ctx, slot, target))) {
        throw new Error(`write denied outside canonical allowlist: ${path}`)
      }
      signal.throwIfAborted()
      // Guarded create: never overwrite a pre-existing target and re-check the
      // backend's per-call policy immediately before atomic publication.
      await ctx.fs.writeText(target, content, { kind: 'createIfAbsent' }, signal, sandboxPolicy)
      ctx.emit('alphalab-safe-writer/phase-committed', String(exec.callId), phase)
      return target.displayPath
    }

    const first = await write(args.first_path, 'phase-1\n', 1)
    await delay(args.pause_ms, undefined, { signal })
    const second = await write(args.second_path, 'phase-2\n', 2)
    return [first, second]
  }

  const tool = defineTool({
    name: 'safe_write_pair',
    description: 'Create two canonical-allowlisted files with one cooperative cancellation point between them.',
    parameters: {
      first_path: { type: 'string', required: true },
      second_path: { type: 'string', required: true },
      pause_ms: { type: 'number', required: true },
    },
    output: {
      schema: {
        type: 'array',
        items: { type: 'string' },
      },
      render: (_args, value) => [{ type: 'text', text: value.join(', ') }],
    },
    async execute(args, exec) {
      if (closing) throw new Error('safe-writer is unloading')
      const operation = run(args, exec)
      inFlight.add(operation)
      try {
        return await operation
      } finally {
        inFlight.delete(operation)
      }
    },
  })

  // One composite effect is essential: yielded disposers run in reverse order,
  // so unload first closes admission, aborts, and drains; only then does it
  // unregister the tool. Separate top-level effects would unload concurrently.
  ctx.effect(function* () {
    yield ctx.tools.register(tool)
    yield async () => {
      closing = true
      lifetime.abort(new Error('safe-writer unmounted'))
      while (inFlight.size > 0) {
        await Promise.allSettled([...inFlight])
      }
    }
  }, 'alphalab-safe-writer:abort-drain-unregister')
}

白話說,allowlist 比「字串開頭是這個資料夾」更嚴格。../、絕對路徑或 workspace 內指向外面的 symlink,在 canonical identity 上都不會同時通過 root 與 exact slot;每次 mutation 還會帶入 calling session 的 policy。這仍是 trusted-code fence:fs-sandbox 明載 canonicalize 到 syscall 之間仍接受一個 ancestor-symlink TOCTOU,且 workspace-write 仍允許平台 temp,不能包裝成 kernel isolation。

驗收 ①:先攻擊 path traversal 與 symlink

測試不要只餵正常路徑。這裡分清兩個時點:若 allowlisted slot 在 Plugin 啟動前已是外連 symlink,apply() 必須直接拒絕 boot;若 mount 後才把 slot 換成外連 symlink,該次 tool call 必須在真正 write 前失敗。兩種情況都要求 0 次底層 write,outside canary 完全不變:

it('fails boot when an allowlisted slot resolves outside its root', async () => {
  await writeFile(join(outside, 'escaped.txt'), 'outside\n')
  await symlink(join(outside, 'escaped.txt'), join(outputRoot, 'phase-1.txt'))
  await expect(boot()).rejects.toThrow('allowed file is not a child of outputRoot')
  expect(fs.writeCount).toBe(0)
  expect(await readFile(join(outside, 'escaped.txt'), 'utf8')).toBe('outside\n')
})

it('denies a runtime symlink replacement without changing its target', async () => {
  await boot()
  await writeFile(join(outside, 'escaped.txt'), 'outside\n')
  await symlink(join(outside, 'escaped.txt'), join(outputRoot, 'phase-1.txt'))
  const result = await call('phase-1.txt', 'phase-2.txt')
  expect(result.isError).toBe(true)
  expect(fs.writeCount).toBe(0)
  expect(await readFile(join(outside, 'escaped.txt'), 'utf8')).toBe('outside\n')
})

Traversal case 另外先 await boot(),再確認 ../escape.txt 回傳 error、fs.writeCount === 0,且 phase 2 不存在。既有 allowlisted 檔則用 createIfAbsent 測試:底層雖收到 1 次 guarded write,sentinel 不得被覆寫,phase 2 也不得出現。只看到錯誤訊息不算通過;必須檢查 call count 與真實 filesystem state。

驗收 ②:Caller cancel 與 unmount 必須擋住下一個副作用

官方 Cordis lifecycle 會在卸載時回收 tool registration 與 effects,但「新 call 看不到工具」不等於「已開始的 Promise 被硬殺」。Tool runtime contract 明確採 cooperative cancellation:已開始工作仍會被 drain,也可能已留下副作用。

先把 caller cancellation 的三個窗口分開:dispatch 前取消必須 body=0、write=0;body 已進入、第一次 write 前取消必須 body=1、write=0;phase 1 commit 後取消則保留 phase 1,但 write count 只能是 1,phase 2 不得出現。這不是回滾,而是阻止下一個副作用:

await boot()
const controller = new AbortController()
let bodyCount = 0
ctx!.on('alphalab-safe-writer/body-start', () => {
  bodyCount += 1
  controller.abort(new Error('cancel at body boundary'))
})
const result = await call('phase-1.txt', 'phase-2.txt', 1, controller.signal)
expect(result.isError).toBe(true)
expect(bodyCount).toBe(1)
expect(fs.writeCount).toBe(0)
expect(existsSync(join(outputRoot, 'phase-1.txt'))).toBe(false)

Unload 還要多驗一件事:disposer 必須等待已 commit、但故意不理 cancellation 的 provider boundary 收斂,等待期間 tool 仍已被 composite effect 擁有,不能提早 unregister。測試用的 CountingSandboxedFileSystem 是純本地 gate,不是 production provider:

const fiber = await boot()
const block = fs.blockNextWriteAfterCommit()
const running = call('phase-1.txt', 'phase-2.txt', 10_000)
let resultSettlements = 0
void running.then(() => { resultSettlements += 1 })
await block.entered.promise

let unloadSettled = false
const unload = fiber.dispose().then(() => { unloadSettled = true })
await Promise.resolve()
await Promise.resolve()
expect(unloadSettled).toBe(false)
expect(ctx!.tools.get('safe_write_pair')).toBeDefined()

block.release.resolve()
await unload
const result = await running
expect(result.isError).toBe(true)
expect(unloadSettled).toBe(true)
expect(resultSettlements).toBe(1)
expect(ctx!.tools.get('safe_write_pair')).toBeUndefined()
expect(fs.writeCount).toBe(1)
expect(existsSync(join(outputRoot, 'phase-2.txt'))).toBe(false)

await new Promise(resolve => setTimeout(resolve, 25))
expect(resultSettlements).toBe(1)
expect(fs.writeCount).toBe(1)

另一個 unload case 在 phase 1 event 後立即 dispose,同樣必須留下 phase-1\n、沒有 phase 2。這也回答「半寫入」的歧義:官方 local FS 對單一 target 採 atomic publication,signal 會在 publication 前檢查;但兩個檔案是兩個已提交副作用,不是 transaction。若 phase 1 代表扣款、phase 2 代表記帳,你需要 idempotency、對帳或補償,不是再多一個 abort check。

驗收 ③:Crash 後做 event projection;未知結果先對帳

DeepSeek Harness 的 Session 是 append-only typed event log;deriveMessages() 才把 user/assistant/tool result 投影成模型下一次看見的 history。所謂 deterministic projection,應限定為:同一個相容版本、同一段已驗證 event prefix,得到相同 model-visible projection。它不是讓程序回到 crash 前的記憶體,也不是重新執行工具。

import { createHash } from 'node:crypto'
import { expect } from 'vitest'
import { Session, SessionId } from '@deepseek-ai/dsh-session'

const digest = (value: unknown) =>
  createHash('sha256').update(JSON.stringify(value)).digest('hex')

function expectStableProjection(original: Session): void {
  const before = digest(original.deriveMessages())
  const projected = Session.create(SessionId('projection-copy'), [...original.events])
  expect(digest(projected.deriveMessages())).toBe(before)
}

Standalone opId fixture:只教決策,不冒充 production Plugin

本文的 durable-op.ts獨立、單 writer、raw node:fs 的教學 fixture;它不是 Harness Plugin、不是可讓模型呼叫的 tool,也不是 production journal。它把 provider 換成完全本地的 inert fake,只驗收下列 decision model:

  • 第一次操作:先 append 並 file-fsync intent(opId, fingerprint),才准呼叫 fake provider;拿到結果後再 append result
  • 已有相同 result:回傳 reused-result,純 modelVisibleProjection(records) 可重建輸出;provider lookup=0、apply=0。
  • 只有相同 intent:一定先 lookup(opId)。若 provider 能證明已提交,記 reconciled;查不到或無法確認就記 UNKNOWN,絕不把舊 intent 當成再次 apply 的授權。
  • opId 重用:相同 fingerprint 去重;不同 fingerprint 拋 OperationConflictError
  • journal 損壞:先驗證所有完整 frame,才可截掉最後一段 torn tail;已換未來 schema 或完整行 JSON 壞掉則 fail closed,原 bytes 不動、provider 不得被碰。

Fixture 的 afterIntentafterEffect hook 只模擬 process window,不是 OS hard crash。它只 fsync journal file、沒有 fsync parent directory;fake provider 會原地 truncate/rewrite,非 atomic store;也沒有 cross-process lease。因此它只能證明「intent 不授權盲目重做」這個決策,不能證明 exactly-once 或真正 crash durability。

這篇刻意不把 AlphaLab 既有四篇混成一篇;四條路徑各自回答不同問題:

真正的 hard-crash 證據只來自官方 subprocess e2e。官方 persistence contract 會把「已記錄 call、沒有 result」修成 TOOL_OUTCOME_UNKNOWN;給模型的指示是:只讀或 idempotent 工作才考慮 retry,有外部副作用就先驗證狀態或詢問使用者。

量化驗收:官方 crash-recovery.e2e.ts 的 2 個 subprocess hard-crash tests 全綠:一個涵蓋 model dispatch 前的 durable request,另一個涵蓋副作用前的 durable tool intent;結果遺失時恢復成 UNKNOWN,不會自動宣告成功或再跑一次。這 2 個才是本文所有「hard crash」字樣的證據。

驗收 ④:Config-only HMR、API key 與 sub-agent teardown

先分清 HMR 的範圍。rc.7 shipped Web 的 packages/bundle/web-app/cordis.patch.yml 明確把 shared-module hmr row 設為 disabled;CLI/profile boot 才會在沒有 HMR service 時掛上一個 { root: [] } 的 watch-only instance,監看 live profile 與 home 的 cordis.patch.yml。因此本文能談的是長生命週期 CLI surface 的 config-only HMR,不是 stock Web source HMR,也不是改任意 Plugin 原始碼都會自動 reload。

Patch row 必須靠穩定 id 命中既有 entry;non-insert patch 缺 id、或 id 不存在時,composer 會 warning 並跳過,不會猜目標。DeepSeek Harness 的 patch 也不是 nested deep merge。官方 CLI reference 說得很精確:命中同一 row 的後層 patch 會替換完整 config,所以每次都要重述所有必要 key。若原本使用 apiKeyEnv: TEAM_DEEPSEEK_KEY,新 patch 只留下 maxTokens,schema 會把 credential ref 回填為預設 DEEPSEEK_API_KEY;若只有團隊變數有值,下一個 request 便得到 MISSING_CREDENTIAL。Plugin 不一定在 load 時就失敗,因為 key 是 per request 解析。

import { expect } from 'vitest'
import { resolveAdapterOptions } from '@deepseek-ai/dsh-llm-deepseek'

expect(resolveAdapterOptions({ apiKeyEnv: 'TEAM_DEEPSEEK_KEY' }).apiKeyEnv)
  .toBe('TEAM_DEEPSEEK_KEY')

// 模擬整個 config 被 { maxTokens: 8192 } 取代
expect(resolveAdapterOptions({ maxTokens: 8192 }).apiKeyEnv)
  .toBe('DEEPSEEK_API_KEY')

Config-only update 會保留同一個 fiber identity,但預設 Fiber.update() 仍會 restart:先卸載 effects,再用新 config 重跑 Plugin callback;invalid candidate rollback 也會以舊 config 重新啟動。因此 ordinary config update 同樣可能有 availability gap,且不保留 Plugin-local in-memory state。nameinjectgroup 變動則走 replacement path:先 dispose 舊 fiber、再啟動新 fiber;新 instance 失敗時,是把舊 Plugin重新啟動成 fresh instance。兩條路徑都不能宣稱 atomic swap。固定版本實測涵蓋 valid、invalid、parse failure、修復與移除,但 User-patch 與 registry 測試分別通過,仍不足以證明這支自製 Plugin 在每一個 HMR failure window 都絕無 duplicate registration。

Credential gates 的固定版本實測是 core 17 tests,加上只篩選 DeepSeek keyless 行為的 2 tests:空 ambient variable 視為沒有 key,request 以 MISSING_CREDENTIAL 失敗,而且 rejected dummy key 不會出現在錯誤輸出。這些測試沒有真實 API key,也沒有送出 provider request。

Sub-agent 也要驗收「失敗不冒充成功」。依 rc.7 的 one-shot result contract,只有 completed 能成為成功輸出;aborted 映射 killed,errormax-tokensrefusal 與未知 stop reason 都是 failed,非 completed 的 partial output 不能當成功答案。One-shot settleRun() 會先 await result、再 await dispose(),完成 child reap 後才回報 completed/killed/failed;SDK child 在 handshake/startup 失敗時也會先 close() 並 reap,才 reject。本文跑的 39 tests 同時涵蓋 run-settlement.spec.tssubagent-dsh-sdk.spec.ts:後者真的啟動本地 fake-runtime stdio child,走 startup failure、caller cancel、dispose 與 provider unregister 路徑。

環境變數也有兩層語意:底層 SDK client 若收到明確 env,那就是 child 的完整環境;subagent-dsh-sdk provider 則主動組成 { ...scrubbedParentEnv(), ...spec.env }。其中 scrubber 一律移除 DSH_*,並依變數名稱是否符合 KEY|PASSWORD|SECRET|TOKEN 的正規式移除 ambient value,再疊加明列的 spec.env。這是名稱啟發式,不是 credential allowlist;名稱沒命中的秘密仍可能被繼承,而且 stock spec.env 只能加值、不能刪掉已保留的 ambient name。敏感部署應以最小 ambient env 啟動 parent Harness,或改用能提供完整最小 env 的自訂 wrapper/provider,再加外層隔離。取消沒有 wire-level prompt-cancel;它先在本地 settle,再由 close() 做 bounded protocol shutdown,接著走 stdin EOF → POSIX SIGTERM → SIGKILL 的 teardown ladder,並等 child exit。測試涵蓋這些 teardown path,但沒有在 dispose 後獨立檢查 PID,也不能證明所有可能的 late callback 都不存在。

DeepSeek Harness 安全 Plugin 九組驗收的輸入、預期結果與限制
九組 break-it-first 驗收:先寫清楚「世界最後應該長什麼樣」,再看 tool result;只有訊息好看、外部狀態不對,仍算失敗。

固定版本實測:9 suites/481 tests

AlphaLab 把前述自製安全測試與官方 regression tests 綁在同一個 99f6f02 checkout:16 個 test files 分成 9 組,每組先核對固定 source、Vitest config 與最低通過數,再用移除 credential-like 與 proxy 變數的環境執行。Custom tests 使用獨立 lab config,官方 unit suites 使用 root vitest.config.ts,只有真正的 subprocess crash suite 使用 vitest.e2e.config.ts

可執行性邊界:本文公開的是可移植的核心 Plugin 與關鍵 test excerpts,不是一份可下載、checksum-bound 的完整 lab bundle;因此讀者可以照著建立自己的驗收,但不能只靠本文重建 AlphaLab 當次完全相同的測試環境。

2026 年 8 月 19 日的固定版本實測結果是 overall: PASS,9 suites、16 個 test files 共 481 tests:

  • custom-safety-lab:19 passed(allowlist、guarded create、cancel/unload、standalone opId 決策)
  • hmr-and-tool-registry:152 passed(valid/invalid patch recovery、整份 patch list replacement、registry ownership)
  • cooperative-cancellation:43 passed(agent-loop cancel、timeout 等待 cooperative settlement)
  • filesystem-policy:25 passed(canonical containment、per-call policy fence)
  • credential-scrubbing-core:17 passed(rejected credential 不洩漏)
  • deepseek-missing-credential:2 passed(keyless request 的 MISSING_CREDENTIAL
  • session-repair-and-projection:182 passed(torn tail、UNKNOWN/NOT_STARTED、projection version mismatch refold)
  • checkpoint-hard-crash:2 passed(官方 subprocess hard-crash fixtures)
  • subagent-settlement-and-sdk-process:39 passed(settlement、fake-runtime env/cancel/dispose)
  • 合計:481 passed;每組 status 0,沒有命中 forbidden output sentinel。

481 tests 不是「rc.7 沒有 bug」的證明,只表示本文明列的 gates 在這份 pinned checkout 通過。換 commit、Node、pnpm、lockfile 或測試輸入,都應視為另一份證據重新執行,不能沿用這次結果。

這 481 tests 明確不能證明的 8 件事

  1. 不是 Web source-HMR 證明:shipped Web 的 shared-module HMR 是 disabled;這次實測只綁 CLI/profile config-only watcher 與當前 source。
  2. 不是所有 HMR window 的 zero-duplicate 證明:user-patch suite 與 registry suite 各自通過,不等於這支 custom Plugin 在每種失敗交錯都沒有重複註冊。
  3. 不是 Cordis 自動取消任意 body:unload 本身不會硬殺 arbitrary tool Promise;本文結果來自 custom Plugin 自己 fuse lifetime signal、停止 admission 並 drain。
  4. 不是 kernel sandbox:fs-sandbox 是 trusted-code path containment;workspace-write 仍允許平台 temp,且有文件明載的 ancestor-symlink TOCTOU。
  5. 不是 exactly-once:JSONL/session repair 只平衡 history 並標記 unknown;它既不證明外部 effect exactly-once,也不授權盲目 rerun。
  6. 不是 production opId store:custom fixture 是 standalone raw-fs、simulated-window、single-writer;沒有 parent-directory fsync、atomic provider store 或 cross-process lease。
  7. 不是 PID 級完整 teardown 證明:SDK subprocess tests 走過 teardown,但未在 disposal 後獨立檢查 child PID,也沒有證明每一種 late callback 都不存在。
  8. 不是線上整合驗收:這次實測沒有真實 API key、provider call、npm artifact、WordPress action 或 external network access。

最常見的 6 個錯誤

  1. 只檢查字串 prefix:/work/a/work/ab..、symlink 都可能欺騙 lexical check;使用 provider 的 canonical target 與 contains()
  2. 把 unregister 當 cancel:它阻止未來查到工具,不代表 started body 停止;Plugin 必須擁有 lifetime controller。
  3. 只在函式開頭查 signal:取消可能發生在 await 之後;每個不可逆副作用前都要再查,並把 signal 傳入底層 API。
  4. 把 atomic file 當 multi-step transaction:單檔不出現半份內容,不代表兩檔一起成功或一起失敗。
  5. 看到 unknown 就 retry:先用 idempotency key 或外部查詢確認 phase 1 是否已發生。
  6. 以為 patch 會保留沒寫的欄位:先用 --dump-config 保存基線,替換整個 row 時明列自訂 credential ref 與必要 expression。

FAQ:DeepSeek Harness 安全 Plugin 常見問題

1. 有 workspace-write 就安全了嗎?

不夠。它是較寬的 filesystem policy,還允許平台 temp,且不限制所有讀取、網路或 process visibility;敏感 Plugin 應再加自己的 canonical allowlist。

2. unmount 會回滾已寫完的檔案嗎?

不會。它會清理 lifecycle effects;已完成的外部副作用需要補償或人工處理。

3. AbortSignal 能硬殺任何 Plugin 嗎?

不能。這是 cooperative signal;registry/timeout path 會等 started Promise settle,才產生最終 cancelled/timeout result。忽略 signal 的同程序 Promise 可能繼續、卡住或留下副作用,不能把 timeout 當 preemption。

4. atomic write 代表 crash 後一定落盤嗎?

不等於。atomic publication 解決讀者看到半份 target 的問題;durability、fsync、兩個檔案的一致性是不同問題。

5. Session recovery/projection 會自動再執行工具嗎?

不會。Persistence load 只修復可修的 torn tail/open turn,projection 從相容 events 推導 model-visible history;未知 tool outcome 反而要求先查外部狀態。

6. API key 不見會讓 DeepSeek Plugin 載入失敗嗎?

不一定。native adapter 的 route 可先註冊;沒有可解析 key 的 request 會以 MISSING_CREDENTIAL 失敗。

7. sub-agent 有部分文字就能算成功嗎?

不能。One-shot path 只有 completed 是成功。Foreground tool 會把 non-completed partial text 留在錯誤內容供診斷;background Jobs 的 settleRun() 則丟棄 partial output,只回 failedkilled。兩條路徑都不能把部分文字當成功答案。

8. stock Web 會熱更新任何 Plugin 原始碼嗎?

不會。rc.7 shipped Web 明確停用 shared-module HMR;CLI/profile 只補上一個 root: [] 的 config-only watcher。改 Plugin source 後應重新啟動並重跑驗收,不能用 config reload 的結果外推 source reload。

給開發者的 5 個最後檢查

  • 固定 release、commit、Node/pnpm 與 lockfile。
  • 用外部 canary 驗證 denied write,而不是只看 log。
  • 把 caller signal 與 Plugin lifetime signal 都傳到底層副作用。
  • 保存 event log,並用 projection digest 驗證 crash 前後 history。
  • 把 unknown、max-tokens、refusal、credential failure 都列為非成功。

接著閱讀

左右滑動查看更多推薦

結語:先定義不能發生什麼,再寫 Plugin

DeepSeek Harness 安全 Plugin 的重點不是多包一層 try/catch,而是把三個世界接起來:canonical allowlist 決定「只能去哪裡」,lifetime cancellation 決定「卸載後不能再做什麼」,append-only log projection 決定「重啟後知道哪些結果仍未知」。記住本文的把手:安全 Plugin = canonical allowlist + cooperative cancellation + append-only log projection/UNKNOWN。測試先把它打破,正式任務才有資格信任它。

ALPHALAB 社群

有問題?來 Telegram 聊

和 Terry、編輯、其他網友一起討論這篇文章。提問、分享觀點,回覆更即時。

加入 Telegram 討論

📩 訂閱 AlphaLab 電子報

每週最多兩封,收到週報精選與關鍵 Alpha Signal。

我們不會 spam,隨時可退訂。