Files
dsh-facet/index.js
T
NotExpectedYet d9caf4eeec feat: scope a session from a declaration the agent cannot widen
The facet root was the session's working directory and nothing else, so the
only way to scope a conversation to a module was to make the module a
workspace — and the only way to reach anything outside it was `extraRoots`,
which is a fact about the deployment rather than about one conversation.

Scope now has two layers, and the split is the point. The **user layer** is
`config.scopes[sessionId]` — a `focus` and a list of `extras`, written only
from the browser context panel through the settings service, which is what
`settings.mutate` requiring a volatile path buys us. The **agent layer** is in
memory: a narrowing `focus` and its grants. Both can only ever reduce what the
user layer allows, so an agent that goes wrong cannot widen its own scope.

`facet action=focus` is therefore the one scope change the agent may make
unasked, bounded by the scope already in force and decided on canonical targets.
`reset` clears the agent's side only, landing on the user's declaration rather
than on the session directory — landing on the directory would have made
`reset` a widening. `extraRoots` demotes to an optional deployment-wide seed.

The binding now names the context panel as the way to widen, because it is: in
a deployment with no approval channel, `facet action=add` cannot be granted at
all.
2026-09-27 06:10:53 +01:00

134 lines
6.1 KiB
JavaScript

/**
* 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* of our own: 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. What the framework projects
* onto this row is the {@link Config} schema below, which is the same patch
* layer reached through the Web UI rather than a second place to store things.
*/
import Schema from '@deepseek-ai/schemastery'
import { createFacetPolicy } from './lib/policy.js'
export const name = 'dsh-facet'
/**
* Every configuration field this plugin exposes.
*
* `mode`, `extraRoots` and `scopes` are marked `volatile()`, and that marker is
* doing two jobs rather than one. It is what puts them on the settings page at
* all — the framework's form projection keeps only fields under a volatile
* ancestor, and drops an entry with no such field from `settings.describe`
* entirely, so without it this row has no card. It is also what makes an edit
* *live*: a volatile field is handed to {@link apply} as a reference rather than
* a value, and the framework commits an edit into that reference without
* re-applying the plugin.
*
* The second job is why every reader of these fields goes through
* `configValue()` in `./lib/policy.js` and reads at the point of use. A plugin
* that takes the reference for a string gets a settings card that appears to
* work and changes nothing.
*
* `scopes` is volatile for a third reason: it is the only field the browser
* context panel writes, and `settings.mutate` refuses a path that is not under a
* volatile node. That is the whole mechanism by which the panel — and not the
* agent — decides what one conversation may see.
*
* `sectionOrder` and `contextOrder` are deliberately NOT volatile. They place
* the rule and the binding among the other prompt sections, which is settled
* once at load; making them volatile would put two knobs nobody should touch on
* the card, and a section cannot be re-ordered without a remount anyway.
*/
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').volatile(),
/**
* A deployment-wide seed of paths always in scope, for code a module
* legitimately depends on — shared types, a common test harness.
*
* Deliberately only a seed. Before per-session scope existed this was the only
* way to widen anything, which meant "this one conversation needs the entity
* folder" had to be written as a fact about every conversation in every
* workspace. Per-session scope belongs in {@link Config.scopes}; this is for
* the paths a deployment genuinely always wants, and it is empty by default.
*
* Relative paths resolve against each session's own directory, so the same
* list is meaningful in every workspace.
*/
extraRoots: Schema.array(String).default([]).volatile(),
/**
* Per-session scope — the user's declaration for one conversation, keyed by
* session id.
*
* Written only by the browser context panel, through the settings service, and
* never by the agent: this plugin exposes no write path to it, and a facet
* session carries no settings tool. `focus` narrows the session to a folder;
* `extras` are paths the conversation may read outside it. Both are relative
* to the session's own directory unless absolute, so a declaration keeps its
* meaning wherever the workspace lives.
*
* Entries outlive their sessions, which is deliberate — reopening a chat
* should give back the scope it had — and costs a little growth in the
* profile patch that the panel can tidy.
*/
scopes: Schema.dict(
Schema.object({
/** The session's facet root, relative to the session directory or absolute. Empty means the session directory itself. */
focus: Schema.string(),
/** Paths this session may also read, outside the root. */
extras: Schema.array(String).default([]),
}),
)
.default({})
.volatile(),
/** 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)
}