Merge origin/master: web permission sandbox, default pi-ai providers
This commit is contained in:
319
packages/guard/source-guard/src/index.ts
Normal file
319
packages/guard/source-guard/src/index.ts
Normal file
@@ -0,0 +1,319 @@
|
||||
/**
|
||||
* Denies model-driven file mutation inside a dsh staging worktree until the
|
||||
* calling session has loaded the required customization skill. Config, git
|
||||
* resolution, and satisfaction semantics live in the package README; rationale
|
||||
* lives in the source-guard Agent Note.
|
||||
* @module @deepseek-ai/dsh-source-guard
|
||||
*/
|
||||
|
||||
import { dirname, isAbsolute, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { canonicalPath } from '@deepseek-ai/dsh-sandbox'
|
||||
import type {} from '@deepseek-ai/dsh-fs'
|
||||
import type { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import type { Session } from '@deepseek-ai/dsh-session'
|
||||
import type { PreToolDecision, ToolExecution } from '@deepseek-ai/dsh-tools'
|
||||
|
||||
export const name = 'source-guard'
|
||||
|
||||
/** The `ctx.fs` provider supplies the git-metadata reads this guard resolves paths with. */
|
||||
export const inject = ['fs']
|
||||
|
||||
/**
|
||||
* Plugin config, validated by the same-named schemastery schema plus the
|
||||
* load-time checks in `apply` (misconfiguration fails loud: an empty `tools`
|
||||
* list, a blank `requiredSkill`, or a relative `protectedCheckout` throws at
|
||||
* plugin load, never a silent fall-back).
|
||||
*/
|
||||
export interface Config {
|
||||
/** Skill whose loaded presence in the session lifts the denial (default `dsh-customize`). */
|
||||
requiredSkill?: string
|
||||
/** Tool names to gate (default `['write', 'edit']`). */
|
||||
tools?: string[]
|
||||
/**
|
||||
* Absolute path inside the checkout this guard protects. Its worktree
|
||||
* supplies BOTH protected identities: the repository (targets in any other
|
||||
* repository are ignored) and the exact branch (only that branch's worktree
|
||||
* is protected). Defaults to this module's own location, which resolves the
|
||||
* checkout the running harness was launched from — the live deployment,
|
||||
* whatever its branch is named. Set it explicitly to guard a different
|
||||
* checkout, or when the harness runs from an installed copy whose own
|
||||
* location is not a checkout at all.
|
||||
*/
|
||||
protectedCheckout?: string
|
||||
}
|
||||
|
||||
export const Config: z<Config> = z.object({
|
||||
requiredSkill: z.string().default('dsh-customize'),
|
||||
tools: z.array(z.string()).default(['write', 'edit']),
|
||||
protectedCheckout: z.string().default(fileURLToPath(import.meta.url)),
|
||||
})
|
||||
|
||||
/**
|
||||
* The tool whose successful call satisfies the guard. Fixed, not configurable:
|
||||
* this is the harness's own skill-loading tool name, so a deployment that
|
||||
* renamed it has no skill to load and nothing for this guard to observe.
|
||||
*/
|
||||
const SKILL_TOOL = 'skill'
|
||||
|
||||
/**
|
||||
* The argument key every gated tool names its target with. `write` and `edit`
|
||||
* share it (`dsh-tool-fs`), and gating a tool that does not is a
|
||||
* misconfiguration the guard reports rather than silently allowing.
|
||||
*/
|
||||
const PATH_ARGUMENT = 'file_path'
|
||||
|
||||
/**
|
||||
* The absolute `file_path` a gated call targets, or `undefined` when the
|
||||
* arguments carry no usable one. Arguments arrive as the loop's parsed model
|
||||
* JSON, so this is a model-input boundary: any shape is possible.
|
||||
*
|
||||
* A relative path resolves against the calling session's workspace, exactly as
|
||||
* the filesystem tools resolve it (`dsh-tool-fs`'s `sessionCwd`). Judging only
|
||||
* absolute paths would leave `write` with a relative `file_path` as an
|
||||
* unguarded path to the same file.
|
||||
*/
|
||||
function targetPath(argumentsValue: unknown, sessionCwd: string | undefined): string | undefined {
|
||||
if (typeof argumentsValue !== 'object' || argumentsValue === null) return undefined
|
||||
const value = (argumentsValue as Record<string, unknown>)[PATH_ARGUMENT]
|
||||
if (typeof value !== 'string' || value.length === 0) return undefined
|
||||
if (isAbsolute(value)) return resolve(value)
|
||||
// Without a session cwd the tools fall back to a provider-owned default this
|
||||
// guard cannot observe, so the target is genuinely unresolvable here.
|
||||
return sessionCwd === undefined ? undefined : resolve(sessionCwd, value)
|
||||
}
|
||||
|
||||
/** One resolved worktree's identity: the branch its HEAD names, and the repository it belongs to. */
|
||||
interface Worktree {
|
||||
/** Branch name from `HEAD`, or `undefined` for a detached HEAD. */
|
||||
branch: string | undefined
|
||||
/**
|
||||
* Symlink-resolved absolute path of the shared git directory, identifying the
|
||||
* repository across worktrees. Canonical because two paths reaching one
|
||||
* repository by different symlink routes must compare equal — on macOS a
|
||||
* session cwd under `/var/...` and a configured path under `/private/var/...`
|
||||
* name the same directory, and a lexical comparison would fail open.
|
||||
*/
|
||||
commonDir: string
|
||||
}
|
||||
|
||||
/**
|
||||
* What one git-metadata path holds: a file's text, the fact that it is a
|
||||
* directory, or nothing resolvable. Every caller treats the unresolvable case
|
||||
* as "not a worktree" and lets the call proceed, so distinguishing absence
|
||||
* from a permission error would change no decision.
|
||||
*/
|
||||
type GitEntry =
|
||||
| { kind: 'file'; text: string }
|
||||
| { kind: 'directory' }
|
||||
| { kind: 'absent' }
|
||||
|
||||
/** Probe one git-metadata path, reading its text when it is a regular file. */
|
||||
async function readGitEntry(ctx: Context, path: string): Promise<GitEntry> {
|
||||
try {
|
||||
const target = await ctx.fs.resolve(path)
|
||||
const info = await ctx.fs.stat(target)
|
||||
if (info?.type === 'directory') return { kind: 'directory' }
|
||||
if (info?.type !== 'file') return { kind: 'absent' }
|
||||
return { kind: 'file', text: await ctx.fs.readText(target) }
|
||||
} catch {
|
||||
// Any resolve/stat/read failure (absent, denied, unreadable encoding)
|
||||
// yields no git identity. Nothing else can reach here: the guard performs
|
||||
// no other IO.
|
||||
return { kind: 'absent' }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Branch name from a `HEAD` file's contents. A symbolic ref names a branch; a
|
||||
* detached HEAD holds a raw object id and has no branch, which no staging
|
||||
* pattern can match.
|
||||
*/
|
||||
function branchFromHead(head: string): string | undefined {
|
||||
const trimmed = head.trim()
|
||||
const ref = 'ref: refs/heads/'
|
||||
return trimmed.startsWith(ref) ? trimmed.slice(ref.length) : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the git directory a worktree root's `.git` entry designates, plus
|
||||
* the shared common directory. A plain clone's `.git` is a directory that is
|
||||
* its own common dir; a linked worktree's `.git` is a file pointing into the
|
||||
* main repository's `worktrees/<name>`, whose common dir is two levels up.
|
||||
* A `gitdir:` pointer may be relative, which git resolves against the worktree
|
||||
* directory holding it.
|
||||
*/
|
||||
async function resolveGitDir(ctx: Context, root: string): Promise<{ gitDir: string; commonDir: string } | undefined> {
|
||||
const dotGit = resolve(root, '.git')
|
||||
const entry = await readGitEntry(ctx, dotGit)
|
||||
// A plain clone keeps a `.git` DIRECTORY, which is both the git dir and the
|
||||
// common dir; a linked worktree keeps a `.git` FILE pointing elsewhere.
|
||||
if (entry.kind === 'directory') return { gitDir: dotGit, commonDir: canonicalPath(dotGit) }
|
||||
if (entry.kind === 'absent') return undefined
|
||||
const prefix = 'gitdir:'
|
||||
const trimmed = entry.text.trim()
|
||||
if (!trimmed.startsWith(prefix)) return undefined
|
||||
const pointer = trimmed.slice(prefix.length).trim()
|
||||
if (pointer.length === 0) return undefined
|
||||
const gitDir = resolve(root, pointer)
|
||||
// `<common>/worktrees/<name>` — the shared repository is two levels up.
|
||||
return { gitDir, commonDir: canonicalPath(dirname(dirname(gitDir))) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk from a path toward the filesystem root and resolve the first enclosing
|
||||
* worktree, or `undefined` when the path is inside none.
|
||||
*/
|
||||
async function findWorktree(ctx: Context, from: string): Promise<Worktree | undefined> {
|
||||
let current = from
|
||||
for (;;) {
|
||||
const dirs = await resolveGitDir(ctx, current)
|
||||
if (dirs !== undefined) {
|
||||
const head = await readGitEntry(ctx, resolve(dirs.gitDir, 'HEAD'))
|
||||
return {
|
||||
branch: head.kind === 'file' ? branchFromHead(head.text) : undefined,
|
||||
commonDir: dirs.commonDir,
|
||||
}
|
||||
}
|
||||
const parent = dirname(current)
|
||||
if (parent === current) return undefined
|
||||
current = parent
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The skill name a `skill` call's raw argument JSON requested, or `undefined`
|
||||
* when the JSON is malformed or carries no string `name`. The log stores the
|
||||
* model's unparsed argument string, so this is a model-JSON boundary.
|
||||
*/
|
||||
function skillNameOf(rawArguments: string): string | undefined {
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(rawArguments)
|
||||
} catch {
|
||||
// The model produced argument text that is not JSON; the call cannot have
|
||||
// named a skill. Nothing else in this try can throw.
|
||||
return undefined
|
||||
}
|
||||
if (typeof parsed !== 'object' || parsed === null) return undefined
|
||||
const value = (parsed as Record<string, unknown>).name
|
||||
return typeof value === 'string' ? value : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the session's durable log records a successful load of
|
||||
* `requiredSkill`. Replayed from `tool/call` + `tool/result` pairs, so
|
||||
* satisfaction survives a session resume: the log is the only state.
|
||||
*/
|
||||
function skillLoaded(session: Session, requiredSkill: string): boolean {
|
||||
const requested = new Map<CallId, string>()
|
||||
for (const event of session.events) {
|
||||
if (event.type === 'tool/call') {
|
||||
if (event.data.name === SKILL_TOOL) requested.set(event.data.callId, event.data.arguments)
|
||||
continue
|
||||
}
|
||||
const block = event.type === 'tool/result' ? event.data.message.content[0] : undefined
|
||||
if (block === undefined || block.isError === true) continue
|
||||
const rawArguments = requested.get(block.toolCallId)
|
||||
if (rawArguments !== undefined && skillNameOf(rawArguments) === requiredSkill) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** The denial text a blocked call reports to the model. */
|
||||
function denialReason(path: string, branch: string, requiredSkill: string): string {
|
||||
return `Editing "${path}" directly is not allowed: it is inside the dsh checkout this session is running from, on branch ${branch}. `
|
||||
+ `Load the ${requiredSkill} skill first and follow it — implement in a task worktree, then integrate under the staging lock.`
|
||||
}
|
||||
|
||||
/**
|
||||
* Install the guard's listener.
|
||||
* @param ctx - plugin context; the listener is scoped to it and disposed with it.
|
||||
* @param config - validated {@link Config}; re-checked fail-loud here.
|
||||
*/
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
// schemastery's .default() guarantees the fields are set after validation.
|
||||
const requiredSkill = config.requiredSkill as string
|
||||
const tools = config.tools as string[]
|
||||
if (tools.length === 0) {
|
||||
throw new Error('source-guard: `tools` must not be empty')
|
||||
}
|
||||
if (requiredSkill.trim().length === 0) {
|
||||
throw new Error('source-guard: `requiredSkill` must not be blank')
|
||||
}
|
||||
const gated = new Set(tools)
|
||||
|
||||
const protectedCheckout = config.protectedCheckout as string
|
||||
if (!isAbsolute(protectedCheckout)) {
|
||||
throw new Error(`source-guard: \`protectedCheckout\` must be an absolute path, got "${protectedCheckout}"`)
|
||||
}
|
||||
// Resolved once per plugin lifetime: the worktree this guard arms for, which
|
||||
// supplies both the protected repository and the protected branch. A harness
|
||||
// running from an installed copy resolves a different repository (or none)
|
||||
// and therefore guards nothing, which is correct — the rule is meaningless
|
||||
// outside a source checkout.
|
||||
let protectedRepository: Promise<Worktree | undefined> | undefined
|
||||
|
||||
/** The repository containing {@link Config.protectedCheckout}. */
|
||||
function repository(): Promise<Worktree | undefined> {
|
||||
protectedRepository ??= findWorktree(ctx, dirname(protectedCheckout))
|
||||
return protectedRepository
|
||||
}
|
||||
|
||||
// Worktree identity per directory, cached for the plugin's lifetime: a
|
||||
// directory's repository and branch are stable in practice, and re-reading
|
||||
// git metadata on every write would repeat identical IO. A mid-session
|
||||
// branch switch is therefore not observed (see the README).
|
||||
const worktrees = new Map<string, Promise<Worktree | undefined>>()
|
||||
|
||||
/** Resolve (and memoize) the worktree enclosing a target path's directory. */
|
||||
function worktreeOf(path: string): Promise<Worktree | undefined> {
|
||||
const directory = dirname(path)
|
||||
let pending = worktrees.get(directory)
|
||||
if (pending === undefined) {
|
||||
pending = findWorktree(ctx, directory)
|
||||
worktrees.set(directory, pending)
|
||||
}
|
||||
return pending
|
||||
}
|
||||
|
||||
/**
|
||||
* The target path and the staging branch protecting it, or `undefined` when
|
||||
* the call may proceed. Fails open on every unresolvable case: a path outside
|
||||
* any worktree, a detached HEAD, a different repository, or unreadable git
|
||||
* metadata leaves the call to the rest of the chain, because a guard that
|
||||
* blocked writes whenever git identity was unavailable would be worse than
|
||||
* the violation it prevents.
|
||||
*/
|
||||
async function protectedTarget(exec: ToolExecution, session: Session): Promise<{ path: string; branch: string } | undefined> {
|
||||
if (!gated.has(exec.name)) return undefined
|
||||
const path = targetPath(exec.arguments, session.header.cwd)
|
||||
if (path === undefined) return undefined
|
||||
const launcher = await repository()
|
||||
// A detached launcher checkout names no branch to protect, so nothing is.
|
||||
if (launcher?.branch === undefined) return undefined
|
||||
// Resolution walks OUTWARD from the target, so it reports the INNERMOST
|
||||
// enclosing worktree: a task worktree nested under the protected tree
|
||||
// answers with its own task branch, which is not the launcher's. That is
|
||||
// what keeps the prescribed workflow unblocked.
|
||||
const worktree = await worktreeOf(path)
|
||||
if (worktree === undefined || worktree.commonDir !== launcher.commonDir) return undefined
|
||||
// Only the branch the launcher itself runs from is protected: a stale
|
||||
// sibling checkout of the same repository is not the live deployment.
|
||||
if (worktree.branch !== launcher.branch) return undefined
|
||||
return { path, branch: launcher.branch }
|
||||
}
|
||||
|
||||
ctx.on('tools/pre-execute', async (exec, next): Promise<PreToolDecision> => {
|
||||
// A direct `ctx.tools.execute()` caller has no session to replay and no
|
||||
// model to correct; only agent-loop calls are gated.
|
||||
if (exec.agent === undefined) return next()
|
||||
const { session } = exec.agent
|
||||
const target = await protectedTarget(exec, session)
|
||||
if (target === undefined) return next()
|
||||
if (skillLoaded(session, requiredSkill)) return next()
|
||||
return { kind: 'deny', reason: denialReason(target.path, target.branch, requiredSkill) }
|
||||
})
|
||||
}
|
||||
85
packages/guard/source-guard/src/invariant.ts
Normal file
85
packages/guard/source-guard/src/invariant.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-source-guard`.
|
||||
* @module @deepseek-ai/dsh-source-guard/invariant
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-source-guard'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'source-guard-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* The durable shape of this guard's refusal. The denial is the package's only
|
||||
* model-visible output, and it is actionable only when it names all three of
|
||||
* the offending path, the branch that protects it, and the skill that lifts
|
||||
* the denial — a refusal missing any of them tells the model to stop without
|
||||
* telling it how to proceed.
|
||||
*/
|
||||
const DENIAL = new RegExp(
|
||||
'^Error: Editing "(?<path>.+)" directly is not allowed: '
|
||||
+ 'it is inside the dsh checkout this session is running from, on branch (?<branch>\\S+)\\. '
|
||||
+ 'Load the (?<skill>\\S+) skill first and follow it '
|
||||
+ '— implement in a task worktree, then integrate under the staging lock\\.$',
|
||||
)
|
||||
|
||||
/** The denial prefix identifying a result this package produced, before its full shape is validated. */
|
||||
const DENIAL_PREFIX = 'Error: Editing "'
|
||||
|
||||
/** Validate one guard-produced denial result's model-facing text. */
|
||||
function validateDenial(text: string, fail: InvariantFailure): void {
|
||||
const match = DENIAL.exec(text)
|
||||
if (match === null) {
|
||||
fail('source-guard denial must name the path, the protecting branch, and the skill that lifts it')
|
||||
}
|
||||
// The pattern's `\S+` groups already establish a non-empty branch and skill;
|
||||
// only path absoluteness remains to check.
|
||||
const { path } = match.groups as { path: string }
|
||||
if (!path.startsWith('/') && !/^[A-Za-z]:[\\/]/.test(path)) {
|
||||
fail(`source-guard denial must name an absolute path, got ${JSON.stringify(path)}`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Validate every guard denial carried by one session's durable log. */
|
||||
function validateSession(session: Session, fail: InvariantFailure): void {
|
||||
for (const event of session.events) {
|
||||
if (event.type !== 'tool/result') continue
|
||||
validateEvent(event, fail)
|
||||
}
|
||||
}
|
||||
|
||||
/** Validate one durable tool result, when it carries this package's denial. */
|
||||
function validateEvent(event: SessionEvent<'tool/result'>, fail: InvariantFailure): void {
|
||||
const result = event.data.message.content[0]
|
||||
if (result.isError !== true) return
|
||||
for (const block of result.content) {
|
||||
if (block.type !== 'text' || !block.text.startsWith(DENIAL_PREFIX)) continue
|
||||
validateDenial(block.text, fail)
|
||||
}
|
||||
}
|
||||
|
||||
/* jscpd:ignore-start -- package companions share replay and dispatch plumbing */
|
||||
/** Install validation for loaded and newly appended denial results. */
|
||||
const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => {
|
||||
for (const session of ctx.sessions.list()) validateSession(session, fail)
|
||||
ctx.on('internal/dispatch', (_mode, eventName, args) => {
|
||||
if (eventName !== 'session/event') return
|
||||
const [, event] = args as [Session, SessionEvent]
|
||||
if (event.type !== 'tool/result') return
|
||||
validateEvent(event, fail)
|
||||
}, { global: true })
|
||||
}, { inject: ['sessions'] })
|
||||
/* jscpd:ignore-end */
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
Reference in New Issue
Block a user