The guard's rule was that a call naming no path is not judged, because a tool that names no path cannot leave the facet. That is true of a tool with no path argument and false of three that have one with a default: `grep` and `glob` document theirs as "Defaults to the session workspace", and `recall` falls back to a directory its own marker search picks. On any session whose facet is narrower than its directory — every session whose focus was set from the panel rather than by starting the conversation in the folder — a bare `grep` searches outside the facet and returns what it finds. An absent argument is not an absent path; it is the default one. The three tools are now judged against the directory they would have searched, through the same containment test that decides every other call, and the refusal is its own sentence rather than the "that path is outside" one: the path at issue is one the model never wrote, so the remedy is a second argument, not a grant. A tool with no path argument at all is still left alone. `bash` is still the documented way out, and the first deployment running this plugin confirmed it is a real one: an agent scoped to one module used a shell command to parse a config file outside its facet, having been told in the same prompt not to. That is what this plugin is for, and what it cannot do is stated in the README rather than implied.
381 lines
20 KiB
Markdown
381 lines
20 KiB
Markdown
# 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.
|
|
|
|
**A tool that names no path is judged on the path it would use.** `grep`, `glob`
|
|
and `recall` all default to a directory when the model omits one — the session
|
|
directory for the first two, a marker-chosen one for the third — and on a session
|
|
whose facet is narrower than its directory, that default reads outside the facet.
|
|
An absent argument is not an absent path, so those three are judged against the
|
|
default they would have searched, and the refusal says which argument to add.
|
|
`lib/scope.js` holds the list; a tool with no path argument at all is still left
|
|
alone, because it has no default that could leave.
|
|
|
|
### 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
|
|
|
|
From the repository, following releases:
|
|
|
|
```sh
|
|
dsh plugin --profile web add "git+https://git.notexpectedyet.com/Not-Expected-Yet/dsh-facet.git#semver:^0.1.0"
|
|
dsh --profile web --dump-config | grep preset-facet # expect the row
|
|
```
|
|
|
|
Or from a checkout, while working on the plugin itself:
|
|
|
|
```sh
|
|
dsh plugin --profile web add "/path/to/dsh-facet"
|
|
```
|
|
|
|
The `#semver:^0.1.0` fragment is the difference between an install that can be
|
|
updated and one that cannot. Leave it off and pnpm resolves the default branch to
|
|
a **commit** and records that commit in `pnpm-lock.yaml` — `dsh plugin update`
|
|
then has no version to move to, because there is nothing to move between. With
|
|
the fragment, pnpm resolves against the repository's tags, which the release
|
|
workflow creates on every push to `master`. The fragment needs a tag to resolve
|
|
against, so it only works once the first release has been published.
|
|
|
|
Either way the command is `pnpm add` run inside the profile, and it registers the
|
|
bundle in `dsh.profile.bundles` for you. A git-hosted plugin that builds on
|
|
install can be blocked by pnpm's build policy; add the key pnpm asks for under
|
|
`allowBuilds` in the profile's `pnpm-workspace.yaml`. This bundle ships sources
|
|
with no build step, so it does not trip that.
|
|
|
|
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: [] # optional: a seed of paths always in scope, every session
|
|
```
|
|
|
|
Both are also on the plugin's card under **Plugins** in the Web UI, which is the
|
|
same layer reached a different way: a settings write rewrites this entry in the
|
|
profile patch rather than storing anything new. An edit there is live — the guard
|
|
reads `mode` and `extraRoots` on every call, so a change decides the next file
|
|
call without a restart.
|
|
|
|
The one exception is `off`. Choosing it while a session is running stops the guard
|
|
immediately and empties the binding on the next request, because the binding is a
|
|
callback. The rule is not: it was registered at load as a plain string, and
|
|
nothing unregisters it, so the prompt would go on stating a rule that nothing
|
|
enforces. An `off` *deployment* is therefore settled at load — set it in the
|
|
patch. `ask` and `deny` swap freely in both directions.
|
|
|
|
`sectionOrder` and `contextOrder` are deliberately absent from the card. They
|
|
place the rule and the binding among the other prompt sections, which is decided
|
|
once at load, and no section is re-ordered without a remount.
|
|
|
|
### Per-session scope, and who gets to decide it
|
|
|
|
Scope has two layers, and the split is the point:
|
|
|
|
| layer | what it is | who can write it |
|
|
|---|---|---|
|
|
| **the user's declaration** | `scopes[<session id>]` in this row's config — a `focus` and a list of `extras` | only the context panel, through the settings service |
|
|
| **the agent's** | a narrowing `focus` and any session grants, held in memory | the agent, and only ever to *reduce* |
|
|
|
|
The effective root is the agent's narrowing while it still sits inside your root,
|
|
and your root otherwise. So an agent that goes wrong cannot widen its own scope,
|
|
and `facet action=reset` returns it to what you declared rather than to the
|
|
session directory — landing on the directory would have made `reset` a widening.
|
|
|
|
You set the declaration from the **crosshair button on the composer tool row**,
|
|
which appears only in sessions running the `facet` preset. It shows the focus and
|
|
the allowed paths, adds and removes them, and carries the mode details and usage
|
|
notes that the preset picker's guide dialog cannot show for a preset no core
|
|
package ships. Writes go to the profile patch, so reopening a chat gives back the
|
|
scope it had.
|
|
|
|
Both controls are a **directory listing**, not a text box: the panel's vocabulary
|
|
is paths relative to the session folder, and "which folder is `../shared`" is the
|
|
one question free text cannot answer. Pressing it opens a second dialog over the
|
|
panel, shaped like the host's own workspace browser — one level of child folders,
|
|
a breadcrumb trail, a hidden-entries toggle — driven through
|
|
`uiWorkspace.listDirectory`, the same Host primitive that browser uses. A pick is
|
|
stored in the form that keeps its meaning: `/work/root/src/shared` becomes
|
|
`src/shared` when the session started in `/work/root`. The picker's own path field
|
|
stays editable, because the Host lists folders and an extra may legitimately name
|
|
a file — which is why that action reads **Add path or file** and its field **Path
|
|
or file**, rather than promising a folder the list would then fail to show.
|
|
|
|
Because the panel owns widening, `facet action=add` is the *other* path and the
|
|
narrower one: it is gated on an approval channel, and a deployment with none — the
|
|
`danger-full-access` permission preset sets `approval: never` — refuses it before
|
|
anyone is asked. In that deployment the panel is the only way to widen, which is
|
|
exactly what the binding tells the agent.
|
|
|
|
`extraRoots` is only a seed now. Before per-session scope it was the *only* way to
|
|
widen anything, which meant "this conversation needs the entity folder" had to be
|
|
written as a fact about every conversation in every workspace. It is for paths a
|
|
deployment genuinely always wants, and it is empty by default.
|
|
|
|
## 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. It is not a theoretical hole: the first deployment to run this had
|
|
an agent scoped to one module use a shell command to parse a config file
|
|
outside its facet, having been told in the same prompt not to. A prompt is not
|
|
a guard, which is what the rest of this plugin is for.
|
|
- **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:
|
|
|
|
- 81 tests (`npm test`) over the scope algebra, the policy state, the guard, and
|
|
the browser half — 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, that a live `mode` edit decides the next call
|
|
without the guard being remounted, that an agent narrowing the user has since
|
|
narrowed past is dropped rather than honoured, that `reset` lands on the
|
|
declared scope rather than the session directory, that a search naming no path
|
|
is refused when the directory it defaults to is outside the facet, 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.
|
|
|
|
**Verified live.** The bundle is installed in a `web` profile and composing
|
|
sessions: the runtime binding is in the running session's context, the crosshair
|
|
panel writes the declaration, and the guard decides the calls it names. That is
|
|
also where the `bash` boundary above was confirmed the hard way. The first thing
|
|
to check after any change is still 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
|
|
```
|
|
|
|
## Releases
|
|
|
|
`.gitea/workflows/release.yml` runs on every push to `master`: it works out the
|
|
range since the last tag, bumps `package.json`, prepends a `CHANGELOG.md`
|
|
section, commits and tags `vX.Y.Z`, then creates the Gitea release with the
|
|
commit subjects as the body.
|
|
|
|
The bug it fixes is not tidiness. Installed as a bare `git+https://…/repo.git`,
|
|
a plugin is pinned to a **commit** in the profile's lockfile, so with no tags
|
|
there is nothing for `dsh plugin update` to move to. Point the profile at
|
|
`#semver:^0.1.0` and the updates follow releases instead:
|
|
|
|
```jsonc
|
|
"dsh-facet": "git+https://git.notexpectedyet.com/Not-Expected-Yet/dsh-facet.git#semver:^0.1.0"
|
|
```
|
|
|
|
The bump is read from the commit subjects being released:
|
|
|
|
| in a commit subject | effect |
|
|
|---|---|
|
|
| `feat`, as its own word (`feat:`, `feat(x):`) | minor — `0.1.0` → `0.2.0` |
|
|
| `feat!:` / `fix(x)!:` / `BREAKING` | major — `0.1.0` → `1.0.0` |
|
|
| `fix:`, `docs:`, prose, anything else | patch — `0.1.0` → `0.1.1` |
|
|
| `[minor]` / `[major]` | force the bump when the subject cannot carry it |
|
|
| `[skip release]` | this push publishes nothing |
|
|
|
|
Only the **subject** line is read, and `feat` has to be a word — `Fix the feature
|
|
flag leak` is a patch, not a minor. With no tags yet, the version already in
|
|
`package.json` is what gets tagged, so the first release matches the code.
|
|
|
|
The logic lives in `.gitea/scripts/release.sh` so it runs without a runner:
|
|
`npm run test:release` exercises it — an empty range, a repository with no tags
|
|
yet, a `feat` that is only a word — `npm run release:plan` says whether the
|
|
current HEAD would release, and `npm run release:notes` prints the notes it
|
|
would publish.
|
|
|
|
## 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
|
|
client/client.js browser half: the settings card, and the context panel
|
|
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/ 81 tests
|
|
```
|
|
|
|
`client/client.js` is a browser half rather than a module the host imports, and
|
|
it is not optional decoration. The Plugins page does not build a card from a
|
|
`Config` schema — it draws only what browser halves register into the slots it
|
|
declares. `volatile()` on the host schema makes the namespace *servable*
|
|
(`settings.describe()` drops an entry with none, and the card registers only
|
|
while the namespace is served), but a schema alone puts nothing on the page.
|
|
|
|
The two keys in that file are deliberately different strings, and that is the
|
|
easy mistake to make here: the settings **document** is filed under the profile
|
|
entry id (`facet-policy`, the row's `id` in `cordis.patch.yml`) while the
|
|
**card** is keyed by the package name (`dsh-facet`). Every sibling plugin
|
|
happens to name them identically, so binding the form to the wrong one produces
|
|
a card that renders, saves, and writes nowhere. `test/client.test.js` pins both.
|
|
|
|
The same file carries the context panel, registered into
|
|
`conversation.input.left` — the composer tool row — and gated on the session's own
|
|
`agentPreset` projection, so it appears only where the facet is actually in force.
|
|
That gate is why `scopes` is `volatile()` in the host schema: `settings.mutate`
|
|
refuses a path that is not under a volatile node, and refusing is exactly right —
|
|
the panel is the only writer, and a plugin that could not serve the namespace
|
|
would be a control that silently stored nothing.
|
|
|
|
The listing reaches the panel through `ctx.get('uiWorkspace')` rather than through
|
|
an injection, and the injection's `available()` is asked on every render. Both are
|
|
deliberate: `ctx.inject` would make the whole context button conditional on a
|
|
service the plugin does not own, and the panel is still the only way to declare
|
|
scope in a deployment with no directory browser — it would simply have no listing
|
|
to offer, which is what it says instead. The service is resolved at the call
|
|
because the renderer memoizes a registration's inject result, so a boolean read
|
|
there would freeze at whatever the first render saw.
|
|
|
|
The two dialogs are wider than the primitive's 380px card, which is fixed in the
|
|
primitives' own stylesheet. `client/client.js` installs one small stylesheet of
|
|
its own — the widths and a row hover — and passes each card a class through
|
|
`Modal`'s `className`. Every selector in it doubles the class it names, so it
|
|
outranks the single hashed class the primitive applies whatever order the two
|
|
stylesheets land in; there is no `!important` and nothing reaching into a class
|
|
name this bundle does not own. The panel's body is a two-column grid that
|
|
`auto-fit` collapses to one on a narrow card, and each dialog scrolls a bounded
|
|
body rather than growing past the viewport — the panel used to be a tall single
|
|
column of prose on a 380px card, which is what it looked like.
|
|
|
|
## 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.
|