DSH-Arch-Setup/plugins/dsh-touched-git/test/git-write-guard.test.js
Шурупов Илья Викторович cf9443753e Add one-command Arch DSH setup
Package the portable DSH profiles, local plugins, desktop integration, Firefox wrapper, and ownership-aware installer lifecycle. Bundle checksum-pinned OpenVSCode, editor extensions, translation assets, and cloudflared so unreliable upstream artifact downloads cannot break a clean installation.
2026-09-12 19:13:36 +03:00

415 lines
17 KiB
JavaScript

import assert from 'node:assert/strict'
import { execFile } from 'node:child_process'
import { mkdtemp, mkdir, realpath, rm, symlink } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { promisify } from 'node:util'
import test from 'node:test'
import { registerGitWriteGuard } from '../lib/git-write-guard.js'
const execute = promisify(execFile)
async function workspace(t) {
const root = await realpath(await mkdtemp(join(tmpdir(), 'dsh-git-write-guard-')))
t.after(() => rm(root, { recursive: true, force: true }))
const repository = join(root, 'repository')
await mkdir(repository)
await execute('git', ['init', '--quiet', repository])
return { root, repository }
}
function createStore() {
const entries = new Map()
return {
register(sessionId, repository) {
entries.set(`${sessionId}:${repository}`, { ok: true, repository })
},
unregister(sessionId, repository) {
entries.delete(`${sessionId}:${repository}`)
},
async list(sessionId) {
return [...entries].filter(([key]) => key.startsWith(`${sessionId}:`)).map(([, value]) => value)
},
isRegistered(sessionId, repository) {
return entries.has(`${sessionId}:${repository}`)
},
}
}
function createHarness({ store = createStore(), onSuccessfulMutation } = {}) {
const hooks = new Map()
const guards = []
const resolved = []
const context = {
fs: {
async resolve(filePath, options) {
resolved.push({ filePath, options })
return { path: resolve(options.cwd, filePath) }
},
async processPath(target) {
return target.path
},
},
on(name, callback, prepend = false) {
const listeners = hooks.get(name) ?? []
hooks.set(name, listeners)
if (prepend === true || prepend.prepend) listeners.unshift(callback)
else listeners.push(callback)
return () => {
const index = listeners.indexOf(callback)
if (index >= 0) listeners.splice(index, 1)
}
},
tools: {
guard(callback) {
guards.push(callback)
return () => {
const index = guards.indexOf(callback)
if (index >= 0) guards.splice(index, 1)
}
},
},
}
const dispose = registerGitWriteGuard(context, { store, onSuccessfulMutation })
function waterfall(name, args, final) {
const callbacks = [...hooks.get(name) ?? []]
const next = () => (callbacks.shift() ?? final)(...args, next)
return next()
}
function denial(execution) {
return guards.map(guard => guard(execution)).find(reason => reason !== undefined)
}
function result(execution, outcome) {
for (const callback of hooks.get('tools/result') ?? []) callback(execution, outcome)
}
return {
context, store, hooks, guards, resolved, dispose, denial, result,
pre: (execution, decision = { kind: 'allow' }) => waterfall('tools/pre-execute', [execution], () => decision),
intent: (name, target, execution, final = () => undefined) => waterfall(name, [target, execution], final),
post: (execution, outcome, decision = { kind: 'accept' }) => waterfall('tools/post-execute', [execution, outcome], () => decision),
async dispatch(execution, body) {
try {
const decision = await this.pre(execution)
const reason = decision.kind === 'deny' ? decision.reason : denial(execution)
if (reason !== undefined) return { isError: true, error: { message: reason }, content: [{ type: 'text', text: reason }] }
return await body()
} catch (error) {
return { isError: true, error: { message: error.message }, content: [{ type: 'text', text: error.message }] }
}
},
}
}
function execution(cwd, name = 'write', filePath = 'new.txt') {
return {
token: Symbol(),
callId: 'call-1',
name,
arguments: { file_path: filePath },
signal: new AbortController().signal,
agent: { session: { header: { id: 'session-1', cwd } } },
}
}
function success() {
return { isError: false, value: { path: 'new.txt', before: null, after: 'content' }, content: [{ type: 'text', text: 'Saved' }] }
}
test('parent traversal uses the physical session cwd like the filesystem tools', async t => {
const { root, repository } = await workspace(t)
const inside = join(repository, 'inside')
const aliases = join(root, 'aliases')
await mkdir(inside)
await mkdir(aliases)
const alias = join(aliases, 'cwd')
await symlink(inside, alias)
const harness = createHarness()
t.after(harness.dispose)
harness.store.register('session-1', repository)
const call = execution(alias, 'write', '../new.txt')
await harness.pre(call)
assert.equal(harness.denial(call), undefined)
assert.equal(harness.resolved[0].options.cwd, inside)
})
for (const name of ['write', 'edit']) {
test(`${name} blocks an unregistered repository before dispatch`, async t => {
const { repository } = await workspace(t)
const harness = createHarness()
t.after(harness.dispose)
let calls = 0
const outcome = await harness.dispatch(execution(repository, name), () => { calls++; return success() })
assert.equal(calls, 0)
assert.equal(outcome.isError, true)
assert.match(outcome.error.message, /not registered/)
})
test(`${name} allows a registered current-session repository`, async t => {
const { repository } = await workspace(t)
const harness = createHarness()
t.after(harness.dispose)
harness.store.register('session-1', repository)
let calls = 0
const outcome = await harness.dispatch(execution(repository, name), () => { calls++; return success() })
assert.equal(calls, 1)
assert.equal(outcome.isError, false)
})
test(`${name} outside Git appends a warning and awaits successful reporting`, async t => {
const { root } = await workspace(t)
let release
const persisted = new Promise(resolve => { release = resolve })
let mutation
let reportStarted
const started = new Promise(resolve => { reportStarted = resolve })
const harness = createHarness({ onSuccessfulMutation: async value => { mutation = value; reportStarted(); await persisted } })
t.after(harness.dispose)
const call = execution(root, name, ' file with spaces ')
const outcome = await harness.dispatch(call, success)
const promise = harness.post(call, outcome)
await started
assert.deepEqual(harness.resolved[0], { filePath: ' file with spaces ', options: { cwd: root, signal: call.signal } })
assert.equal(mutation.filePath, join(root, ' file with spaces '))
assert.equal(mutation.repository, null)
assert.equal(mutation.sessionId, 'session-1')
assert.equal(mutation.toolName, name)
release()
const decision = await promise
assert.equal(decision.kind, 'accept')
assert.equal(Object.hasOwn(decision, 'value'), false)
assert.equal(decision.content[0], outcome.content[0])
assert.match(decision.content.at(-1).text, /WARNING:.*outside a Git repository/)
})
test(`${name} intent checks the actual target and preserves downstream intent`, async t => {
const { root, repository } = await workspace(t)
const harness = createHarness()
t.after(harness.dispose)
harness.store.register('session-1', repository)
const call = execution(repository, name)
await harness.pre(call)
const expected = { version: 'observed-version' }
let observationCalls = 0
const intentName = `fs/${name}-intent`
harness.context.on(intentName, () => { observationCalls++; return expected })
assert.equal(await harness.intent(intentName, { path: join(repository, 'new.txt') }, call), expected)
assert.equal(observationCalls, 1)
const nested = join(repository, 'nested')
await mkdir(nested)
await execute('git', ['init', '--quiet', nested])
await assert.rejects(harness.intent(intentName, { path: join(nested, 'new.txt') }, call), /not registered/)
assert.equal(observationCalls, 1)
assert.notEqual(root, repository)
})
}
test('missing pre-execution certificate blocks even if another hook grants permission', async t => {
const { repository } = await workspace(t)
const harness = createHarness()
t.after(harness.dispose)
harness.store.register('session-1', repository)
harness.context.on('tools/pre-execute', () => ({ kind: 'allow' }), true)
let calls = 0
const outcome = await harness.dispatch(execution(repository), () => { calls++; return success() })
assert.equal(calls, 0)
assert.match(outcome.error.message, /did not complete/)
})
test('other tools pass through without filesystem resolution or reporting', async t => {
let reports = 0
const harness = createHarness({ onSuccessfulMutation: () => { reports++ } })
t.after(harness.dispose)
const call = execution('/unused', 'bash')
const decision = { kind: 'ask', reason: 'Existing policy' }
assert.equal(await harness.pre(call, decision), decision)
assert.equal(harness.denial(call), undefined)
const intent = { version: 'untouched' }
assert.equal(await harness.intent('fs/write-intent', {}, call, () => intent), intent)
assert.deepEqual(await harness.post(call, success()), { kind: 'accept' })
assert.equal(harness.resolved.length, 0)
assert.equal(reports, 0)
})
test('pre-execution preserves downstream deny and ask decisions', async t => {
const { repository } = await workspace(t)
const harness = createHarness()
t.after(harness.dispose)
harness.store.register('session-1', repository)
const call = execution(repository)
const denied = { kind: 'deny', reason: 'Other policy refused' }
assert.equal(await harness.pre(call, denied), denied)
assert.equal(harness.resolved.length, 0)
const ask = { kind: 'ask', reason: 'Approval required' }
assert.equal(await harness.pre(call, ask), ask)
assert.equal(harness.denial(call), undefined)
})
test('last-moment unregistration and session identity changes invalidate certificates', async t => {
const { repository } = await workspace(t)
const harness = createHarness()
t.after(harness.dispose)
harness.store.register('session-1', repository)
const call = execution(repository)
await harness.pre(call)
harness.store.unregister('session-1', repository)
assert.match(harness.denial(call), /not registered/)
harness.store.register('session-1', repository)
call.agent.session.header.id = 'session-2'
assert.match(harness.denial(call), /session or working directory changed/)
call.agent.session.header.id = 'session-1'
call.agent.session.header.cwd = '/other'
assert.match(harness.denial(call), /session or working directory changed/)
})
test('registration in another session never authorizes a mutation', async t => {
const { repository } = await workspace(t)
const harness = createHarness()
t.after(harness.dispose)
harness.store.register('parent-session', repository)
const call = execution(repository)
await harness.pre(call)
assert.match(harness.denial(call), /not registered/)
})
test('nested repository and physical symlink target require their own registration', async t => {
const { root, repository } = await workspace(t)
const nested = join(repository, 'nested')
await mkdir(nested)
await execute('git', ['init', '--quiet', nested])
const alias = join(root, 'alias')
await symlink(nested, alias)
const harness = createHarness()
t.after(harness.dispose)
harness.store.register('session-1', repository)
const call = execution(root, 'write', join(alias, 'missing', 'new.txt'))
await harness.pre(call)
assert.match(harness.denial(call), /not registered/)
harness.store.register('session-1', nested)
await harness.pre(call)
assert.equal(harness.denial(call), undefined)
})
test('cancellation prevents dispatch and reaches filesystem resolution unchanged', async t => {
const { repository } = await workspace(t)
const harness = createHarness()
t.after(harness.dispose)
harness.store.register('session-1', repository)
const controller = new AbortController()
const call = execution(repository)
call.signal = controller.signal
controller.abort(new Error('Cancelled by caller'))
let calls = 0
const outcome = await harness.dispatch(call, () => { calls++; return success() })
assert.equal(outcome.isError, true)
assert.match(outcome.error.message, /Cancelled by caller/)
assert.equal(calls, 0)
assert.equal(harness.resolved.length, 0)
})
test('cancellation during classification prevents a certificate from allowing dispatch', async t => {
const { root } = await workspace(t)
const harness = createHarness()
t.after(harness.dispose)
const controller = new AbortController()
const call = execution(root)
call.signal = controller.signal
harness.context.fs.processPath = async target => { controller.abort(new Error('Cancelled during resolution')); return target.path }
await assert.rejects(harness.pre(call), /Cancelled during resolution/)
assert.notEqual(harness.denial(call), undefined)
})
test('intent rechecks registration after downstream observation settles', async t => {
const { repository } = await workspace(t)
const harness = createHarness()
t.after(harness.dispose)
harness.store.register('session-1', repository)
const call = execution(repository)
await harness.pre(call)
await assert.rejects(harness.intent('fs/write-intent', { path: join(repository, 'new.txt') }, call, () => {
harness.store.unregister('session-1', repository)
return { createIfAbsent: true }
}), /not registered/)
})
test('normal tool failures retain failure content and never report success', async t => {
const { root } = await workspace(t)
let reports = 0
const harness = createHarness({ onSuccessfulMutation: () => { reports++ } })
t.after(harness.dispose)
const call = execution(root)
await harness.pre(call)
const failure = { isError: true, error: { message: 'Literal not found' }, content: [{ type: 'text', text: 'Literal not found' }] }
const decision = await harness.post(call, failure)
assert.equal(decision.content[0], failure.content[0])
assert.equal(Object.hasOwn(decision, 'value'), false)
assert.equal(failure.isError, true)
assert.equal(reports, 0)
})
test('persistence failure warns without rejecting a completed mutation', async t => {
const { repository } = await workspace(t)
const harness = createHarness({ onSuccessfulMutation: async () => { throw new Error('disk unavailable') } })
t.after(harness.dispose)
harness.store.register('session-1', repository)
const call = execution(repository)
await harness.pre(call)
const decision = await harness.post(call, success())
assert.equal(decision.kind, 'accept')
assert.match(decision.content.at(-1).text, /operation completed.*could not be saved: disk unavailable/)
})
test('outside warning preserves downstream blocks and replacement values', async t => {
const { root } = await workspace(t)
const harness = createHarness()
t.after(harness.dispose)
const call = execution(root)
await harness.pre(call)
const block = { kind: 'block', feedback: [{ type: 'text', text: 'Other post policy' }], additionalContexts: [] }
const blocked = await harness.post(call, success(), block)
assert.equal(blocked.kind, 'block')
assert.equal(blocked.feedback, block.feedback)
assert.equal(blocked.additionalContexts[0].role, 'user')
assert.match(blocked.additionalContexts[0].content[0].text, /WARNING/)
const replacement = { kind: 'accept', value: { path: 'replacement' } }
const replaced = await harness.post(call, success(), replacement)
assert.equal(replaced.value, replacement.value)
assert.equal(Object.hasOwn(replaced, 'content'), false)
assert.equal(replaced.additionalContexts.length, 1)
})
test('post content replacement and Code Mode warnings preserve existing contexts', async t => {
const { root } = await workspace(t)
const harness = createHarness()
t.after(harness.dispose)
const call = execution(root)
call.parent = Symbol('code-call')
await harness.pre(call)
const existingContext = { id: 'existing', role: 'user', source: { kind: 'plugin', plugin: 'test' }, content: [] }
const decision = await harness.post(call, success(), { kind: 'accept', content: [{ type: 'text', text: 'Replacement' }], additionalContexts: [existingContext] })
assert.equal(decision.content[0].text, 'Replacement')
assert.equal(decision.additionalContexts[0], existingContext)
assert.equal(decision.additionalContexts[1].source.plugin, 'dsh-touched-git')
})
test('tools/result and disposal clear certificates and unregister every hook', async t => {
const { repository } = await workspace(t)
const harness = createHarness()
harness.store.register('session-1', repository)
const call = execution(repository)
await harness.pre(call)
assert.equal(harness.denial(call), undefined)
harness.result(call, success())
assert.match(harness.denial(call), /did not complete/)
await harness.pre(call)
const guard = harness.guards[0]
harness.dispose()
harness.dispose()
assert.match(guard(call), /did not complete/)
assert.equal(harness.guards.length, 0)
assert.ok([...harness.hooks.values()].every(callbacks => callbacks.length === 0))
})