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.
200 lines
7.4 KiB
JavaScript
200 lines
7.4 KiB
JavaScript
import { randomUUID } from 'node:crypto'
|
||
import { canonicalWriteTarget, checkGitWriteScope } from './git-write-scope.js'
|
||
|
||
function isFileMutation(execution) {
|
||
return execution?.name === 'write' || execution?.name === 'edit'
|
||
}
|
||
|
||
function sessionIdentity(execution) {
|
||
return {
|
||
sessionId: execution.agent?.session.header.id,
|
||
cwd: execution.agent?.session.header.cwd,
|
||
}
|
||
}
|
||
|
||
function failureMessage(error) {
|
||
return error instanceof Error ? error.message : String(error)
|
||
}
|
||
|
||
function throwIfCancelled(execution) {
|
||
execution.signal?.throwIfAborted()
|
||
}
|
||
|
||
function warningContext(warnings) {
|
||
return {
|
||
id: randomUUID(),
|
||
role: 'user',
|
||
content: warnings.map(text => ({ type: 'text', text })),
|
||
source: { kind: 'plugin', plugin: 'dsh-touched-git' },
|
||
}
|
||
}
|
||
|
||
function augmentDecision(execution, result, decision, warnings) {
|
||
if (warnings.length === 0) return decision
|
||
|
||
const needsContext = execution.parent !== undefined || decision.kind === 'block' || Object.hasOwn(decision, 'value')
|
||
const additionalContexts = needsContext
|
||
? [...decision.additionalContexts ?? [], warningContext(warnings)]
|
||
: decision.additionalContexts
|
||
|
||
if (decision.kind === 'block' || Object.hasOwn(decision, 'value')) {
|
||
return { ...decision, additionalContexts }
|
||
}
|
||
|
||
return {
|
||
...decision,
|
||
content: [...decision.content ?? result.content, ...warnings.map(text => ({ type: 'text', text }))],
|
||
...(additionalContexts === undefined ? {} : { additionalContexts }),
|
||
}
|
||
}
|
||
|
||
export function registerGitWriteGuard(context, { store, onSuccessfulMutation }) {
|
||
const certificates = new Map()
|
||
let disposed = false
|
||
|
||
function denialReason(execution) {
|
||
if (!isFileMutation(execution)) return undefined
|
||
|
||
const certificate = certificates.get(execution.token)
|
||
if (!certificate || disposed) return 'Write blocked: the Git scope pre-execution check did not complete.'
|
||
if (certificate.denial) return certificate.denial
|
||
|
||
const { sessionId, cwd } = sessionIdentity(execution)
|
||
if (sessionId !== certificate.sessionId || cwd !== certificate.cwd) {
|
||
return 'Write blocked: the session or working directory changed after the Git scope check.'
|
||
}
|
||
|
||
try {
|
||
if (certificate.repository && !store.isRegistered(sessionId, certificate.repository)) {
|
||
return `Write blocked: ${certificate.repository} is not registered in this session’s Git Working Dirs menu. Call add_touched_git_directory for this repository or worktree before writing.`
|
||
}
|
||
} catch (error) {
|
||
return `Write blocked: Git registration could not be checked: ${failureMessage(error)}`
|
||
}
|
||
return undefined
|
||
}
|
||
|
||
async function classify(execution, target) {
|
||
throwIfCancelled(execution)
|
||
const identity = sessionIdentity(execution)
|
||
if (typeof identity.sessionId !== 'string' || identity.sessionId === '' || typeof identity.cwd !== 'string' || identity.cwd === '') {
|
||
throw new Error('Write blocked: an active session with a working directory is required.')
|
||
}
|
||
|
||
const filePath = await context.fs.processPath(target)
|
||
throwIfCancelled(execution)
|
||
const scope = await checkGitWriteScope({ filePath, sessionId: identity.sessionId, store, signal: execution.signal })
|
||
throwIfCancelled(execution)
|
||
if (disposed) throw new Error('Write blocked: the Git scope guard was disposed during the check.')
|
||
|
||
const current = sessionIdentity(execution)
|
||
if (identity.sessionId !== current.sessionId || identity.cwd !== current.cwd) {
|
||
throw new Error('Write blocked: the session or working directory changed during the Git scope check.')
|
||
}
|
||
|
||
const certificate = { ...identity, ...scope }
|
||
certificates.set(execution.token, certificate)
|
||
const denial = denialReason(execution)
|
||
if (denial) throw new Error(denial)
|
||
return certificate
|
||
}
|
||
|
||
async function beforeExecution(execution, next) {
|
||
if (!isFileMutation(execution)) return next()
|
||
|
||
certificates.delete(execution.token)
|
||
const decision = await next()
|
||
if (decision.kind === 'deny') return decision
|
||
|
||
try {
|
||
throwIfCancelled(execution)
|
||
const identity = sessionIdentity(execution)
|
||
if (typeof identity.sessionId !== 'string' || identity.sessionId === '' || typeof identity.cwd !== 'string' || identity.cwd === '') {
|
||
throw new Error('Write blocked: an active session with a working directory is required.')
|
||
}
|
||
const requestedPath = execution.arguments.file_path
|
||
const parentSegment = /(?:^|[\\/])\.\.(?:[\\/]|$)/
|
||
const cwd = parentSegment.test(identity.cwd) || parentSegment.test(requestedPath)
|
||
? await canonicalWriteTarget(identity.cwd, execution.signal)
|
||
: identity.cwd
|
||
const target = await context.fs.resolve(requestedPath, { cwd, signal: execution.signal })
|
||
if (sessionIdentity(execution).sessionId !== identity.sessionId || sessionIdentity(execution).cwd !== identity.cwd) {
|
||
throw new Error('Write blocked: the session or working directory changed during path resolution.')
|
||
}
|
||
await classify(execution, target)
|
||
} catch (error) {
|
||
if (!disposed) certificates.set(execution.token, { denial: failureMessage(error) })
|
||
throwIfCancelled(execution)
|
||
}
|
||
return decision
|
||
}
|
||
|
||
async function beforeMutation(target, execution, next) {
|
||
if (!isFileMutation(execution)) return next()
|
||
|
||
throwIfCancelled(execution)
|
||
const denial = denialReason(execution)
|
||
if (denial) throw new Error(denial)
|
||
|
||
try {
|
||
await classify(execution, target)
|
||
} catch (error) {
|
||
if (!disposed) certificates.set(execution.token, { denial: failureMessage(error) })
|
||
throw error
|
||
}
|
||
|
||
const intent = await next()
|
||
throwIfCancelled(execution)
|
||
const finalDenial = denialReason(execution)
|
||
if (finalDenial) throw new Error(finalDenial)
|
||
return intent
|
||
}
|
||
|
||
async function afterExecution(execution, result, next) {
|
||
const decision = await next()
|
||
if (!isFileMutation(execution)) return decision
|
||
|
||
const certificate = certificates.get(execution.token)
|
||
if (!certificate || certificate.denial) return decision
|
||
|
||
const warnings = certificate.warning ? [certificate.warning] : []
|
||
if (!result.isError && !certificate.reported && onSuccessfulMutation) {
|
||
certificate.reported = true
|
||
try {
|
||
await onSuccessfulMutation({
|
||
sessionId: certificate.sessionId,
|
||
callId: execution.callId,
|
||
toolName: execution.name,
|
||
filePath: certificate.filePath,
|
||
repository: certificate.repository,
|
||
warning: certificate.warning,
|
||
})
|
||
} catch (error) {
|
||
warnings.push(`WARNING: The ${execution.name} operation completed, but its Git mutation record could not be saved: ${failureMessage(error)}`)
|
||
}
|
||
}
|
||
return augmentDecision(execution, result, decision, warnings)
|
||
}
|
||
|
||
const disposers = []
|
||
try {
|
||
disposers.push(context.on('tools/pre-execute', beforeExecution))
|
||
disposers.push(context.tools.guard(denialReason))
|
||
disposers.push(context.on('fs/write-intent', beforeMutation, true))
|
||
disposers.push(context.on('fs/edit-intent', beforeMutation, true))
|
||
disposers.push(context.on('tools/post-execute', afterExecution, true))
|
||
disposers.push(context.on('tools/result', execution => certificates.delete(execution.token)))
|
||
} catch (error) {
|
||
disposed = true
|
||
certificates.clear()
|
||
for (const dispose of disposers.reverse()) dispose()
|
||
throw error
|
||
}
|
||
|
||
return () => {
|
||
if (disposed) return
|
||
disposed = true
|
||
certificates.clear()
|
||
for (const dispose of disposers.reverse()) dispose()
|
||
}
|
||
}
|