packages/dsh-plugin/docs/spikes/2026-08-16-restrict-semantics.md
ctx.tools.restrict() reach a child scope's tools?Verdict up front: restrict reaches child-scope tools: NO
The plugin's own apply(ctx, config) — a bare Cordis plugin context that
never mints a dsh-scope scope — cannot call ctx.tools.restrict() at all.
It throws. When the arrangement is fixed so restrict() is at least
callable (a real agent-style scope), it still refuses to touch a tool the
bridge registered inside that same scope's own layer. Neither path reaches
the topology this plugin needs. The tools config key must not ship in
0.1.0.
Scaffolded a throwaway workspace at /tmp/dsh-spike (deleted after this
spike; nothing there is part of the plugin repo).
mkdir -p /tmp/dsh-spike && cd /tmp/dsh-spike && pnpm init
Version resolution problem. pnpm add @deepseek-ai/cordis @deepseek-ai/dsh-tools @deepseek-ai/schemastery
with no version pins resolves dsh-tools to its next dist-tag
(0.1.0-rc.6), whose peer chain requires @deepseek-ai/dsh-agent and
@deepseek-ai/dsh-session, both of which peer-depend on
@deepseek-ai/dsh-type-meta — a package that returns a plain 404 from the
public npm registry (confirmed directly: npm view @deepseek-ai/dsh-type-meta
→ 404 Not Found). Pinning dsh-tools to 0.0.1-rc.1 (the version this
plugin actually targets — confirmed via npm view @deepseek-ai/dsh-tools dist-tags, where latest is 0.0.1-rc.1) does not by itself fix this: pnpm
still auto-installs peer dependencies for the lockfile, so the same
dsh-type-meta 404 recurs indirectly through dsh-agent's and
dsh-session's peer graph.
Fix that worked: disable pnpm's peer auto-install and add only the
packages dsh-tools's compiled lib/index.js actually imports at
runtime (checked directly — import type lines don't need the package on
disk, real import lines do):
import { Service } from "@deepseek-ai/cordis";
import z from "@deepseek-ai/schemastery";
import { AnonymousEntries, NamedEntries, ScopedLayers, scopeOf, scopeTarget } from "@deepseek-ai/dsh-scope";
import { CallId, HarnessError, assertNever, deepFreeze } from "@deepseek-ai/dsh-llm";
import { isJsonValue, snapshotJsonValue } from "@deepseek-ai/dsh-session";
.npmrc:
auto-install-peers=false
strict-peer-dependencies=false
Install commands, in order (each added only after the previous run's
ERR_MODULE_NOT_FOUND named the next missing runtime import):
pnpm add @deepseek-ai/cordis@^4.0.1-rc.1 @deepseek-ai/[email protected] @deepseek-ai/schemastery@^3.18.1-rc.1 \
--config.auto-install-peers=false --config.strict-peer-dependencies=false
pnpm add @deepseek-ai/[email protected] @deepseek-ai/[email protected] @deepseek-ai/[email protected] \
--config.auto-install-peers=false --config.strict-peer-dependencies=false
pnpm add @deepseek-ai/[email protected] @deepseek-ai/[email protected] \
--config.auto-install-peers=false --config.strict-peer-dependencies=false
pnpm add @deepseek-ai/[email protected] \
--config.auto-install-peers=false --config.strict-peer-dependencies=false
dsh-system-prompt and dsh-invariants were needed for a second reason,
not just a dsh-tools runtime import: ToolRegistry.inject = ["systemPrompt"]
and its constructor calls ctx.systemPrompt.tools(...) immediately, so a
systemPrompt service must be mounted on ctx before ToolRegistry is.
dsh-timeout surfaced one level further down, as a real (non-type) import
inside dsh-llm's compiled output.
None of the packages actually needed at 0.0.1-rc.1 depend on
dsh-type-meta — only dsh-agent and dsh-session's peer list does
(dsh-session doesn't import it at runtime, so it was never installed and
never missed). Final resolved set: [email protected], [email protected],
[email protected], [email protected], [email protected],
[email protected], [email protected],
[email protected], [email protected].
Dead end worth recording: before finding the npm-registry version-pin
fix, I found a fully-resolved install of these packages already on disk at
~/.local/share/mise/installs/node/24.19.0/lib/node_modules/@deepseek-ai/dsh/node_modules/@deepseek-ai/*
(the globally-installed dsh CLI's own bundled node_modules). That
install is [email protected], not 0.0.1-rc.1 — a different minor
line with a renamed class (ToolRuntime, not ToolRegistry) and a
different register/restrict/schemas signature (scope?: ScopeKey
parameter instead of implicit calling-context resolution). I did not
use this install for the verdict below — it's the wrong pinned version for
this plugin — but it's why the bootstrapping path above took several
iterations: I initially assumed the API surface from that install, then had
to re-derive it from the actually-pinned 0.0.1-rc.1 types.
ToolDefinition shape (0.0.1-rc.1)The brief's guessed shape (parameters, bare async execute() { return {content:[...]} }) doesn't compile against node_modules/@deepseek-ai/dsh-tools/lib/types/index.d.ts.
The real shape:
export interface ToolDefinition extends ToolSchema {
// ToolSchema = { name: string; description: string; parameters: Record<string, unknown> }
readonly output: ToolOutputDefinition; // MANDATORY, not optional
execute(args: unknown, exec: ToolRunContext): Promise<unknown>; // returns the canonical value, not ContentBlock[]
finalizeContent?(...): ContentBlock[] | undefined;
timeoutMs?: number;
isConcurrencySafe?(args: unknown): boolean;
presentCall?(args: unknown): ToolCallView | undefined;
presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;
}
interface ToolOutputDefinition {
readonly schema: JsonSchemaNode; // enforced JSON Schema subset
render(args: unknown, value: JsonValue): ContentBlock[]; // projects the canonical value to model-facing content
presentationMeta?(args: unknown, value: JsonValue): JsonValue;
}
execute() returns the tool's canonical JSON value (validated against
output.schema); output.render() is what turns that value into
ContentBlock[]. register() throws a TypeError if output is missing
or output.render isn't a function — confirmed by reading
ToolRegistry.register in the compiled lib/index.js.
ToolRegistry itself: export { ToolRegistry, ToolRegistry as default } —
it's a Cordis Service subclass (super(ctx, "tools")), so it's mounted
with ctx.plugin(ToolRegistry, config), not ctx.plugin(tools) where
tools is the whole module namespace (the brief's guess).
declare module '@deepseek-ai/cordis' { interface Context { tools: ToolRegistry } }
— ctx.tools is one Cordis service singleton, shared down the whole
context tree exactly like every other Cordis service. There is no
per-Cordis-child-context instance of the registry; nesting a plugin under
ctx.plugin(...) does not give it a private tools.
What actually gates register()/restrict()'s visibility isn't the Cordis
plugin-context tree at all — it's a separate, opt-in scoping layer from
@deepseek-ai/dsh-scope:
scopeOf(ctx) reads "the nearest scope tag inherited by a context" — and
a context only carries a scope tag if something called
createScope(ctx, key) on it (or a Cordis ancestor of it). A plain
ctx.plugin(child) context is not scoped by that call alone.ToolRegistry.register(definition): this.layers.effect(this.ctx, (layer) => layer.tools.insert(name, definition), ...) —
lands in whatever layer scopeOf(this.ctx) resolves to (the global layer
if unscoped). No scope requirement to call it.ToolRegistry.restrict(filter): the first line is
const scope = scopeOf(this.ctx); if (scope === void 0) throw new Error("tools.restrict() requires a scoped context (agent.ctx): ...").
It is unconditionally unusable from an unscoped context — this is not a
silent no-op, it's a thrown error.scope !== undefined, restrict() computes
known = this.view(scope).restrictableNames (the scope's inherited
surface — global + ancestor layers) and rejects any name not in that set:
"a restriction filters what this scope inherits, never what it registers itself".
A tool registered as a child of the exact scope doing the restricting
is, by construction, in that scope's own layer, not its inherited surface
— so it is unconditionally unreachable by that scope's own restrict()
call, confirmed by the second probe below.This is a stricter, more mechanical version of what the ToolRestriction
docstring already said in prose ("do not affect the scope's own registrations") — the two probes below hit it from two different angles
and got two different thrown errors, not one graceful no-op.
ctx.plugin(mcpClient) mounts the bridge as a Cordis child of the
plugin's own apply(ctx, config); the plugin then calls
ctx.tools.restrict(...) from its own (parent, unscoped) ctx. This
reproduces the plugin's real design as literally as a stub allows.
/tmp/dsh-spike/probe.ts:
import { Context } from "@deepseek-ai/cordis";
import SystemPrompt from "@deepseek-ai/dsh-system-prompt";
import ToolRegistry from "@deepseek-ai/dsh-tools";
const root = new Context();
// ToolRegistry.inject = ["systemPrompt"], and its constructor calls
// ctx.systemPrompt.tools(...) immediately, so systemPrompt must be mounted first.
await root.plugin(SystemPrompt);
await root.plugin(ToolRegistry);
/** Stands in for dsh-mcp-client: registers one tool in whatever scope loads it. */
const stubBridge = {
name: "stub-bridge",
inject: ["tools"],
apply(ctx: Context) {
ctx.tools.register({
name: "mcp__resume__list_applications",
description: "stub",
parameters: { type: "object", properties: {} },
output: {
schema: { type: "string" },
render: (_args: unknown, value: unknown) => [{ type: "text", text: String(value) }],
},
async execute() {
return "ok";
},
});
},
};
// The plugin under design mounts the bridge as a child, exactly like this.
await root.plugin(stubBridge);
const names = () => root.tools.schemas().map((s) => s.name);
console.log("BEFORE", names());
try {
const dispose = root.tools.restrict({ deny: ["mcp__resume__list_applications"] });
console.log("AFTER", names());
dispose();
console.log("DISPOSED", names());
} catch (err) {
console.log("RESTRICT_THREW", (err as Error).message);
}
Run with node --experimental-strip-types probe.ts. Actual output,
verbatim:
BEFORE [ 'mcp__resume__list_applications' ]
RESTRICT_THREW tools.restrict() requires a scoped context (agent.ctx): a context-global restriction would mask every agent — deny the tool for the intended agent instead
restrict() never gets a chance to filter anything — it throws before
touching the tool set, because root (and every context under it, absent
an explicit createScope()) is unscoped.
Per the brief's Step 4, tried fixing the exception by giving the plugin a
real dsh-scope scope (what createScope() provides) and calling
restrict() from inside that scope, matching "registering the
restriction inside the same scope the bridge loads into."
Where a real agent gets this: @deepseek-ai/[email protected]
lib/index.js:375 —
this.scope = createScope(loopCtx, this);
this.ctx = this.scope.ctx.extend({ agent: this });
— not dsh-agent, which never calls createScope (its compiled
lib/index.js only calls scopeTarget, for event routing, not for minting
a scope). This is the exact line that builds agent.ctx — the thing
ToolRegistry's thrown error message names as what restrict() requires.
Confirmed directly: installed both [email protected] and
[email protected] with the same
--config.auto-install-peers=false --config.strict-peer-dependencies=false
workaround used for the rest of the dependency tree (dsh-type-meta is only
a peer dependency of dsh-agent, never a runtime import — same situation
as dsh-session/dsh-scope above — so it's never actually needed on disk),
then grepped the installed lib/index.js files for createScope.
/tmp/dsh-spike/probe2.ts:
import { Context } from "@deepseek-ai/cordis";
import { createScope } from "@deepseek-ai/dsh-scope";
import SystemPrompt from "@deepseek-ai/dsh-system-prompt";
import ToolRegistry from "@deepseek-ai/dsh-tools";
const root = new Context();
await root.plugin(SystemPrompt);
await root.plugin(ToolRegistry);
const stubBridge = {
name: "stub-bridge",
inject: ["tools"],
apply(ctx: Context) {
ctx.tools.register({
name: "mcp__resume__list_applications",
description: "stub",
parameters: { type: "object", properties: {} },
output: {
schema: { type: "string" },
render: (_args: unknown, value: unknown) => [{ type: "text", text: String(value) }],
},
async execute() {
return "ok";
},
});
},
};
// Mint a dsh-scope "Scope" (what dsh-agent-loop does to build agent.ctx --
// see lib/index.js:375) and mount the bridge as a CHILD of that scope's ctx
// -- i.e. an inherited/ancestor registration relative to the scope itself,
// not the scope's own layer.
const scopeKey = {};
const scope = createScope(root, scopeKey);
await scope.ctx.plugin(stubBridge);
const namesFor = (ctx: Context) => ctx.tools.schemas(scopeKey).map((s) => s.name);
console.log("BEFORE(scoped view)", namesFor(root));
// Cordis requires a plugin to declare inject: ['tools'] to bare-access
// ctx.tools; call restrict() from inside a plugin mounted on scope.ctx so
// the "calling scope" Cordis sees is the scope itself (agent.ctx-equivalent).
let disposeRestrict: (() => void) | undefined;
const restrictor = {
name: "stub-restrictor",
inject: ["tools"],
apply(ctx: Context) {
try {
disposeRestrict = ctx.tools.restrict({ deny: ["mcp__resume__list_applications"] });
console.log("AFTER(scoped view)", namesFor(root));
} catch (err) {
console.log("RESTRICT_THREW", (err as Error).message);
}
},
};
await scope.ctx.plugin(restrictor);
if (disposeRestrict) {
disposeRestrict();
console.log("DISPOSED(scoped view)", namesFor(root));
}
Run with node --experimental-strip-types probe2.ts. Actual output,
verbatim:
BEFORE(scoped view) [ 'mcp__resume__list_applications' ]
RESTRICT_THREW tools.restrict() names unknown inherited tool "mcp__resume__list_applications"; a restriction filters what this scope inherits, never what it registers itself. Restrictable tools: (none)
restrict() is now at least callable, but it explicitly refuses: the
bridge's tool lives in the same scope's own layer (it was mounted as a
Cordis child of scope.ctx, which is what makes it inherit that scope
tag), and restrict()'s error message says outright that it will never
touch a scope's own registrations, only what it inherits from an ancestor.
Restrictable tools: (none) — there was nothing in this arrangement for
the scope to restrict, because nothing was registered in any ancestor of
it.
I did not chase the remaining permutation (bridge registered as an
ancestor scope's own layer, restrict called from a descendant scope of
that ancestor) — the class-level doc comment in dsh-tools confirms that
shape is the one restrict() is actually built for (a parent scope curbing
what a child scope inherits from it), but it doesn't match this plugin's
topology: the plugin's apply(ctx, config) runs once at harness startup,
before any real agent scope exists, and isn't in a position to be an
ancestor of the eventual agent's scope. Chasing it further would still not
produce an arrangement reachable from this plugin's own apply(ctx, config),
which is the brief's actual bar for flipping the verdict to YES. Time spent
on Step 4: about 15 minutes, well under the 30-minute cap.
I did not spin up a full agent loop from dsh-agent/dsh-agent-loop (the
stub plugins above stand in for one, per the brief's instruction not to need
a live agent) — but I did install both packages and grep their compiled
output to confirm the createScope() attribution above, as noted earlier in
this section. No residual gap remains on that point.
restrict reaches child-scope tools: NO
Both the literal plugin topology (unscoped restrict() call → throws
immediately) and the best-case fix for that (scoped restrict() call →
throws with "unknown inherited tool" because the bridge's registration is
the scope's own, not inherited) fail to hide the child-registered tool. The
tools config key is deferred out of 0.1.0, per Task 6's fallback note.
rm -rf /tmp/dsh-spike