commit 0858862cda9ec918ef5e739a091fa9fa6ac69047 Author: NotExpectedYet Date: Sat Sep 26 06:38:23 2026 +0100 init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..552f221 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +*.log diff --git a/README.md b/README.md new file mode 100644 index 0000000..e4664eb --- /dev/null +++ b/README.md @@ -0,0 +1,207 @@ +# dsh-facet + +Scope an agent session to **one facet of a system** — the folder the +conversation was started in, plus whatever you explicitly grant it. + +A DeepSeek Harness bundle: one host row (the policy) and one agent preset (the +enforcement). + +--- + +## Why this exists + +A multi-faceted system is built from modules that look alike. In the project this +was written against there are fourteen controllers — the same idioms, the same +naming, the same shape — and a conversation about one of them that can see all +fourteen spends its first several steps working out which one it is looking at. +That is a quality problem before it is a cost problem, and the cost is real too: +in a long session the dominant expense is re-reading accumulated history, and +most of that history is file content the agent did not need. + +So this narrows the world to one module rather than trying to steer through all +of them. It is the same idea as the `focus` argument in +[`dsh-recall`](https://github.com/green-dalii/dsh-recall) — which confines a +*search* to one module — applied to the whole agent: what it may read, where it +may search, and what its prompt says it is. + +## What it does + +Three things, and they are deliberately in three different places: + +| | where | why there | +|---|---|---| +| **the rule** ("work inside the facet; ask to go outside") | system prompt | invariant for the session, so it does not invalidate the prompt cache | +| **the binding** (which folder, what is granted) | runtime-context snapshot | it changes when you grant something, and a changed system prompt costs a full-context cache miss | +| **the guard** | `tools/pre-execute` | the part that does not depend on the model agreeing | + +**The scope is not configured — it is where you already are.** A session's +working directory is recorded immutably when the session is created, so starting +a conversation in `src/controllers/room/occupancy` makes that controller the +facet, and starting one at the project root simply leaves the session unscoped +(which is the honest answer for a conversation that is genuinely about +everything). + +**Going outside is a question, not a refusal.** In that same project 41.6% of the +code — shared types, services, infrastructure — sits outside every controller. A +wall that cannot be opened would not confine the agent, it would only make it +wrong, and an agent that cannot reach a shared type does not stop needing it. +So an out-of-scope file call is put to you, and saying yes records the grant for +the rest of the conversation. + +### The `facet` tool + +| action | effect | +|---|---| +| `status` | the root, the mode, the standing grants and this session's grants | +| `add` | asks you to approve an extra path, then records it for this session | +| `remove` | drops a session grant | +| `reset` | returns to the session's own folder | + +`add` is the only one that needs approval. It is also asked in `deny` mode, +because otherwise there would be no way to grant anything at all. + +## Install + +```sh +dsh plugin --profile web add "/path/to/dsh-facet" +dsh --profile web --dump-config | grep preset-facet # expect the row +``` + +The bundle contributes two layers: + +- **`cordis.patch.yml`** — the host row `facet-policy`, which owns the mode and + the standing grants and publishes them as `ctx.facetPolicy`. +- **`presets/facet.patch.yml`** — the agent preset `facet`, which is the shipped + `standard` composition plus the guard and a configured compaction row. + +Then pick **Facet** as the preset for a session — in the Web UI that is the +preset picker in the new-session surface, or *Settings → General → Agent preset* +to make it the default for new sessions. The preset is fixed once a session's +first turn has run, so choose it when you start the conversation. + +### Configure it in your profile, not in the package + +The host row's config is where this deployment's facts belong — the profile's own +`cordis.patch.yml`: + +```yaml +- id: facet-policy + name: dsh-facet + config: + mode: ask # ask | deny | off + extraRoots: # always in scope, in every session + - ../../shared # relative to each session's own directory +``` + +`extraRoots` is for code a module legitimately always depends on. Anything you +grant by hand is per-conversation and is never persisted — "this session may +also see `src/shared`" is a statement about one conversation, and writing it down +would quietly widen every future one, which is the failure this plugin exists to +prevent. + +## The compaction row + +`presets/facet.patch.yml` also configures `compaction-basic`, because a scoped +session and a budgeted context are two halves of one idea — scoping reduces +context at the source, compaction is the ceiling: + +```yaml +thresholdRatio: 0.35 # a 350k ceiling, not the shipped 800k +retainTokens: 80000 # an absolute working set, not 16% of a 1M window +maxTokens: 16384 # the summarisation cap, stated rather than inherited +``` + +The shipped ratio is `0.80` of a 1,000,000-token window, i.e. 800k. Measured +across this deployment's 40 recorded sessions, the deepest ever reached 787,668 +pressure tokens — **the trigger had never once fired**, so every token below it +was re-read on every subsequent step. `maxTokens` is worth stating explicitly: +its default is not 8192 but `headroomTokens`, 65536. + +If you want that budget on *every* session rather than only scoped ones, +`presets/standard-compaction.patch.yml` is an opt-in overlay that applies the +same row to the shipped `standard` preset. It is not in the bundle's default +patch list, and the header of that file explains why: a preset declaration +replaces the whole plugin list rather than merging into it, so the file is a +frozen copy of `standard` that will not follow the harness when `standard` +changes. + +## What it does not cover + +Honest boundaries, because each one is a decision rather than an oversight: + +- **`bash` is not guarded.** A shell command cannot be judged reliably from + argv, so the facet is enforced on the file tools and `bash` remains a + legitimate way out. The prompt tells the model this rather than pretending + otherwise. +- **Tools that name a path in a shape the table cannot read are not guessed at** — + including MCP tools. See `PATH_ARGUMENTS` in `lib/scope.js`; it is a list of + tools whose path argument is a plain top-level string. +- **This guards the tool pipeline, not the filesystem.** There is no read seam in + the harness — `dsh-fs-sandbox` states plainly that "reads pass through + untouched: every mode permits reading" — so anything that reads through + `ctx.fs` without going through tool dispatch (another plugin, the HTTP + workspace-files endpoint) is unaffected. Fencing *all* readers would mean + replacing `ctx.fs` with a stricter `FileSystem` subclass. +- **A grant is not persisted**, so it does not survive a process restart. +- **Sessions at the project root are not scoped at all**, which is deliberate. + +## What has been verified, and what has not + +Verified: + +- 42 tests (`npm test`) over the scope algebra, the policy state, and the guard — + including that containment is decided on canonical targets (a symlink inside + the facet pointing outside it is refused), that a prefix is not a parent + (`/a/room` does not contain `/a/roommate`), that a malformed tool call never + makes the guard throw, and that every `facet` result satisfies the tool's own + declared output schema. +- The bundle's patch layers parse, merge and land correctly against the real + profile: `dsh --profile web --patch ./cordis.patch.yml --patch + ./presets/facet.patch.yml --dump-config` shows `facet-policy`, `preset-facet`, + the `facet-guard` row and the compaction config, with `preset-standard` + untouched. The optional `standard-compaction` overlay was checked the same way. + +**Not yet verified: a live session.** The plugin has not been loaded by a running +harness, because that needs the bundle installed. The first thing to check after +installing is that `preset-facet` is not `broken` — a preset whose child row +fails to resolve still appears on the roster, with its diagnostic, and cannot +compose a session: + +```sh +dsh --profile web --dump-config | grep -A 3 preset-facet +``` + +## Development + +```sh +node scripts/dev-links.mjs # link the harness's cordis/schemastery/dsh-tools +npm test +``` + +`dev-links.mjs` links rather than installs those three packages, and for +`@deepseek-ai/cordis` that is a correctness requirement rather than tidiness: a +second copy of Cordis means a second `Context` class, two service registries in +one process, and a plugin whose services are invisible to the harness that loaded +it. + +## Layout + +``` +index.js host row: the policy, published as ctx.facetPolicy +lib/policy.js the stateful half — scope resolution, grants +lib/scope.js the pure half — argument extraction, text, reasons +lib/guard.js preset row: rule, binding, tool, pre-execute guard +presets/facet.patch.yml the `facet` agent preset +presets/standard-compaction.patch.yml optional: the same budget for `standard` +test/ 42 tests +``` + +## Also in this repository + +- [`facet-preset.md`](../facet-preset.md) — the design note this was built from, + including the harness mechanisms it rests on. +- [`compaction-tuning.md`](../compaction-tuning.md) — the investigation into why + compaction had never fired. Its measurements reproduce exactly; note that its + "The change" section prescribes `$DSH_HOME/.agent-presets/`, a directory + **nothing reads any more** on the installed harness. Presets are bundle patch + declarations now, which is what `presets/facet.patch.yml` is. diff --git a/cordis.patch.yml b/cordis.patch.yml new file mode 100644 index 0000000..cd83c75 --- /dev/null +++ b/cordis.patch.yml @@ -0,0 +1,29 @@ +# The layer this bundle contributes to a dsh profile that lists it. +# +# Two rows, and they are different halves of one idea: +# +# facet-policy — the HOST half. Owns the deployment's facet configuration +# (enforcement mode, standing extra roots) and the live +# per-session grants, and publishes them as `ctx.facetPolicy`. +# Mounted globally because the policy is a fact about this +# deployment, not about one agent. +# +# The PRESET half lives in ./presets/facet.patch.yml: it mounts the `facet` +# agent preset, which is where enforcement actually happens — a guard mounted +# inside a preset sees only that preset's agents. +# +# Configuration belongs here, in the profile's own patch layer, because that is +# the layer where this machine's facts are known: +# +# - id: facet-policy +# name: dsh-facet +# config: +# mode: ask +# extraRoots: +# - /srv/my-project/src/shared + +- insert: + - id: facet-policy + name: dsh-facet + config: + mode: ask diff --git a/index.js b/index.js new file mode 100644 index 0000000..7cc9cbf --- /dev/null +++ b/index.js @@ -0,0 +1,71 @@ +/** + * dsh-facet — the host half. + * + * A facet session is confined to one module of a multi-faceted system: the + * folder the conversation was started in, plus whatever the user has explicitly + * granted. This row owns the *policy* — the enforcement mode, the standing + * grants, and the live per-session grants — and publishes it as `ctx.facetPolicy`. + * + * **Why the policy is a host row and the enforcement is not.** The rule that + * confines an agent has to hold per agent, so it is mounted inside the `facet` + * agent preset; a guard registered on the host plane would confine every session + * in the profile, including the ones deliberately working across the whole + * system. The *configuration*, though, is a fact about this deployment — one + * mode, one set of standing grants — and duplicating it into the preset would + * mean two places to edit and two answers to the same question. So the policy is + * published once here and consumed there, which is also why the preset can be + * swapped without touching any of this. + * + * There is deliberately no `settings` namespace. A facet grant is a statement + * about one conversation, so the only thing worth persisting is the deployment's + * standing grants, and those belong in the profile patch — the layer where this + * machine's facts already live. + */ + +import Schema from '@deepseek-ai/schemastery' + +import { createFacetPolicy } from './lib/policy.js' + +export const name = 'dsh-facet' + +/** Every configuration field this plugin exposes. */ +export const Config = Schema.object({ + /** + * How a call outside the facet is handled. `ask` questions the user, `deny` + * refuses outright, `off` disables enforcement while leaving the preset in + * place — useful for telling a scoping problem from an unrelated one. + */ + mode: Schema.union(['ask', 'deny', 'off']).default('ask'), + + /** + * Paths always in scope, for code a module legitimately depends on — shared + * types, a common test harness. Relative paths resolve against each session's + * own directory, so the same list is meaningful in every workspace. + */ + extraRoots: Schema.array(String).default([]), + + /** Placement of the invariant rule in the system prompt. */ + sectionOrder: Schema.number().default(100), + + /** Placement of the per-session binding in the runtime-context snapshot. */ + contextOrder: Schema.number().default(105), +}) + +/** + * Publish the deployment's facet policy. + * + * @param {import('@deepseek-ai/cordis').Context} ctx - host context. + * @param {object} config - validated {@link Config}. + */ +export function apply(ctx, config) { + const policy = createFacetPolicy(config) + + // A grant belongs to the conversation that asked for it. Without this the map + // would grow for the life of the process, and a resumed session id would + // inherit a grant nobody remembers making. + ctx.on('session/disposed', (session) => { + policy.forget(session.id) + }) + + ctx.provide('facetPolicy', policy) +} diff --git a/lib/guard.js b/lib/guard.js new file mode 100644 index 0000000..4278d1d --- /dev/null +++ b/lib/guard.js @@ -0,0 +1,303 @@ +/** + * dsh-facet — the enforcement half, mounted inside the `facet` agent preset. + * + * Three contributions, and each exists because a different audience needs to + * know the scope: + * + * 1. a **rule** in the system prompt — invariant for the life of the session; + * 2. a **binding** in the runtime context — which folder, what is granted, + * which changes as grants are made; + * 3. a **guard** on the tool pipeline — the part that does not depend on the + * model agreeing. + * + * ## Why the rule and the binding are in different places + * + * The system prompt is the expensive, stable half of a request: changing it + * invalidates the prompt cache for the whole conversation. The rule never + * changes, so it belongs there. The binding changes the moment a grant is made, + * so it goes in the runtime-context snapshot — the same treatment the sandbox + * policy and the approval policy already get, and for the same reason. Putting + * the binding in the system prompt would mean paying a full-context cache miss + * for every `facet add`. + * + * ## Why the guard asks rather than refuses + * + * A facet is a window, not a relevance filter: in the project this was written + * against, 41.6% of the code sits outside every module. A wall that cannot be + * opened would not confine the agent, it would only make it wrong — and an agent + * that cannot get at a shared type does not stop needing it, it goes looking for + * another route. So the default is a question, and the answer is recorded for + * the rest of the conversation. + */ + +import { defineTool } from '@deepseek-ai/dsh-tools' +import Schema from '@deepseek-ai/schemastery' + +import { + FACET_TOOL, + facetContextText, + facetRuleText, + isWideningCall, + outsideReason, + pathArgumentsFor, + widenReason, +} from './scope.js' + +export const name = 'facet-guard' + +/** + * `facetPolicy` is the host row's published policy. `fs` is required rather than + * probed: containment is decided on canonical filesystem targets, and a guard + * that cannot canonicalise cannot tell `/a/b` from `/a/bc`, so without it the + * honest behaviour is not to mount. + */ +export const inject = ['facetPolicy', 'tools', 'systemPrompt', 'fs'] + +export const Config = Schema.object({ + /** Register the `facet` tool. On by default; the guard works without it. */ + tool: Schema.boolean().default(true), +}) + +/** + * @param {import('@deepseek-ai/cordis').Context} ctx - preset-scoped context. + * @param {{tool: boolean}} config - validated {@link Config}. + */ +export function apply(ctx, config) { + const policy = ctx.facetPolicy + /** @type {'ask'|'deny'|'off'} */ + const mode = policy.mode + + if (mode === 'off') return + + const sectionOrder = policy.sectionOrder ?? 100 + const contextOrder = policy.contextOrder ?? 105 + + // Resolving the facet root costs a realpath per call and its answer cannot + // change within a session, so it is memoised. The key set is bounded by the + // standing grants plus whatever this session has been granted by hand. + /** @type {Map} */ + const targets = new Map() + const targetFor = async (path, cwd, signal) => { + const key = `${cwd}\u0000${path}` + const cached = targets.get(key) + if (cached !== undefined) return cached + const target = await ctx.fs.resolve(path, { cwd, signal }) + targets.set(key, target) + return target + } + + /** + * The verdict on one path, carrying the path worth showing a reader. + * + * A path that cannot be resolved is treated as inside: `resolve` fails on a + * malformed path, and the tool being guarded will report that far better than + * this guard can. Failing closed here would turn "you typed the path wrong" + * into a permission problem, which is a worse answer and a more confusing one. + * + * @param {{cwd: string, root: string, extras: string[]}} scope - the session's scope. + * @param {string} raw - the path the model named. + * @param {AbortSignal|undefined} signal - the call's cancellation signal. + * @returns {Promise<{inside: boolean, display: string}>} the verdict. + */ + const inside = async (scope, raw, signal) => { + let target + try { + target = await targetFor(raw, scope.cwd, signal) + } catch { + return { inside: true, display: raw } + } + // `resolve` canonicalises, so the question the user is asked names a real + // file rather than whatever form the model happened to choose. + const display = target.displayPath ?? raw + for (const boundary of [scope.root, ...scope.extras]) { + try { + if (ctx.fs.contains(await targetFor(boundary, scope.cwd, signal), target)) return { inside: true, display } + } catch { + // A boundary that cannot be resolved grants nothing; the root is the + // only one that must hold, and a session always has one. + } + } + return { inside: false, display } + } + + /** + * The guard's opinion on one call, or `undefined` for no opinion. + * + * @param {import('@deepseek-ai/dsh-tools').ToolExecution} exec - the call about to dispatch. + * @returns {Promise<{kind: 'ask'|'deny', reason: string}|undefined>} the decision. + */ + const classify = async (exec) => { + if (exec.signal?.aborted) return undefined + + // Widening is the one act that always needs the user, whatever the mode: + // it is explicit, deliberate, and the only thing standing between a scoped + // agent and an unscoped one. + if (isWideningCall(exec.name, exec.arguments)) { + return { kind: 'ask', reason: widenReason(requestedPaths(exec.arguments)) } + } + + const scope = policy.scopeFor(exec.agent) + if (scope === undefined) return undefined + + const paths = pathArgumentsFor(exec.name, exec.arguments) + if (paths.length === 0) return undefined + + for (const raw of paths) { + const verdict = await inside(scope, raw, exec.signal) + if (verdict.inside) continue + const kind = mode === 'deny' ? 'deny' : 'ask' + return { + kind, + reason: outsideReason({ cwd: scope.cwd, path: verdict.display, root: scope.root, mode: kind }), + } + } + return undefined + } + + // The rule. `interpolate: false` because every value in it is already + // literal; there is nothing here for the harness to substitute. + ctx.systemPrompt.section({ + name: 'facet-scope', + order: Number(sectionOrder), + text: facetRuleText({ mode }), + interpolate: false, + }) + + // The binding, re-materialised only when it changes. + ctx.systemPrompt.context({ + name: 'facet-binding', + order: Number(contextOrder), + // Total, because this runs inside prompt assembly: a provider that throws + // takes the whole assembly with it, and "no context" is not a reason to + // fail a model request. + text: (context) => { + const scope = policy.scopeFor(context?.agent) + if (scope === undefined) return '' + return facetContextText({ cwd: scope.cwd, root: scope.root, extras: scope.extras, mode }) + }, + }) + + if (config.tool) ctx.tools.register(facetTool(ctx, policy, mode)) + + // `next()` first, so an earlier policy plugin keeps its say: this listener + // only ever adds a question or a refusal to a call that was otherwise allowed. + ctx.on('tools/pre-execute', async (exec, next) => { + const inner = await next() + if (inner.kind !== 'allow') return inner + return (await classify(exec)) ?? inner + }) +} + +/** The paths a widening call is asking for, in the shapes models actually send. */ +function requestedPaths(args) { + if (args === null || typeof args !== 'object') return [] + const record = /** @type {Record} */ (args) + const raw = Array.isArray(record.paths) ? record.paths : typeof record.path === 'string' ? [record.path] : [] + return raw.filter((path) => typeof path === 'string' && path.trim() !== '').map((path) => path.trim()) +} + +/** + * The `facet` tool: read the scope, or ask to widen it. + * + * `add` does not enforce anything itself — the pre-execute listener has already + * put the question to the user, and the body only runs if they said yes. That + * split is deliberate: the approval is a property of the *call*, so it holds + * even if this tool is later replaced or dispatched from `run_code`. + * + * @param {import('@deepseek-ai/cordis').Context} ctx - the guard's context. + * @param {object} policy - the `facetPolicy` service. + * @param {'ask'|'deny'|'off'} mode - the deployment's enforcement mode. + */ +function facetTool(ctx, policy, mode) { + return defineTool({ + name: FACET_TOOL, + description: + 'Read this session\u2019s facet scope, or ask the user to grant an extra path. A facet is the folder the ' + + 'conversation was started in; the session is scoped to it. Use `status` to see the current scope. Use `add` ' + + 'only when you actually need a file outside the facet — it asks the user for permission, and the grant lasts ' + + 'for the rest of the conversation. Never call `add` for a path you merely expect to need.', + parameters: { + action: { + type: 'string', + required: true, + description: 'One of: status, add, remove, reset.', + }, + paths: { + type: 'array', + items: { type: 'string' }, + description: + 'Paths to grant, relative to the session directory or absolute. Required for add and remove; ignored otherwise.', + }, + }, + output: { + schema: { + type: 'object', + additionalProperties: false, + properties: { + action: { type: 'string', required: true }, + ok: { type: 'boolean', required: true }, + root: { type: 'string', required: true }, + facet: { type: 'string', required: true }, + mode: { type: 'string', required: true }, + standing: { type: 'array', items: { type: 'string' }, required: true }, + granted: { type: 'array', items: { type: 'string' }, required: true }, + scope: { type: 'array', items: { type: 'string' }, required: true }, + note: { type: 'string' }, + }, + }, + render: (_args, value) => [{ type: 'text', text: renderFacet(value) }], + }, + async execute(args, exec) { + const cwd = exec.agent?.session?.header?.cwd + if (typeof cwd !== 'string' || cwd.trim() === '') { + throw new Error('facet: this session has no working directory, so it has no facet to report') + } + const sessionId = String(exec.agent.session.id) + const requested = requestedPaths(args) + const action = String(args.action ?? '').trim().toLowerCase() + + const problem = + (action === 'add' || action === 'remove') && requested.length === 0 + ? `facet: action "${action}" needs \`paths\`` + : !['status', 'add', 'remove', 'reset'].includes(action) + ? `facet: unknown action "${action}"; expected status, add, remove or reset` + : undefined + if (problem !== undefined) throw new Error(problem) + + const before = policy.grantsFor(sessionId) + if (action === 'add') policy.grant(sessionId, requested, cwd) + else if (action === 'remove') policy.revoke(sessionId, requested, cwd) + else if (action === 'reset') policy.reset(sessionId) + + const scope = policy.scopeFor(exec.agent) + const granted = policy.grantsFor(sessionId) + return { + action, + ok: true, + root: scope.root, + facet: scope.root, + mode, + standing: policy.standing, + granted, + scope: scope.extras, + // Granting is idempotent, so an `add` that changed nothing says so + // rather than reporting success as though the scope had moved. + ...(action === 'add' && granted.length === before.length + ? { note: 'Already granted; the scope is unchanged.' } + : {}), + } + }, + }) +} + +/** Model-facing text for one `facet` result. Pure. */ +function renderFacet(value) { + const lines = [ + `facet: ${value.root}`, + `mode: ${value.mode}`, + value.standing.length > 0 ? `standing grants: ${value.standing.join(', ')}` : 'standing grants: none', + value.granted.length > 0 ? `granted this session: ${value.granted.join(', ')}` : 'granted this session: none', + ] + if (value.note !== undefined) lines.push(value.note) + return lines.join('\n') +} diff --git a/lib/policy.js b/lib/policy.js new file mode 100644 index 0000000..b410a20 --- /dev/null +++ b/lib/policy.js @@ -0,0 +1,148 @@ +/** + * The deployment half of the facet: what is always in scope, and what one + * session has been granted. + * + * Split from {@link ./scope.js} because these two halves fail differently. The + * scope algebra is pure and total — it must hold for any input, including a + * malformed tool call. This module holds mutable state keyed by session, and its + * mistakes are leaks: a grant that outlives its session, or a path kept in a + * form that no longer resolves. Keeping the state here and the rules there means + * the rules can be tested exhaustively without standing up a session. + * + * A grant is deliberately ephemeral. It lives for the conversation that asked + * for it and is not written anywhere, because "this session may also see + * `src/shared`" is a statement about one conversation, and persisting it would + * quietly widen every future one — which is the failure mode the whole plugin + * exists to prevent. + */ + +import { isAbsolute, resolve } from 'node:path' + +/** + * @typedef {object} FacetScope + * @property {string} cwd - the session working directory, absolute. + * @property {string} root - the facet root; the session's own folder. + * @property {string[]} extras - absolute granted paths outside the root. + */ + +/** + * Build the policy for one deployment. + * + * @param {object} config + * @param {'ask'|'deny'|'off'} config.mode - how an out-of-scope call is handled. + * @param {readonly string[]} [config.extraRoots] - standing grants, resolved against each session's cwd. + * @returns {object} the `facetPolicy` service implementation. + */ +export function createFacetPolicy(config) { + /** @type {Map} session id → granted paths, in the order granted. */ + const grants = new Map() + const standing = Array.isArray(config.extraRoots) ? config.extraRoots.filter(isPath) : [] + + /** + * The scope of one agent, or `undefined` when it has none. + * + * An agent without a working directory is not scoped rather than scoped to + * something arbitrary: `undefined` here means the guard has no opinion, which + * is the only safe reading of "we do not know where this agent is". + * + * @param {{session?: {header?: {cwd?: unknown}, id?: unknown}}|undefined} agent - the calling agent. + * @returns {FacetScope|undefined} the scope. + */ + function scopeFor(agent) { + const cwd = agent?.session?.header?.cwd + if (typeof cwd !== 'string' || cwd.trim() === '') return undefined + const root = resolve(cwd) + return { cwd: root, root, extras: extrasFor(agent, root) } + } + + /** + * Every path in scope beyond the root: standing grants first, then this + * session's, deduplicated so the prompt does not repeat itself. + * + * @param {{session?: {id?: unknown}}|undefined} agent - the calling agent. + * @param {string} cwd - the resolved session directory. + * @returns {string[]} absolute paths. + */ + function extrasFor(agent, cwd) { + const sessionId = typeof agent?.session?.id === 'string' ? agent.session.id : undefined + const own = sessionId === undefined ? [] : grants.get(sessionId) ?? [] + const all = [...standing, ...own].map((path) => (isAbsolute(path) ? resolve(path) : resolve(cwd, path))) + return [...new Set(all)] + } + + /** + * @param {string} sessionId - the session being widened. + * @param {readonly string[]} paths - raw paths, resolved against `cwd`. + * @param {string} cwd - the session directory. + * @returns {string[]} the resulting grant list. + */ + function grant(sessionId, paths, cwd) { + const resolved = paths + .filter(isPath) + .map((path) => (isAbsolute(path) ? resolve(path) : resolve(cwd, path))) + const next = [...new Set([...(grants.get(sessionId) ?? []), ...resolved])] + grants.set(sessionId, next) + return next + } + + /** + * @param {string} sessionId - the session being narrowed. + * @param {readonly string[]} paths - raw paths to drop. + * @param {string} cwd - the session directory. + * @returns {string[]} the resulting grant list. + */ + function revoke(sessionId, paths, cwd) { + const drop = new Set( + paths.filter(isPath).map((path) => (isAbsolute(path) ? resolve(path) : resolve(cwd, path))), + ) + const next = (grants.get(sessionId) ?? []).filter((path) => !drop.has(path)) + if (next.length === 0) grants.delete(sessionId) + else grants.set(sessionId, next) + return next + } + + /** + * @param {string} sessionId - the session to clear. + * @returns {string[]} always empty. + */ + function reset(sessionId) { + grants.delete(sessionId) + return [] + } + + /** + * @param {string} sessionId - the session to read. + * @returns {string[]} its own grants, without the standing ones. + */ + function grantsFor(sessionId) { + return [...(grants.get(sessionId) ?? [])] + } + + /** Drop a finished session's grants. Called on `session/disposed`. */ + function forget(sessionId) { + grants.delete(sessionId) + } + + return { + mode: config.mode, + /** Prompt placements, carried here so the preset row needs no configuration of its own. */ + sectionOrder: config.sectionOrder ?? 100, + contextOrder: config.contextOrder ?? 105, + standing: [...standing], + scopeFor, + grant, + revoke, + reset, + grantsFor, + forget, + /** Session ids currently holding a grant — for tests and diagnostics. */ + get sessions() { + return [...grants.keys()] + }, + } +} + +/** @param {unknown} value @returns {value is string} */ +function isPath(value) { + return typeof value === 'string' && value.trim() !== '' +} diff --git a/lib/scope.js b/lib/scope.js new file mode 100644 index 0000000..28ffc55 --- /dev/null +++ b/lib/scope.js @@ -0,0 +1,224 @@ +/** + * The facet scope: which part of a project one session is allowed to see. + * + * ## Why the scope is the session's own folder + * + * A multi-faceted system is built from modules that look alike. In the project + * this was written against there are fourteen controllers — the same idioms, the + * same naming, the same shape — and a conversation about one of them that can + * see all fourteen is a conversation that keeps having to work out which one it + * is looking at. Narrowing it to the one being worked on removes the + * confusability rather than trying to steer through it. + * + * The scope is therefore not configured. It is **where the session already is**: + * the session's working directory, which the harness records immutably at + * creation. Starting a conversation in `src/controllers/room/occupancy` makes + * that controller the world the agent sees; nothing has to be declared, and a + * session started at the project root is simply not scoped, which is the honest + * answer for a conversation that is genuinely about the whole project. + * + * ## Why scope is additive rather than exclusive + * + * A facet is a window, not a relevance filter. In the same project 41.6% of the + * code — shared types, services, infrastructure — sits outside every controller, + * so a wall that cannot be opened would not confine the agent, it would only + * make it wrong. Scope is therefore a *default*, and the way past it is an + * explicit grant: `facet action=add` records a path for the rest of the session, + * with the user's approval. Nothing here tries to infer that a needed file is + * needed. + * + * This module is the pure half — argument extraction, text, reasons. It performs + * no I/O and knows nothing about Cordis, so every rule below is testable without + * a harness. + */ + +/** + * Model-supplied arguments that name a location, per tool. + * + * Deliberately conservative: only tools whose path argument is a plain top-level + * string are listed. A tool that takes a path in a shape this table cannot read + * is not guessed at — it is left unguarded, which is a decision, not an + * oversight. `bash` is the deliberate one: a shell command cannot be judged + * reliably from argv, so the facet is enforced on the file tools and `bash` + * remains a legitimate way out. The prompt says so rather than pretending + * otherwise. + */ +export const PATH_ARGUMENTS = Object.freeze({ + read: ['file_path'], + write: ['file_path'], + edit: ['file_path'], + read_image: ['file_path'], + grep: ['path'], + glob: ['path'], + str_replace_editor: ['path'], + recall: ['path', 'focus'], +}) + +/** The tool this plugin registers, and the action on it that widens the scope. */ +export const FACET_TOOL = 'facet' +export const WIDENING_ACTION = 'add' + +/** + * Every path a tool call names, as the model wrote them. + * + * Tolerant by design: arguments arrive as unvalidated JSON, and a policy plugin + * that throws on a malformed call would break tools it has no business breaking. + * Anything that is not a non-empty string is skipped. + * + * An empty result means "no opinion" — either the tool names no path (so it + * cannot leave the facet) or it names one in a shape we refuse to guess at. + * + * @param {string} toolName - the tool being called. + * @param {unknown} args - the raw model arguments. + * @returns {string[]} raw paths, in argument order. + */ +export function pathArgumentsFor(toolName, args) { + if (args === null || typeof args !== 'object') return [] + const record = /** @type {Record} */ (args) + + const names = PATH_ARGUMENTS[toolName] + if (names === undefined) return [] + + const out = [] + for (const name of names) { + const value = record[name] + if (typeof value !== 'string') continue + const trimmed = value.trim() + if (trimmed !== '') out.push(trimmed) + } + return out +} + +/** + * True when this call would widen the facet and therefore needs the user. + * + * Only `add` widens. `status` reads, `remove` narrows, `reset` returns to the + * session's own folder — none of them can show the agent anything it could not + * already see, so none of them is worth interrupting anyone for. Keeping the + * interruption to the single case that matters is what stops the approval from + * becoming something to click through. + * + * @param {string} toolName - the tool being called. + * @param {unknown} args - the raw model arguments. + * @returns {boolean} whether the call needs an explicit grant. + */ +export function isWideningCall(toolName, args) { + if (toolName !== FACET_TOOL) return false + if (args === null || typeof args !== 'object') return false + return /** @type {Record} */ (args).action === WIDENING_ACTION +} + +/** + * The stable half of the facet, for the system prompt. + * + * This states the rule and never the binding: the binding moves (a grant is + * added), and a changing system prompt invalidates the prompt cache for the + * whole conversation. The mutable half goes in {@link facetContextText}, which + * the harness materialises as a durable runtime-context snapshot — the same + * treatment the sandbox policy and approval policy already get, for the same + * reason. + * + * @param {object} input + * @param {'ask'|'deny'|'off'} input.mode - how an out-of-scope call is handled. + * @returns {string} prompt section text, or `''` to contribute nothing. + */ +export function facetRuleText(input) { + const { mode } = input + if (mode === 'off') return '' + + const reach = + mode === 'deny' + ? 'A call outside it is refused. `facet action=add` asks the user to grant a path, and is the only way to widen the scope.' + : 'A call outside it is not refused: it is put to the user as a question, so say in one line what you need and why. If they agree, record it with `facet action=add` so the rest of the session does not have to ask again.' + + return [ + '# Facet scope', + '', + 'This session is scoped to one facet of the project — one module rather than the whole system. Your runtime context states which.', + '', + `Work inside the facet. ${reach}`, + '', + 'Never widen the scope on your own initiative: a grant is the user\u2019s decision, and an unrequested one is a bug, not initiative. Do not add paths you merely expect to need — ask when you actually need them.', + '', + '`bash` is not scope-checked, so it is not a way to look outside the facet without asking. Use the file tools when the answer is a file.', + ].join('\n') +} + +/** + * Split a path into the form worth showing a reader. + * + * Relative reads better when the path is under the session, and absolute is the + * only honest form when it is not. This is presentation only — containment is + * decided on canonical targets, never on strings. + * + * @param {string} cwd - the session working directory. + * @param {string} path - an absolute path. + * @returns {string} the shortest unambiguous form. + */ +export function displayPath(cwd, path) { + if (typeof cwd !== 'string' || cwd === '') return path + const prefix = cwd.endsWith('/') ? cwd : `${cwd}/` + return path === cwd ? '.' : path.startsWith(prefix) ? path.slice(prefix.length) : path +} + +/** + * The mutable half of the facet, for the runtime-context snapshot. + * + * Kept to three short lines because it is re-sent as part of the conversation + * whenever it changes, and a scope statement that costs more than the scope is + * worth is a scope that does not pay for itself. + * + * @param {object} input + * @param {string} input.cwd - the session working directory. + * @param {string|undefined} input.root - the facet root, or undefined when unscoped. + * @param {readonly string[]} input.extras - absolute granted paths, outside the root. + * @param {'ask'|'deny'|'off'} input.mode - how an out-of-scope call is handled. + * @returns {string} context text, or `''` to contribute nothing. + */ +export function facetContextText(input) { + const { cwd, root, extras = [], mode } = input + if (mode === 'off' || typeof root !== 'string' || root === '') return '' + + // The root is stated absolutely even though it is the session's own directory + // and a relative form would be shorter. `facet: .` is true and useless: the + // model needs a path it can reason about, and the user reading an approval + // prompt needs to see which folder they are being asked about. + const lines = [`facet: ${root}`] + if (extras.length > 0) lines.push(`granted outside it: ${extras.map((path) => displayPath(cwd, path)).join(', ')}`) + lines.push(`outside the facet: ${mode === 'deny' ? 'refused' : 'ask the user'}`) + return lines.join('\n') +} + +/** + * Why one call is being questioned, in a sentence that reads for both audiences. + * + * The same string is the user's approval prompt and the model's error text, so + * it names the path, the boundary, and the way forward — a denial that does not + * say how to proceed is a denial the agent works around with `bash`. + * + * @param {object} input + * @param {string} input.cwd - the session working directory. + * @param {string} input.path - the raw path the model named. + * @param {string} input.root - the facet root. + * @param {'ask'|'deny'} input.mode - the decision this reason explains. + * @returns {string} the reason. + */ +export function outsideReason(input) { + const { cwd, path, root, mode } = input + const what = displayPath(cwd, path) + const verb = mode === 'deny' ? 'refused' : 'outside' + // The boundary is named absolutely: the person reading this is deciding + // whether to open it, and "the facet is ." tells them nothing. + return `${what} is ${verb} — this session's facet is ${root}. Ask the user to grant it (\`facet action=add\`), or work within the facet.` +} + +/** + * Why widening the scope is being put to the user. + * + * @param {readonly string[]} paths - the raw paths the model asked to add. + * @returns {string} the reason. + */ +export function widenReason(paths) { + const list = paths.length === 0 ? 'nothing' : paths.join(', ') + return `Grant ${list} to this session — outside its facet — for the rest of the conversation?` +} diff --git a/lib/types/guard.d.ts b/lib/types/guard.d.ts new file mode 100644 index 0000000..6e7eace --- /dev/null +++ b/lib/types/guard.d.ts @@ -0,0 +1,23 @@ +/** + * Public surface of the `dsh-facet/guard` row — the half mounted inside the + * `facet` agent preset. + */ + +import type { Context } from '@deepseek-ai/cordis' +import type z from '@deepseek-ai/schemastery' + +/** Plugin name. */ +export declare const name = 'facet-guard' + +/** Services the guard cannot work without; it stays pending until all are present. */ +export declare const inject: readonly ['facetPolicy', 'tools', 'systemPrompt', 'fs'] + +/** Configuration for the preset-scoped half. */ +export interface Config { + /** Register the `facet` tool. Defaults to `true`; the guard works without it. */ + tool?: boolean +} + +export declare const Config: z + +export declare function apply(ctx: Context, config: Config): void diff --git a/lib/types/index.d.ts b/lib/types/index.d.ts new file mode 100644 index 0000000..ca234bc --- /dev/null +++ b/lib/types/index.d.ts @@ -0,0 +1,75 @@ +/** + * Public surface of the `dsh-facet` host row. + * + * The augmentation below is the point of this file: `ctx.facetPolicy` is a + * service published by `provide`, so without it a TypeScript consumer sees the + * property as missing. The preset half (`dsh-facet/guard`) is typed in + * `./guard.d.ts`. + */ + +import type { Context } from '@deepseek-ai/cordis' +import type z from '@deepseek-ai/schemastery' + +/** One session's resolved scope. */ +export interface FacetScope { + /** The session working directory, absolute — also the boundary. */ + readonly cwd: string + /** The facet root; the session's own folder. */ + readonly root: string + /** Absolute granted paths outside the root: standing grants, then this session's. */ + readonly extras: string[] +} + +/** How an out-of-scope call is handled. */ +export type FacetMode = 'ask' | 'deny' | 'off' + +/** The deployment's facet policy, published by the host row. */ +export interface FacetPolicy { + readonly mode: FacetMode + /** System-prompt placement of the invariant rule. */ + readonly sectionOrder: number + /** Runtime-context placement of the per-session binding. */ + readonly contextOrder: number + /** Every path always in scope, as configured. */ + readonly standing: string[] + /** Session ids currently holding a grant. */ + readonly sessions: string[] + /** The scope of one agent, or `undefined` when it has no working directory. */ + scopeFor(agent: { session?: { id?: unknown; header?: { cwd?: unknown } } } | undefined): FacetScope | undefined + /** Add paths to one session's grants. */ + grant(sessionId: string, paths: readonly string[], cwd: string): string[] + /** Drop paths from one session's grants. */ + revoke(sessionId: string, paths: readonly string[], cwd: string): string[] + /** Clear one session's grants. */ + reset(sessionId: string): string[] + /** One session's own grants, without the standing ones. */ + grantsFor(sessionId: string): string[] + /** Drop a finished session's grants. */ + forget(sessionId: string): void +} + +declare module '@deepseek-ai/cordis' { + interface Context { + /** Published by `dsh-facet`; consumed by `dsh-facet/guard`. */ + facetPolicy: FacetPolicy + } +} + +/** Plugin name. */ +export declare const name = 'dsh-facet' + +/** Configuration: the deployment's facet policy. */ +export interface Config { + /** How an out-of-scope call is handled. Defaults to `ask`. */ + mode?: FacetMode + /** Paths always in scope; relative paths resolve against each session's directory. */ + extraRoots?: string[] + /** Placement of the invariant rule in the system prompt. Defaults to `100`. */ + sectionOrder?: number + /** Placement of the per-session binding in the runtime-context snapshot. Defaults to `105`. */ + contextOrder?: number +} + +export declare const Config: z + +export declare function apply(ctx: Context, config: Config): void diff --git a/package.json b/package.json new file mode 100644 index 0000000..3bc038a --- /dev/null +++ b/package.json @@ -0,0 +1,52 @@ +{ + "name": "dsh-facet", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "Scope an agent session to one facet of a multi-faceted system: the session's own folder, plus whatever files the user grants it.", + "main": "index.js", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./index.js" + }, + "./guard": { + "types": "./lib/types/guard.d.ts", + "default": "./lib/guard.js" + }, + "./cordis.patch.yml": "./cordis.patch.yml", + "./presets/facet.patch.yml": "./presets/facet.patch.yml", + "./package.json": "./package.json" + }, + "files": [ + "index.js", + "lib", + "presets", + "cordis.patch.yml", + "README.md" + ], + "engines": { + "node": ">=22.5" + }, + "scripts": { + "test": "node --test test/*.test.js" + }, + "dsh": { + "bundle": { + "patch": [ + "./cordis.patch.yml", + "./presets/facet.patch.yml" + ] + } + }, + "keywords": [ + "dsh-plugin", + "deepseek-harness", + "cordis", + "agent-preset", + "context", + "scope", + "tokens" + ], + "license": "MIT" +} diff --git a/presets/facet.patch.yml b/presets/facet.patch.yml new file mode 100644 index 0000000..7fe1193 --- /dev/null +++ b/presets/facet.patch.yml @@ -0,0 +1,183 @@ +# Agent preset facet: one `@deepseek-ai/dsh-agent-preset` declaration inserted +# after the web patch. +# +# The composition is `standard` with two changes, and this file is the only place +# either of them is decided: +# +# * compaction is configured (see the `compaction` row below) so the pressure +# trigger is a budget rather than a formality. The shipped ratio is 0.80 of a +# 1,000,000-token window, i.e. 800k, and on this deployment nothing has ever +# come within 1.5% of it — compaction had never once run; +# +# * `facet-guard` is mounted, which confines the session to its own folder. +# +# Everything else is copied verbatim from the shipped `standard` patch, and that +# is deliberate: a preset declaration replaces the whole plugin list rather than +# merging into it, so a clone that drifts from the original silently loses +# whatever it drifted past. When the harness next changes `standard`, re-copy +# this file and re-apply the two changes — do not hand-patch around it. +- insert: + - id: preset-facet + name: '@deepseek-ai/dsh-agent-preset' + config: + id: facet + name: Facet + description: One module of the system, not all of it — the session's own folder, plus the files you grant. + order: 5 + plugins: + - id: persona + name: '@deepseek-ai/dsh-persona' + config: + suffix: Your working directory is {{cwd}}. + prefix: You are a coding agent powered by the {{model}} model. + - id: agent-instructions + name: '@deepseek-ai/dsh-agent-instructions' + config: + maxBytes: 65536 + # The facet itself: a rule in the system prompt, the binding in the + # runtime context, the `facet` tool, and the pre-execute guard that + # asks before a file call leaves the session's folder. Configured by + # the `facet-policy` host row, not here — one place for the mode and + # the standing grants, whichever preset is selected. + - id: facet-guard + name: 'dsh-facet/guard' + - id: tool-bash + name: '@deepseek-ai/dsh-tool-bash' + disabled: !!js process.platform === 'win32' + - id: tool-pwsh + name: '@deepseek-ai/dsh-tool-pwsh' + disabled: !!js process.platform !== 'win32' + - id: tool-fs + name: '@deepseek-ai/dsh-tool-fs' + - id: tool-fs-search + name: '@deepseek-ai/dsh-tool-fs-search' + config: + sampleOverCapGlobResults: false + - id: tool-jobs + name: '@deepseek-ai/dsh-tool-jobs' + - id: skill-filesystem + name: '@deepseek-ai/dsh-skill-filesystem' + - id: tool-skill + name: '@deepseek-ai/dsh-tool-skill' + - id: command-goal + name: '@deepseek-ai/dsh-command-goal' + - id: tool-goal + name: '@deepseek-ai/dsh-tool-goal' + - id: planning + name: cordis:group + group: true + isolate: + planMode: true + config: + - id: plan-mode + name: '@deepseek-ai/dsh-plan-mode' + config: + section: | + You are in plan mode. Stay in plan mode until exit_plan_mode succeeds or the user switches the session mode. Imperative language to implement changes means plan the implementation, not execute it. A user's conversational agreement — including an answer confirming something you asked — approves nothing and does not end plan mode; fold the confirmed decision into the plan and submit it through exit_plan_mode. + + Explore first. Use non-mutating reads, searches, static analysis, and checks to ground the plan in the actual repository. Do not edit or write files, change configuration, run formatters or code generation that rewrites tracked files, commit, or otherwise carry out the plan. Prefer existing functions and patterns over new machinery. + + The tool catalog stays the same across modes for request-cache stability. These plan-mode rules override any later tool description or guidance that suggests using mutation tools; those tools remain listed to keep the tool catalog unchanged. Do not use todo_write to track this planning phase: it tracks implementation after an approved plan, while the plan itself belongs in exit_plan_mode. + + Resolve discoverable facts by inspection. Use ask_user_question only for user-owned choices or material ambiguity that inspection cannot answer. Do not ask the user where code lives or how current behavior works when you can find out. + + Make the plan decision-complete: state the goal and success criteria; group implementation changes by subsystem; identify public API, schema, and data-flow changes; cover edge cases, failure modes, tests, acceptance criteria, and explicit assumptions. Keep it concise enough to review but detailed enough that another engineer can implement it without making design decisions. + + When ready, call exit_plan_mode with the complete plan markdown, starting with a # title. Make exit_plan_mode the only and final tool call in that assistant response: it presents the plan for approval, and implementation begins only in a later step after approval. Do not paste the final plan as a plain reply or ask "should I proceed?" through prose or ask_user_question. If review rejects it, incorporate the feedback and present again. If the review channel is unavailable or aborted, stay in plan mode and ask the user to switch modes manually; do not proceed with implementation. + - id: compaction + name: cordis:group + group: true + isolate: + compaction: true + toolResultPruner: true + config: + - id: compaction-basic + name: '@deepseek-ai/dsh-compaction-basic' + config: + # 0.80 of a 1M window is a 800k trigger, and the deepest session + # ever recorded here peaked at 787,668 pressure tokens: the + # trigger was never reached, so every token below it was re-read + # on every subsequent step. 0.35 puts the ceiling at 350k. + thresholdRatio: 0.35 + # Retention is absolute rather than a ratio, because "16% of a + # 1M window" (160k) is not a working-set measurement. Where the + # summarisation call is capped explicitly: the schema default + # inherits `headroomTokens` (65536), which is a far larger + # output bill than this needs. + retainTokens: 80000 + maxTokens: 16384 + - id: command-compact + name: '@deepseek-ai/dsh-command-compact' + - id: tool-result-pruner + name: '@deepseek-ai/dsh-compaction-tool-result-pruner' + config: + thresholdChars: 8192 + headChars: 4096 + tailChars: 1024 + - id: delegation + name: cordis:group + group: true + isolate: + workflowEngine: true + config: + - id: tool-subagent-control + name: '@deepseek-ai/dsh-tool-subagent-control' + - id: tool-subagent-list-agents + name: '@deepseek-ai/dsh-tool-subagent-control/list-agents' + - id: tool-subagent + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: spawn + toolName: subagent + modelSelectionSettings: true + backgroundMode: continuable + - id: tool-subagent-fork + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: fork + toolName: subagent_fork + backgroundMode: continuable + - id: tool-subagent-codex + name: '@deepseek-ai/dsh-tool-subagent' + disabled: true + config: + provider: codex + toolName: subagent_codex + backgroundMode: one-shot + maxDepth: provider-managed + - id: tool-subagent-claude-code + name: '@deepseek-ai/dsh-tool-subagent' + disabled: true + config: + provider: claude-code + toolName: subagent_claude_code + backgroundMode: one-shot + maxDepth: provider-managed + - id: workflow-ptc + name: '@deepseek-ai/dsh-workflow-ptc' + config: + provider: spawn + - id: tool-workflow + name: '@deepseek-ai/dsh-tool-workflow' + - id: tool-ralph + name: '@deepseek-ai/dsh-tool-ralph' + disabled: true + config: + subagentProvider: spawn + maxRounds: 64 + - id: tool-ask-user + name: '@deepseek-ai/dsh-tool-ask-user' + - id: tool-todo + name: '@deepseek-ai/dsh-tool-todo' + config: + allowParallelInProgress: true + - id: tool-web + name: '@deepseek-ai/dsh-tool-web' + config: + fetch: true + searchTimeoutMs: 60000 + - id: present + name: '@deepseek-ai/dsh-tool-present' + - id: tool-plugin-manager + name: '@deepseek-ai/dsh-plugin-manager/tools' + disabled: true diff --git a/presets/standard-compaction.patch.yml b/presets/standard-compaction.patch.yml new file mode 100644 index 0000000..f524c41 --- /dev/null +++ b/presets/standard-compaction.patch.yml @@ -0,0 +1,168 @@ +# OPTIONAL, and NOT part of this bundle's default patch list. +# +# It gives the shipped `standard` preset the same compaction budget the `facet` +# preset uses, for anyone who wants the cost fix on every session rather than +# only on scoped ones. Apply it as an overlay: +# +# dsh --profile web --patch ./presets/standard-compaction.patch.yml +# +# or add its path to `dsh.bundle.patch` in package.json. +# +# It is deliberately not applied for you, and the reason is a real hazard rather +# than caution: a preset declaration replaces the whole plugin list, never merges +# into it, so this file is a frozen copy of the shipped `standard` composition +# plus one change. When the harness next updates `standard` — a new tool, a +# renamed row — this copy will not follow it, and the sessions on this preset +# will silently lose whatever was added. Use it if you are prepared to re-copy +# and re-apply when that happens; otherwise take the compaction settings into +# whichever preset you actually run. +# +# The only change from the shipped file is the `compaction-basic` row's config. +- insert: + - id: preset-standard + name: '@deepseek-ai/dsh-agent-preset' + config: + id: standard + order: 1 + plugins: + - id: persona + name: '@deepseek-ai/dsh-persona' + config: + suffix: Your working directory is {{cwd}}. + prefix: You are a coding agent powered by the {{model}} model. + - id: agent-instructions + name: '@deepseek-ai/dsh-agent-instructions' + config: + maxBytes: 65536 + - id: tool-bash + name: '@deepseek-ai/dsh-tool-bash' + disabled: !!js process.platform === 'win32' + - id: tool-pwsh + name: '@deepseek-ai/dsh-tool-pwsh' + disabled: !!js process.platform !== 'win32' + - id: tool-fs + name: '@deepseek-ai/dsh-tool-fs' + - id: tool-fs-search + name: '@deepseek-ai/dsh-tool-fs-search' + config: + sampleOverCapGlobResults: false + - id: tool-jobs + name: '@deepseek-ai/dsh-tool-jobs' + - id: skill-filesystem + name: '@deepseek-ai/dsh-skill-filesystem' + - id: tool-skill + name: '@deepseek-ai/dsh-tool-skill' + - id: command-goal + name: '@deepseek-ai/dsh-command-goal' + - id: tool-goal + name: '@deepseek-ai/dsh-tool-goal' + - id: planning + name: cordis:group + group: true + isolate: + planMode: true + config: + - id: plan-mode + name: '@deepseek-ai/dsh-plan-mode' + config: + section: | + You are in plan mode. Stay in plan mode until exit_plan_mode succeeds or the user switches the session mode. Imperative language to implement changes means plan the implementation, not execute it. A user's conversational agreement — including an answer confirming something you asked — approves nothing and does not end plan mode; fold the confirmed decision into the plan and submit it through exit_plan_mode. + + Explore first. Use non-mutating reads, searches, static analysis, and checks to ground the plan in the actual repository. Do not edit or write files, change configuration, run formatters or code generation that rewrites tracked files, commit, or otherwise carry out the plan. Prefer existing functions and patterns over new machinery. + + The tool catalog stays the same across modes for request-cache stability. These plan-mode rules override any later tool description or guidance that suggests using mutation tools; those tools remain listed to keep the tool catalog unchanged. Do not use todo_write to track this planning phase: it tracks implementation after an approved plan, while the plan itself belongs in exit_plan_mode. + + Resolve discoverable facts by inspection. Use ask_user_question only for user-owned choices or material ambiguity that inspection cannot answer. Do not ask the user where code lives or how current behavior works when you can find out. + + Make the plan decision-complete: state the goal and success criteria; group implementation changes by subsystem; identify public API, schema, and data-flow changes; cover edge cases, failure modes, tests, acceptance criteria, and explicit assumptions. Keep it concise enough to review but detailed enough that another engineer can implement it without making design decisions. + + When ready, call exit_plan_mode with the complete plan markdown, starting with a # title. Make exit_plan_mode the only and final tool call in that assistant response: it presents the plan for approval, and implementation begins only in a later step after approval. Do not paste the final plan as a plain reply or ask "should I proceed?" through prose or ask_user_question. If review rejects it, incorporate the feedback and present again. If the review channel is unavailable or aborted, stay in plan mode and ask the user to switch modes manually; do not proceed with implementation. + - id: compaction + name: cordis:group + group: true + isolate: + compaction: true + toolResultPruner: true + config: + - id: compaction-basic + name: '@deepseek-ai/dsh-compaction-basic' + config: + # See presets/facet.patch.yml for why these three values. + thresholdRatio: 0.35 + retainTokens: 80000 + maxTokens: 16384 + - id: command-compact + name: '@deepseek-ai/dsh-command-compact' + - id: tool-result-pruner + name: '@deepseek-ai/dsh-compaction-tool-result-pruner' + config: + thresholdChars: 8192 + headChars: 4096 + tailChars: 1024 + - id: delegation + name: cordis:group + group: true + isolate: + workflowEngine: true + config: + - id: tool-subagent-control + name: '@deepseek-ai/dsh-tool-subagent-control' + - id: tool-subagent-list-agents + name: '@deepseek-ai/dsh-tool-subagent-control/list-agents' + - id: tool-subagent + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: spawn + toolName: subagent + modelSelectionSettings: true + backgroundMode: continuable + - id: tool-subagent-fork + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: fork + toolName: subagent_fork + backgroundMode: continuable + - id: tool-subagent-codex + name: '@deepseek-ai/dsh-tool-subagent' + disabled: true + config: + provider: codex + toolName: subagent_codex + backgroundMode: one-shot + maxDepth: provider-managed + - id: tool-subagent-claude-code + name: '@deepseek-ai/dsh-tool-subagent' + disabled: true + config: + provider: claude-code + toolName: subagent_claude_code + backgroundMode: one-shot + maxDepth: provider-managed + - id: workflow-ptc + name: '@deepseek-ai/dsh-workflow-ptc' + config: + provider: spawn + - id: tool-workflow + name: '@deepseek-ai/dsh-tool-workflow' + - id: tool-ralph + name: '@deepseek-ai/dsh-tool-ralph' + disabled: true + config: + subagentProvider: spawn + maxRounds: 64 + - id: tool-ask-user + name: '@deepseek-ai/dsh-tool-ask-user' + - id: tool-todo + name: '@deepseek-ai/dsh-tool-todo' + config: + allowParallelInProgress: true + - id: tool-web + name: '@deepseek-ai/dsh-tool-web' + config: + fetch: true + searchTimeoutMs: 60000 + - id: present + name: '@deepseek-ai/dsh-tool-present' + - id: tool-plugin-manager + name: '@deepseek-ai/dsh-plugin-manager/tools' + disabled: true diff --git a/scripts/dev-links.mjs b/scripts/dev-links.mjs new file mode 100644 index 0000000..4eb9847 --- /dev/null +++ b/scripts/dev-links.mjs @@ -0,0 +1,68 @@ +#!/usr/bin/env node +/** + * Link the harness's own packages into this checkout so `npm test` can import + * them, the way a plugin resolves them once installed into a profile. + * + * ## Why this exists + * + * The plugin imports `@deepseek-ai/dsh-tools` and `@deepseek-ai/schemastery`. + * Once installed into a profile those resolve from the profile's own + * `node_modules`, which is where the harness lives — but a bare checkout has no + * such tree, so the tests cannot even load the module under test. + * + * The alternative is declaring them as dependencies, and for `@deepseek-ai/cordis` + * that would be a genuine bug rather than untidiness: a second copy of Cordis + * means a second `Context` class, two service registries in one process, and a + * plugin whose services are invisible to the harness that loaded it. So the + * packages are linked, not installed. + * + * Idempotent, and safe to re-run after the harness is upgraded. + */ + +import { existsSync, mkdirSync, readdirSync, rmSync, symlinkSync } from 'node:fs' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +/** Packages this checkout imports at runtime, and which the harness must supply. */ +const NEEDED = ['cordis', 'schemastery', 'dsh-tools'] + +const here = dirname(fileURLToPath(import.meta.url)) +const packageRoot = resolve(here, '..') + +/** Where a dsh installation keeps the scope, newest layout first. */ +function findHarnessScope() { + const candidates = [ + process.env.DSH_HARNESS_NODE_MODULES, + resolve(process.env.DSH_HOME ?? join(process.env.HOME ?? '', '.dsh'), 'profiles', 'node_modules'), + '/usr/local/lib/node_modules/@deepseek-ai/dsh/node_modules', + ] + for (const candidate of candidates) { + if (typeof candidate === 'string' && existsSync(join(candidate, '@deepseek-ai', 'cordis'))) return candidate + } + return undefined +} + +const scope = findHarnessScope() +if (scope === undefined) { + console.error('dev-links: no dsh installation found. Set DSH_HARNESS_NODE_MODULES to its node_modules directory.') + process.exit(1) +} + +const target = join(packageRoot, 'node_modules') +mkdirSync(join(target, '@deepseek-ai'), { recursive: true }) + +let linked = 0 +for (const name of NEEDED) { + const source = join(scope, '@deepseek-ai', name) + if (!existsSync(source)) { + console.error(`dev-links: ${name} is not in ${scope}; skipping`) + continue + } + const link = join(target, '@deepseek-ai', name) + if (existsSync(link)) rmSync(link, { recursive: true, force: true }) + symlinkSync(source, link, 'dir') + linked += 1 +} + +const scopeLabel = readdirSync(join(scope, '@deepseek-ai')).length +console.log(`dev-links: linked ${linked}/${NEEDED.length} packages from ${scope} (${scopeLabel} available)`) diff --git a/test/guard.test.js b/test/guard.test.js new file mode 100644 index 0000000..ac3fe5b --- /dev/null +++ b/test/guard.test.js @@ -0,0 +1,365 @@ +/** + * The guard, driven through the real `apply` against a fake context and a real + * filesystem. + * + * The filesystem is real because the interesting failures are filesystem + * failures: `/a/b` is not inside `/a/bc`, and a symlink inside the facet that + * points outside it is the one case where deciding on strings rather than on + * canonical targets would quietly open the scope. Both are tested against + * `mkdtemp` and `realpath`, not against a mock's opinion of path semantics. + */ + +import assert from 'node:assert/strict' +import { mkdtemp, mkdir, rm, symlink, writeFile } from 'node:fs/promises' +import { realpath } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path' +import { after, before, test } from 'node:test' + +import { apply as guard } from '../lib/guard.js' +import { createFacetPolicy } from '../lib/policy.js' + +/** A filesystem provider with `dsh-fs-local`'s two relevant behaviours. */ +const fsLikeLocal = { + /** Canonical target, walking up past a leaf that does not exist yet. */ + async resolve(path, opts = {}) { + const displayPath = isAbsolute(path) ? resolve(path) : resolve(opts.cwd ?? process.cwd(), path) + try { + return { targetKey: await realpath(displayPath), displayPath } + } catch (error) { + if (error.code !== 'ENOENT') throw error + } + const missing = [basename(displayPath)] + let ancestor = dirname(displayPath) + for (;;) { + try { + return { targetKey: join(await realpath(ancestor), ...missing), displayPath } + } catch (error) { + if (error.code !== 'ENOENT') throw error + } + const parent = dirname(ancestor) + if (parent === ancestor) throw new Error(`cannot resolve ${displayPath}`) + missing.unshift(basename(ancestor)) + ancestor = parent + } + }, + contains(parent, child) { + const path = relative(parent.targetKey, child.targetKey) + return path === '' || (path !== '..' && !path.startsWith(`..${sep}`) && !isAbsolute(path)) + }, +} + +/** Capture what `apply` registered, so the test can drive it. */ +function harness({ mode = 'ask', extraRoots = [], tool = true } = {}) { + const policy = createFacetPolicy({ mode, extraRoots }) + const captured = { section: undefined, context: undefined, tool: undefined, preExecute: undefined } + const ctx = { + facetPolicy: policy, + fs: fsLikeLocal, + systemPrompt: { + section: (value) => { + captured.section = value + }, + context: (value) => { + captured.context = value + }, + }, + tools: { + register: (value) => { + captured.tool = value + }, + }, + on: (event, listener) => { + if (event === 'tools/pre-execute') captured.preExecute = listener + }, + } + guard(ctx, { tool }) + return { policy, ctx, captured } +} + +/** Dispatch one call the way the tool pipeline would: our listener wraps `next()`. */ +const dispatch = (captured, { name, args, agent, inner = { kind: 'allow' } }) => + captured.preExecute({ name, arguments: args, agent, signal: new AbortController().signal }, async () => inner) + +/** @type {string} */ let root +/** @type {string} */ let facet +const agent = (cwd = facet, id = 'session-1') => ({ session: { id, header: { cwd } } }) + +before(async () => { + root = await realpath(await mkdtemp(join(tmpdir(), 'dsh-facet-'))) + facet = join(root, 'src', 'controllers', 'room') + await mkdir(facet, { recursive: true }) + await mkdir(join(root, 'src', 'controllers', 'other'), { recursive: true }) + await mkdir(join(root, 'src', 'shared'), { recursive: true }) + await writeFile(join(facet, 'index.ts'), 'export {}\n') + await writeFile(join(root, 'src', 'shared', 'types.ts'), 'export type T = 1\n') + // The case a string comparison would miss. + await symlink(join(root, 'src', 'shared', 'types.ts'), join(facet, 'link.ts')) +}) + +after(async () => { + await rm(root, { recursive: true, force: true }) +}) + +test('a path inside the facet is ordinary work', async () => { + const { captured } = harness() + assert.deepEqual(await dispatch(captured, { name: 'read', args: { file_path: 'index.ts' }, agent: agent() }), { + kind: 'allow', + }) + assert.deepEqual( + await dispatch(captured, { name: 'grep', args: { pattern: 'x', path: '.' }, agent: agent() }), + { kind: 'allow' }, + ) +}) + +test('a path outside the facet is put to the user', async () => { + const { captured } = harness() + const decision = await dispatch(captured, { + name: 'read', + args: { file_path: '../../shared/types.ts' }, + agent: agent(), + }) + assert.equal(decision.kind, 'ask') + assert.match(decision.reason, /src\/shared\/types\.ts/) + assert.match(decision.reason, /src\/controllers\/room/) +}) + +test('the sibling module is outside, which is the entire point', async () => { + const { captured } = harness() + const decision = await dispatch(captured, { + name: 'read', + args: { file_path: '../other/index.ts' }, + agent: agent(), + }) + assert.equal(decision.kind, 'ask') +}) + +test('a symlink inside the facet does not open it', async () => { + const { captured } = harness() + const decision = await dispatch(captured, { name: 'read', args: { file_path: 'link.ts' }, agent: agent() }) + assert.equal(decision.kind, 'ask', 'containment must be decided on canonical targets') +}) + +test('a prefix is not a parent', async () => { + const { captured } = harness() + await mkdir(join(root, 'src', 'controllers', 'roommate'), { recursive: true }) + const decision = await dispatch(captured, { + name: 'read', + args: { file_path: '../roommate/x.ts' }, + agent: agent(), + }) + assert.equal(decision.kind, 'ask') +}) + +test('an omitted path means the session directory, which is in scope', async () => { + const { captured } = harness() + assert.deepEqual(await dispatch(captured, { name: 'glob', args: { pattern: '**/*.ts' }, agent: agent() }), { + kind: 'allow', + }) +}) + +test('tools the guard cannot judge are left alone, deliberately', async () => { + const { captured } = harness() + // A shell command cannot be read reliably from argv, so the facet is enforced + // on the file tools and `bash` stays a legitimate way out. The prompt says so. + assert.deepEqual( + await dispatch(captured, { name: 'bash', args: { command: `cat ${root}/src/shared/types.ts` }, agent: agent() }), + { kind: 'allow' }, + ) + assert.deepEqual(await dispatch(captured, { name: 'mcp_files', args: { path: '/etc' }, agent: agent() }), { + kind: 'allow', + }) +}) + +test('an agent with no working directory is not scoped', async () => { + const { captured } = harness() + assert.deepEqual(await dispatch(captured, { name: 'read', args: { file_path: '/etc/hosts' } }), { kind: 'allow' }) +}) + +test('a standing grant and a session grant both open the scope', async () => { + const standing = harness({ extraRoots: ['../../shared'] }) + assert.deepEqual( + await dispatch(standing.captured, { name: 'read', args: { file_path: '../../shared/types.ts' }, agent: agent() }), + { kind: 'allow' }, + ) + + const granted = harness() + granted.policy.grant('session-1', ['../../shared'], facet) + assert.deepEqual( + await dispatch(granted.captured, { name: 'read', args: { file_path: '../../shared/types.ts' }, agent: agent() }), + { kind: 'allow' }, + ) +}) + +test('deny mode refuses instead of asking, and still allows the way out', async () => { + const { captured } = harness({ mode: 'deny' }) + const decision = await dispatch(captured, { + name: 'read', + args: { file_path: '../../shared/types.ts' }, + agent: agent(), + }) + assert.equal(decision.kind, 'deny') + assert.match(decision.reason, /facet action=add/) + + // Widening is explicit and intentional, so it is asked even here — otherwise + // the user would have no way to grant anything at all. + assert.equal( + (await dispatch(captured, { name: 'facet', args: { action: 'add', paths: ['../../shared'] }, agent: agent() })) + .kind, + 'ask', + ) +}) + +test('off mode registers nothing', () => { + const { captured } = harness({ mode: 'off' }) + assert.equal(captured.preExecute, undefined) + assert.equal(captured.section, undefined) + assert.equal(captured.tool, undefined) +}) + +test('the guard adds to a decision, never overrides one', async () => { + const { captured } = harness() + const denied = await dispatch(captured, { + name: 'read', + args: { file_path: '../../shared/types.ts' }, + agent: agent(), + inner: { kind: 'deny', reason: 'an earlier policy said no' }, + }) + assert.deepEqual(denied, { kind: 'deny', reason: 'an earlier policy said no' }) +}) + +test('the rule is registered with no variables to interpolate', () => { + const { captured } = harness() + assert.equal(captured.section.name, 'facet-scope') + assert.equal(captured.section.interpolate, false) + assert.match(captured.section.text, /Facet scope/) +}) + +test('the binding follows the agent, and is empty without one', () => { + const { captured, policy } = harness() + assert.equal(captured.context.name, 'facet-binding') + assert.match(captured.context.text({ agent: agent() }), new RegExp(`facet: ${facet}$`, 'm')) + + policy.grant('session-1', ['../../shared'], facet) + assert.match(captured.context.text({ agent: agent() }), /granted outside it: .*src\/shared/) + + assert.equal(captured.context.text({}), '') + assert.equal(captured.context.text(undefined), '') +}) + +test('the facet tool reports the scope', async () => { + const { captured } = harness() + const value = await captured.tool.execute({ action: 'status' }, { agent: agent() }) + assert.equal(value.ok, true) + assert.equal(value.root, facet) + assert.deepEqual(value.granted, []) + assert.equal(value.mode, 'ask') +}) + +test('the facet tool grants, narrows and clears', async () => { + const { captured, policy } = harness() + const exec = { agent: agent() } + + await captured.tool.execute({ action: 'add', paths: ['../../shared'] }, exec) + assert.deepEqual(policy.grantsFor('session-1'), [join(root, 'src', 'shared')]) + + // Granting twice is a no-op, and says so rather than reporting a change. + const again = await captured.tool.execute({ action: 'add', paths: ['../../shared'] }, exec) + assert.match(again.note, /Already granted/) + assert.deepEqual(policy.grantsFor('session-1'), [join(root, 'src', 'shared')]) + + await captured.tool.execute({ action: 'remove', paths: ['../../shared'] }, exec) + assert.deepEqual(policy.grantsFor('session-1'), []) + + await captured.tool.execute({ action: 'add', paths: ['../../shared'] }, exec) + await captured.tool.execute({ action: 'reset' }, exec) + assert.deepEqual(policy.grantsFor('session-1'), []) +}) + +test('the facet tool refuses a call it cannot act on', async () => { + const { captured } = harness() + const exec = { agent: agent() } + await assert.rejects(() => captured.tool.execute({ action: 'add' }, exec), /needs `paths`/) + await assert.rejects(() => captured.tool.execute({ action: 'remove' }, exec), /needs `paths`/) + await assert.rejects(() => captured.tool.execute({ action: 'explode' }, exec), /unknown action/) + await assert.rejects( + () => captured.tool.execute({ action: 'status' }, { agent: { session: { id: 's', header: {} } } }), + /no working directory/, + ) +}) + +test('the tool can be left unregistered', () => { + const { captured } = harness({ tool: false }) + assert.equal(captured.tool, undefined) + // The guard is the part that matters and it is still there. + assert.equal(typeof captured.preExecute, 'function') +}) + +/** + * Validate a value against the tool's own declared output schema. + * + * The host enforces this schema before the model sees a result, and the schema + * is `additionalProperties: false`, so a field leaking out of `execute` is not a + * cosmetic extra — it is a failed call. The check reads the declared schema + * rather than a copy of it, so the two cannot drift apart. + */ +function schemaErrors(schema, value, at = '$') { + const errors = [] + if (schema.oneOf !== undefined) { + if (!schema.oneOf.some((branch) => schemaErrors(branch, value, at).length === 0)) { + errors.push(`${at}: matched no oneOf branch`) + } + return errors + } + if (schema.type === 'object') { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + errors.push(`${at}: expected an object`) + return errors + } + const properties = schema.properties ?? {} + for (const [key, sub] of Object.entries(properties)) { + if (sub.required === true && value[key] === undefined) errors.push(`${at}.${key}: required but missing`) + } + if (schema.additionalProperties === false) { + for (const key of Object.keys(value)) { + if (!(key in properties)) errors.push(`${at}.${key}: not declared, but additionalProperties is false`) + } + } + for (const [key, sub] of Object.entries(properties)) { + if (value[key] !== undefined) errors.push(...schemaErrors(sub, value[key], `${at}.${key}`)) + } + return errors + } + if (schema.type === 'array') { + if (!Array.isArray(value)) return [`${at}: expected an array`] + return value.flatMap((item, index) => schemaErrors(schema.items ?? {}, item, `${at}[${index}]`)) + } + if (schema.type === 'string' && typeof value !== 'string') return [`${at}: expected a string`] + if (schema.type === 'boolean' && typeof value !== 'boolean') return [`${at}: expected a boolean`] + return errors +} + +test('every facet result satisfies the schema the model is given', async () => { + const { captured } = harness() + const schema = captured.tool.output.schema + const exec = { agent: agent() } + + const results = [ + await captured.tool.execute({ action: 'status' }, exec), + await captured.tool.execute({ action: 'add', paths: ['../../shared'] }, exec), + // The case that produces the optional `note`: granting is idempotent. + await captured.tool.execute({ action: 'add', paths: ['../../shared'] }, exec), + await captured.tool.execute({ action: 'remove', paths: ['../../shared'] }, exec), + await captured.tool.execute({ action: 'reset' }, exec), + ] + for (const [index, value] of results.entries()) { + assert.deepEqual(schemaErrors(schema, value), [], `result ${index} does not match the declared schema`) + } + + // And the renderer must be a pure function of that value. + for (const value of results) { + const blocks = captured.tool.output.render({ action: value.action }, value) + assert.equal(Array.isArray(blocks), true) + assert.equal(blocks[0].type, 'text') + } +}) diff --git a/test/index.test.js b/test/index.test.js new file mode 100644 index 0000000..3df3c43 --- /dev/null +++ b/test/index.test.js @@ -0,0 +1,70 @@ +/** + * The host row: the deployment's policy and its lifetime. + * + * Short, but it is the half that is easiest to leave untested — it has no + * interesting logic and one interesting obligation, which is that a grant must + * not outlive the session that asked for it. + */ + +import assert from 'node:assert/strict' +import { test } from 'node:test' + +import { apply, Config } from '../index.js' + +/** The smallest context this row touches. */ +function harness() { + const captured = { provided: undefined, onDisposed: undefined } + const ctx = { + provide: (name, value) => { + captured.provided = { name, value } + }, + on: (event, listener) => { + if (event === 'session/disposed') captured.onDisposed = listener + }, + } + apply(ctx, Config({ mode: 'ask' })) + return captured +} + +test('the schema fills every default, so a bare row is a working row', () => { + const config = Config({}) + assert.equal(config.mode, 'ask') + assert.deepEqual(config.extraRoots, []) + assert.equal(config.sectionOrder, 100) + assert.equal(config.contextOrder, 105) +}) + +test('a bad mode fails loudly at load rather than at the first file call', () => { + assert.throws(() => Config({ mode: 'maybe' })) +}) + +test('the policy is published under the name the guard injects', () => { + const captured = harness() + assert.equal(captured.provided.name, 'facetPolicy') + assert.equal(captured.provided.value.mode, 'ask') +}) + +test('the service reads the configuration it was given', () => { + const provided = [] + apply( + { provide: (name, value) => provided.push({ name, value }), on: () => {} }, + Config({ mode: 'deny', extraRoots: ['../../shared'], sectionOrder: 7, contextOrder: 8 }), + ) + const policy = provided[0].value + assert.equal(policy.mode, 'deny') + assert.deepEqual(policy.standing, ['../../shared']) + assert.equal(policy.sectionOrder, 7) + assert.equal(policy.contextOrder, 8) +}) + +test('a disposed session does not leave its grant behind', () => { + const captured = harness() + const policy = captured.provided.value + policy.grant('session-1', ['../../shared'], '/w/src/room') + assert.deepEqual(policy.grantsFor('session-1'), ['/w/shared']) + + assert.equal(typeof captured.onDisposed, 'function') + captured.onDisposed({ id: 'session-1' }) + assert.deepEqual(policy.grantsFor('session-1'), []) + assert.deepEqual(policy.sessions, []) +}) diff --git a/test/policy.test.js b/test/policy.test.js new file mode 100644 index 0000000..11913fe --- /dev/null +++ b/test/policy.test.js @@ -0,0 +1,95 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' + +import { createFacetPolicy } from '../lib/policy.js' + +/** A stand-in for the agent shape the policy reads: session id plus working directory. */ +const agentAt = (cwd, id = 's1') => ({ session: { id, header: { cwd } } }) + +test('the scope is the session directory, and nothing else', () => { + const policy = createFacetPolicy({ mode: 'ask' }) + const scope = policy.scopeFor(agentAt('/w/src/controllers/room')) + assert.equal(scope.root, '/w/src/controllers/room') + assert.equal(scope.cwd, scope.root) + assert.deepEqual(scope.extras, []) +}) + +test('an agent with no working directory has no facet', () => { + const policy = createFacetPolicy({ mode: 'ask' }) + assert.equal(policy.scopeFor(undefined), undefined) + assert.equal(policy.scopeFor({}), undefined) + assert.equal(policy.scopeFor({ session: { header: {} } }), undefined) + assert.equal(policy.scopeFor({ session: { header: { cwd: ' ' } } }), undefined) + assert.equal(policy.scopeFor({ session: { header: { cwd: 7 } } }), undefined) +}) + +test('a grant widens one session and leaves every other alone', () => { + const policy = createFacetPolicy({ mode: 'ask' }) + policy.grant('s1', ['../shared'], '/w/src/room') + + assert.deepEqual(policy.scopeFor(agentAt('/w/src/room', 's1')).extras, ['/w/src/shared']) + assert.deepEqual(policy.scopeFor(agentAt('/w/src/room', 's2')).extras, []) + assert.deepEqual(policy.grantsFor('s1'), ['/w/src/shared']) + assert.deepEqual(policy.grantsFor('s2'), []) +}) + +test('grants are absolute, deduplicated and order-preserving', () => { + const policy = createFacetPolicy({ mode: 'ask' }) + // `shared` is relative to the session directory, `/w/src/shared` is not, and + // both are kept: they are different places that happen to share a name. + policy.grant('s1', ['shared', '/w/src/shared', 'shared', 'other'], '/w/src/room') + assert.deepEqual(policy.grantsFor('s1'), ['/w/src/room/shared', '/w/src/shared', '/w/src/room/other']) +}) + +test('revoke and reset narrow without touching anyone else', () => { + const policy = createFacetPolicy({ mode: 'ask' }) + policy.grant('s1', ['a', 'b'], '/w') + policy.grant('s2', ['c'], '/w') + + assert.deepEqual(policy.revoke('s1', ['a'], '/w'), ['/w/b']) + assert.deepEqual(policy.revoke('s1', ['b'], '/w'), []) + assert.equal(policy.sessions.includes('s1'), false) + assert.deepEqual(policy.grantsFor('s2'), ['/w/c']) + + policy.grant('s3', ['d'], '/w') + assert.deepEqual(policy.reset('s3'), []) + assert.deepEqual(policy.grantsFor('s3'), []) +}) + +test('a disposed session does not leave its grant behind', () => { + const policy = createFacetPolicy({ mode: 'ask' }) + policy.grant('s1', ['shared'], '/w') + assert.deepEqual(policy.sessions, ['s1']) + + policy.forget('s1') + assert.deepEqual(policy.sessions, []) + // A resumed session id must not inherit a grant nobody remembers making. + assert.deepEqual(policy.scopeFor(agentAt('/w', 's1')).extras, []) +}) + +test('standing grants resolve against each session, not the process', () => { + const policy = createFacetPolicy({ mode: 'ask', extraRoots: ['../shared', '/abs/common'] }) + assert.deepEqual(policy.scopeFor(agentAt('/w/a/one')).extras, ['/w/a/shared', '/abs/common']) + assert.deepEqual(policy.scopeFor(agentAt('/w/b/two')).extras, ['/w/b/shared', '/abs/common']) +}) + +test('junk in the configuration and in a grant is dropped, not thrown', () => { + const policy = createFacetPolicy({ mode: 'ask', extraRoots: ['ok', '', ' ', 42, null] }) + assert.deepEqual(policy.standing, ['ok']) + + policy.grant('s1', ['', ' ', 42, null, 'real'], '/w') + assert.deepEqual(policy.grantsFor('s1'), ['/w/real']) +}) + +test('standing grants and session grants are merged without repetition', () => { + const policy = createFacetPolicy({ mode: 'ask', extraRoots: ['shared'] }) + policy.grant('s1', ['shared', 'other'], '/w') + assert.deepEqual(policy.scopeFor(agentAt('/w', 's1')).extras, ['/w/shared', '/w/other']) +}) + +test('prompt placements are carried, so the preset row needs no config', () => { + assert.equal(createFacetPolicy({ mode: 'ask' }).sectionOrder, 100) + assert.equal(createFacetPolicy({ mode: 'ask' }).contextOrder, 105) + assert.equal(createFacetPolicy({ mode: 'ask', sectionOrder: 7, contextOrder: 8 }).sectionOrder, 7) + assert.equal(createFacetPolicy({ mode: 'ask', sectionOrder: 7, contextOrder: 8 }).contextOrder, 8) +}) diff --git a/test/scope.test.js b/test/scope.test.js new file mode 100644 index 0000000..151cd6a --- /dev/null +++ b/test/scope.test.js @@ -0,0 +1,106 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' + +import { + displayPath, + facetContextText, + facetRuleText, + isWideningCall, + outsideReason, + pathArgumentsFor, + widenReason, +} from '../lib/scope.js' + +test('pathArgumentsFor reads the path argument of every guarded tool', () => { + assert.deepEqual(pathArgumentsFor('read', { file_path: 'src/a.ts' }), ['src/a.ts']) + assert.deepEqual(pathArgumentsFor('write', { file_path: 'src/a.ts', content: 'x' }), ['src/a.ts']) + assert.deepEqual(pathArgumentsFor('edit', { file_path: 'src/a.ts' }), ['src/a.ts']) + assert.deepEqual(pathArgumentsFor('read_image', { file_path: 'a.png' }), ['a.png']) + assert.deepEqual(pathArgumentsFor('grep', { pattern: 'x', path: 'src' }), ['src']) + assert.deepEqual(pathArgumentsFor('glob', { pattern: '*.ts', path: 'src' }), ['src']) + assert.deepEqual(pathArgumentsFor('str_replace_editor', { path: 'src/a.ts' }), ['src/a.ts']) + assert.deepEqual(pathArgumentsFor('recall', { path: 'src', focus: 'src/controllers' }), [ + 'src', + 'src/controllers', + ]) +}) + +test('pathArgumentsFor has no opinion where it cannot have one', () => { + // The deliberate hole: a shell command cannot be judged from argv. + assert.deepEqual(pathArgumentsFor('bash', { command: 'cat /etc/passwd' }), []) + // Unknown tools are not guessed at. + assert.deepEqual(pathArgumentsFor('mcp_something', { path: '/etc' }), []) + // An omitted path means the session directory, which is already in scope. + assert.deepEqual(pathArgumentsFor('grep', { pattern: 'x' }), []) + // Unvalidated JSON must never make the guard throw. + assert.deepEqual(pathArgumentsFor('read', null), []) + assert.deepEqual(pathArgumentsFor('read', 'nonsense'), []) + assert.deepEqual(pathArgumentsFor('read', { file_path: 42 }), []) + assert.deepEqual(pathArgumentsFor('read', { file_path: ' ' }), []) + assert.deepEqual(pathArgumentsFor('grep', { path: { nested: '/etc' } }), []) +}) + +test('isWideningCall is true only for the one act that needs the user', () => { + assert.equal(isWideningCall('facet', { action: 'add', paths: ['x'] }), true) + // Narrowing and reading cannot show the agent anything new. + assert.equal(isWideningCall('facet', { action: 'remove', paths: ['x'] }), false) + assert.equal(isWideningCall('facet', { action: 'reset' }), false) + assert.equal(isWideningCall('facet', { action: 'status' }), false) + assert.equal(isWideningCall('read', { action: 'add' }), false) + assert.equal(isWideningCall('facet', null), false) +}) + +test('facetRuleText states the rule and never the binding', () => { + const ask = facetRuleText({ mode: 'ask' }) + assert.match(ask, /put to the user as a question/) + // The two modes must not read alike: ask must not claim the call is refused. + assert.doesNotMatch(ask, /A call outside it is refused/) + + const deny = facetRuleText({ mode: 'deny' }) + assert.match(deny, /A call outside it is refused/) + + // The rule must not name a path: a changing system prompt invalidates the + // prompt cache for the whole conversation. + assert.doesNotMatch(ask, /\/|src\//) + + assert.equal(facetRuleText({ mode: 'off' }), '') + assert.equal(facetContextText({ cwd: '/a', root: '/a/b', extras: [], mode: 'off' }), '') +}) + +test('facetContextText carries the binding that does change', () => { + assert.equal( + facetContextText({ cwd: '/w', root: '/w/src/room', extras: [], mode: 'ask' }), + 'facet: /w/src/room\noutside the facet: ask the user', + ) + assert.equal( + facetContextText({ cwd: '/w', root: '/w/src/room', extras: ['/w/src/shared'], mode: 'deny' }), + 'facet: /w/src/room\ngranted outside it: src/shared\noutside the facet: refused', + ) + // Unscoped sessions contribute nothing rather than an empty heading. + assert.equal(facetContextText({ cwd: '/w', root: undefined, extras: [], mode: 'ask' }), '') +}) + +test('displayPath prefers the shortest unambiguous form', () => { + assert.equal(displayPath('/w', '/w/src/a.ts'), 'src/a.ts') + assert.equal(displayPath('/w', '/w'), '.') + assert.equal(displayPath('/w', '/other/a.ts'), '/other/a.ts') + assert.equal(displayPath('', '/w/a.ts'), '/w/a.ts') +}) + +test('outsideReason names the path, the boundary and the way forward', () => { + const reason = outsideReason({ + cwd: '/w/src/controllers/room', + path: '/w/src/shared/types.ts', + root: '/w/src/controllers/room', + mode: 'ask', + }) + assert.match(reason, /src\/shared\/types\.ts/) + assert.match(reason, /src\/controllers\/room/) + // A denial that does not say how to proceed is one the agent routes around. + assert.match(reason, /facet action=add/) +}) + +test('widenReason survives an add with no usable paths', () => { + assert.match(widenReason(['src/shared']), /src\/shared/) + assert.match(widenReason([]), /nothing/) +})