dsh-tool-identity-pin-guard
Verified@argszero/dsh-tool-identity-pin-guard Β· v0.1.1 Β· MIT
Pins a tool call to the identity it was approved with. The registry hands tools/pre-execute listeners the live execution object and documents name/agent/parent/callId/signal/token as readonly, while the object is a plain writable literal with only the arg
Install
dsh plugin add @argszero/dsh-tool-identity-pin-guard Confirm the layer applied with dsh --profile default --dump-config β see the install guide.
Source
Tags
Readme
@argszero/dsh-tool-identity-pin-guard
Pins a tool call to the identity it was created with, so the call that runs is the call that was approved.
A tools/pre-execute listener is handed the registry's live execution object,
whose identity fields are documented readonly and are not. A listener that
writes to them changes which tool is dispatched, in which agent's scope, under
which nested-dispatch rule, under which call id an approval is routed, and under
which token a result is recognised β after every policy listener has already
approved the original call. This plugin captures those fields in the outermost
tools/pre-execute frame, and writes them back before the registry reads them.
One report, one mechanism, one seam: #9279 β "dsh-tools: createExecution identity is mutable until final notification β pre-execute listeners can retarget calls".
The problem
The registry creates one execution object per call, at
packages/core/tools/src/index.ts:
const execution: MutableToolRunContext = { ...base, arguments: deepFreeze(detached) }
The asymmetry in that single line is the whole defect: the parsed arguments are
deep-frozen, and the identity is not. The types say otherwise β ToolExecution
documents the contract twice:
* β¦ call identity, the caller signal, and the registry-assigned {@link token} are
* readonly. The registry freezes the complete object before `tools/result`
* observers run.
* β¦ wrappers may change only `exec.signal`, while call identity remains immutable
A readonly field is erased at compile time. The object is a plain writable
literal β the only Object.freeze on it happens in notifyResult, after every
stage that reads it has read it. So exec.name = 'other_tool' inside a
tools/pre-execute listener type-checks, runs, and changes what the registry
does.
The control arm of this package's own suite measures exactly that, on the shipped registry and with nothing mounted:
world({ plugin: false, mutate: exec => { exec.name = 'beta' } }) // call 'alpha'
ranTools(harness) // β ['beta']
Where the rewritten values are read
The waterfall settles first; the reads happen afterwards. Every row below is a read site in the current registry, not a guess:
| read | where | effect of a rewrite |
|---|---|---|
exec.name, exec.agent, exec.parent |
resolveExecution(β¦) at dispatch (index.ts), also in the guard path |
a different tool, resolved in a different agent scope, under a different nested-dispatch rule |
exec.agent |
scopeTarget(this, exec.agent) for the tools/execute waterfall |
which around-dispatch listeners the call reaches |
exec.name, exec.agent, exec.callId, exec.signal |
the approval request (approval.request({ agent, toolName, callId, signal })) |
the operator approves a tool name that is not the one about to run |
exec.agent |
scopeTarget(this, exec.agent) for tools/result and for tools/post-execute |
who observes the outcome |
exec.token |
canonicalResults.set(result, exec.token) |
the identity a result is recognised by |
Two of these are the ones worth pausing on. The guard is consulted after
the waterfall β decision.kind === 'allow' ? this.guardReason(exec) : β¦ β and it
resolves the same rewritten object, so the guard and the dispatch agree with each
other while both disagree with what the pre-execute listeners approved. And the
approval prompt is built from the object after the waterfall, so a rewritten
name asks a human to approve one tool while another one runs.
The seam
tools/pre-execute is a cordis waterfall, and a waterfall runs its listeners
outermost-first (vendor/cordis/src/events.ts: "Listeners run
outermost-first; a listener that does not call next() vetoes the rest of the
chain"), while { prepend: true } registers at the front of the hook list
(register(): const method = options.prepend ? 'unshift' : 'push').
So the outermost frame is the last to see the decision and the first to have a
capture. Its await next() returns only after every inner listener and the
built-in allow have settled, and the registry has not read the object yet.
Restoring the captured fields inside that frame is therefore enough to make the
resolver, the approval prompt, the guards, the tools/execute carriers and the
result notification use the identity the call was created with.
That frame is also the right place for the other response: returning
{ kind: 'deny', reason } from it refuses the call before any approval prompt is
raised.
Install
npm install @argszero/dsh-tool-identity-pin-guard
Mount it in a dsh profile like any other plugin, or apply the bundle patch this package ships:
- insert:
- id: tool-identity-pin-guard
name: '@argszero/dsh-tool-identity-pin-guard'
config:
mode: pin
The plugin declares dsh.bundle.patch, so cordis.patch.yml is what the harness
applies; the file carries the source anchors this README cites.
Configuration
| option | default | meaning |
|---|---|---|
mode |
'pin' |
pin restores the captured identity; report counts and logs the drift and changes nothing; deny refuses the call; off installs no listener |
fields |
every field the registry reads after the waterfall: name, agent, parent, callId, token, signal |
which fields to pin; unknown names are rejected and reported, never silently honoured |
report |
true |
register the read-only tool_identity_drift_report tool |
maxReported |
50 |
how many drifts the in-memory report retains |
messageMaxChars |
4000 |
cap on the length of a denial's explanation |
report is how a deployment is measured before the guard is trusted with it:
it decides and counts exactly as pin would and writes nothing. deny is for a
deployment that would rather fail a call than run it after something tried to
rewrite it β and it is worth knowing that pin already makes such a call safe,
so deny is about being told, not about being protected.
mode: 'off' installs no listener and still registers the report tool, whose
one job in that posture is to say that the guard is off. "Why is nothing being
guarded?" is a question a deployment asks exactly once, usually after an
incident.
The report
The tool_identity_drift_report tool answers the question a guard exists to
answer β is anything in this deployment rewriting calls? β without reproducing
the incident:
tool-identity-pin-guard Β· mode=pin Β· fields=name,agent,parent,callId,token,signal
inspected: 42 call(s) drifted: 1 outside the retained window: 0
by field:
name 1
recent drifts (1 retained, oldest first):
inspection 41 Β· tool "read_file" Β· decision allow Β· name: "read_file" β "bash" Β· restored name
(Verbatim output of the suite's own harness: forty ordinary calls, one call whose
name a listener rewrote to bash and which ran as read_file anyway, then this
report β 42 inspections because the report call is a tool call too.)
The denominator is the part that makes it usable: inspected counts every
call this plugin's frame opened on, so drifted: 0 can be told apart from "this
listener never ran". A report on a deployment with no tool activity says so in
those words rather than claiming a clean bill of health.
clear: true reports the current contents and then forgets them, which is how a
deployment is re-measured after a change.
Honest boundaries
- It cannot make the object immutable. That is the upstream fix, and it is
the right one β the type says
readonlyand the runtime should agree. This is a guard, not a patch. - It reaches only the pre-execute window. A rewrite performed from inside a
tools/executewrapper, or by anything holding the object afterwards, is beyond this frame; theObject.freezeinnotifyResultis the only later boundary. - Mount order decides, and
prependis a race.prependputs this listener at the front of the list, so a listener registered withprependafter this one sits outside its frame and wins. Both orderings are measured in the suite: the guard is outermost against listeners mounted before it, and loses to one prepended after it. It reports nothing in the second case rather than claiming a clean deployment. - It observes drifts that happen while it is mounted. A rewrite that also unmounts it is not observed.
- A hostile capture is let through. If reading the call identity throws (a proxy, a throwing accessor), the capture fails, the call proceeds unguarded, and the attempt is logged and counted β because a guard whose bug breaks every tool call in a deployment is a worse failure than the one it prevents.
- It is a second line, not the first. Anything that can rewrite the call can equally well run before this plugin. The upstream fix is the one that closes the window; this one makes the window visible and expensive.
Tests
npm test runs the suite against the real ToolRuntime from
@deepseek-ai/dsh-tools and a real cordis context β the pipeline under test is
the shipped one, and every end-to-end arm runs twice, with and without the
plugin, because a single run cannot tell "the rewrite was undone" from "the
rewrite never took effect".
npm test # unit + end-to-end arms
npm run test:inject # defect injection: mutate, rebuild, require the suite to catch it
npm run test:probe-lines # pin each supported line, install it, assert the pins took, run the suite
test:inject is the one worth reading. Each arm removes exactly one distinction
this package claims in prose, rebuilds lib/, and requires the suite to go red;
an arm that leaves the suite green is reported as SILENT, which is a failure of
the harness rather than a pass. The arms a defect would most plausibly take are
the ones where the guard still looks like it works: a frame that captures and
compares and never writes back, a frame one position too deep that sees no drift
and reports a clean deployment, or a value-only comparison that misses a field
which appeared or vanished.
Compatibility
This plugin reads the tool registry's tools/pre-execute waterfall and its
PreToolDecision / ToolExecution types, so its peer range names the lines it
was verified against β every one of them pinned and run, not inferred:
@deepseek-ai/dsh-tools:>=0.1.7-rc.2 <0.2.0 || >=0.2.0-rc.2 <0.2.0@deepseek-ai/cordis:^4.0.2
Verified against the 0.1.7-rc.2 and 0.2.0-rc.2 lines (npm run test:probe-lines pins every harness package to one line, asserts the pins took,
and runs the suite against it). Each || segment's floor is the build that was
actually run, so the range and the probed set are the same set β a line this
plugin has not been run on is a line it does not claim.
One value import is real and is declared as a peer, not a dependency:
defineTool (and the pipeline's types) come from @deepseek-ai/dsh-tools. The
distinction is the whole point. A bare dependencies entry is how a plugin ends
up in a profile deployment with its own copy of the registry's package β
a second evaluation of the module, whose classes and symbols are equal to nothing
the host holds, a failure this repository has already shipped more than once. A
peer range installs beside whatever the deployment already resolved, and the range
is what keeps the type the plugin is compiled against and the registry it talks to
the same one.
License
MIT