@henols/vice-mcp 0.2.1 → 0.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -3
- package/THIRD-PARTY-NOTICES.md +423 -25
- package/{r2000-acme-ident.ts → anno-acme-ident.ts} +13 -13
- package/anno-bank.ts +171 -0
- package/anno-cli.ts +3040 -0
- package/{r2000-confidence.ts → anno-confidence.ts} +22 -22
- package/anno-coverage.ts +2465 -0
- package/anno-derive.ts +590 -0
- package/anno-details.ts +169 -0
- package/anno-enum-gen.ts +919 -0
- package/anno-export-asm.ts +2396 -0
- package/anno-graphics.ts +338 -0
- package/anno-hazard-report.ts +1367 -0
- package/anno-import.ts +495 -0
- package/anno-index.ts +150 -0
- package/anno-join.ts +480 -0
- package/{r2000-memmap-render.ts → anno-memmap-render.ts} +236 -95
- package/anno-provenance-ledger.ts +472 -0
- package/{r2000-regbits-gen.ts → anno-regbits-gen.ts} +20 -15
- package/{r2000-regbits.json → anno-regbits.json} +2 -2
- package/anno-register.ts +399 -0
- package/anno-store-export.ts +661 -0
- package/anno-store.ts +4002 -0
- package/anno-symbols.ts +266 -0
- package/anno-tools.ts +3264 -0
- package/anno-types.ts +1909 -0
- package/backend-detect.mts +124 -312
- package/block-class.ts +201 -0
- package/build.ts +4 -2
- package/capture-predicate.ts +597 -0
- package/channel-lock.ts +349 -0
- package/disasm-decoder.ts +14 -14
- package/disasm-opcodes.ts +4 -4
- package/disasm-renderer.ts +2 -2
- package/evid-ingest.ts +217 -0
- package/evid-reconcile.ts +316 -0
- package/host-tool-client.ts +430 -0
- package/hostpath.ts +1 -1
- package/incident-record.ts +23 -12
- package/install-resources.ts +30 -14
- package/memmap-lookup.ts +285 -0
- package/package.json +48 -23
- package/prg-image.ts +118 -0
- package/repo-root.ts +107 -8
- package/resources/backend-detect.mjs +98 -236
- package/resources/broker-control.mjs +189 -16
- package/resources/broker-epoch.mjs +1 -1
- package/resources/broker-kill.mjs +8 -2
- package/resources/broker-launch.mjs +373 -214
- package/resources/broker-state.mjs +64 -18
- package/resources/container-guard.mjs +1 -1
- package/resources/ghidra-project.mjs +790 -0
- package/resources/host-tool.mjs +2561 -0
- package/resources/vice-broker.mjs +330 -184
- package/resources/vice-launcher.sh +130 -12
- package/stock-address.ts +6 -6
- package/stock-cia.ts +2 -2
- package/stock-condition.ts +8 -8
- package/stock-connect.ts +10 -6
- package/stock-derived.ts +29 -37
- package/stock-diagnose.ts +200 -36
- package/stock-dispatch.ts +200 -68
- package/stock-execution.ts +5 -3
- package/stock-handler.ts +1 -1
- package/stock-input.ts +9 -9
- package/stock-machine.ts +17 -6
- package/stock-paths.ts +18 -14
- package/stock-petscii.ts +1 -1
- package/stock-protocol.ts +17 -12
- package/stock-recycle.ts +83 -2
- package/stock-registers.ts +54 -29
- package/stock-reproducible-run.ts +811 -0
- package/stock-run-until.ts +100 -1
- package/stock-sprites.ts +3 -3
- package/stock-symbols.ts +13 -13
- package/stock-timing.ts +2 -2
- package/stock-vicii.ts +1 -1
- package/stop-oracle.ts +167 -0
- package/text-capability-probe.ts +660 -0
- package/text-connect.ts +157 -0
- package/text-protocol.ts +810 -0
- package/text-tools.ts +778 -0
- package/textmon-backtrace.ts +385 -0
- package/textmon-cpuhistory.ts +335 -0
- package/textmon-memmap.ts +494 -0
- package/textmon-profile.ts +458 -0
- package/textmon-registers.ts +748 -0
- package/tools-manifest.stock.json +864 -3
- package/version.ts +1 -1
- package/vice-broker-client.ts +189 -42
- package/vice-errors.ts +268 -0
- package/vice-proxy.ts +392 -2175
- package/vsf-slice.ts +640 -0
- package/capability-registry.ts +0 -388
- package/r2000-cli.ts +0 -1103
- package/r2000-d64.ts +0 -310
- package/r2000-enum-gen.ts +0 -574
- package/r2000-launch.ts +0 -357
- package/r2000-mcp-client.ts +0 -596
- package/r2000-project.ts +0 -190
- package/r2000-symbols.ts +0 -388
- package/r2000-tools.ts +0 -914
- package/r2000-verify.ts +0 -184
- package/refresh-manifest.ts +0 -124
- package/tools-manifest.json +0 -1223
- package/vice-probe.ts +0 -278
- package/vice-sync.ts +0 -336
- package/vice.ts +0 -772
package/anno-tools.ts
ADDED
|
@@ -0,0 +1,3264 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// anno-tools.ts
|
|
3
|
+
//
|
|
4
|
+
// WHAT THIS IS THE ONE AUTHORITATIVE PLACE FOR: the curated `anno_*` tool
|
|
5
|
+
// surface. The `AnnoToolDefinition`s themselves (`ANNO_TOOL_DEFINITIONS`), the
|
|
6
|
+
// allow-list DERIVED from them (`CURATED_ANNO_TOOLS`), its enforcement
|
|
7
|
+
// (`assertAnnoTool()`) together with the per-verb argument validators that gate
|
|
8
|
+
// shares with the batch verb, the caller-supplied store-path validation, and
|
|
9
|
+
// the runner (`runAnnoTool()`) that opens the owned annotation store, answers
|
|
10
|
+
// exactly one call against it, and closes it again. No other module may
|
|
11
|
+
// hand-list a curated `anno_*` name, hand-validate an `anno_*` store path, or
|
|
12
|
+
// reach `anno-store.ts` on behalf of an MCP call -- `vice-proxy.ts` imports
|
|
13
|
+
// `ANNO_TOOL_DEFINITIONS` and `runAnnoTool` from here and nothing else.
|
|
14
|
+
//
|
|
15
|
+
// WHY THIS FILE EXISTS, in the words of the decisions that shaped it:
|
|
16
|
+
//
|
|
17
|
+
// D-05 (ONE PREFIX). `anno_` names the tools, `anno-` names the modules.
|
|
18
|
+
// There is no second annotation family advertised alongside this one: the
|
|
19
|
+
// registration loop in `vice-proxy.ts` was SUBSTITUTED, not appended to, so
|
|
20
|
+
// an agent never has to choose between two surfaces over the same subject.
|
|
21
|
+
// `stock-dispatch.test.ts`'s ordered two-entry `BACKEND_SEAM_BYPASS_KEYS`
|
|
22
|
+
// goes red the instant a second family is registered beside this one.
|
|
23
|
+
//
|
|
24
|
+
// D-06 (OPEN/CLOSE PER CALL, EXPLICIT `store` ON EVERY VERB). This module
|
|
25
|
+
// holds NO module-level store handle and no ambient "current store" -- every
|
|
26
|
+
// verb takes `store` as an argument, `runAnnoTool()` opens it, and the
|
|
27
|
+
// `finally` below closes it on every path including the throwing one. That
|
|
28
|
+
// is why there is no session to crash, no revision to go stale between
|
|
29
|
+
// calls, and nothing for a second concurrent caller to corrupt: the store is
|
|
30
|
+
// open for the duration of one tool call and not one instruction longer.
|
|
31
|
+
//
|
|
32
|
+
// D-07 (EVERY DERIVED READ NAMES ITS OWN IMAGE). The store holds
|
|
33
|
+
// annotations, never program bytes. Every verb that derives an answer FROM
|
|
34
|
+
// the bytes -- the disassembly, the region read, the binary info, the
|
|
35
|
+
// cross-references, the search, the address details -- takes an explicit
|
|
36
|
+
// `image` path. An optional-argument-with-fallback hybrid was rejected
|
|
37
|
+
// outright: an omitted argument would read as a plausible-looking success
|
|
38
|
+
// against whatever image happened to be recorded last.
|
|
39
|
+
//
|
|
40
|
+
// D-09 (THERE IS NO CURSOR, ANYWHERE). Upstream's own procedure text says
|
|
41
|
+
// never to rely on a current cursor address, and this project has no editor
|
|
42
|
+
// to have one. The verb that would have exposed it is folded into
|
|
43
|
+
// `anno_disassemble`'s explicit address argument. Nothing on this surface --
|
|
44
|
+
// no identifier, no schema property, no dispatch branch -- names a cursor or
|
|
45
|
+
// a current address, and `anno-tools.test.ts` asserts that over this file's
|
|
46
|
+
// comment-and-string-stripped source so this paragraph cannot satisfy the
|
|
47
|
+
// check by containing the word.
|
|
48
|
+
//
|
|
49
|
+
// MCP-02 (THE HOST-PATH SEAM IS UNREACHABLE FROM HERE, BY CONSTRUCTION).
|
|
50
|
+
// CLAUDE.md requires derived tools to be intercepted before
|
|
51
|
+
// `forwardToVice()`, because `rewriteArguments()` runs inside it and would
|
|
52
|
+
// hand a container-translated path to a runner acting proxy-locally. This
|
|
53
|
+
// family needs no such interception: `runAnnoTool()` is registered through
|
|
54
|
+
// `buildViceTool()` directly, so it can never reach `forwardToVice()`,
|
|
55
|
+
// `call()` or `ensureViceSession()`, and this module must never import
|
|
56
|
+
// `hostpath.ts` -- `hostpath-consumers.test.ts` names it as forbidden and
|
|
57
|
+
// keeps that consumer set at exactly five modules. Both the store path and
|
|
58
|
+
// the image path are PROXY-LOCAL filesystem paths and translating either
|
|
59
|
+
// would point this code at a file on the wrong side of the container
|
|
60
|
+
// boundary.
|
|
61
|
+
//
|
|
62
|
+
// TWO REFUSAL CHANNELS, AND THE DIFFERENCE IS DELIBERATE:
|
|
63
|
+
//
|
|
64
|
+
// 1. AN INVALID ARGUMENT resolves `{isError:true}` naming the
|
|
65
|
+
// `AnnoStoreError` subclass that fired. The caller passed something this
|
|
66
|
+
// surface cannot act on, and it should not send it again unchanged.
|
|
67
|
+
// 2. A WELL-FORMED REQUEST THIS SURFACE CANNOT ANSWER resolves
|
|
68
|
+
// `{isError:false}` carrying `{available:false, reason}` in the body.
|
|
69
|
+
// That is not a caller error -- the question was legal, the answer is
|
|
70
|
+
// "no". Returning `isError:true` for it teaches an agent to retry
|
|
71
|
+
// something that will never succeed; returning `[]` or `0` for it is the
|
|
72
|
+
// plausible-looking zero MCP-04 exists against. Every reason names what
|
|
73
|
+
// was asked for, why it cannot be answered, and where the nearest
|
|
74
|
+
// answerable thing lives, in the shape `stock-cia.ts:116-124` established
|
|
75
|
+
// and at the >= 40-character length `check-skill-tool-coverage.mjs:285`
|
|
76
|
+
// already enforces in CI.
|
|
77
|
+
//
|
|
78
|
+
// `anno_batch_execute` IS THE ONE SANCTIONED NESTED-ARGUMENT VERB ON THIS
|
|
79
|
+
// SURFACE, AND NO SECOND MAY JOIN IT. A meta-tool that takes an arbitrary tool
|
|
80
|
+
// name inside its own arguments is precisely the confused-deputy shape a
|
|
81
|
+
// generic meta-tool surface this project closed once had, with an
|
|
82
|
+
// outer-name-only refusal array: the outer name passes the gate while the
|
|
83
|
+
// inner name never sees it. This one verb earns the exception by being the only
|
|
84
|
+
// route to the multi-edit pass an annotation run actually performs, and it
|
|
85
|
+
// pays for it with `assertAnnoBatch()` below -- a recursive, DEPTH-CAPPED
|
|
86
|
+
// pre-validator that refuses the WHOLE batch, before any store is opened, if
|
|
87
|
+
// anything at any depth is wrong. Adding a second such verb would reopen the
|
|
88
|
+
// hole this one closes.
|
|
89
|
+
//
|
|
90
|
+
// WHAT NOT TO DO:
|
|
91
|
+
// - Never hand-type a second list of curated names. `CURATED_ANNO_TOOLS` is
|
|
92
|
+
// derived from `ANNO_TOOL_DEFINITIONS`'s own `name` values precisely so a
|
|
93
|
+
// name cannot be curated in one place and absent from the other (T-29-02).
|
|
94
|
+
// - Never widen `CURATED_ANNO_TOOLS` without adding the definition here with
|
|
95
|
+
// a named criterion. The gate's FIRST statement is set membership; a name
|
|
96
|
+
// that is not in the set is refused before any argument is looked at.
|
|
97
|
+
// - Never re-implement an argument rule the store already owns. Addresses go
|
|
98
|
+
// through `parseStoreAddress`, ranges through `assertRangeShape`, data
|
|
99
|
+
// types through `assertDataType`, label names through `assertLegalLabel`,
|
|
100
|
+
// comment text through `assertCommentText`, enum names through
|
|
101
|
+
// `assertEnumName`. A second, divergent rule here would accept a value the
|
|
102
|
+
// store then refuses, or the reverse, and the disagreement would be
|
|
103
|
+
// invisible because both look authoritative.
|
|
104
|
+
// - Never sanitize. An illegal label, enum name or comment is REJECTED by
|
|
105
|
+
// name, never quoted, trimmed, coerced or normalized into a legal one:
|
|
106
|
+
// the store's printed name must never diverge from the symbol an export
|
|
107
|
+
// would emit (T-29-23).
|
|
108
|
+
// - Never add a second comment-length check or a truncation. The byte bound
|
|
109
|
+
// is `assertCommentText()`'s and it is measured in UTF-8 BYTES, not code
|
|
110
|
+
// units; this layer adds nothing on top of it.
|
|
111
|
+
// - Never map `changed: false` to an error. A repeated identical edit
|
|
112
|
+
// SUCCEEDING while reporting no change is the store's own idempotency, and
|
|
113
|
+
// an agent re-running an annotation pass must not have to diff first.
|
|
114
|
+
// - Never drop `contradictedComments` or `reinterpretedSplitTables` from
|
|
115
|
+
// `anno_set_data_type`'s body. 28-VERIFICATION.md hands this phase the
|
|
116
|
+
// obligation in writing: the disclosure must be SURFACED where the human
|
|
117
|
+
// sees it, or the human never sees it. A success that quietly drops it is
|
|
118
|
+
// exactly the plausible-looking clean answer this surface forbids.
|
|
119
|
+
// - Never move `assertAnnoTool()` out of `runAnnoTool()`'s `try`. That
|
|
120
|
+
// asymmetry is WR-02, recorded as out of scope at `anno-tools.ts:772-774`
|
|
121
|
+
// and CLOSED here: inside the `try`, a refusal RESOLVES `{isError:true}`
|
|
122
|
+
// like every other failure instead of REJECTING the returned promise, so
|
|
123
|
+
// the caller has one shape to handle rather than two.
|
|
124
|
+
// - Never resolve a store or image path with `resolve()` + `startsWith`.
|
|
125
|
+
// Containment goes through `storePathWithinWorkspace()`, which resolves the
|
|
126
|
+
// deepest EXISTING ancestor's realpath (WR-01) -- a not-yet-existing leaf
|
|
127
|
+
// under a directory symlink escaped the naive form entirely.
|
|
128
|
+
// - Never hold the handle beyond the call, and never open a store outside a
|
|
129
|
+
// `try`/`finally` that closes it (T-29-03).
|
|
130
|
+
// - Never collapse a failure into a bare string. The runner's catch names
|
|
131
|
+
// the error CLASS, so a caller can tell an `AnnoStoreCorruptError` from an
|
|
132
|
+
// `AnnoStorePathError` from the text alone (T-29-04, D18-12).
|
|
133
|
+
//
|
|
134
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
135
|
+
import { extname } from "node:path";
|
|
136
|
+
|
|
137
|
+
import {
|
|
138
|
+
addExcludedRange,
|
|
139
|
+
addScope,
|
|
140
|
+
applyEnumUsage,
|
|
141
|
+
applyWrite,
|
|
142
|
+
clearEnumUsage,
|
|
143
|
+
closeStore,
|
|
144
|
+
createProjectEnum,
|
|
145
|
+
currentRevision,
|
|
146
|
+
deleteExecObservationsForRun,
|
|
147
|
+
insertExecObservations,
|
|
148
|
+
listComments,
|
|
149
|
+
listEnumUsage,
|
|
150
|
+
listExcludedRanges,
|
|
151
|
+
listExecObservations,
|
|
152
|
+
listLabels,
|
|
153
|
+
listObservedRuns,
|
|
154
|
+
listProjectEnums,
|
|
155
|
+
listRanges,
|
|
156
|
+
listScopes,
|
|
157
|
+
listXrefs,
|
|
158
|
+
openStore,
|
|
159
|
+
removeExcludedRange,
|
|
160
|
+
removeScope,
|
|
161
|
+
setComment,
|
|
162
|
+
setDataType,
|
|
163
|
+
setLabel,
|
|
164
|
+
updateProjectEnum,
|
|
165
|
+
} from "./anno-store.ts";
|
|
166
|
+
import type { AnnoStoreHandle } from "./anno-store.ts";
|
|
167
|
+
import {
|
|
168
|
+
AnnoRevisionArgumentError,
|
|
169
|
+
AnnoStoreError,
|
|
170
|
+
AnnoStorePathError,
|
|
171
|
+
AnnoStoreStaleRevisionError,
|
|
172
|
+
assertCommentText,
|
|
173
|
+
assertCommentType,
|
|
174
|
+
assertDataType,
|
|
175
|
+
assertEnumName,
|
|
176
|
+
assertLabelKind,
|
|
177
|
+
assertLegalLabel,
|
|
178
|
+
assertRangeShape,
|
|
179
|
+
parseStoreAddress,
|
|
180
|
+
parseVariantKey,
|
|
181
|
+
storePathWithinWorkspace,
|
|
182
|
+
} from "./anno-types.ts";
|
|
183
|
+
import type { AnnoStoreErrorOptions, CommentRow, EnumUsageRow, LabelRow, ProjectEnumRow } from "./anno-types.ts";
|
|
184
|
+
import { crossReferencesTo, searchAnnotations } from "./anno-derive.ts";
|
|
185
|
+
import { composeAddressDetails } from "./anno-details.ts";
|
|
186
|
+
import { decode } from "./disasm-decoder.ts";
|
|
187
|
+
import type { Instruction } from "./disasm-decoder.ts";
|
|
188
|
+
import { render } from "./disasm-renderer.ts";
|
|
189
|
+
// D-16's SECOND renderer (plan 45-05): `anno-export-asm.ts` carries the
|
|
190
|
+
// proof (a real-ACME byte-diff oracle), this file carries the readability --
|
|
191
|
+
// both call decomposeRegisterValue(), the ONE owning decoder, and NEITHER
|
|
192
|
+
// decodes a bit itself. `REGISTER_ENUM_NAME_RE` below is deliberately a
|
|
193
|
+
// SEPARATE, small predicate from `anno-export-asm.ts`'s own copy: D-16 names
|
|
194
|
+
// two renderers, each owning its own substitution glue, and only the decoder
|
|
195
|
+
// itself is shared. `hasRegBitsEntry()` is likewise shared (45-REVIEW CR-01,
|
|
196
|
+
// fixed 2026-09-11): both renderers gate the decoder attempt on TABLE
|
|
197
|
+
// MEMBERSHIP, not name shape alone, via this one exported predicate -- a
|
|
198
|
+
// second, locally-derived membership test would be exactly the kind of
|
|
199
|
+
// "two answers to one question" this file's own header elsewhere refuses.
|
|
200
|
+
import { decomposeRegisterValue, hasRegBitsEntry, type RegisterDecomposition } from "./anno-enum-gen.ts";
|
|
201
|
+
import { importGhidraExport } from "./anno-import.ts";
|
|
202
|
+
import type { ConstWriteFact } from "./anno-import.ts";
|
|
203
|
+
import { runMemmapJoin } from "./anno-join.ts";
|
|
204
|
+
import { accessMapRanges, parseAccessMap } from "./textmon-memmap.ts";
|
|
205
|
+
import { ingestAccessMap, runIdentityFrom, type IngestRunIdentity } from "./evid-ingest.ts";
|
|
206
|
+
import { reconcileObservedExecution } from "./evid-reconcile.ts";
|
|
207
|
+
// The pure, read-only movement-hazard report. Declares its own
|
|
208
|
+
// input shapes and never reads a store, a file or a tool on its own behalf --
|
|
209
|
+
// the SAME caller-fetches-everything split `anno-coverage.ts`'s own header
|
|
210
|
+
// states for the coverage instrument, and exactly why the store re-point
|
|
211
|
+
// below is a CALLER-side change and nothing more.
|
|
212
|
+
import { buildHazardReport } from "./anno-hazard-report.ts";
|
|
213
|
+
import { flatImageOrigin, parsePrg } from "./prg-image.ts";
|
|
214
|
+
import { repoRoot } from "./repo-root.ts";
|
|
215
|
+
|
|
216
|
+
// ---------------------------------------------------------------------------
|
|
217
|
+
// The wire shapes this module produces/consumes. Deliberately NOT imported
|
|
218
|
+
// from vice-proxy.ts (that file has no exported ToolDefinition/ToolCallResult
|
|
219
|
+
// -- both are file-local types there); these are structurally identical so a
|
|
220
|
+
// value built here is interchangeable wherever vice-proxy.ts combines it with
|
|
221
|
+
// its own manifest-sourced tools.
|
|
222
|
+
// ---------------------------------------------------------------------------
|
|
223
|
+
|
|
224
|
+
export interface AnnoToolDefinition {
|
|
225
|
+
name: string;
|
|
226
|
+
description: string;
|
|
227
|
+
inputSchema: {
|
|
228
|
+
type: "object";
|
|
229
|
+
properties: Record<string, unknown>;
|
|
230
|
+
required?: string[];
|
|
231
|
+
};
|
|
232
|
+
// Structural compatibility with vice.ts's own ToolInfo (vice-proxy.ts's
|
|
233
|
+
// ToolDefinition alias), which carries this index signature -- lets
|
|
234
|
+
// vice-proxy.ts's buildViceTool() accept an AnnoToolDefinition directly,
|
|
235
|
+
// with no per-call cast at the registration site.
|
|
236
|
+
[key: string]: unknown;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
interface ToolCallResult {
|
|
240
|
+
content: { type: "text"; text: string }[];
|
|
241
|
+
isError: boolean;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
245
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function okText(text: string): ToolCallResult {
|
|
249
|
+
return { content: [{ type: "text", text }], isError: false };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function errText(text: string): ToolCallResult {
|
|
253
|
+
return { content: [{ type: "text", text }], isError: true };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// ---------------------------------------------------------------------------
|
|
257
|
+
// Refusals. Both are `AnnoStoreError` subclasses and therefore `ViceError`s --
|
|
258
|
+
// never a bare `Error` -- so one `catch` can take the whole family, and the
|
|
259
|
+
// runner's `[${errName}]` prefix below names which member fired.
|
|
260
|
+
// ---------------------------------------------------------------------------
|
|
261
|
+
|
|
262
|
+
export interface AnnoUncuratedToolErrorOptions extends AnnoStoreErrorOptions {
|
|
263
|
+
toolName?: string;
|
|
264
|
+
batchIndex?: number;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** A tool name outside `CURATED_ANNO_TOOLS` was dispatched, directly or as an
|
|
268
|
+
* inner call of a batch. The message names BOTH resolution routes, so the
|
|
269
|
+
* refusal is actionable without reading this file. */
|
|
270
|
+
export class AnnoUncuratedToolError extends AnnoStoreError {
|
|
271
|
+
toolName?: string;
|
|
272
|
+
batchIndex?: number;
|
|
273
|
+
|
|
274
|
+
constructor(message: string, { toolName, batchIndex, ...rest }: AnnoUncuratedToolErrorOptions = {}) {
|
|
275
|
+
super(message, rest);
|
|
276
|
+
this.name = "AnnoUncuratedToolError";
|
|
277
|
+
this.toolName = toolName;
|
|
278
|
+
this.batchIndex = batchIndex;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
export interface AnnoToolArgumentErrorOptions extends AnnoStoreErrorOptions {
|
|
283
|
+
toolName?: string;
|
|
284
|
+
argument?: string;
|
|
285
|
+
batchIndex?: number;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** A curated tool was called with an argument the transport cannot have
|
|
289
|
+
* checked. `vice-proxy.ts:3230`'s `validate: (value) => ({ value })` means the
|
|
290
|
+
* MCP transport validates NOTHING -- `required` in an `inputSchema` is
|
|
291
|
+
* documentation for the model, not an enforced contract -- so every required
|
|
292
|
+
* argument is re-checked here, at the only boundary that actually runs. */
|
|
293
|
+
export class AnnoToolArgumentError extends AnnoStoreError {
|
|
294
|
+
toolName?: string;
|
|
295
|
+
argument?: string;
|
|
296
|
+
batchIndex?: number;
|
|
297
|
+
|
|
298
|
+
constructor(message: string, { toolName, argument, batchIndex, ...rest }: AnnoToolArgumentErrorOptions = {}) {
|
|
299
|
+
super(message, rest);
|
|
300
|
+
this.name = "AnnoToolArgumentError";
|
|
301
|
+
this.toolName = toolName;
|
|
302
|
+
this.argument = argument;
|
|
303
|
+
this.batchIndex = batchIndex;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// ---------------------------------------------------------------------------
|
|
308
|
+
// Shared argument helpers. `batchIndex` is threaded through EVERY validator so
|
|
309
|
+
// one refusal message serves both call routes: `anno_set_label_name refused:`
|
|
310
|
+
// when the verb was called directly, `anno_set_label_name refused (calls[3]):`
|
|
311
|
+
// when it was smuggled inside a batch payload. That is the shared-validator
|
|
312
|
+
// discipline `anno-tools.ts:790-800` records -- one validator per verb, called
|
|
313
|
+
// from both sites, so a refusal fires identically either way.
|
|
314
|
+
// ---------------------------------------------------------------------------
|
|
315
|
+
|
|
316
|
+
function argBag(args: unknown): Record<string, unknown> {
|
|
317
|
+
return isPlainObject(args) ? args : {};
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function whereOf(batchIndex?: number): string {
|
|
321
|
+
return batchIndex !== undefined ? ` (calls[${batchIndex}])` : "";
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function refuseArg(name: string, argument: string, detail: string, batchIndex?: number): never {
|
|
325
|
+
throw new AnnoToolArgumentError(`${name} refused${whereOf(batchIndex)}: ${detail}`, { toolName: name, argument, batchIndex });
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** Narrows the universally-required `store` argument to a non-empty string.
|
|
329
|
+
* Path CONTAINMENT is a separate concern and lives in `resolveWorkspacePath()`
|
|
330
|
+
* below; this only establishes that there is a path to contain. */
|
|
331
|
+
function assertStoreArg(name: string, args: unknown, batchIndex?: number): string {
|
|
332
|
+
const bag = argBag(args);
|
|
333
|
+
if (typeof bag.store !== "string" || bag.store.trim() === "") {
|
|
334
|
+
refuseArg(
|
|
335
|
+
name,
|
|
336
|
+
"store",
|
|
337
|
+
'"store" must be a non-empty string naming an annotation store -- every anno_* verb names its own store (D-06), ' +
|
|
338
|
+
"because there is no ambient current store to inherit.",
|
|
339
|
+
batchIndex,
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
return bag.store as string;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** Narrows `max_results` to a positive integer. Required, with no default:
|
|
346
|
+
* see the description on each list-returning definition for why a silent
|
|
347
|
+
* default is worse than a refusal here. */
|
|
348
|
+
function assertMaxResults(name: string, args: unknown, batchIndex?: number): number {
|
|
349
|
+
const raw = argBag(args).max_results;
|
|
350
|
+
if (typeof raw !== "number" || !Number.isInteger(raw) || raw <= 0) {
|
|
351
|
+
refuseArg(
|
|
352
|
+
name,
|
|
353
|
+
"max_results",
|
|
354
|
+
`"max_results" must be a positive integer, got ${JSON.stringify(raw)} -- it is REQUIRED and has no default on ` +
|
|
355
|
+
"this surface, so a truncated answer is always an explicit ceiling.",
|
|
356
|
+
batchIndex,
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
return raw as number;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/** `anno_evid_disagreements`'s own OPTIONAL `max_results` (plan 43-06).
|
|
363
|
+
* Unlike every other list-returning verb (`assertMaxResults` above, REQUIRED
|
|
364
|
+
* with no default), an unbounded disagreement report is the ordinary case: a
|
|
365
|
+
* sound store often disagrees nowhere at all, and forcing a ceiling on a
|
|
366
|
+
* legitimately small or empty answer would buy nothing. When SUPPLIED, the
|
|
367
|
+
* bound and refusal wording are the SAME as `assertMaxResults`'s -- this is
|
|
368
|
+
* not a second, looser rule, only an optional one. */
|
|
369
|
+
function assertOptionalMaxResults(name: string, args: unknown, batchIndex?: number): number | undefined {
|
|
370
|
+
const raw = argBag(args).max_results;
|
|
371
|
+
if (raw === undefined) return undefined;
|
|
372
|
+
if (typeof raw !== "number" || !Number.isInteger(raw) || raw <= 0) {
|
|
373
|
+
refuseArg(name, "max_results", `"max_results" must be a positive integer when supplied, got ${JSON.stringify(raw)}.`, batchIndex);
|
|
374
|
+
}
|
|
375
|
+
return raw as number;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** Requires a present argument and hands it to `parseStoreAddress` -- the ONE
|
|
379
|
+
* address parser, which owns the `$`/`0x` forms and the deliberate refusal of
|
|
380
|
+
* an unprefixed numeric string. Absence is a DIFFERENT fact from malformity,
|
|
381
|
+
* so it gets its own refusal rather than being folded into the parser's. */
|
|
382
|
+
function assertAddressArg(name: string, args: unknown, key: string, batchIndex?: number): number {
|
|
383
|
+
const raw = argBag(args)[key];
|
|
384
|
+
if (raw === undefined) {
|
|
385
|
+
refuseArg(name, key, `"${key}" is required and was not supplied.`, batchIndex);
|
|
386
|
+
}
|
|
387
|
+
return parseStoreAddress(raw, { what: key });
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/** The inclusive span two verbs in three share. Both ends go through the one
|
|
391
|
+
* address parser; the SHAPE (ends inside the address space, end not below
|
|
392
|
+
* start, and -- for a split layout -- the even-byte-count rule) goes through
|
|
393
|
+
* `assertRangeShape`, which owns all three. */
|
|
394
|
+
function assertSpanArgs(name: string, args: unknown, dataType: Parameters<typeof assertRangeShape>[2], batchIndex?: number): { start: number; end: number } {
|
|
395
|
+
const start = assertAddressArg(name, args, "start_address", batchIndex);
|
|
396
|
+
const end = assertAddressArg(name, args, "end_address", batchIndex);
|
|
397
|
+
assertRangeShape(start, end, dataType);
|
|
398
|
+
return { start, end };
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/** Validates the optional `base_revision` compare-and-swap argument.
|
|
402
|
+
*
|
|
403
|
+
* `anno-store.ts`'s own `assertRevisionArgument` is module-private, so this
|
|
404
|
+
* throws that module's OWN exported `AnnoRevisionArgumentError` rather than a
|
|
405
|
+
* fourth class: WR-22's recorded failure was a revision-shaped argument
|
|
406
|
+
* (`"0001"`) surviving as far as SQLite, whose INTEGER affinity turned an
|
|
407
|
+
* argument error into a corruption refusal. A caller must be able to tell
|
|
408
|
+
* "you passed the wrong thing" from "the annotations are gone" BY CLASS. */
|
|
409
|
+
function assertBaseRevisionArg(name: string, args: unknown, batchIndex?: number): number | undefined {
|
|
410
|
+
const raw = argBag(args).base_revision;
|
|
411
|
+
if (raw === undefined) return undefined;
|
|
412
|
+
if (typeof raw !== "number" || !Number.isInteger(raw) || raw < 0) {
|
|
413
|
+
throw new AnnoRevisionArgumentError(
|
|
414
|
+
`${name} refused${whereOf(batchIndex)}: "base_revision" must be a non-negative integer, got ${JSON.stringify(raw)} -- ` +
|
|
415
|
+
"a numeric STRING in particular is refused here rather than left to SQLite's column affinity, which turns an argument " +
|
|
416
|
+
"error into a corruption refusal (WR-22).",
|
|
417
|
+
{ value: raw, parameter: "base_revision" },
|
|
418
|
+
);
|
|
419
|
+
}
|
|
420
|
+
return raw;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/** Validates a label `name` argument through the ONE identifier rule
|
|
424
|
+
* (`assertLegalLabel`) and re-throws as an `AnnoToolArgumentError` carrying the
|
|
425
|
+
* offending name and, inside a batch, the offending index. REJECT, NEVER
|
|
426
|
+
* SANITIZE (T-29-23): substituting a character would merge this name with
|
|
427
|
+
* whatever the substitution produces, and nothing would record that it
|
|
428
|
+
* happened -- the store's printed name must never diverge from the symbol an
|
|
429
|
+
* export would emit. */
|
|
430
|
+
function assertLegalLabelArg(name: string, args: unknown, batchIndex?: number): void {
|
|
431
|
+
const raw = argBag(args).name;
|
|
432
|
+
try {
|
|
433
|
+
assertLegalLabel(raw);
|
|
434
|
+
} catch (err) {
|
|
435
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
436
|
+
refuseArg(
|
|
437
|
+
name,
|
|
438
|
+
"name",
|
|
439
|
+
`${JSON.stringify(raw)} is not a legal ACME identifier (${reason}) -- REJECTED, never sanitized or quoted.`,
|
|
440
|
+
batchIndex,
|
|
441
|
+
);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// ---------------------------------------------------------------------------
|
|
446
|
+
// The curated tool definitions.
|
|
447
|
+
//
|
|
448
|
+
// `store` is on EVERY definition and is always required (D-06). There is no
|
|
449
|
+
// "current store" for a verb to inherit, which is what makes a call's effect a
|
|
450
|
+
// function of its own arguments alone. Each description is written for an
|
|
451
|
+
// AGENT: what the verb answers, what it costs, and what it will refuse.
|
|
452
|
+
// ---------------------------------------------------------------------------
|
|
453
|
+
|
|
454
|
+
/** How deep a nested `anno_batch_execute` may go before the payload is refused
|
|
455
|
+
* by name rather than walked (T-29-24). Four levels is far past any legitimate
|
|
456
|
+
* use -- a batch of batches of batches has no procedure behind it -- and is
|
|
457
|
+
* chosen to be obviously sufficient rather than tuned. */
|
|
458
|
+
export const ANNO_MAX_BATCH_DEPTH = 4;
|
|
459
|
+
|
|
460
|
+
const STORE_PROPERTY = {
|
|
461
|
+
store: {
|
|
462
|
+
type: "string",
|
|
463
|
+
description:
|
|
464
|
+
"Absolute or workspace-relative path to the .annostore annotation store. Refused if it resolves outside the " +
|
|
465
|
+
"workspace root, including via a symlink. REQUIRED on every verb: there is no ambient 'current store'.",
|
|
466
|
+
},
|
|
467
|
+
} as const;
|
|
468
|
+
|
|
469
|
+
const IMAGE_PROPERTY = {
|
|
470
|
+
image: {
|
|
471
|
+
type: "string",
|
|
472
|
+
description:
|
|
473
|
+
"Absolute or workspace-relative path to the program image this answer is DERIVED from -- a .prg (2-byte " +
|
|
474
|
+
"little-endian load address plus payload) or an exactly-65536-byte flat capture (.raw/.bin, dispatched by " +
|
|
475
|
+
"extension before any length check). REQUIRED on every derived read (D-07): the store holds annotations and " +
|
|
476
|
+
"never bytes, so an omitted image would read as a plausible success against whatever was recorded last. " +
|
|
477
|
+
"Refused if it resolves outside the workspace root, including via a symlink.",
|
|
478
|
+
},
|
|
479
|
+
} as const;
|
|
480
|
+
|
|
481
|
+
const BASE_REVISION_PROPERTY = {
|
|
482
|
+
base_revision: {
|
|
483
|
+
type: "integer",
|
|
484
|
+
description:
|
|
485
|
+
"Optional compare-and-swap guard: the revision this edit was computed against. The write is refused with a " +
|
|
486
|
+
"named stale-revision error if the store has moved on. Omit it for an unconditional write. A numeric STRING " +
|
|
487
|
+
"is refused rather than coerced.",
|
|
488
|
+
},
|
|
489
|
+
} as const;
|
|
490
|
+
|
|
491
|
+
export const ANNO_TOOL_DEFINITIONS: readonly AnnoToolDefinition[] = [
|
|
492
|
+
{
|
|
493
|
+
name: "anno_set_label_name",
|
|
494
|
+
description:
|
|
495
|
+
"Binds a name to one address in the annotation store, so a disassembly reads as `jsr irq_handler` rather than " +
|
|
496
|
+
"`jsr $c000`. Costs one store open, one write and one close. REFUSES, never rewrites: a name that is not a legal " +
|
|
497
|
+
"ACME identifier (letter or underscore, then letters/digits/underscores) or that is a 6502/6510 mnemonic is " +
|
|
498
|
+
"rejected with the offending name in the message, because the store's printed name must never diverge from the " +
|
|
499
|
+
"symbol an export would emit. Also refuses a name already bound to a DIFFERENT address rather than rebinding it. " +
|
|
500
|
+
"Setting the same name at the same address again SUCCEEDS and reports `changed: false` -- re-running an " +
|
|
501
|
+
"annotation pass is not an error.",
|
|
502
|
+
inputSchema: {
|
|
503
|
+
type: "object",
|
|
504
|
+
properties: {
|
|
505
|
+
...STORE_PROPERTY,
|
|
506
|
+
address: {
|
|
507
|
+
description:
|
|
508
|
+
"The address to name. An integer 0..65535, a \"$hex\" string, or a \"0x\" string; an unprefixed numeric " +
|
|
509
|
+
"string is refused on purpose, because a mis-based address written into the store is persistent and silently wrong.",
|
|
510
|
+
},
|
|
511
|
+
name: {
|
|
512
|
+
type: "string",
|
|
513
|
+
description:
|
|
514
|
+
"The label name. Must be a legal ACME identifier and must not be a 6502/6510 mnemonic. An illegal name is " +
|
|
515
|
+
"REJECTED, never sanitized or quoted.",
|
|
516
|
+
},
|
|
517
|
+
kind: {
|
|
518
|
+
type: "string",
|
|
519
|
+
enum: ["User", "Auto", "System", "Platform"],
|
|
520
|
+
description:
|
|
521
|
+
"Provenance of the name. 'User' (the default when omitted) = a human chose it; 'Auto' = generated; " +
|
|
522
|
+
"'System'/'Platform' = a known ROM or hardware name.",
|
|
523
|
+
},
|
|
524
|
+
...BASE_REVISION_PROPERTY,
|
|
525
|
+
},
|
|
526
|
+
required: ["store", "address", "name"],
|
|
527
|
+
},
|
|
528
|
+
},
|
|
529
|
+
{
|
|
530
|
+
name: "anno_set_comment",
|
|
531
|
+
description:
|
|
532
|
+
"Stores a comment at one address, replacing whatever that placement held. 'line' comments sit on their own line " +
|
|
533
|
+
"before the instruction; 'side' comments sit inline on the same line. The two placements coexist at one address. " +
|
|
534
|
+
"Carrier for the [confirmed-code]/[probable-code]/[confirmed-data]/[probable-data]/[unknown] confidence-prefix " +
|
|
535
|
+
"convention. Do NOT include a leading ';' -- the store holds the words and the exporter adds the prefix, so a " +
|
|
536
|
+
"stored ';' would be emitted twice and is refused. Over-long text is REFUSED rather than truncated, and the bound " +
|
|
537
|
+
"is measured in UTF-8 BYTES, so a multi-byte comment is bounded by what actually lands in the file. A " +
|
|
538
|
+
"byte-identical repeat SUCCEEDS and reports `changed: false`.",
|
|
539
|
+
inputSchema: {
|
|
540
|
+
type: "object",
|
|
541
|
+
properties: {
|
|
542
|
+
...STORE_PROPERTY,
|
|
543
|
+
address: { description: "The address to comment. Integer, \"$hex\" or \"0x\" string; an unprefixed numeric string is refused." },
|
|
544
|
+
comment: { type: "string", description: "The comment text, without the ';' prefix." },
|
|
545
|
+
type: {
|
|
546
|
+
type: "string",
|
|
547
|
+
enum: ["line", "side"],
|
|
548
|
+
description: "'line' = own line before the instruction. 'side' = inline on the same line.",
|
|
549
|
+
},
|
|
550
|
+
...BASE_REVISION_PROPERTY,
|
|
551
|
+
},
|
|
552
|
+
required: ["store", "address", "comment", "type"],
|
|
553
|
+
},
|
|
554
|
+
},
|
|
555
|
+
{
|
|
556
|
+
name: "anno_set_data_type",
|
|
557
|
+
description:
|
|
558
|
+
"Types an inclusive address range, preserving whatever the overlapping rows said about the addresses outside it. " +
|
|
559
|
+
"A SUCCESSFUL result can carry two disclosures, and both are always present in the body: `contradictedComments` " +
|
|
560
|
+
"names comments whose recorded confidence now contradicts the type just applied, and `reinterpretedSplitTables` " +
|
|
561
|
+
"names every split table this write FRAGMENTED, with the entry-address pairs it read before, the pairs each " +
|
|
562
|
+
"surviving remainder reads now, and the pairs preserved. Neither is an error and neither is dropped: a split " +
|
|
563
|
+
"table's entries re-pair as a function of the row's start AND its length, so a fragment decodes to different " +
|
|
564
|
+
"16-bit values than the ones a human recorded, and a success that hid that would be worse than a refusal. " +
|
|
565
|
+
"A split layout REFUSES an odd byte count (the low half and the high half must be the same length). Retyping the " +
|
|
566
|
+
"same range the same way SUCCEEDS and reports `changed: false`.",
|
|
567
|
+
inputSchema: {
|
|
568
|
+
type: "object",
|
|
569
|
+
properties: {
|
|
570
|
+
...STORE_PROPERTY,
|
|
571
|
+
start_address: { description: "Start of the range, INCLUSIVE. Integer, \"$hex\" or \"0x\" string." },
|
|
572
|
+
end_address: { description: "End of the range, INCLUSIVE. A one-byte range has end_address === start_address." },
|
|
573
|
+
data_type: {
|
|
574
|
+
type: "string",
|
|
575
|
+
enum: [
|
|
576
|
+
"code",
|
|
577
|
+
"byte",
|
|
578
|
+
"word",
|
|
579
|
+
"address",
|
|
580
|
+
"petscii",
|
|
581
|
+
"screencode",
|
|
582
|
+
"lo_hi_address",
|
|
583
|
+
"hi_lo_address",
|
|
584
|
+
"lo_hi_word",
|
|
585
|
+
"hi_lo_word",
|
|
586
|
+
"external_file",
|
|
587
|
+
"undefined",
|
|
588
|
+
],
|
|
589
|
+
description:
|
|
590
|
+
"code=6502/6510 instructions; byte=raw 8-bit data (sprites, charset, tables, unknowns); word=16-bit LE " +
|
|
591
|
+
"values; address=16-bit LE pointers (produces cross-references, use for jump tables and vectors); " +
|
|
592
|
+
"petscii=PETSCII text; screencode=screen-code text; lo_hi_address=split address table, low bytes first " +
|
|
593
|
+
"then high bytes (even count required); hi_lo_address=split address table, high bytes first (even count " +
|
|
594
|
+
"required); lo_hi_word=split word table, low bytes first (e.g. SID frequency tables); hi_lo_word=split " +
|
|
595
|
+
"word table, high bytes first; external_file=large binary blob to export as-is; undefined=reset the range " +
|
|
596
|
+
"to unknown.",
|
|
597
|
+
},
|
|
598
|
+
...BASE_REVISION_PROPERTY,
|
|
599
|
+
},
|
|
600
|
+
required: ["store", "start_address", "end_address", "data_type"],
|
|
601
|
+
},
|
|
602
|
+
},
|
|
603
|
+
{
|
|
604
|
+
name: "anno_add_scope",
|
|
605
|
+
description:
|
|
606
|
+
"Adds a lexical scope over an inclusive range, so symbols inside it are local to it. Nested and overlapping " +
|
|
607
|
+
"scopes are UNSUPPORTED by the schema this store mirrors and are REFUSED, naming both the incoming span and the " +
|
|
608
|
+
"existing scope's id and span; the incoming scope is neither trimmed nor split. Two scopes that merely TOUCH at " +
|
|
609
|
+
"a boundary are disjoint and both accepted. An identical repeat SUCCEEDS and reports `changed: false`. " +
|
|
610
|
+
"MIND THE ENDS: one transposed end (say $1000..$ffff instead of $1000..$10ff) makes every later scope above that " +
|
|
611
|
+
"start refuse -- use anno_remove_scope to undo it rather than burning revisions off the 32-deep snapshot ring.",
|
|
612
|
+
inputSchema: {
|
|
613
|
+
type: "object",
|
|
614
|
+
properties: {
|
|
615
|
+
...STORE_PROPERTY,
|
|
616
|
+
start_address: { description: "Start of the scope, INCLUSIVE. Integer, \"$hex\" or \"0x\" string." },
|
|
617
|
+
end_address: { description: "End of the scope, INCLUSIVE." },
|
|
618
|
+
...BASE_REVISION_PROPERTY,
|
|
619
|
+
},
|
|
620
|
+
required: ["store", "start_address", "end_address"],
|
|
621
|
+
},
|
|
622
|
+
},
|
|
623
|
+
{
|
|
624
|
+
name: "anno_remove_scope",
|
|
625
|
+
description:
|
|
626
|
+
"Removes the scope whose span is EXACTLY start_address..end_address -- the inverse of anno_add_scope, and the " +
|
|
627
|
+
"recovery route for a transposed span, which would otherwise be undoable only by reverting through the 32-deep " +
|
|
628
|
+
"snapshot ring. The span must match both stored ends exactly: a scope is never trimmed, split or partially " +
|
|
629
|
+
"removed, because a partial removal would leave a shape nothing downstream can express while reporting success. " +
|
|
630
|
+
"Read the stored spans with anno_get_blocks (include: [\"scopes\"]) first if you are unsure. Removing a scope " +
|
|
631
|
+
"that is not there SUCCEEDS and reports `changed: false`.",
|
|
632
|
+
inputSchema: {
|
|
633
|
+
type: "object",
|
|
634
|
+
properties: {
|
|
635
|
+
...STORE_PROPERTY,
|
|
636
|
+
start_address: { description: "Start of the scope to remove, INCLUSIVE. Must match the stored start exactly." },
|
|
637
|
+
end_address: { description: "End of the scope to remove, INCLUSIVE. Must match the stored end exactly." },
|
|
638
|
+
...BASE_REVISION_PROPERTY,
|
|
639
|
+
},
|
|
640
|
+
required: ["store", "start_address", "end_address"],
|
|
641
|
+
},
|
|
642
|
+
},
|
|
643
|
+
{
|
|
644
|
+
name: "anno_exclude_range",
|
|
645
|
+
description:
|
|
646
|
+
"Records the user's request to leave an inclusive span out, WITH the reason, as a durable row (BUILD-05/BUILD-07). " +
|
|
647
|
+
"RECORDING AN EXCLUSION DOES NOT REMOVE ANYTHING: the export still emits every byte of that span; the record is " +
|
|
648
|
+
"what makes the request VISIBLE in the output instead of invisible as a gap. What gets reversed, kept or left out " +
|
|
649
|
+
"is the end-user's decision, and this verb is how the user states it -- it is not the tool deciding. An " +
|
|
650
|
+
"overlapping span is REFUSED naming both spans; two records that merely TOUCH at a boundary are disjoint and both " +
|
|
651
|
+
"accepted; an identical repeat SUCCEEDS reporting `changed: false`; the same extent with a DIFFERENT reason is " +
|
|
652
|
+
"REFUSED rather than overwriting the stored reason. MIND THE ENDS: one transposed end makes every later exclusion " +
|
|
653
|
+
"overlapping that start refuse -- use anno_include_range to undo it rather than burning revisions off the 32-deep " +
|
|
654
|
+
"snapshot ring.",
|
|
655
|
+
inputSchema: {
|
|
656
|
+
type: "object",
|
|
657
|
+
properties: {
|
|
658
|
+
...STORE_PROPERTY,
|
|
659
|
+
start_address: { description: "Start of the excluded span, INCLUSIVE. Integer, \"$hex\" or \"0x\" string." },
|
|
660
|
+
end_address: { description: "End of the excluded span, INCLUSIVE." },
|
|
661
|
+
reason: {
|
|
662
|
+
type: "string",
|
|
663
|
+
description:
|
|
664
|
+
"Why the user asked for this span to be left out. REQUIRED and must be non-empty: a reason column " +
|
|
665
|
+
"satisfied by an empty string records that something was excluded and loses WHY.",
|
|
666
|
+
},
|
|
667
|
+
...BASE_REVISION_PROPERTY,
|
|
668
|
+
},
|
|
669
|
+
required: ["store", "start_address", "end_address", "reason"],
|
|
670
|
+
},
|
|
671
|
+
},
|
|
672
|
+
{
|
|
673
|
+
name: "anno_include_range",
|
|
674
|
+
description:
|
|
675
|
+
"Removes the exclusion whose span is EXACTLY start_address..end_address -- the exact inverse of anno_exclude_range. " +
|
|
676
|
+
"Both stored ends must match exactly, because a record is never trimmed, split or partially removed. Read the " +
|
|
677
|
+
"stored spans with anno_exclude_range's sibling read (the excludedRanges list on either verb's own success body) " +
|
|
678
|
+
"first if you are unsure. Removing an exclusion that is not there SUCCEEDS and reports `changed: false`.",
|
|
679
|
+
inputSchema: {
|
|
680
|
+
type: "object",
|
|
681
|
+
properties: {
|
|
682
|
+
...STORE_PROPERTY,
|
|
683
|
+
start_address: { description: "Start of the exclusion to remove, INCLUSIVE. Must match the stored start exactly." },
|
|
684
|
+
end_address: { description: "End of the exclusion to remove, INCLUSIVE. Must match the stored end exactly." },
|
|
685
|
+
...BASE_REVISION_PROPERTY,
|
|
686
|
+
},
|
|
687
|
+
required: ["store", "start_address", "end_address"],
|
|
688
|
+
},
|
|
689
|
+
},
|
|
690
|
+
{
|
|
691
|
+
name: "anno_get_symbols",
|
|
692
|
+
description:
|
|
693
|
+
"Returns labels held in an annotation store, in ascending insertion order, optionally narrowed to " +
|
|
694
|
+
"an address range. Every call names its own store (there is no ambient 'current store') and the " +
|
|
695
|
+
"store is opened and closed within the call. `max_results` is REQUIRED and has no default on this " +
|
|
696
|
+
"surface: pass an explicit ceiling and compare the returned count against it to detect truncation.",
|
|
697
|
+
inputSchema: {
|
|
698
|
+
type: "object",
|
|
699
|
+
properties: {
|
|
700
|
+
...STORE_PROPERTY,
|
|
701
|
+
max_results: {
|
|
702
|
+
type: "integer",
|
|
703
|
+
description:
|
|
704
|
+
"Maximum number of labels to return. REQUIRED -- no default on this surface, so a truncated " +
|
|
705
|
+
"answer is always the caller's own explicit ceiling rather than a silent one.",
|
|
706
|
+
},
|
|
707
|
+
start_address: {
|
|
708
|
+
description:
|
|
709
|
+
"Optional lower bound (inclusive) of the address range to filter by. An integer 0..65535, a " +
|
|
710
|
+
"\"$hex\" string, or a \"0x\" string; an unprefixed numeric string is refused.",
|
|
711
|
+
},
|
|
712
|
+
end_address: {
|
|
713
|
+
description:
|
|
714
|
+
"Optional upper bound (inclusive) of the address range to filter by. Same accepted forms as " +
|
|
715
|
+
"start_address.",
|
|
716
|
+
},
|
|
717
|
+
},
|
|
718
|
+
required: ["store", "max_results"],
|
|
719
|
+
},
|
|
720
|
+
},
|
|
721
|
+
{
|
|
722
|
+
name: "anno_get_comments",
|
|
723
|
+
description:
|
|
724
|
+
"Returns stored comments, each with its address, its placement ('line' or 'side') and its text, in ascending " +
|
|
725
|
+
"insertion order. Filters are combined with AND: specific `addresses`, an inclusive `start_address`/`end_address` " +
|
|
726
|
+
"window, and a placement `type`. The confidence-prefix convention lives in the returned text -- filter by prefix " +
|
|
727
|
+
"on your own side, or use anno_search. `max_results` is REQUIRED with no default; the true match count is " +
|
|
728
|
+
"returned beside the truncated list, so truncation is a fact you are told rather than one you infer.",
|
|
729
|
+
inputSchema: {
|
|
730
|
+
type: "object",
|
|
731
|
+
properties: {
|
|
732
|
+
...STORE_PROPERTY,
|
|
733
|
+
max_results: { type: "integer", description: "Maximum number of comments to return. REQUIRED -- no default on this surface." },
|
|
734
|
+
addresses: {
|
|
735
|
+
type: "array",
|
|
736
|
+
description: "Optional list of specific addresses. Integers, \"$hex\" or \"0x\" strings; unprefixed numeric strings are refused.",
|
|
737
|
+
},
|
|
738
|
+
start_address: { description: "Optional lower bound (inclusive) of the address window." },
|
|
739
|
+
end_address: { description: "Optional upper bound (inclusive) of the address window." },
|
|
740
|
+
type: { type: "string", enum: ["line", "side"], description: "Optional placement filter." },
|
|
741
|
+
},
|
|
742
|
+
required: ["store", "max_results"],
|
|
743
|
+
},
|
|
744
|
+
},
|
|
745
|
+
{
|
|
746
|
+
name: "anno_get_blocks",
|
|
747
|
+
description:
|
|
748
|
+
"Returns the typed ranges (blocks) this store holds -- each with its inclusive span and its data type -- " +
|
|
749
|
+
"optionally narrowed by `block_type`. This is also the read route for the store's other structural annotations: " +
|
|
750
|
+
"pass `include` to add `scopes` (every lexical scope's id and span, which anno_remove_scope needs to match " +
|
|
751
|
+
"exactly), `enums` (every project enum with its variants mapping) and `enum_usage` (every address-to-enum " +
|
|
752
|
+
"association, with the enum's name resolved through its id at read time). `max_results` is REQUIRED with no " +
|
|
753
|
+
"default and bounds the RANGE list; the true match count is returned beside it.",
|
|
754
|
+
inputSchema: {
|
|
755
|
+
type: "object",
|
|
756
|
+
properties: {
|
|
757
|
+
...STORE_PROPERTY,
|
|
758
|
+
max_results: { type: "integer", description: "Maximum number of ranges to return. REQUIRED -- no default on this surface." },
|
|
759
|
+
block_type: {
|
|
760
|
+
type: "string",
|
|
761
|
+
description: "Optional exact data-type filter, e.g. 'code' or 'lo_hi_address'. Must be one of the twelve data types.",
|
|
762
|
+
},
|
|
763
|
+
include: {
|
|
764
|
+
type: "array",
|
|
765
|
+
items: { type: "string", enum: ["scopes", "enums", "enum_usage"] },
|
|
766
|
+
description:
|
|
767
|
+
"Optional extra structural annotations to return alongside the ranges. Each is returned whole (these " +
|
|
768
|
+
"collections are small by construction), so they are not governed by max_results.",
|
|
769
|
+
},
|
|
770
|
+
},
|
|
771
|
+
required: ["store", "max_results"],
|
|
772
|
+
},
|
|
773
|
+
},
|
|
774
|
+
{
|
|
775
|
+
name: "anno_create_project_enum",
|
|
776
|
+
description:
|
|
777
|
+
"Creates a project-local enum -- a name, a variants mapping and an optional description -- embedded in the " +
|
|
778
|
+
"annotation store rather than anywhere machine-global. Variant keys are numeric strings in decimal, 0x/$ hex or " +
|
|
779
|
+
"0b/% binary; two keys naming the SAME number are refused, because that would mean two variant names for one " +
|
|
780
|
+
"value and nothing downstream could say which. A name already held with DIFFERENT contents is refused rather " +
|
|
781
|
+
"than overwritten -- use anno_update_project_enum, which replaces the variants mapping wholesale and says so. " +
|
|
782
|
+
"An identical re-create SUCCEEDS and reports `changed: false`. The body returns every enum the store now holds.",
|
|
783
|
+
inputSchema: {
|
|
784
|
+
type: "object",
|
|
785
|
+
properties: {
|
|
786
|
+
...STORE_PROPERTY,
|
|
787
|
+
name: { type: "string", description: "Unique identifier: a letter or underscore, then letters/digits/underscores. Refused, never sanitized." },
|
|
788
|
+
variants: {
|
|
789
|
+
type: "object",
|
|
790
|
+
description: "Variant mapping. Keys are numeric strings (decimal, 0x/$ hex, 0b/% binary); values are variant names.",
|
|
791
|
+
},
|
|
792
|
+
description: { type: "string", description: "Optional summary explaining the enum's purpose." },
|
|
793
|
+
...BASE_REVISION_PROPERTY,
|
|
794
|
+
},
|
|
795
|
+
required: ["store", "name", "variants"],
|
|
796
|
+
},
|
|
797
|
+
},
|
|
798
|
+
{
|
|
799
|
+
name: "anno_update_project_enum",
|
|
800
|
+
description:
|
|
801
|
+
"Renames a project enum, replaces its variants mapping, replaces its description, or any combination. THE " +
|
|
802
|
+
"VARIANTS MAPPING IS REPLACED WHOLESALE when supplied, never merged: a merge would make a variant impossible to " +
|
|
803
|
+
"REMOVE, since there would be no way to express its absence. A rename onto a name another enum already holds is " +
|
|
804
|
+
"refused rather than merging two enums into one. Renaming does NOT orphan an enum usage: usages are associated " +
|
|
805
|
+
"by enum id, not by name. Updating an enum that does not exist is refused. A no-op update SUCCEEDS and reports " +
|
|
806
|
+
"`changed: false`. The body returns every enum the store now holds.",
|
|
807
|
+
inputSchema: {
|
|
808
|
+
type: "object",
|
|
809
|
+
properties: {
|
|
810
|
+
...STORE_PROPERTY,
|
|
811
|
+
name: { type: "string", description: "Existing name of the enum to update." },
|
|
812
|
+
new_name: { type: "string", description: "Optional new name. Same identifier rule; refused, never sanitized." },
|
|
813
|
+
variants: { type: "object", description: "Optional COMPLETE replacement variants mapping. Omit to leave the mapping alone." },
|
|
814
|
+
description: { type: "string", description: "Optional replacement description." },
|
|
815
|
+
...BASE_REVISION_PROPERTY,
|
|
816
|
+
},
|
|
817
|
+
required: ["store", "name"],
|
|
818
|
+
},
|
|
819
|
+
},
|
|
820
|
+
{
|
|
821
|
+
name: "anno_apply_enum_usage",
|
|
822
|
+
description:
|
|
823
|
+
"Associates one address with one project enum, so an immediate operand or constant reference at that address " +
|
|
824
|
+
"formats as a variant name. OMITTING `name`, or passing an empty string, CLEARS the association at that address " +
|
|
825
|
+
"instead -- that is the schema's own contract for this verb, and clearing an address that carries none SUCCEEDS " +
|
|
826
|
+
"reporting `changed: false`. One address carries at most one enum, so applying a different enum REPLACES rather " +
|
|
827
|
+
"than refuses. Applying an enum that does not exist is refused rather than creating it implicitly, because a " +
|
|
828
|
+
"mistyped name would otherwise become a real, empty enum that formats nothing and looks deliberate. The body " +
|
|
829
|
+
"returns every address-to-enum association the store now holds.",
|
|
830
|
+
inputSchema: {
|
|
831
|
+
type: "object",
|
|
832
|
+
properties: {
|
|
833
|
+
...STORE_PROPERTY,
|
|
834
|
+
address: { description: "The instruction address. Integer, \"$hex\" or \"0x\" string; an unprefixed numeric string is refused." },
|
|
835
|
+
name: { type: "string", description: "The enum to apply. OMIT, or pass an empty string, to CLEAR the association at this address." },
|
|
836
|
+
...BASE_REVISION_PROPERTY,
|
|
837
|
+
},
|
|
838
|
+
required: ["store", "address"],
|
|
839
|
+
},
|
|
840
|
+
},
|
|
841
|
+
{
|
|
842
|
+
name: "anno_save_project",
|
|
843
|
+
description:
|
|
844
|
+
"Reports the store's current revision. IT PERFORMS NO WRITE, and it exists to say so: every mutating verb on " +
|
|
845
|
+
"this surface has ALREADY committed and fsynced its own write by the time it returns, so there is no unsaved " +
|
|
846
|
+
"state for an explicit save to flush and no window in which a crash could lose an edit this verb would have " +
|
|
847
|
+
"rescued. Durability belongs to the store, not to a verb an agent has to remember to call. Use this to read the " +
|
|
848
|
+
"revision -- for a subsequent `base_revision` compare-and-swap, or to confirm that a pass advanced the store as " +
|
|
849
|
+
"far as expected. The body states the no-write property alongside the revision, so a caller is never left " +
|
|
850
|
+
"inferring it from an empty success.",
|
|
851
|
+
inputSchema: {
|
|
852
|
+
type: "object",
|
|
853
|
+
properties: { ...STORE_PROPERTY },
|
|
854
|
+
required: ["store"],
|
|
855
|
+
},
|
|
856
|
+
},
|
|
857
|
+
{
|
|
858
|
+
name: "anno_disassemble",
|
|
859
|
+
description:
|
|
860
|
+
"Renders ACME-ready `!cpu 6510` source for the instructions starting AT AN EXPLICIT ADDRESS you supply. " +
|
|
861
|
+
"There is no cursor and no 'current address' on this surface -- upstream's own procedure text says never to rely " +
|
|
862
|
+
"on one and this project has no editor to have one, so the address is always yours and always in the call. " +
|
|
863
|
+
"Decoded fresh from the image bytes on every call; nothing is cached and nothing is written. An opcode ACME " +
|
|
864
|
+
"cannot express is emitted as `!byte` with the mnemonic moved into a comment, never as a mnemonic that would " +
|
|
865
|
+
"fail to reassemble. The extent is bounded by the SAME byte cap that governs anno_read_region -- one cap, both " +
|
|
866
|
+
"views, so there is no per-view rule to get subtly wrong -- and defaults to that cap when end_address is " +
|
|
867
|
+
"omitted. A wider range is REFUSED by name with the cap and the requested width in the message, never " +
|
|
868
|
+
"silently truncated. A register write bound to a project enum (via anno_apply_enum_usage) renders through " +
|
|
869
|
+
"its named member instead of a hex literal -- a single-field register as `#<enum>_<VARIANT>`, or, for a " +
|
|
870
|
+
"multi-field register, as its bits OR-ed together by name (`#D018_SELECT..0 | D018_CHARACTER..2 | " +
|
|
871
|
+
"D018_VIDEO..0`) with a trailing comment naming every field and its decoded value, so a bound write reads " +
|
|
872
|
+
"the same way here as it does in the exported ACME source.",
|
|
873
|
+
inputSchema: {
|
|
874
|
+
type: "object",
|
|
875
|
+
properties: {
|
|
876
|
+
...STORE_PROPERTY,
|
|
877
|
+
...IMAGE_PROPERTY,
|
|
878
|
+
address: {
|
|
879
|
+
description:
|
|
880
|
+
"The address to start decoding at, EXPLICITLY. Integer 0..65535, \"$hex\" or \"0x\" string; an unprefixed " +
|
|
881
|
+
"numeric string is refused.",
|
|
882
|
+
},
|
|
883
|
+
end_address: {
|
|
884
|
+
description:
|
|
885
|
+
"Optional last address to decode, INCLUSIVE. Omitted, the extent is the byte cap (or the end of the image, " +
|
|
886
|
+
"whichever comes first).",
|
|
887
|
+
},
|
|
888
|
+
},
|
|
889
|
+
required: ["store", "image", "address"],
|
|
890
|
+
},
|
|
891
|
+
},
|
|
892
|
+
{
|
|
893
|
+
name: "anno_read_region",
|
|
894
|
+
description:
|
|
895
|
+
"Reads ONE routine or table at an explicit inclusive address range, instead of exporting the whole program. " +
|
|
896
|
+
"`view: 'disasm'` is what routine documentation wants; `view: 'hexdump'` is what data-table classification and " +
|
|
897
|
+
"table extraction want; omitted, the view is 'disasm'. The combined byte count (end_address - start_address + 1) " +
|
|
898
|
+
"is capped, and the SAME cap governs anno_disassemble -- one cap, both views. A request above the cap is REFUSED " +
|
|
899
|
+
"by name, naming the cap and the requested width, rather than silently truncated: a full-64K disassembly view " +
|
|
900
|
+
"dumped into an agent's context is exactly the hazard the cap exists to prevent, and this family is not chunked, " +
|
|
901
|
+
"so the cap is the only bound there is.",
|
|
902
|
+
inputSchema: {
|
|
903
|
+
type: "object",
|
|
904
|
+
properties: {
|
|
905
|
+
...STORE_PROPERTY,
|
|
906
|
+
...IMAGE_PROPERTY,
|
|
907
|
+
start_address: { description: "Start of the range, INCLUSIVE. Integer, \"$hex\" or \"0x\" string." },
|
|
908
|
+
end_address: { description: "End of the range, INCLUSIVE." },
|
|
909
|
+
view: {
|
|
910
|
+
type: "string",
|
|
911
|
+
enum: ["disasm", "hexdump"],
|
|
912
|
+
description: "'disasm' = rendered 6510 source. 'hexdump' = raw hex bytes. Omitted defaults to 'disasm'.",
|
|
913
|
+
},
|
|
914
|
+
},
|
|
915
|
+
required: ["store", "image", "start_address", "end_address"],
|
|
916
|
+
},
|
|
917
|
+
},
|
|
918
|
+
{
|
|
919
|
+
name: "anno_get_binary_info",
|
|
920
|
+
description:
|
|
921
|
+
"Reports what the named image FILE is: how it was dispatched (a .prg's 2-byte little-endian load address, or a " +
|
|
922
|
+
"flat 64K capture's origin of 0), the origin, the total byte length, the payload byte length, and the Shannon " +
|
|
923
|
+
"entropy of the payload -- a value above 7.5 suggests the image is compressed or packed and that a depack pass " +
|
|
924
|
+
"is needed before any of it will decode sensibly. DISPATCH IS BY EXTENSION FIRST, never by byte length: a " +
|
|
925
|
+
"truncated .raw capture that fell through to the .prg parser once produced an origin read backwards out of its " +
|
|
926
|
+
"own payload bytes, exited zero, and made every downstream address silently wrong. A file too short to be a .prg " +
|
|
927
|
+
"is REFUSED by name.",
|
|
928
|
+
inputSchema: {
|
|
929
|
+
type: "object",
|
|
930
|
+
properties: { ...STORE_PROPERTY, ...IMAGE_PROPERTY },
|
|
931
|
+
required: ["store", "image"],
|
|
932
|
+
},
|
|
933
|
+
},
|
|
934
|
+
{
|
|
935
|
+
name: "anno_get_cross_references",
|
|
936
|
+
description:
|
|
937
|
+
"Every address that references the address you name, unioned from three sources and returned ascending and " +
|
|
938
|
+
"de-duplicated: the instructions decoded fresh out of every range typed `code`, the typed split ADDRESS tables " +
|
|
939
|
+
"(the `_address` forms produce cross-references and the `_word` forms do not -- that is the schema's own " +
|
|
940
|
+
"distinction, not a judgement made here), and the stored rows, which are the only half on disk and only because " +
|
|
941
|
+
"a computed dispatch or a hand-asserted edge cannot be recovered from bytes at all. DERIVED ON EVERY CALL AND " +
|
|
942
|
+
"NEVER CACHED: a cached derivation is a second on-disk truth that can disagree with the range table it came " +
|
|
943
|
+
"from. `max_results` is REQUIRED with no default; the true total rides beside the truncated list.",
|
|
944
|
+
inputSchema: {
|
|
945
|
+
type: "object",
|
|
946
|
+
properties: {
|
|
947
|
+
...STORE_PROPERTY,
|
|
948
|
+
...IMAGE_PROPERTY,
|
|
949
|
+
address: { description: "The target address to find references TO. Integer, \"$hex\" or \"0x\" string." },
|
|
950
|
+
max_results: { type: "integer", description: "Maximum number of referencing addresses to return. REQUIRED -- no default." },
|
|
951
|
+
},
|
|
952
|
+
required: ["store", "image", "address", "max_results"],
|
|
953
|
+
},
|
|
954
|
+
},
|
|
955
|
+
{
|
|
956
|
+
name: "anno_search",
|
|
957
|
+
description:
|
|
958
|
+
"Searches three corpora for a substring: label names, comment text, and the instruction text rendered from every " +
|
|
959
|
+
"range typed `code`. MATCHING IS BYTE-EXACT AND CASE-SENSITIVE, applied identically to all three, and the rule " +
|
|
960
|
+
"is restated in the body so an empty answer tells you which rule produced it. Every corpus is named in the body " +
|
|
961
|
+
"with the number of entries it held, so a genuine zero over a real corpus is distinguishable from a corpus this " +
|
|
962
|
+
"surface does not have. NAMING A CORPUS THIS SURFACE DOES NOT HAVE (any search_<name> other than the three) is " +
|
|
963
|
+
"answered with `{available:false, reason}` in a SUCCESSFUL body -- not an error, because the request was " +
|
|
964
|
+
"well-formed, and not an empty result set, because an empty result set for an unanswerable question is a lie " +
|
|
965
|
+
"that reads like an answer. `max_results` is REQUIRED with no default: an implicit default would silently " +
|
|
966
|
+
"truncate a full-program pass.",
|
|
967
|
+
inputSchema: {
|
|
968
|
+
type: "object",
|
|
969
|
+
properties: {
|
|
970
|
+
...STORE_PROPERTY,
|
|
971
|
+
...IMAGE_PROPERTY,
|
|
972
|
+
query: { type: "string", description: "The substring to find. Case-sensitive and byte-exact. An empty query is refused -- that is a listing, not a search." },
|
|
973
|
+
max_results: { type: "integer", description: "Maximum number of hits to return. REQUIRED -- no default on this surface." },
|
|
974
|
+
search_labels: { type: "boolean", description: "Search the label-name corpus. Defaults to true." },
|
|
975
|
+
search_comments: { type: "boolean", description: "Search the comment-text corpus. Defaults to true." },
|
|
976
|
+
search_instructions: { type: "boolean", description: "Search the rendered instruction corpus. Defaults to true. This is the expensive one: it decodes every code range." },
|
|
977
|
+
},
|
|
978
|
+
required: ["store", "image", "query", "max_results"],
|
|
979
|
+
},
|
|
980
|
+
},
|
|
981
|
+
{
|
|
982
|
+
name: "anno_get_address_details",
|
|
983
|
+
description:
|
|
984
|
+
"Everything this project knows about ONE address, composed from four reads: the labels bound there, the comments " +
|
|
985
|
+
"there, the typed range that covers it (resolved narrowest-range-wins through the paint index, never by a " +
|
|
986
|
+
"start/end bracket scan), and the cross-references that reach it. THE COMPOSITION IS DISCLOSED: the body carries " +
|
|
987
|
+
"`composed_client_side` and a `composed_from` list naming all four sources, so a composition is never mistaken " +
|
|
988
|
+
"for something the store held whole. A component with no answer comes back as `{available:false, reason}` rather " +
|
|
989
|
+
"than as an empty list, so an address that genuinely has no comments stays distinguishable from a question this " +
|
|
990
|
+
"composition could not put. Nothing is written on any path.",
|
|
991
|
+
inputSchema: {
|
|
992
|
+
type: "object",
|
|
993
|
+
properties: {
|
|
994
|
+
...STORE_PROPERTY,
|
|
995
|
+
...IMAGE_PROPERTY,
|
|
996
|
+
address: { description: "The address to inspect. Integer, \"$hex\" or \"0x\" string." },
|
|
997
|
+
},
|
|
998
|
+
required: ["store", "image", "address"],
|
|
999
|
+
},
|
|
1000
|
+
},
|
|
1001
|
+
{
|
|
1002
|
+
name: "anno_batch_execute",
|
|
1003
|
+
description:
|
|
1004
|
+
"Executes several curated anno_* calls against ONE store, in order, inside one open/close pair. Use it for a " +
|
|
1005
|
+
"multi-edit pass -- marking many regions, renaming many labels -- and not for calls that depend on each other's " +
|
|
1006
|
+
"results. The store (and the image, when the inner calls need one) is named ONCE at the top level and every " +
|
|
1007
|
+
"inner call inherits it, INCLUDING through nesting -- a batch inside a batch inherits it too, and so does that " +
|
|
1008
|
+
"batch's own inner calls; an inner `store` is overridden at every depth, never honoured. TWO PHASES, and the difference matters " +
|
|
1009
|
+
"when you read the answer. FIRST, the whole payload is pre-validated before anything is opened: a malformed " +
|
|
1010
|
+
"payload, an EMPTY calls array, a malformed entry, an inner name outside the curated set at any depth, an " +
|
|
1011
|
+
"illegal label name, or an over-cap region range refuses the WHOLE batch by index, and nothing executes. " +
|
|
1012
|
+
"SECOND, execution runs to COMPLETION, pushing a success or error status for every entry and never aborting on " +
|
|
1013
|
+
"the first failure. So `isError:true` means this batch should never have been sent; an error ENTRY inside a " +
|
|
1014
|
+
"successful result means that one call did not work. Nesting deeper than " +
|
|
1015
|
+
String(ANNO_MAX_BATCH_DEPTH) +
|
|
1016
|
+
" levels is refused by name rather than walked.",
|
|
1017
|
+
inputSchema: {
|
|
1018
|
+
type: "object",
|
|
1019
|
+
properties: {
|
|
1020
|
+
...STORE_PROPERTY,
|
|
1021
|
+
image: {
|
|
1022
|
+
type: "string",
|
|
1023
|
+
description:
|
|
1024
|
+
"Optional program image, inherited by every inner call that derives an answer from bytes. Required only " +
|
|
1025
|
+
"if the batch contains such a call.",
|
|
1026
|
+
},
|
|
1027
|
+
calls: {
|
|
1028
|
+
type: "array",
|
|
1029
|
+
items: {
|
|
1030
|
+
type: "object",
|
|
1031
|
+
properties: {
|
|
1032
|
+
name: { type: "string", description: "The curated anno_* verb to run. An uncurated name refuses the WHOLE batch." },
|
|
1033
|
+
arguments: { type: "object", description: "That verb's own arguments, minus store (and image), which the batch supplies." },
|
|
1034
|
+
},
|
|
1035
|
+
required: ["name", "arguments"],
|
|
1036
|
+
},
|
|
1037
|
+
description: "The calls to run, in order. Must be a NON-EMPTY array: an empty batch is refused, never run as a zero-length success.",
|
|
1038
|
+
},
|
|
1039
|
+
},
|
|
1040
|
+
required: ["store", "calls"],
|
|
1041
|
+
},
|
|
1042
|
+
},
|
|
1043
|
+
{
|
|
1044
|
+
name: "anno_import_ghidra_export",
|
|
1045
|
+
description:
|
|
1046
|
+
"Imports a host-written Ghidra export transfer file (GhidraStructExport.java's `## `-delimited format) into " +
|
|
1047
|
+
"the store, writing one anno_xref row per surviving `## REFERENCES` line and DELETING the transfer file once " +
|
|
1048
|
+
"every write has durably committed. Costs one store open and one close. REFUSES, writes nothing and deletes " +
|
|
1049
|
+
"nothing: on a malformed, truncated or digest-mismatched export (naming the section and the offending line), " +
|
|
1050
|
+
"on an export_path that resolves outside the workspace root, or on an absent transfer file. Reports " +
|
|
1051
|
+
"referencesSeen, xrefsWritten, xrefsAlreadyPresent (duplicate references are deduped, never double-counted), " +
|
|
1052
|
+
"and kindsSeenNotImported -- reference types this store's four-member vocabulary does not carry, dropped and " +
|
|
1053
|
+
"counted rather than refused, because a real corpus binary carries ordinary jump and call references " +
|
|
1054
|
+
"constantly. Every written row's bank column is null: this verb does not resolve bank state itself. Also " +
|
|
1055
|
+
"reports constWrites -- the export's `## CONST_WRITES` facts (recovered $01/$D011/$D018/$DD00 stores), always " +
|
|
1056
|
+
"present (possibly empty). The transfer file naming them is DELETED by this same call (IMP-02), so this " +
|
|
1057
|
+
"return value is the only place they survive: pass the SAME constWrites array, unchanged, to a following " +
|
|
1058
|
+
"anno_join_memmap call's own const_writes argument to activate bank-state resolution (AUTO-04/AUTO-05) and " +
|
|
1059
|
+
"VIC-register graphics-range derivation (AUTO-06/AUTO-07) for this image.",
|
|
1060
|
+
inputSchema: {
|
|
1061
|
+
type: "object",
|
|
1062
|
+
properties: {
|
|
1063
|
+
...STORE_PROPERTY,
|
|
1064
|
+
export_path: {
|
|
1065
|
+
type: "string",
|
|
1066
|
+
description:
|
|
1067
|
+
"Absolute or workspace-relative path to the host-written transfer file. CONSUMED AND DELETED by a " +
|
|
1068
|
+
"successful call -- refused if it resolves outside the workspace root, including via a symlink.",
|
|
1069
|
+
},
|
|
1070
|
+
sha256: {
|
|
1071
|
+
type: "string",
|
|
1072
|
+
description:
|
|
1073
|
+
"Optional sha256 digest the producer reported for the transfer file's bytes. When supplied, a mismatch " +
|
|
1074
|
+
"against the file's own computed digest refuses the whole call before anything is read further -- a " +
|
|
1075
|
+
"corruption/drift detector, never a security boundary.",
|
|
1076
|
+
},
|
|
1077
|
+
...BASE_REVISION_PROPERTY,
|
|
1078
|
+
},
|
|
1079
|
+
required: ["store", "export_path"],
|
|
1080
|
+
},
|
|
1081
|
+
},
|
|
1082
|
+
{
|
|
1083
|
+
name: "anno_join_memmap",
|
|
1084
|
+
description:
|
|
1085
|
+
"The mechanical join: reads every distinct cross-reference target the store already holds, skips addresses " +
|
|
1086
|
+
"inside the supplied image's own loaded range (those are program addresses, never looked up), and annotates " +
|
|
1087
|
+
"every remaining address with the narrowest c64-memory-mapping/memmap.json entry containing it. No agent " +
|
|
1088
|
+
"call, no queue walk and no skill invocation anywhere in this call. Reports addressesConsidered, annotated, " +
|
|
1089
|
+
"skippedInImage, skippedNoMapEntry, declined and commentsChanged, plus a per-address decisions array naming " +
|
|
1090
|
+
"the outcome and, for every skip, WHY. Running this twice over an unchanged store reports commentsChanged: 0 " +
|
|
1091
|
+
"on the second run -- re-running a join pass is not an error. Passing const_writes (typically the SAME " +
|
|
1092
|
+
"constWrites array anno_import_ghidra_export just returned for this image, unchanged) additionally activates " +
|
|
1093
|
+
"bank-state resolution: a $01-conditional address (AUTO-04) declines with a named reason rather than " +
|
|
1094
|
+
"guessing when the reaching processor-port value is absent or disagreeing (AUTO-05), and VIC-register " +
|
|
1095
|
+
"graphics ranges are derived and written back (AUTO-06/AUTO-07, graphics_map_index selects which of several " +
|
|
1096
|
+
"derived combinations when more than one exists, default 0). Omitting const_writes entirely is a complete " +
|
|
1097
|
+
"no-op for both of these -- every address resolves exactly as if this argument did not exist.",
|
|
1098
|
+
inputSchema: {
|
|
1099
|
+
type: "object",
|
|
1100
|
+
properties: {
|
|
1101
|
+
...STORE_PROPERTY,
|
|
1102
|
+
...IMAGE_PROPERTY,
|
|
1103
|
+
...BASE_REVISION_PROPERTY,
|
|
1104
|
+
const_writes: {
|
|
1105
|
+
type: "array",
|
|
1106
|
+
items: {
|
|
1107
|
+
type: "object",
|
|
1108
|
+
properties: {
|
|
1109
|
+
store_address: { type: "integer", description: "The instruction's own address that performed the store." },
|
|
1110
|
+
target_address: { type: "integer", description: "The watched hardware address ($0001/$D011/$D018/$DD00) written to." },
|
|
1111
|
+
value: { type: "integer", description: "The compile-time constant value written." },
|
|
1112
|
+
},
|
|
1113
|
+
required: ["store_address", "target_address", "value"],
|
|
1114
|
+
},
|
|
1115
|
+
description:
|
|
1116
|
+
"Optional recovered const-write facts (AUTO-04/AUTO-05/AUTO-06/AUTO-07) -- pass back the constWrites " +
|
|
1117
|
+
"array anno_import_ghidra_export returned for the SAME image, unchanged. Supplying it (even []) " +
|
|
1118
|
+
"activates bank-state resolution and graphics-range derivation/write-back; omitting it entirely is a " +
|
|
1119
|
+
"complete no-op for both.",
|
|
1120
|
+
},
|
|
1121
|
+
graphics_map_index: {
|
|
1122
|
+
type: "integer",
|
|
1123
|
+
description:
|
|
1124
|
+
"Which of several derived VIC-register-value combinations to write back as graphics ranges, when " +
|
|
1125
|
+
"const_writes yields more than one distinct combination (D-37-27: several valid maps are never merged " +
|
|
1126
|
+
"into one). Defaults to 0. Consulted ONLY when const_writes is supplied at all. Out of range for the " +
|
|
1127
|
+
"derived map count REFUSES the whole call rather than silently clamping or picking a default.",
|
|
1128
|
+
},
|
|
1129
|
+
},
|
|
1130
|
+
required: ["store", "image"],
|
|
1131
|
+
},
|
|
1132
|
+
},
|
|
1133
|
+
{
|
|
1134
|
+
name: "anno_evid_ingest",
|
|
1135
|
+
description:
|
|
1136
|
+
"Turns one raw memmapshow reply plus one run identity into durable runtime-execution evidence rows, so a later " +
|
|
1137
|
+
"session can query what the emulator actually executed instead of re-running the program. Writes a row ONLY for " +
|
|
1138
|
+
"an OBSERVED execute bit: an address memmapshow mentioned with read or write access but no execute gets NO row, " +
|
|
1139
|
+
"and an address the reply never mentioned at all gets NO row either -- an address with no row is the ABSENCE of " +
|
|
1140
|
+
"an assertion, never an assertion that the address is data. Requires the EXACT launch argv and digests it itself " +
|
|
1141
|
+
"(argv_digest is never accepted as an argument), so a caller cannot invent a run identity. A memmapshow reply " +
|
|
1142
|
+
"this surface cannot parse is REFUSED, naming its refusal code and offending line, rather than partially " +
|
|
1143
|
+
"absorbed -- nothing is written on a refusal. Re-ingesting the SAME reply for the SAME run identity succeeds " +
|
|
1144
|
+
"and reports changed:false with observationsWritten:0 -- re-running an ingest pass is not an error. Every count " +
|
|
1145
|
+
"in the answer carries a denominator (addressesQueried) beside it; no percentage is ever reported.",
|
|
1146
|
+
inputSchema: {
|
|
1147
|
+
type: "object",
|
|
1148
|
+
properties: {
|
|
1149
|
+
...STORE_PROPERTY,
|
|
1150
|
+
memmap_text: {
|
|
1151
|
+
type: "string",
|
|
1152
|
+
description:
|
|
1153
|
+
"The raw memmapshow reply exactly as the text monitor returned it -- never a pre-parsed object. A reply " +
|
|
1154
|
+
"this parser cannot decode is REFUSED, naming its refusal code and offending line; nothing is written.",
|
|
1155
|
+
},
|
|
1156
|
+
image_sha256: {
|
|
1157
|
+
type: "string",
|
|
1158
|
+
description:
|
|
1159
|
+
"The program image this run executed, named by the sha256 digest of its own bytes -- exactly 64 " +
|
|
1160
|
+
"lowercase hex characters. This verb does not read image bytes itself and accepts no path to one.",
|
|
1161
|
+
},
|
|
1162
|
+
argv: {
|
|
1163
|
+
type: "array",
|
|
1164
|
+
items: { type: "string" },
|
|
1165
|
+
description:
|
|
1166
|
+
"The EXACT emulator launch argument vector, including argv[0] -- a different binary is a different " +
|
|
1167
|
+
"launch. This verb digests it itself; a pre-computed digest is never accepted, so a caller cannot invent " +
|
|
1168
|
+
"a run identity.",
|
|
1169
|
+
},
|
|
1170
|
+
seed: {
|
|
1171
|
+
type: "string",
|
|
1172
|
+
description: "The determinism seed the launch pinned. A non-empty string; not a digest and carries no shape beyond that.",
|
|
1173
|
+
},
|
|
1174
|
+
...BASE_REVISION_PROPERTY,
|
|
1175
|
+
},
|
|
1176
|
+
required: ["store", "memmap_text", "image_sha256", "argv", "seed"],
|
|
1177
|
+
},
|
|
1178
|
+
},
|
|
1179
|
+
{
|
|
1180
|
+
name: "anno_evid_disagreements",
|
|
1181
|
+
description:
|
|
1182
|
+
"Answers where the byte-derived block classification and the observed-execution evidence DISAGREE, with the " +
|
|
1183
|
+
"disagreements reported FIRST: an address the block table calls 'data' at which the emulator was observed " +
|
|
1184
|
+
"executing is proof a byte-derived guess was wrong, from a source (real execution) that never saw the guess. " +
|
|
1185
|
+
"Agreement (block table says 'code', evidence confirms it) is reported as agreementCount ONLY -- never as rows, " +
|
|
1186
|
+
"because a wall of agreeing rows would bury the one output this query exists to surface. An address the block " +
|
|
1187
|
+
"table covers with NO observation anywhere is blockCoveredNeverObservedCount, and is NOT evidence that the " +
|
|
1188
|
+
"address is data -- an address never observed executing proves nothing. Two further counts " +
|
|
1189
|
+
"(observedOutsideAnyBlockCount, observedAtUndefinedBlockCount) name evidence about addresses the block table " +
|
|
1190
|
+
"does not classify as code or data at all, so the denominator can never quietly drop real evidence. This verb " +
|
|
1191
|
+
"READS the block table and the runtime evidence table; it writes to NEITHER, and a repeated call never changes " +
|
|
1192
|
+
"either. Optional image_sha256/argv_digest/seed scope the question to ONE run identity's observations rather " +
|
|
1193
|
+
"than the union across every run that has ever contributed -- supply all three together or none; a partial " +
|
|
1194
|
+
"identity is refused. max_results bounds the returned disagreements array only, and is OPTIONAL (an empty or " +
|
|
1195
|
+
"small disagreement report is the ordinary, sound case, so no ceiling is forced); the true disagreement count " +
|
|
1196
|
+
"and whether truncation occurred are always reported beside it. Every count in the answer carries denominator " +
|
|
1197
|
+
"beside it; no percentage or rate is ever formed.",
|
|
1198
|
+
inputSchema: {
|
|
1199
|
+
type: "object",
|
|
1200
|
+
properties: {
|
|
1201
|
+
...STORE_PROPERTY,
|
|
1202
|
+
max_results: {
|
|
1203
|
+
type: "integer",
|
|
1204
|
+
description:
|
|
1205
|
+
"Optional bound on the returned disagreements array only. Unlike every other list-returning anno_* verb, " +
|
|
1206
|
+
"this is NOT required -- an empty or small disagreement report is the ordinary, sound case. When " +
|
|
1207
|
+
"supplied, must be a positive integer.",
|
|
1208
|
+
},
|
|
1209
|
+
image_sha256: {
|
|
1210
|
+
type: "string",
|
|
1211
|
+
description:
|
|
1212
|
+
"Optional run-identity filter: the program image this run executed, exactly 64 lowercase hex characters. " +
|
|
1213
|
+
"Supply image_sha256, argv_digest AND seed together to scope to one run, or omit all three to see the " +
|
|
1214
|
+
"union across every run this store holds.",
|
|
1215
|
+
},
|
|
1216
|
+
argv_digest: {
|
|
1217
|
+
type: "string",
|
|
1218
|
+
description:
|
|
1219
|
+
"Optional run-identity filter: the exact digest anno_evid_ingest/anno_evid_runs already computed for a " +
|
|
1220
|
+
"run's launch argv, exactly 64 lowercase hex characters. Never invented by a caller -- pass back what " +
|
|
1221
|
+
"anno_evid_runs reported. Required alongside image_sha256/seed when filtering by run identity.",
|
|
1222
|
+
},
|
|
1223
|
+
seed: {
|
|
1224
|
+
type: "string",
|
|
1225
|
+
description:
|
|
1226
|
+
"Optional run-identity filter: the determinism seed that run's launch pinned. A non-empty string. " +
|
|
1227
|
+
"Required alongside image_sha256/argv_digest when filtering by run identity.",
|
|
1228
|
+
},
|
|
1229
|
+
},
|
|
1230
|
+
required: ["store"],
|
|
1231
|
+
},
|
|
1232
|
+
},
|
|
1233
|
+
{
|
|
1234
|
+
name: "anno_evid_runs",
|
|
1235
|
+
description:
|
|
1236
|
+
"Answers every run identity the store holds an observed-execution row for, with its accumulated observation " +
|
|
1237
|
+
"count and the denominator that count is a fraction of -- so a later session can see what evidence already " +
|
|
1238
|
+
"exists without re-running the program. However many runs contribute observations, their union is NEVER " +
|
|
1239
|
+
"exhaustive coverage of the image: observationCount is a count against denominator, never a rate, and this " +
|
|
1240
|
+
"verb forms no percentage from it.",
|
|
1241
|
+
inputSchema: {
|
|
1242
|
+
type: "object",
|
|
1243
|
+
properties: {
|
|
1244
|
+
...STORE_PROPERTY,
|
|
1245
|
+
},
|
|
1246
|
+
required: ["store"],
|
|
1247
|
+
},
|
|
1248
|
+
},
|
|
1249
|
+
{
|
|
1250
|
+
name: "anno_evid_reset",
|
|
1251
|
+
description:
|
|
1252
|
+
"Clears every observed-execution row for ONE run identity, so that bracket can be re-measured from nothing. " +
|
|
1253
|
+
"Touches no other run identity's rows and no row of the byte-derived block table. Requires the EXACT launch " +
|
|
1254
|
+
"argv and digests it itself (a pre-computed digest is never accepted), so a caller cannot invent a run identity " +
|
|
1255
|
+
"-- the same discipline anno_evid_ingest uses. A run identity holding no observations SUCCEEDS and reports " +
|
|
1256
|
+
"changed:false and observationsRemoved:0 -- resetting an empty bracket is the ordinary thing, not a mistake. " +
|
|
1257
|
+
"Clearing the emulator's own accumulated access map is a DIFFERENT operation, reached through vice_memmap_zap " +
|
|
1258
|
+
"-- a caller re-measuring a bracket from nothing does BOTH: vice_memmap_zap on the emulator side, " +
|
|
1259
|
+
"anno_evid_reset on the store side.",
|
|
1260
|
+
inputSchema: {
|
|
1261
|
+
type: "object",
|
|
1262
|
+
properties: {
|
|
1263
|
+
...STORE_PROPERTY,
|
|
1264
|
+
image_sha256: {
|
|
1265
|
+
type: "string",
|
|
1266
|
+
description:
|
|
1267
|
+
"The program image this run executed, named by the sha256 digest of its own bytes -- exactly 64 " +
|
|
1268
|
+
"lowercase hex characters. This verb does not read image bytes itself and accepts no path to one.",
|
|
1269
|
+
},
|
|
1270
|
+
argv: {
|
|
1271
|
+
type: "array",
|
|
1272
|
+
items: { type: "string" },
|
|
1273
|
+
description:
|
|
1274
|
+
"The EXACT emulator launch argument vector, including argv[0] -- a different binary is a different " +
|
|
1275
|
+
"launch. This verb digests it itself; a pre-computed digest is never accepted, so a caller cannot invent " +
|
|
1276
|
+
"a run identity.",
|
|
1277
|
+
},
|
|
1278
|
+
seed: {
|
|
1279
|
+
type: "string",
|
|
1280
|
+
description: "The determinism seed the launch pinned. A non-empty string; not a digest and carries no shape beyond that.",
|
|
1281
|
+
},
|
|
1282
|
+
...BASE_REVISION_PROPERTY,
|
|
1283
|
+
},
|
|
1284
|
+
required: ["store", "image_sha256", "argv", "seed"],
|
|
1285
|
+
},
|
|
1286
|
+
},
|
|
1287
|
+
{
|
|
1288
|
+
name: "anno_hazard_report",
|
|
1289
|
+
description:
|
|
1290
|
+
"Enumerates what blocks a program's code or data from being MOVED, relocated, rebased or stripped, across " +
|
|
1291
|
+
"the movement-hazard constructions this surface can detect from decoded bytes alone. It REPORTS " +
|
|
1292
|
+
"and changes NOTHING: it never removes, strips, relocates or rebases any part of the image, and it never " +
|
|
1293
|
+
"emits an instruction, flag or field a caller could act on as an automatic relocation -- the operator " +
|
|
1294
|
+
"decides what happens to the bytes it describes. Each finding carries its own detection mechanism and a " +
|
|
1295
|
+
"detection-strength token (observed-corroborated, static-shape-matched, static-signature-only) -- a " +
|
|
1296
|
+
"SEPARATE, smaller vocabulary from this store's own five-grade confidence grades, answering a different " +
|
|
1297
|
+
"question (how strong is this ONE static signal, never what does this address classify as). Every checked " +
|
|
1298
|
+
"region reports exactly one of three outcomes -- hazard-reported, no-signal, unclassified -- and NONE of " +
|
|
1299
|
+
"them is a safety claim: a region with no finding is explicitly NOT a claim that the region is safe to " +
|
|
1300
|
+
"move, clean, or hazard-free, only that nothing this report knows how to look for fired there. " +
|
|
1301
|
+
"Always-emitted named limits (for example, a self-modification through a runtime-computed pointer is " +
|
|
1302
|
+
"undetected by construction) accompany every answer. Opens the store READ-ONLY and reads no other table: " +
|
|
1303
|
+
"this verb creates nothing and writes nothing.",
|
|
1304
|
+
inputSchema: {
|
|
1305
|
+
type: "object",
|
|
1306
|
+
properties: {
|
|
1307
|
+
...STORE_PROPERTY,
|
|
1308
|
+
...IMAGE_PROPERTY,
|
|
1309
|
+
max_results: {
|
|
1310
|
+
type: "integer",
|
|
1311
|
+
description:
|
|
1312
|
+
"Optional bound on the returned findings array only. Unlike most list-returning anno_* verbs, this is " +
|
|
1313
|
+
"NOT required -- an empty or small finding set is the ordinary, sound case. When supplied, must be a " +
|
|
1314
|
+
"positive integer.",
|
|
1315
|
+
},
|
|
1316
|
+
},
|
|
1317
|
+
required: ["store", "image"],
|
|
1318
|
+
},
|
|
1319
|
+
},
|
|
1320
|
+
];
|
|
1321
|
+
|
|
1322
|
+
/** The allow-list, DERIVED from the definitions above rather than hand-typed
|
|
1323
|
+
* (T-29-02): a name cannot be curated in one place and absent from the other,
|
|
1324
|
+
* because there is only one place. */
|
|
1325
|
+
export const CURATED_ANNO_TOOLS: readonly string[] = ANNO_TOOL_DEFINITIONS.map((def) => def.name);
|
|
1326
|
+
|
|
1327
|
+
// ---------------------------------------------------------------------------
|
|
1328
|
+
// Per-verb argument validators. Each is called from BOTH the outer gate
|
|
1329
|
+
// (`assertAnnoTool`) and, when a call arrives inside `anno_batch_execute`, that
|
|
1330
|
+
// verb's own inner loop -- through the ONE dispatch below, so there is no way
|
|
1331
|
+
// to add a verb to one route and forget the other.
|
|
1332
|
+
// ---------------------------------------------------------------------------
|
|
1333
|
+
|
|
1334
|
+
/** Validates `anno_get_symbols`'s own arguments. The optional range bounds go
|
|
1335
|
+
* through `parseStoreAddress()` -- the ONE address parser -- so `$d020`,
|
|
1336
|
+
* `0xd020` and `53280` are accepted or refused here exactly as the store
|
|
1337
|
+
* itself would accept or refuse them, never by a second, divergent rule. */
|
|
1338
|
+
function assertGetSymbolsArgs(args: unknown, batchIndex?: number): void {
|
|
1339
|
+
assertStoreArg("anno_get_symbols", args, batchIndex);
|
|
1340
|
+
assertMaxResults("anno_get_symbols", args, batchIndex);
|
|
1341
|
+
const bag = argBag(args);
|
|
1342
|
+
if (bag.start_address !== undefined) parseStoreAddress(bag.start_address, { what: "start_address" });
|
|
1343
|
+
if (bag.end_address !== undefined) parseStoreAddress(bag.end_address, { what: "end_address" });
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
function assertSetLabelArgs(args: unknown, batchIndex?: number): void {
|
|
1347
|
+
assertStoreArg("anno_set_label_name", args, batchIndex);
|
|
1348
|
+
assertAddressArg("anno_set_label_name", args, "address", batchIndex);
|
|
1349
|
+
assertLegalLabelArg("anno_set_label_name", args, batchIndex);
|
|
1350
|
+
const bag = argBag(args);
|
|
1351
|
+
if (bag.kind !== undefined) assertLabelKind(bag.kind);
|
|
1352
|
+
assertBaseRevisionArg("anno_set_label_name", args, batchIndex);
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
function assertSetCommentArgs(args: unknown, batchIndex?: number): void {
|
|
1356
|
+
assertStoreArg("anno_set_comment", args, batchIndex);
|
|
1357
|
+
assertAddressArg("anno_set_comment", args, "address", batchIndex);
|
|
1358
|
+
const bag = argBag(args);
|
|
1359
|
+
if (bag.comment === undefined) refuseArg("anno_set_comment", "comment", '"comment" is required and was not supplied.', batchIndex);
|
|
1360
|
+
// The byte bound, the ';'-prefix rule and the refuse-never-truncate policy
|
|
1361
|
+
// are ALL `assertCommentText()`'s. This layer adds no second length check,
|
|
1362
|
+
// no truncation and no Unicode normalization -- the bound is measured in
|
|
1363
|
+
// UTF-8 BYTES there, and a second rule here would disagree with it silently.
|
|
1364
|
+
assertCommentText(bag.comment);
|
|
1365
|
+
assertCommentType(bag.type);
|
|
1366
|
+
assertBaseRevisionArg("anno_set_comment", args, batchIndex);
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
function assertSetDataTypeArgs(args: unknown, batchIndex?: number): void {
|
|
1370
|
+
assertStoreArg("anno_set_data_type", args, batchIndex);
|
|
1371
|
+
// ORDERING IS LOAD-BEARING, and it is the store's own: the data type is
|
|
1372
|
+
// narrowed FIRST because `assertRangeShape` needs it to decide whether the
|
|
1373
|
+
// even-byte-count rule applies at all.
|
|
1374
|
+
const dataType = assertDataType(argBag(args).data_type);
|
|
1375
|
+
assertSpanArgs("anno_set_data_type", args, dataType, batchIndex);
|
|
1376
|
+
assertBaseRevisionArg("anno_set_data_type", args, batchIndex);
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
function assertScopeArgs(name: string, args: unknown, batchIndex?: number): void {
|
|
1380
|
+
assertStoreArg(name, args, batchIndex);
|
|
1381
|
+
// "byte" selects the two shape rules that DO apply to a scope (both ends
|
|
1382
|
+
// inside the address space; the end not below the start) and none of the
|
|
1383
|
+
// ones that do not -- a scope is not a table, so a three-byte routine is a
|
|
1384
|
+
// perfectly good scope. This mirrors `addScope`'s own choice exactly.
|
|
1385
|
+
assertSpanArgs(name, args, "byte", batchIndex);
|
|
1386
|
+
assertBaseRevisionArg(name, args, batchIndex);
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
/** Shared validator for `anno_exclude_range` / `anno_include_range`, called
|
|
1390
|
+
* from `assertVerbArgs()` by two arms so the direct route and
|
|
1391
|
+
* `anno_batch_execute`'s inner loop cannot diverge (mirrors `assertScopeArgs`
|
|
1392
|
+
* exactly). "byte" selects the same two span shape rules a scope uses --
|
|
1393
|
+
* an exclusion is not a table. `reason` is required ONLY for the setter: the
|
|
1394
|
+
* unsetter names an existing record by its span alone. This layer refuses an
|
|
1395
|
+
* absent, non-string or empty/whitespace-only reason at the surface; the
|
|
1396
|
+
* store's own `assertCommentText()` re-checks the full comment-text
|
|
1397
|
+
* vocabulary at write time (T-46-01) -- this is not a second, divergent rule,
|
|
1398
|
+
* only an earlier gate on the same three malformed shapes. */
|
|
1399
|
+
function assertExcludedRangeArgs(name: string, args: unknown, batchIndex?: number): void {
|
|
1400
|
+
assertStoreArg(name, args, batchIndex);
|
|
1401
|
+
assertSpanArgs(name, args, "byte", batchIndex);
|
|
1402
|
+
if (name === "anno_exclude_range") {
|
|
1403
|
+
const reason = argBag(args).reason;
|
|
1404
|
+
if (typeof reason !== "string" || reason.trim() === "") {
|
|
1405
|
+
refuseArg(
|
|
1406
|
+
name,
|
|
1407
|
+
"reason",
|
|
1408
|
+
`"reason" must be a non-empty string stating why the user asked for this span to be left out, got ${JSON.stringify(reason)}.`,
|
|
1409
|
+
batchIndex,
|
|
1410
|
+
);
|
|
1411
|
+
}
|
|
1412
|
+
}
|
|
1413
|
+
assertBaseRevisionArg(name, args, batchIndex);
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
function assertGetCommentsArgs(args: unknown, batchIndex?: number): void {
|
|
1417
|
+
assertStoreArg("anno_get_comments", args, batchIndex);
|
|
1418
|
+
assertMaxResults("anno_get_comments", args, batchIndex);
|
|
1419
|
+
const bag = argBag(args);
|
|
1420
|
+
if (bag.addresses !== undefined) {
|
|
1421
|
+
if (!Array.isArray(bag.addresses)) {
|
|
1422
|
+
refuseArg("anno_get_comments", "addresses", '"addresses" must be an array of addresses when supplied.', batchIndex);
|
|
1423
|
+
}
|
|
1424
|
+
for (const entry of bag.addresses as unknown[]) parseStoreAddress(entry, { what: "addresses[]" });
|
|
1425
|
+
}
|
|
1426
|
+
if (bag.start_address !== undefined) parseStoreAddress(bag.start_address, { what: "start_address" });
|
|
1427
|
+
if (bag.end_address !== undefined) parseStoreAddress(bag.end_address, { what: "end_address" });
|
|
1428
|
+
if (bag.type !== undefined) assertCommentType(bag.type);
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
const BLOCK_INCLUDES: readonly string[] = Object.freeze(["scopes", "enums", "enum_usage"]);
|
|
1432
|
+
|
|
1433
|
+
function assertGetBlocksArgs(args: unknown, batchIndex?: number): void {
|
|
1434
|
+
assertStoreArg("anno_get_blocks", args, batchIndex);
|
|
1435
|
+
assertMaxResults("anno_get_blocks", args, batchIndex);
|
|
1436
|
+
const bag = argBag(args);
|
|
1437
|
+
if (bag.block_type !== undefined) assertDataType(bag.block_type);
|
|
1438
|
+
if (bag.include !== undefined) {
|
|
1439
|
+
if (!Array.isArray(bag.include)) {
|
|
1440
|
+
refuseArg("anno_get_blocks", "include", '"include" must be an array when supplied.', batchIndex);
|
|
1441
|
+
}
|
|
1442
|
+
for (const entry of bag.include as unknown[]) {
|
|
1443
|
+
if (typeof entry !== "string" || !BLOCK_INCLUDES.includes(entry)) {
|
|
1444
|
+
refuseArg(
|
|
1445
|
+
"anno_get_blocks",
|
|
1446
|
+
"include",
|
|
1447
|
+
`${JSON.stringify(entry)} is not one of the ${BLOCK_INCLUDES.length} extra collections -- expected one of: ${BLOCK_INCLUDES.join(", ")}.`,
|
|
1448
|
+
batchIndex,
|
|
1449
|
+
);
|
|
1450
|
+
}
|
|
1451
|
+
}
|
|
1452
|
+
}
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1455
|
+
function assertEnumNameArg(name: string, args: unknown, key: string, batchIndex?: number): void {
|
|
1456
|
+
const raw = argBag(args)[key];
|
|
1457
|
+
try {
|
|
1458
|
+
assertEnumName(raw);
|
|
1459
|
+
} catch (err) {
|
|
1460
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
1461
|
+
refuseArg(name, key, `${JSON.stringify(raw)} is not a legal enum name (${reason}) -- REJECTED, never sanitized.`, batchIndex);
|
|
1462
|
+
}
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
function assertCreateEnumArgs(args: unknown, batchIndex?: number): void {
|
|
1466
|
+
assertStoreArg("anno_create_project_enum", args, batchIndex);
|
|
1467
|
+
assertEnumNameArg("anno_create_project_enum", args, "name", batchIndex);
|
|
1468
|
+
const bag = argBag(args);
|
|
1469
|
+
if (!isPlainObject(bag.variants)) {
|
|
1470
|
+
refuseArg("anno_create_project_enum", "variants", '"variants" must be an object mapping numeric-string keys to variant names.', batchIndex);
|
|
1471
|
+
}
|
|
1472
|
+
if (bag.description !== undefined) assertCommentText(bag.description, { what: "description", allowLeadingSemicolon: true });
|
|
1473
|
+
assertBaseRevisionArg("anno_create_project_enum", args, batchIndex);
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
function assertUpdateEnumArgs(args: unknown, batchIndex?: number): void {
|
|
1477
|
+
assertStoreArg("anno_update_project_enum", args, batchIndex);
|
|
1478
|
+
assertEnumNameArg("anno_update_project_enum", args, "name", batchIndex);
|
|
1479
|
+
const bag = argBag(args);
|
|
1480
|
+
if (bag.new_name !== undefined) assertEnumNameArg("anno_update_project_enum", args, "new_name", batchIndex);
|
|
1481
|
+
if (bag.variants !== undefined && !isPlainObject(bag.variants)) {
|
|
1482
|
+
refuseArg("anno_update_project_enum", "variants", '"variants" must be an object when supplied -- it REPLACES the mapping wholesale.', batchIndex);
|
|
1483
|
+
}
|
|
1484
|
+
if (bag.description !== undefined) assertCommentText(bag.description, { what: "description", allowLeadingSemicolon: true });
|
|
1485
|
+
assertBaseRevisionArg("anno_update_project_enum", args, batchIndex);
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1488
|
+
/** True when this call is the CLEAR form -- `name` omitted, or an empty
|
|
1489
|
+
* string. The schema's own contract ("Omit or send empty to clear"), read in
|
|
1490
|
+
* ONE place so the validator and the dispatcher can never disagree about which
|
|
1491
|
+
* of the two store functions a given payload selects. */
|
|
1492
|
+
function isEnumUsageClear(args: unknown): boolean {
|
|
1493
|
+
const raw = argBag(args).name;
|
|
1494
|
+
return raw === undefined || raw === "";
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1497
|
+
function assertApplyEnumUsageArgs(args: unknown, batchIndex?: number): void {
|
|
1498
|
+
assertStoreArg("anno_apply_enum_usage", args, batchIndex);
|
|
1499
|
+
assertAddressArg("anno_apply_enum_usage", args, "address", batchIndex);
|
|
1500
|
+
if (!isEnumUsageClear(args)) assertEnumNameArg("anno_apply_enum_usage", args, "name", batchIndex);
|
|
1501
|
+
assertBaseRevisionArg("anno_apply_enum_usage", args, batchIndex);
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1504
|
+
function assertSaveProjectArgs(args: unknown, batchIndex?: number): void {
|
|
1505
|
+
assertStoreArg("anno_save_project", args, batchIndex);
|
|
1506
|
+
}
|
|
1507
|
+
|
|
1508
|
+
function assertImportGhidraExportArgs(args: unknown, batchIndex?: number): void {
|
|
1509
|
+
assertStoreArg("anno_import_ghidra_export", args, batchIndex);
|
|
1510
|
+
const bag = argBag(args);
|
|
1511
|
+
if (typeof bag.export_path !== "string" || bag.export_path.trim() === "") {
|
|
1512
|
+
refuseArg("anno_import_ghidra_export", "export_path", '"export_path" is required and must be a non-empty string.', batchIndex);
|
|
1513
|
+
}
|
|
1514
|
+
if (bag.sha256 !== undefined && (typeof bag.sha256 !== "string" || bag.sha256.trim() === "")) {
|
|
1515
|
+
refuseArg("anno_import_ghidra_export", "sha256", '"sha256" must be a non-empty string when supplied.', batchIndex);
|
|
1516
|
+
}
|
|
1517
|
+
assertBaseRevisionArg("anno_import_ghidra_export", args, batchIndex);
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
/** Validates one `const_writes[i]` element against the wire shape declared on
|
|
1521
|
+
* `anno_join_memmap`'s own schema, and narrows it to a `ConstWriteFact`
|
|
1522
|
+
* (CR-01 fix). Each of the three fields is required and must be a
|
|
1523
|
+
* non-negative integer -- these are ALREADY-RESOLVED facts a caller is
|
|
1524
|
+
* round-tripping from a prior anno_import_ghidra_export call, never an
|
|
1525
|
+
* agent-typed address, so there is no `$`/`0x` ambiguity to route through
|
|
1526
|
+
* `parseStoreAddress()` here. */
|
|
1527
|
+
function assertConstWriteFactArg(name: string, raw: unknown, index: number, batchIndex?: number): ConstWriteFact {
|
|
1528
|
+
if (!isPlainObject(raw)) {
|
|
1529
|
+
refuseArg(
|
|
1530
|
+
name,
|
|
1531
|
+
"const_writes",
|
|
1532
|
+
`"const_writes[${index}]" must be an object with store_address/target_address/value fields, got ${JSON.stringify(raw)}.`,
|
|
1533
|
+
batchIndex,
|
|
1534
|
+
);
|
|
1535
|
+
}
|
|
1536
|
+
const bag = raw as Record<string, unknown>;
|
|
1537
|
+
for (const key of ["store_address", "target_address", "value"] as const) {
|
|
1538
|
+
const value = bag[key];
|
|
1539
|
+
if (typeof value !== "number" || !Number.isInteger(value) || value < 0) {
|
|
1540
|
+
refuseArg(
|
|
1541
|
+
name,
|
|
1542
|
+
"const_writes",
|
|
1543
|
+
`"const_writes[${index}].${key}" must be a non-negative integer, got ${JSON.stringify(value)}.`,
|
|
1544
|
+
batchIndex,
|
|
1545
|
+
);
|
|
1546
|
+
}
|
|
1547
|
+
}
|
|
1548
|
+
return {
|
|
1549
|
+
storeAddress: bag.store_address as number,
|
|
1550
|
+
targetAddress: bag.target_address as number,
|
|
1551
|
+
value: bag.value as number,
|
|
1552
|
+
};
|
|
1553
|
+
}
|
|
1554
|
+
|
|
1555
|
+
/** Validates the optional `const_writes` array, returning `undefined` when
|
|
1556
|
+
* omitted -- OMISSION, not emptiness, is what `runMemmapJoin()` treats as
|
|
1557
|
+
* "skip the bank-state/graphics machinery entirely" (D-37-24's own
|
|
1558
|
+
* documented activation switch), so this must not default an absent
|
|
1559
|
+
* argument to `[]`. */
|
|
1560
|
+
function assertConstWritesArg(name: string, args: unknown, batchIndex?: number): ConstWriteFact[] | undefined {
|
|
1561
|
+
const raw = argBag(args).const_writes;
|
|
1562
|
+
if (raw === undefined) return undefined;
|
|
1563
|
+
if (!Array.isArray(raw)) {
|
|
1564
|
+
refuseArg(name, "const_writes", `"const_writes" must be an array when supplied, got ${JSON.stringify(raw)}.`, batchIndex);
|
|
1565
|
+
}
|
|
1566
|
+
return raw.map((entry, i) => assertConstWriteFactArg(name, entry, i, batchIndex));
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1569
|
+
/** Validates the optional `graphics_map_index` argument. */
|
|
1570
|
+
function assertGraphicsMapIndexArg(name: string, args: unknown, batchIndex?: number): number | undefined {
|
|
1571
|
+
const raw = argBag(args).graphics_map_index;
|
|
1572
|
+
if (raw === undefined) return undefined;
|
|
1573
|
+
if (typeof raw !== "number" || !Number.isInteger(raw) || raw < 0) {
|
|
1574
|
+
refuseArg(
|
|
1575
|
+
name,
|
|
1576
|
+
"graphics_map_index",
|
|
1577
|
+
`"graphics_map_index" must be a non-negative integer when supplied, got ${JSON.stringify(raw)}.`,
|
|
1578
|
+
batchIndex,
|
|
1579
|
+
);
|
|
1580
|
+
}
|
|
1581
|
+
return raw;
|
|
1582
|
+
}
|
|
1583
|
+
|
|
1584
|
+
function assertJoinMemmapArgs(args: unknown, batchIndex?: number): void {
|
|
1585
|
+
assertStoreArg("anno_join_memmap", args, batchIndex);
|
|
1586
|
+
assertImageArg("anno_join_memmap", args, batchIndex);
|
|
1587
|
+
assertBaseRevisionArg("anno_join_memmap", args, batchIndex);
|
|
1588
|
+
assertConstWritesArg("anno_join_memmap", args, batchIndex);
|
|
1589
|
+
assertGraphicsMapIndexArg("anno_join_memmap", args, batchIndex);
|
|
1590
|
+
}
|
|
1591
|
+
|
|
1592
|
+
/** The run-identity digest shape: exactly 64 lowercase hex characters. This
|
|
1593
|
+
* module's own copy of the check (mirroring `evid-ingest.ts`'s identical,
|
|
1594
|
+
* deliberately un-imported copy): `image_sha256` never reaches a digest
|
|
1595
|
+
* function here, so there is nothing to route through a shared regex, and a
|
|
1596
|
+
* caller-visible refusal must fire BEFORE any store is opened -- before
|
|
1597
|
+
* `evid-ingest.ts`'s own `runIdentityFrom()` ever runs. */
|
|
1598
|
+
const EVID_DIGEST_RE = /^[0-9a-f]{64}$/;
|
|
1599
|
+
|
|
1600
|
+
/** `anno_evid_ingest`'s own argument assertion, wired into `assertVerbArgs`
|
|
1601
|
+
* beside `anno_join_memmap`'s. Refuses BY NAME, before any store is opened: a
|
|
1602
|
+
* non-string/empty `memmap_text`, an `image_sha256` that is not exactly 64
|
|
1603
|
+
* lowercase hex characters, an `argv` that is not a non-empty array of
|
|
1604
|
+
* strings, and a `seed` that is not a non-empty string (T-43-21). */
|
|
1605
|
+
function assertEvidIngestArgs(args: unknown, batchIndex?: number): void {
|
|
1606
|
+
assertStoreArg("anno_evid_ingest", args, batchIndex);
|
|
1607
|
+
assertBaseRevisionArg("anno_evid_ingest", args, batchIndex);
|
|
1608
|
+
const bag = argBag(args);
|
|
1609
|
+
if (typeof bag.memmap_text !== "string" || bag.memmap_text.trim() === "") {
|
|
1610
|
+
refuseArg(
|
|
1611
|
+
"anno_evid_ingest",
|
|
1612
|
+
"memmap_text",
|
|
1613
|
+
`"memmap_text" must be a non-empty string carrying the raw memmapshow reply, got ${JSON.stringify(bag.memmap_text)}.`,
|
|
1614
|
+
batchIndex,
|
|
1615
|
+
);
|
|
1616
|
+
}
|
|
1617
|
+
if (typeof bag.image_sha256 !== "string" || !EVID_DIGEST_RE.test(bag.image_sha256)) {
|
|
1618
|
+
refuseArg(
|
|
1619
|
+
"anno_evid_ingest",
|
|
1620
|
+
"image_sha256",
|
|
1621
|
+
`"image_sha256" must be exactly 64 lowercase hex characters, got ${JSON.stringify(bag.image_sha256)}.`,
|
|
1622
|
+
batchIndex,
|
|
1623
|
+
);
|
|
1624
|
+
}
|
|
1625
|
+
if (!Array.isArray(bag.argv) || bag.argv.length === 0 || bag.argv.some((entry) => typeof entry !== "string")) {
|
|
1626
|
+
refuseArg(
|
|
1627
|
+
"anno_evid_ingest",
|
|
1628
|
+
"argv",
|
|
1629
|
+
`"argv" must be a non-empty array of strings naming the exact emulator launch argument vector, got ${JSON.stringify(bag.argv)}.`,
|
|
1630
|
+
batchIndex,
|
|
1631
|
+
);
|
|
1632
|
+
}
|
|
1633
|
+
if (typeof bag.seed !== "string" || bag.seed.length === 0) {
|
|
1634
|
+
refuseArg("anno_evid_ingest", "seed", `"seed" must be a non-empty string, got ${JSON.stringify(bag.seed)}.`, batchIndex);
|
|
1635
|
+
}
|
|
1636
|
+
}
|
|
1637
|
+
|
|
1638
|
+
/** `anno_evid_disagreements`'s own argument assertion (plan 43-06). The
|
|
1639
|
+
* three run-identity filters are ALL-OR-NONE, mirroring
|
|
1640
|
+
* `listExecObservations()`'s own rule in `anno-store.ts` exactly: a partial
|
|
1641
|
+
* identity would silently widen the match to every run sharing the supplied
|
|
1642
|
+
* field, which is not what "filter by run identity" means. */
|
|
1643
|
+
function assertEvidDisagreementsArgs(args: unknown, batchIndex?: number): void {
|
|
1644
|
+
assertStoreArg("anno_evid_disagreements", args, batchIndex);
|
|
1645
|
+
assertOptionalMaxResults("anno_evid_disagreements", args, batchIndex);
|
|
1646
|
+
const bag = argBag(args);
|
|
1647
|
+
const filterFieldsGiven = [bag.image_sha256, bag.argv_digest, bag.seed].filter((v) => v !== undefined).length;
|
|
1648
|
+
if (filterFieldsGiven > 0 && filterFieldsGiven < 3) {
|
|
1649
|
+
refuseArg(
|
|
1650
|
+
"anno_evid_disagreements",
|
|
1651
|
+
"image_sha256",
|
|
1652
|
+
"a run-identity filter requires image_sha256, argv_digest AND seed together -- a partial identity would " +
|
|
1653
|
+
"silently widen the match to every run sharing the supplied field(s).",
|
|
1654
|
+
batchIndex,
|
|
1655
|
+
);
|
|
1656
|
+
}
|
|
1657
|
+
if (filterFieldsGiven === 3) {
|
|
1658
|
+
if (typeof bag.image_sha256 !== "string" || !EVID_DIGEST_RE.test(bag.image_sha256)) {
|
|
1659
|
+
refuseArg(
|
|
1660
|
+
"anno_evid_disagreements",
|
|
1661
|
+
"image_sha256",
|
|
1662
|
+
`"image_sha256" must be exactly 64 lowercase hex characters, got ${JSON.stringify(bag.image_sha256)}.`,
|
|
1663
|
+
batchIndex,
|
|
1664
|
+
);
|
|
1665
|
+
}
|
|
1666
|
+
if (typeof bag.argv_digest !== "string" || !EVID_DIGEST_RE.test(bag.argv_digest)) {
|
|
1667
|
+
refuseArg(
|
|
1668
|
+
"anno_evid_disagreements",
|
|
1669
|
+
"argv_digest",
|
|
1670
|
+
`"argv_digest" must be exactly 64 lowercase hex characters, got ${JSON.stringify(bag.argv_digest)}.`,
|
|
1671
|
+
batchIndex,
|
|
1672
|
+
);
|
|
1673
|
+
}
|
|
1674
|
+
if (typeof bag.seed !== "string" || bag.seed.length === 0) {
|
|
1675
|
+
refuseArg("anno_evid_disagreements", "seed", `"seed" must be a non-empty string, got ${JSON.stringify(bag.seed)}.`, batchIndex);
|
|
1676
|
+
}
|
|
1677
|
+
}
|
|
1678
|
+
}
|
|
1679
|
+
|
|
1680
|
+
/** `anno_evid_runs`'s own argument assertion (plan 43-06): just the
|
|
1681
|
+
* universal `store` argument, since this verb takes no other input. */
|
|
1682
|
+
function assertEvidRunsArgs(args: unknown, batchIndex?: number): void {
|
|
1683
|
+
assertStoreArg("anno_evid_runs", args, batchIndex);
|
|
1684
|
+
}
|
|
1685
|
+
|
|
1686
|
+
/** `anno_evid_reset`'s own argument assertion (plan 43-06), the SAME shape
|
|
1687
|
+
* as `assertEvidIngestArgs` minus `memmap_text` -- refuses BY NAME, before
|
|
1688
|
+
* any store is opened: an `image_sha256` that is not exactly 64 lowercase
|
|
1689
|
+
* hex characters, an `argv` that is not a non-empty array of strings, and a
|
|
1690
|
+
* `seed` that is not a non-empty string. */
|
|
1691
|
+
function assertEvidResetArgs(args: unknown, batchIndex?: number): void {
|
|
1692
|
+
assertStoreArg("anno_evid_reset", args, batchIndex);
|
|
1693
|
+
assertBaseRevisionArg("anno_evid_reset", args, batchIndex);
|
|
1694
|
+
const bag = argBag(args);
|
|
1695
|
+
if (typeof bag.image_sha256 !== "string" || !EVID_DIGEST_RE.test(bag.image_sha256)) {
|
|
1696
|
+
refuseArg(
|
|
1697
|
+
"anno_evid_reset",
|
|
1698
|
+
"image_sha256",
|
|
1699
|
+
`"image_sha256" must be exactly 64 lowercase hex characters, got ${JSON.stringify(bag.image_sha256)}.`,
|
|
1700
|
+
batchIndex,
|
|
1701
|
+
);
|
|
1702
|
+
}
|
|
1703
|
+
if (!Array.isArray(bag.argv) || bag.argv.length === 0 || bag.argv.some((entry) => typeof entry !== "string")) {
|
|
1704
|
+
refuseArg(
|
|
1705
|
+
"anno_evid_reset",
|
|
1706
|
+
"argv",
|
|
1707
|
+
`"argv" must be a non-empty array of strings naming the exact emulator launch argument vector, got ${JSON.stringify(bag.argv)}.`,
|
|
1708
|
+
batchIndex,
|
|
1709
|
+
);
|
|
1710
|
+
}
|
|
1711
|
+
if (typeof bag.seed !== "string" || bag.seed.length === 0) {
|
|
1712
|
+
refuseArg("anno_evid_reset", "seed", `"seed" must be a non-empty string, got ${JSON.stringify(bag.seed)}.`, batchIndex);
|
|
1713
|
+
}
|
|
1714
|
+
}
|
|
1715
|
+
|
|
1716
|
+
/** `anno_hazard_report`'s own argument assertion. Reuses the shared store,
|
|
1717
|
+
* image and optional-max-results assertions rather than inlining a fourth
|
|
1718
|
+
* check -- this verb has no argument shape of its own beyond those three. */
|
|
1719
|
+
function assertHazardReportArgs(args: unknown, batchIndex?: number): void {
|
|
1720
|
+
assertStoreArg("anno_hazard_report", args, batchIndex);
|
|
1721
|
+
assertImageArg("anno_hazard_report", args, batchIndex);
|
|
1722
|
+
assertOptionalMaxResults("anno_hazard_report", args, batchIndex);
|
|
1723
|
+
}
|
|
1724
|
+
|
|
1725
|
+
// ---------------------------------------------------------------------------
|
|
1726
|
+
// THE ONE SIZE CAP, GOVERNING BOTH VIEWS (T-29-25).
|
|
1727
|
+
//
|
|
1728
|
+
// A full-64K disassembly view dumped into an agent's context is the hazard this
|
|
1729
|
+
// cap exists to prevent; these verbs read a ROUTINE at a range, not the whole
|
|
1730
|
+
// program. 4096 is one sixteenth of the address space and far above any
|
|
1731
|
+
// realistic single routine. ONE cap covers the region read AND the disassemble
|
|
1732
|
+
// view, deliberately, so there is no per-view rule to get subtly wrong -- and
|
|
1733
|
+
// the disassembly view at the cap is the worst case, since the hexdump view of
|
|
1734
|
+
// the same byte count renders far less text.
|
|
1735
|
+
//
|
|
1736
|
+
// THIS CAP IS NOT THE ONLY BOUND FOR THIS FAMILY, BUT IT REMAINS THE
|
|
1737
|
+
// LOAD-BEARING ONE. `vice-proxy.ts`'s `wrapPossiblyChunked()` runs at the
|
|
1738
|
+
// proxy's single tools/call choke point -- the one place every registered
|
|
1739
|
+
// tool's result is checked before it reaches the wire -- so an over-cap
|
|
1740
|
+
// answer from this family crosses that same override exactly like any
|
|
1741
|
+
// other tool's, and is split across a continuation sequence rather than
|
|
1742
|
+
// delivered whole. That does not make this cap redundant: the client's own
|
|
1743
|
+
// inline-response ceiling was measured at 40-60 KB, far below the proxy's
|
|
1744
|
+
// 500,000-character output cap, so a result that never trips the proxy's
|
|
1745
|
+
// split can still be far too large to be useful. That is why the second
|
|
1746
|
+
// mitigation -- `max_results` REQUIRED with no default on every
|
|
1747
|
+
// list-returning verb, with the true total returned beside the truncated list
|
|
1748
|
+
// -- is not optional either.
|
|
1749
|
+
// ---------------------------------------------------------------------------
|
|
1750
|
+
|
|
1751
|
+
export const ANNO_READ_REGION_MAX_BYTES = 4096;
|
|
1752
|
+
|
|
1753
|
+
/** The environment variable that overrides the cap. Exported so a caller and a
|
|
1754
|
+
* test name it in one place rather than two. */
|
|
1755
|
+
export const ANNO_READ_REGION_MAX_BYTES_ENV = "ANNO_READ_REGION_MAX_BYTES";
|
|
1756
|
+
|
|
1757
|
+
/** Reads the cap override AT CALL TIME, never frozen at module load -- the same
|
|
1758
|
+
* read-at-call-time convention `repoRoot()` is called under above, so one
|
|
1759
|
+
* `node --test` process can point several different caps at this code within a
|
|
1760
|
+
* single run. Falls back to the named default on an absent, non-finite or
|
|
1761
|
+
* non-positive override. */
|
|
1762
|
+
function currentReadRegionMaxBytes(): number {
|
|
1763
|
+
const raw = process.env[ANNO_READ_REGION_MAX_BYTES_ENV];
|
|
1764
|
+
if (raw === undefined) return ANNO_READ_REGION_MAX_BYTES;
|
|
1765
|
+
const n = Number(raw);
|
|
1766
|
+
return Number.isFinite(n) && n > 0 ? n : ANNO_READ_REGION_MAX_BYTES;
|
|
1767
|
+
}
|
|
1768
|
+
|
|
1769
|
+
export interface AnnoRegionRangeErrorOptions extends AnnoStoreErrorOptions {
|
|
1770
|
+
toolName?: string;
|
|
1771
|
+
start?: number;
|
|
1772
|
+
end?: number;
|
|
1773
|
+
requestedBytes?: number;
|
|
1774
|
+
cap?: number;
|
|
1775
|
+
batchIndex?: number;
|
|
1776
|
+
}
|
|
1777
|
+
|
|
1778
|
+
/** A region or disassembly extent wider than the cap. Its own class, because a
|
|
1779
|
+
* caller must be able to tell "your range is too wide" from every other
|
|
1780
|
+
* argument refusal without substring-matching a message. */
|
|
1781
|
+
export class AnnoRegionRangeError extends AnnoStoreError {
|
|
1782
|
+
toolName?: string;
|
|
1783
|
+
start?: number;
|
|
1784
|
+
end?: number;
|
|
1785
|
+
requestedBytes?: number;
|
|
1786
|
+
cap?: number;
|
|
1787
|
+
batchIndex?: number;
|
|
1788
|
+
|
|
1789
|
+
constructor(message: string, { toolName, start, end, requestedBytes, cap, batchIndex, ...rest }: AnnoRegionRangeErrorOptions = {}) {
|
|
1790
|
+
super(message, rest);
|
|
1791
|
+
this.name = "AnnoRegionRangeError";
|
|
1792
|
+
this.toolName = toolName;
|
|
1793
|
+
this.start = start;
|
|
1794
|
+
this.end = end;
|
|
1795
|
+
this.requestedBytes = requestedBytes;
|
|
1796
|
+
this.cap = cap;
|
|
1797
|
+
this.batchIndex = batchIndex;
|
|
1798
|
+
}
|
|
1799
|
+
}
|
|
1800
|
+
|
|
1801
|
+
/** Enforces the ONE cap over an inclusive span, naming BOTH the cap and the
|
|
1802
|
+
* requested width so the message is actionable without reading this file.
|
|
1803
|
+
* Called from `anno_read_region` and `anno_disassemble` alike. */
|
|
1804
|
+
function assertWithinRegionCap(name: string, start: number, end: number, batchIndex?: number): void {
|
|
1805
|
+
const requestedBytes = end - start + 1;
|
|
1806
|
+
const cap = currentReadRegionMaxBytes();
|
|
1807
|
+
if (requestedBytes > cap) {
|
|
1808
|
+
throw new AnnoRegionRangeError(
|
|
1809
|
+
`${name} refused${whereOf(batchIndex)}: requested ${requestedBytes} bytes ($${start.toString(16).padStart(4, "0")}..` +
|
|
1810
|
+
`$${end.toString(16).padStart(4, "0")} inclusive), which exceeds the ${ANNO_READ_REGION_MAX_BYTES_ENV} cap of ${cap} -- ` +
|
|
1811
|
+
`valid range is 1..${cap} bytes. This verb reads a routine at a range, not the whole program, and this family is NOT ` +
|
|
1812
|
+
`chunked, so the cap is the only bound there is. Narrow the range, or set ${ANNO_READ_REGION_MAX_BYTES_ENV} to override.`,
|
|
1813
|
+
{ toolName: name, start, end, requestedBytes, cap, batchIndex },
|
|
1814
|
+
);
|
|
1815
|
+
}
|
|
1816
|
+
}
|
|
1817
|
+
|
|
1818
|
+
/** Narrows the universally-required `image` argument (D-07) to a non-empty
|
|
1819
|
+
* string. Containment is `resolveWorkspacePath()`'s concern, exactly as for the
|
|
1820
|
+
* store path. */
|
|
1821
|
+
function assertImageArg(name: string, args: unknown, batchIndex?: number): string {
|
|
1822
|
+
const bag = argBag(args);
|
|
1823
|
+
if (typeof bag.image !== "string" || bag.image.trim() === "") {
|
|
1824
|
+
refuseArg(
|
|
1825
|
+
name,
|
|
1826
|
+
"image",
|
|
1827
|
+
'"image" must be a non-empty string naming the program image this answer is derived from -- the store holds ' +
|
|
1828
|
+
"annotations, never bytes, and an omitted image would read as a plausible success against whatever was recorded last (D-07).",
|
|
1829
|
+
batchIndex,
|
|
1830
|
+
);
|
|
1831
|
+
}
|
|
1832
|
+
return bag.image as string;
|
|
1833
|
+
}
|
|
1834
|
+
|
|
1835
|
+
function assertQueryArg(name: string, args: unknown, batchIndex?: number): void {
|
|
1836
|
+
const raw = argBag(args).query;
|
|
1837
|
+
if (typeof raw !== "string" || raw === "") {
|
|
1838
|
+
refuseArg(
|
|
1839
|
+
name,
|
|
1840
|
+
"query",
|
|
1841
|
+
`"query" must be a non-empty string, got ${JSON.stringify(raw)} -- an empty query matches every entry of every corpus, ` +
|
|
1842
|
+
"which is a listing rather than a search, and the list verbs are what listing is for.",
|
|
1843
|
+
batchIndex,
|
|
1844
|
+
);
|
|
1845
|
+
}
|
|
1846
|
+
}
|
|
1847
|
+
|
|
1848
|
+
function assertDisassembleArgs(args: unknown, batchIndex?: number): void {
|
|
1849
|
+
assertStoreArg("anno_disassemble", args, batchIndex);
|
|
1850
|
+
assertImageArg("anno_disassemble", args, batchIndex);
|
|
1851
|
+
const start = assertAddressArg("anno_disassemble", args, "address", batchIndex);
|
|
1852
|
+
const bag = argBag(args);
|
|
1853
|
+
if (bag.end_address !== undefined) {
|
|
1854
|
+
const end = parseStoreAddress(bag.end_address, { what: "end_address" });
|
|
1855
|
+
assertRangeShape(start, end, "byte");
|
|
1856
|
+
assertWithinRegionCap("anno_disassemble", start, end, batchIndex);
|
|
1857
|
+
}
|
|
1858
|
+
}
|
|
1859
|
+
|
|
1860
|
+
function assertReadRegionArgs(args: unknown, batchIndex?: number): void {
|
|
1861
|
+
assertStoreArg("anno_read_region", args, batchIndex);
|
|
1862
|
+
assertImageArg("anno_read_region", args, batchIndex);
|
|
1863
|
+
const { start, end } = assertSpanArgs("anno_read_region", args, "byte", batchIndex);
|
|
1864
|
+
assertWithinRegionCap("anno_read_region", start, end, batchIndex);
|
|
1865
|
+
const view = argBag(args).view;
|
|
1866
|
+
if (view !== undefined && view !== "disasm" && view !== "hexdump") {
|
|
1867
|
+
refuseArg("anno_read_region", "view", `${JSON.stringify(view)} is not a view -- expected "disasm" or "hexdump".`, batchIndex);
|
|
1868
|
+
}
|
|
1869
|
+
}
|
|
1870
|
+
|
|
1871
|
+
function assertBinaryInfoArgs(args: unknown, batchIndex?: number): void {
|
|
1872
|
+
assertStoreArg("anno_get_binary_info", args, batchIndex);
|
|
1873
|
+
assertImageArg("anno_get_binary_info", args, batchIndex);
|
|
1874
|
+
}
|
|
1875
|
+
|
|
1876
|
+
function assertCrossReferencesArgs(args: unknown, batchIndex?: number): void {
|
|
1877
|
+
assertStoreArg("anno_get_cross_references", args, batchIndex);
|
|
1878
|
+
assertImageArg("anno_get_cross_references", args, batchIndex);
|
|
1879
|
+
assertAddressArg("anno_get_cross_references", args, "address", batchIndex);
|
|
1880
|
+
assertMaxResults("anno_get_cross_references", args, batchIndex);
|
|
1881
|
+
}
|
|
1882
|
+
|
|
1883
|
+
function assertSearchArgs(args: unknown, batchIndex?: number): void {
|
|
1884
|
+
assertStoreArg("anno_search", args, batchIndex);
|
|
1885
|
+
assertImageArg("anno_search", args, batchIndex);
|
|
1886
|
+
assertQueryArg("anno_search", args, batchIndex);
|
|
1887
|
+
assertMaxResults("anno_search", args, batchIndex);
|
|
1888
|
+
}
|
|
1889
|
+
|
|
1890
|
+
function assertAddressDetailsArgs(args: unknown, batchIndex?: number): void {
|
|
1891
|
+
assertStoreArg("anno_get_address_details", args, batchIndex);
|
|
1892
|
+
assertImageArg("anno_get_address_details", args, batchIndex);
|
|
1893
|
+
assertAddressArg("anno_get_address_details", args, "address", batchIndex);
|
|
1894
|
+
}
|
|
1895
|
+
|
|
1896
|
+
|
|
1897
|
+
// ---------------------------------------------------------------------------
|
|
1898
|
+
// `anno_batch_execute` -- TWO EXPLICITLY SEPARATE PHASES, documented as two.
|
|
1899
|
+
//
|
|
1900
|
+
// PHASE ONE, PRE-VALIDATION (`assertAnnoBatch`), runs before any store is
|
|
1901
|
+
// opened. It refuses the WHOLE batch on: a malformed payload, an empty `calls`
|
|
1902
|
+
// array, a malformed entry, an uncurated inner name at ANY depth, or an inner
|
|
1903
|
+
// call whose own per-verb validator refuses -- each naming the offending index.
|
|
1904
|
+
// Nothing has executed when it fires, so there is no partial write to explain.
|
|
1905
|
+
//
|
|
1906
|
+
// PHASE TWO, EXECUTION, runs inside ONE `openStore`/`closeStore` pair for the
|
|
1907
|
+
// whole batch. It loops to COMPLETION, pushing a per-entry `{status:"success"}`
|
|
1908
|
+
// or `{status:"error"}` for every entry, and never aborts on the first failure.
|
|
1909
|
+
//
|
|
1910
|
+
// THE TWO ARE NOT IN CONFLICT, and this is the reconciliation the plan records:
|
|
1911
|
+
// per-item status reporting and whole-batch refusal are two PHASES of one call,
|
|
1912
|
+
// not two answers to one question. A refusal in phase one becomes
|
|
1913
|
+
// `isError: true` through the runner's own catch and means "this batch should
|
|
1914
|
+
// never have been sent". An inner call failing in phase two becomes an error
|
|
1915
|
+
// ENTRY inside a successful outer result and means "this call in the batch did
|
|
1916
|
+
// not work". The measured upstream note at `anno-tools.ts:63-75` establishes
|
|
1917
|
+
// the second half: the loop always runs to completion and each outcome is
|
|
1918
|
+
// pushed with its own status.
|
|
1919
|
+
//
|
|
1920
|
+
// TWO THINGS THIS VALIDATOR HAS THAT ITS ANALOG DID NOT:
|
|
1921
|
+
//
|
|
1922
|
+
// 1. AN EXPLICIT DEPTH CAP. The original recursion was unbounded and was safe
|
|
1923
|
+
// only because a child-process spawn cost dominated any nesting an
|
|
1924
|
+
// attacker could send. That cost is gone -- this runs in-process -- so a
|
|
1925
|
+
// deeply nested payload is a stack-exhaustion route (T-29-24). Past the
|
|
1926
|
+
// cap the batch is REFUSED BY NAME, naming the cap, rather than walked.
|
|
1927
|
+
// 2. AN EXPLICIT REFUSAL FOR AN EMPTY `calls` ARRAY. A zero-length batch is
|
|
1928
|
+
// an ambiguous request, and executing it as a zero-length SUCCESS is
|
|
1929
|
+
// exactly the plausible-looking zero this surface forbids. A malformed
|
|
1930
|
+
// payload is a refusal; so is an empty one.
|
|
1931
|
+
// ---------------------------------------------------------------------------
|
|
1932
|
+
|
|
1933
|
+
/**
|
|
1934
|
+
* PHASE ONE. Walks an `anno_batch_execute` payload and refuses the WHOLE batch
|
|
1935
|
+
* if anything, at any depth, is wrong.
|
|
1936
|
+
*
|
|
1937
|
+
* The per-verb argument validators fire through `assertVerbArgs()` -- the SAME
|
|
1938
|
+
* function the outer gate calls -- with the entry's index interpolated into the
|
|
1939
|
+
* message, so an illegal label name or an over-cap region range is refused
|
|
1940
|
+
* identically whether the verb was called directly or smuggled inside a batch.
|
|
1941
|
+
* That is the shared-validator discipline, and it is what makes the outer
|
|
1942
|
+
* allow-list gate mean anything for a nested-argument verb.
|
|
1943
|
+
*
|
|
1944
|
+
* THE SAME DISCIPLINE APPLIES TO THE ARGUMENTS THEMSELVES. Every inner
|
|
1945
|
+
* payload this function walks -- a leaf verb's or a nested batch's -- is
|
|
1946
|
+
* obtained from `batchArgumentsFor()`, the one function phase two also asks.
|
|
1947
|
+
* A phase that computed an inner call's arguments its own way would be
|
|
1948
|
+
* validating a payload the executor never runs, which is what CR-06 was.
|
|
1949
|
+
*/
|
|
1950
|
+
export function assertAnnoBatch(args: unknown, depth = 0): void {
|
|
1951
|
+
if (depth > ANNO_MAX_BATCH_DEPTH) {
|
|
1952
|
+
throw new AnnoUncuratedToolError(
|
|
1953
|
+
`anno_batch_execute refused: nesting deeper than ${ANNO_MAX_BATCH_DEPTH} levels -- refused BY NAME rather than walked, ` +
|
|
1954
|
+
"because an unbounded walk over an attacker-shaped payload is a stack-exhaustion route (T-29-24). Flatten the batch.",
|
|
1955
|
+
{ toolName: "anno_batch_execute" },
|
|
1956
|
+
);
|
|
1957
|
+
}
|
|
1958
|
+
if (!isPlainObject(args) || !Array.isArray(args.calls)) {
|
|
1959
|
+
throw new AnnoUncuratedToolError(
|
|
1960
|
+
'anno_batch_execute refused: "calls" must be an array of {name, arguments} objects -- a malformed batch payload is ' +
|
|
1961
|
+
"treated as a REFUSAL, never as an empty batch that passes through.",
|
|
1962
|
+
{ toolName: "anno_batch_execute" },
|
|
1963
|
+
);
|
|
1964
|
+
}
|
|
1965
|
+
const calls = args.calls as unknown[];
|
|
1966
|
+
if (calls.length === 0) {
|
|
1967
|
+
throw new AnnoUncuratedToolError(
|
|
1968
|
+
'anno_batch_execute refused: "calls" is an EMPTY array. A zero-length batch is an ambiguous request, and running it as a ' +
|
|
1969
|
+
"zero-length success would be a plausible-looking zero -- the caller would be told a pass completed when nothing was asked for.",
|
|
1970
|
+
{ toolName: "anno_batch_execute" },
|
|
1971
|
+
);
|
|
1972
|
+
}
|
|
1973
|
+
calls.forEach((call, i) => {
|
|
1974
|
+
if (!isPlainObject(call) || typeof call.name !== "string") {
|
|
1975
|
+
throw new AnnoUncuratedToolError(
|
|
1976
|
+
`anno_batch_execute refused WHOLE: calls[${i}] is malformed (missing a string "name") -- treated as a refusal, never ` +
|
|
1977
|
+
"as an empty batch that passes through.",
|
|
1978
|
+
{ toolName: "anno_batch_execute", batchIndex: i },
|
|
1979
|
+
);
|
|
1980
|
+
}
|
|
1981
|
+
if (!CURATED_ANNO_TOOLS.includes(call.name)) {
|
|
1982
|
+
throw new AnnoUncuratedToolError(
|
|
1983
|
+
`anno_batch_execute refused WHOLE: calls[${i}].name "${call.name}" is outside the curated anno_* tool surface -- a batch ` +
|
|
1984
|
+
"is refused whole if any inner name is outside the curated set (D-33).",
|
|
1985
|
+
{ toolName: call.name, batchIndex: i },
|
|
1986
|
+
);
|
|
1987
|
+
}
|
|
1988
|
+
if (call.name === "anno_batch_execute") {
|
|
1989
|
+
// RECURSES ON THE EFFECTIVE ARGUMENTS, NOT THE RAW BAG, and that is the
|
|
1990
|
+
// whole of CR-06. Phase two -- `dispatchBatchExecute()` -- has always
|
|
1991
|
+
// recursed on `batchArgumentsFor(bag, call)`; phase one used to recurse
|
|
1992
|
+
// on `call.arguments`. The two phases therefore disagreed about what the
|
|
1993
|
+
// inner payload WAS, and a nested batch written the documented way (the
|
|
1994
|
+
// store named ONCE at the top, every inner call inheriting it) was
|
|
1995
|
+
// refused whole at every depth -- with a message saying there is no
|
|
1996
|
+
// ambient store to inherit, the exact opposite of this verb's own
|
|
1997
|
+
// description. Read this line as a pair with the executor's recursion:
|
|
1998
|
+
// one function, `batchArgumentsFor()`, defines an inner call's effective
|
|
1999
|
+
// arguments, and both phases ask it.
|
|
2000
|
+
assertAnnoBatch(batchArgumentsFor(args, call), depth + 1);
|
|
2001
|
+
return;
|
|
2002
|
+
}
|
|
2003
|
+
assertVerbArgs(call.name, batchArgumentsFor(args, call), i);
|
|
2004
|
+
});
|
|
2005
|
+
}
|
|
2006
|
+
|
|
2007
|
+
/** An inner call's effective arguments. The batch names the store ONCE, at the
|
|
2008
|
+
* top level, and every inner call inherits it -- an inner call that named its
|
|
2009
|
+
* own store would be a different store for one entry of a batch that reads as
|
|
2010
|
+
* one transaction's worth of work, which is a shape nothing here wants. An
|
|
2011
|
+
* inner `store` is therefore OVERRIDDEN by the batch's own, never merged with
|
|
2012
|
+
* it and never silently honoured. */
|
|
2013
|
+
function batchArgumentsFor(batchArgs: Record<string, unknown>, call: Record<string, unknown>): Record<string, unknown> {
|
|
2014
|
+
return { ...argBag(call.arguments), store: batchArgs.store, ...(batchArgs.image !== undefined ? { image: batchArgs.image } : {}) };
|
|
2015
|
+
}
|
|
2016
|
+
|
|
2017
|
+
/**
|
|
2018
|
+
* THE ONE PER-VERB VALIDATOR DISPATCH. Both the outer gate and (once it lands)
|
|
2019
|
+
* the batch pre-validator call THIS function, never the individual validators
|
|
2020
|
+
* directly, so a verb cannot be validated on one route and waved through on the
|
|
2021
|
+
* other. `batchIndex` is `undefined` for a direct call and the offending index
|
|
2022
|
+
* for a batch entry; every refusal message interpolates it.
|
|
2023
|
+
*/
|
|
2024
|
+
function assertVerbArgs(name: string, args: unknown, batchIndex?: number): void {
|
|
2025
|
+
if (name === "anno_get_symbols") return assertGetSymbolsArgs(args, batchIndex);
|
|
2026
|
+
if (name === "anno_set_label_name") return assertSetLabelArgs(args, batchIndex);
|
|
2027
|
+
if (name === "anno_set_comment") return assertSetCommentArgs(args, batchIndex);
|
|
2028
|
+
if (name === "anno_set_data_type") return assertSetDataTypeArgs(args, batchIndex);
|
|
2029
|
+
if (name === "anno_add_scope") return assertScopeArgs("anno_add_scope", args, batchIndex);
|
|
2030
|
+
if (name === "anno_remove_scope") return assertScopeArgs("anno_remove_scope", args, batchIndex);
|
|
2031
|
+
if (name === "anno_exclude_range") return assertExcludedRangeArgs("anno_exclude_range", args, batchIndex);
|
|
2032
|
+
if (name === "anno_include_range") return assertExcludedRangeArgs("anno_include_range", args, batchIndex);
|
|
2033
|
+
if (name === "anno_get_comments") return assertGetCommentsArgs(args, batchIndex);
|
|
2034
|
+
if (name === "anno_get_blocks") return assertGetBlocksArgs(args, batchIndex);
|
|
2035
|
+
if (name === "anno_create_project_enum") return assertCreateEnumArgs(args, batchIndex);
|
|
2036
|
+
if (name === "anno_update_project_enum") return assertUpdateEnumArgs(args, batchIndex);
|
|
2037
|
+
if (name === "anno_apply_enum_usage") return assertApplyEnumUsageArgs(args, batchIndex);
|
|
2038
|
+
if (name === "anno_save_project") return assertSaveProjectArgs(args, batchIndex);
|
|
2039
|
+
if (name === "anno_import_ghidra_export") return assertImportGhidraExportArgs(args, batchIndex);
|
|
2040
|
+
if (name === "anno_join_memmap") return assertJoinMemmapArgs(args, batchIndex);
|
|
2041
|
+
if (name === "anno_evid_ingest") return assertEvidIngestArgs(args, batchIndex);
|
|
2042
|
+
if (name === "anno_evid_disagreements") return assertEvidDisagreementsArgs(args, batchIndex);
|
|
2043
|
+
if (name === "anno_evid_runs") return assertEvidRunsArgs(args, batchIndex);
|
|
2044
|
+
if (name === "anno_evid_reset") return assertEvidResetArgs(args, batchIndex);
|
|
2045
|
+
if (name === "anno_hazard_report") return assertHazardReportArgs(args, batchIndex);
|
|
2046
|
+
if (name === "anno_disassemble") return assertDisassembleArgs(args, batchIndex);
|
|
2047
|
+
if (name === "anno_read_region") return assertReadRegionArgs(args, batchIndex);
|
|
2048
|
+
if (name === "anno_get_binary_info") return assertBinaryInfoArgs(args, batchIndex);
|
|
2049
|
+
if (name === "anno_get_cross_references") return assertCrossReferencesArgs(args, batchIndex);
|
|
2050
|
+
if (name === "anno_search") return assertSearchArgs(args, batchIndex);
|
|
2051
|
+
if (name === "anno_get_address_details") return assertAddressDetailsArgs(args, batchIndex);
|
|
2052
|
+
if (name === "anno_batch_execute") return assertAnnoBatch(args);
|
|
2053
|
+
// Every curated verb has an arm above. A curated name reaching here is a bug
|
|
2054
|
+
// in THIS file, and saying so by name is cheaper than a validator silently
|
|
2055
|
+
// accepting a payload nobody checked.
|
|
2056
|
+
throw new AnnoUncuratedToolError(
|
|
2057
|
+
`"${name}" is curated but has no argument validator in anno-tools.ts. Resolution routes: add one to ` +
|
|
2058
|
+
"assertVerbArgs, or remove the definition.",
|
|
2059
|
+
{ toolName: name, batchIndex },
|
|
2060
|
+
);
|
|
2061
|
+
}
|
|
2062
|
+
|
|
2063
|
+
/**
|
|
2064
|
+
* The allow-list gate. Its body's FIRST check is set membership (see WHAT NOT
|
|
2065
|
+
* TO DO above, and the same confused-deputy precedent inverted into an
|
|
2066
|
+
* allow-list): a `name` outside `CURATED_ANNO_TOOLS` is refused outright,
|
|
2067
|
+
* before any argument is inspected, so an unknown verb can never reach a
|
|
2068
|
+
* validator that might coincidentally accept its payload. Only then are the
|
|
2069
|
+
* named verb's own arguments checked.
|
|
2070
|
+
*/
|
|
2071
|
+
export function assertAnnoTool(name: string, args?: unknown): void {
|
|
2072
|
+
if (!CURATED_ANNO_TOOLS.includes(name)) {
|
|
2073
|
+
throw new AnnoUncuratedToolError(
|
|
2074
|
+
`"${name}" is not part of the curated anno_* tool surface. Resolution routes: implement it and ` +
|
|
2075
|
+
"add it to ANNO_TOOL_DEFINITIONS with a named criterion, or remove the caller reference.",
|
|
2076
|
+
{ toolName: name },
|
|
2077
|
+
);
|
|
2078
|
+
}
|
|
2079
|
+
assertVerbArgs(name, args);
|
|
2080
|
+
}
|
|
2081
|
+
|
|
2082
|
+
// ---------------------------------------------------------------------------
|
|
2083
|
+
// Workspace path validation (T-29-01). The same posture `anno-tools.ts` took
|
|
2084
|
+
// for a caller-supplied project path and `stock-symbols.ts` takes for a `.lbl`
|
|
2085
|
+
// file: an LLM-supplied path reaching the filesystem. Resolved against
|
|
2086
|
+
// `repoRoot()` through `storePathWithinWorkspace()`, which carries WR-01's
|
|
2087
|
+
// finding -- containment is enforced against the deepest EXISTING ancestor's
|
|
2088
|
+
// realpath, so a not-yet-existing leaf under a directory symlink cannot slip
|
|
2089
|
+
// past by way of an ENOENT fallback to the literal path.
|
|
2090
|
+
//
|
|
2091
|
+
// The STORE path and the IMAGE path go through the SAME helper. They are two
|
|
2092
|
+
// LLM-supplied paths with one containment rule, and giving the image its own
|
|
2093
|
+
// rule would be a second answer to the one question this function answers once.
|
|
2094
|
+
//
|
|
2095
|
+
// `repoRoot()` is called at DISPATCH time, never frozen at module load, for
|
|
2096
|
+
// the same reason the region cap's override is read at call time: one
|
|
2097
|
+
// `node --test` process can then point several different workspace roots at
|
|
2098
|
+
// this code within a single run.
|
|
2099
|
+
// ---------------------------------------------------------------------------
|
|
2100
|
+
|
|
2101
|
+
function resolveWorkspacePath(raw: string): string {
|
|
2102
|
+
return storePathWithinWorkspace(raw, repoRoot());
|
|
2103
|
+
}
|
|
2104
|
+
|
|
2105
|
+
function resolveStoreArg(name: string, args: unknown): string {
|
|
2106
|
+
return resolveWorkspacePath(assertStoreArg(name, args));
|
|
2107
|
+
}
|
|
2108
|
+
|
|
2109
|
+
// ---------------------------------------------------------------------------
|
|
2110
|
+
// "GONE" AND "EMPTY" MUST NOT READ THE SAME, ON THE WRITE PATH TOO.
|
|
2111
|
+
//
|
|
2112
|
+
// `openStore`'s `mustExist` option bundles two inseparable halves -- refuse an
|
|
2113
|
+
// absent path, AND open the connection `readOnly` -- because it exists to judge
|
|
2114
|
+
// a file the caller is about to install, and a judge that can modify what it
|
|
2115
|
+
// judges is not a judge. That bundling is right for its purpose and wrong for
|
|
2116
|
+
// this one: a write verb needs the refusal WITHOUT the read-only open, and
|
|
2117
|
+
// there is no third state to ask `openStore` for.
|
|
2118
|
+
//
|
|
2119
|
+
// So the refusal is made HERE, by name, before the connection is constructed,
|
|
2120
|
+
// and the residual window that `mustExist`'s read-only open would otherwise
|
|
2121
|
+
// have closed is closed by INODE IDENTITY instead. The window is real: between
|
|
2122
|
+
// the existence check and the constructor the file can be unlinked, after
|
|
2123
|
+
// which a writable open CREATES it and the verb writes into a store it
|
|
2124
|
+
// invented, reporting success. Comparing the inode across the open detects
|
|
2125
|
+
// exactly that -- an unlinked-and-recreated file is a different inode -- and
|
|
2126
|
+
// turns an invented store into a named refusal.
|
|
2127
|
+
// ---------------------------------------------------------------------------
|
|
2128
|
+
|
|
2129
|
+
/** The verbs that only READ. They get `openStore`'s `mustExist` (and therefore
|
|
2130
|
+
* its read-only connection), which is strictly the safer open; every other verb
|
|
2131
|
+
* takes the existence-check-plus-inode-guard route below. Derived from nothing
|
|
2132
|
+
* -- it is a hand-listed property of each verb, and a verb missing from here is
|
|
2133
|
+
* merely opened writably, never wrongly refused. */
|
|
2134
|
+
export const READ_ONLY_ANNO_VERBS: readonly string[] = Object.freeze([
|
|
2135
|
+
"anno_get_symbols",
|
|
2136
|
+
"anno_get_comments",
|
|
2137
|
+
"anno_get_blocks",
|
|
2138
|
+
"anno_save_project",
|
|
2139
|
+
"anno_disassemble",
|
|
2140
|
+
"anno_read_region",
|
|
2141
|
+
"anno_get_binary_info",
|
|
2142
|
+
"anno_get_cross_references",
|
|
2143
|
+
"anno_search",
|
|
2144
|
+
"anno_get_address_details",
|
|
2145
|
+
"anno_evid_disagreements",
|
|
2146
|
+
"anno_evid_runs",
|
|
2147
|
+
"anno_hazard_report",
|
|
2148
|
+
]);
|
|
2149
|
+
|
|
2150
|
+
/** Refuses an absent store BY NAME, returning the inode the later guard
|
|
2151
|
+
* compares against. A write verb must never CREATE the file it was asked to
|
|
2152
|
+
* annotate: "the annotations are gone" and "there are no annotations" are
|
|
2153
|
+
* different facts and must not read the same. */
|
|
2154
|
+
function assertStorePresent(name: string, storePath: string): number {
|
|
2155
|
+
if (!existsSync(storePath)) {
|
|
2156
|
+
throw new AnnoStorePathError(
|
|
2157
|
+
`${name} refused: no annotation store exists at ${JSON.stringify(storePath)} -- refusing to CREATE one, because "the ` +
|
|
2158
|
+
'annotations are gone" and "there are no annotations" must not read the same. Create the store deliberately first.',
|
|
2159
|
+
{ path: storePath },
|
|
2160
|
+
);
|
|
2161
|
+
}
|
|
2162
|
+
return statSync(storePath).ino;
|
|
2163
|
+
}
|
|
2164
|
+
|
|
2165
|
+
/** Closes the window between the existence check and the open. */
|
|
2166
|
+
function assertSameFile(name: string, storePath: string, inodeBefore: number): void {
|
|
2167
|
+
if (statSync(storePath).ino !== inodeBefore) {
|
|
2168
|
+
throw new AnnoStorePathError(
|
|
2169
|
+
`${name} refused: the file at ${JSON.stringify(storePath)} was replaced between the existence check and the open, so this ` +
|
|
2170
|
+
"call would have written into a store it created itself rather than the one it was asked to annotate. Nothing was written.",
|
|
2171
|
+
{ path: storePath },
|
|
2172
|
+
);
|
|
2173
|
+
}
|
|
2174
|
+
}
|
|
2175
|
+
|
|
2176
|
+
// ---------------------------------------------------------------------------
|
|
2177
|
+
// The dispatch table. Each dispatcher receives an ALREADY-OPEN handle it does
|
|
2178
|
+
// not own: opening and closing are `runAnnoTool`'s job and only
|
|
2179
|
+
// `runAnnoTool`'s, so there is exactly one `finally` in this module to get
|
|
2180
|
+
// right rather than one per verb.
|
|
2181
|
+
//
|
|
2182
|
+
// Every dispatcher surfaces `changed` from its `AnnoWriteResult` and NEVER maps
|
|
2183
|
+
// `changed: false` to an error.
|
|
2184
|
+
// ---------------------------------------------------------------------------
|
|
2185
|
+
|
|
2186
|
+
function dispatchGetSymbols(handle: AnnoStoreHandle, args: unknown): unknown {
|
|
2187
|
+
const maxResults = assertMaxResults("anno_get_symbols", args);
|
|
2188
|
+
const bag = argBag(args);
|
|
2189
|
+
const start = bag.start_address !== undefined ? parseStoreAddress(bag.start_address, { what: "start_address" }) : undefined;
|
|
2190
|
+
const end = bag.end_address !== undefined ? parseStoreAddress(bag.end_address, { what: "end_address" }) : undefined;
|
|
2191
|
+
|
|
2192
|
+
const all: LabelRow[] = listLabels(handle);
|
|
2193
|
+
const matched = all.filter((row) => {
|
|
2194
|
+
if (start !== undefined && row.address < start) return false;
|
|
2195
|
+
if (end !== undefined && row.address > end) return false;
|
|
2196
|
+
return true;
|
|
2197
|
+
});
|
|
2198
|
+
const symbols = matched.slice(0, maxResults);
|
|
2199
|
+
// `truncated` is reported rather than left for the caller to infer from a
|
|
2200
|
+
// count that happens to equal its own ceiling -- the ceiling being hit and
|
|
2201
|
+
// the answer being complete-at-exactly-the-ceiling are different facts.
|
|
2202
|
+
return { store: handle.path, symbols, returned: symbols.length, matched: matched.length, truncated: matched.length > symbols.length };
|
|
2203
|
+
}
|
|
2204
|
+
|
|
2205
|
+
function dispatchSetLabelName(handle: AnnoStoreHandle, args: unknown): unknown {
|
|
2206
|
+
const bag = argBag(args);
|
|
2207
|
+
const written = setLabel(handle, {
|
|
2208
|
+
address: bag.address as number | string,
|
|
2209
|
+
name: bag.name,
|
|
2210
|
+
// 'User' is the default because a name arriving through this surface was
|
|
2211
|
+
// chosen by whoever made the call; an unstated provenance is a human's.
|
|
2212
|
+
kind: bag.kind === undefined ? "User" : bag.kind,
|
|
2213
|
+
baseRevision: assertBaseRevisionArg("anno_set_label_name", args),
|
|
2214
|
+
});
|
|
2215
|
+
return { store: handle.path, address: parseStoreAddress(bag.address, { what: "address" }), name: bag.name, kind: bag.kind ?? "User", ...written };
|
|
2216
|
+
}
|
|
2217
|
+
|
|
2218
|
+
function dispatchSetComment(handle: AnnoStoreHandle, args: unknown): unknown {
|
|
2219
|
+
const bag = argBag(args);
|
|
2220
|
+
const written = setComment(handle, {
|
|
2221
|
+
address: bag.address as number | string,
|
|
2222
|
+
commentType: bag.type,
|
|
2223
|
+
text: bag.comment,
|
|
2224
|
+
baseRevision: assertBaseRevisionArg("anno_set_comment", args),
|
|
2225
|
+
});
|
|
2226
|
+
return { store: handle.path, address: parseStoreAddress(bag.address, { what: "address" }), type: bag.type, ...written };
|
|
2227
|
+
}
|
|
2228
|
+
|
|
2229
|
+
function dispatchSetDataType(handle: AnnoStoreHandle, args: unknown): unknown {
|
|
2230
|
+
const bag = argBag(args);
|
|
2231
|
+
const written = setDataType(handle, {
|
|
2232
|
+
start: bag.start_address as number | string,
|
|
2233
|
+
endInclusive: bag.end_address as number | string,
|
|
2234
|
+
dataType: bag.data_type,
|
|
2235
|
+
baseRevision: assertBaseRevisionArg("anno_set_data_type", args),
|
|
2236
|
+
});
|
|
2237
|
+
// BOTH disclosures ride out on the SUCCESSFUL body, as named top-level
|
|
2238
|
+
// fields, every time -- including when they are empty, so "this write
|
|
2239
|
+
// contradicted nothing" is a fact the caller is told rather than the absence
|
|
2240
|
+
// of a field it has to know to look for. This is 28-VERIFICATION.md's F-4
|
|
2241
|
+
// obligation, discharged at the layer the human actually reads.
|
|
2242
|
+
return {
|
|
2243
|
+
store: handle.path,
|
|
2244
|
+
start_address: parseStoreAddress(bag.start_address, { what: "start_address" }),
|
|
2245
|
+
end_address: parseStoreAddress(bag.end_address, { what: "end_address" }),
|
|
2246
|
+
data_type: bag.data_type,
|
|
2247
|
+
revision: written.revision,
|
|
2248
|
+
changed: written.changed,
|
|
2249
|
+
contradictedComments: written.contradictedComments,
|
|
2250
|
+
reinterpretedSplitTables: written.reinterpretedSplitTables,
|
|
2251
|
+
};
|
|
2252
|
+
}
|
|
2253
|
+
|
|
2254
|
+
function dispatchScope(name: string, handle: AnnoStoreHandle, args: unknown): unknown {
|
|
2255
|
+
const bag = argBag(args);
|
|
2256
|
+
const span = {
|
|
2257
|
+
start: bag.start_address as number | string,
|
|
2258
|
+
endInclusive: bag.end_address as number | string,
|
|
2259
|
+
baseRevision: assertBaseRevisionArg(name, args),
|
|
2260
|
+
};
|
|
2261
|
+
const written = name === "anno_add_scope" ? addScope(handle, span) : removeScope(handle, span);
|
|
2262
|
+
return {
|
|
2263
|
+
store: handle.path,
|
|
2264
|
+
start_address: parseStoreAddress(bag.start_address, { what: "start_address" }),
|
|
2265
|
+
end_address: parseStoreAddress(bag.end_address, { what: "end_address" }),
|
|
2266
|
+
...written,
|
|
2267
|
+
scopes: listScopes(handle),
|
|
2268
|
+
};
|
|
2269
|
+
}
|
|
2270
|
+
|
|
2271
|
+
/** One dispatcher serving `anno_exclude_range` / `anno_include_range`,
|
|
2272
|
+
* modelled on `dispatchScope()`. `excludedRanges` rides on EVERY successful
|
|
2273
|
+
* body, including when it is empty, for the same reason `dispatchSetDataType`'s
|
|
2274
|
+
* own disclosures do: the resulting state is a fact the caller is told, not
|
|
2275
|
+
* the absence of a field it has to know to look for. */
|
|
2276
|
+
function dispatchExcludedRange(name: string, handle: AnnoStoreHandle, args: unknown): unknown {
|
|
2277
|
+
const bag = argBag(args);
|
|
2278
|
+
const span = {
|
|
2279
|
+
start: bag.start_address as number | string,
|
|
2280
|
+
endInclusive: bag.end_address as number | string,
|
|
2281
|
+
baseRevision: assertBaseRevisionArg(name, args),
|
|
2282
|
+
};
|
|
2283
|
+
const written =
|
|
2284
|
+
name === "anno_exclude_range"
|
|
2285
|
+
? addExcludedRange(handle, { ...span, reason: bag.reason as string })
|
|
2286
|
+
: removeExcludedRange(handle, span);
|
|
2287
|
+
return {
|
|
2288
|
+
store: handle.path,
|
|
2289
|
+
start_address: parseStoreAddress(bag.start_address, { what: "start_address" }),
|
|
2290
|
+
end_address: parseStoreAddress(bag.end_address, { what: "end_address" }),
|
|
2291
|
+
...written,
|
|
2292
|
+
excludedRanges: listExcludedRanges(handle),
|
|
2293
|
+
};
|
|
2294
|
+
}
|
|
2295
|
+
|
|
2296
|
+
function dispatchGetComments(handle: AnnoStoreHandle, args: unknown): unknown {
|
|
2297
|
+
const maxResults = assertMaxResults("anno_get_comments", args);
|
|
2298
|
+
const bag = argBag(args);
|
|
2299
|
+
const wanted =
|
|
2300
|
+
bag.addresses === undefined ? undefined : new Set((bag.addresses as unknown[]).map((entry) => parseStoreAddress(entry, { what: "addresses[]" })));
|
|
2301
|
+
const start = bag.start_address !== undefined ? parseStoreAddress(bag.start_address, { what: "start_address" }) : undefined;
|
|
2302
|
+
const end = bag.end_address !== undefined ? parseStoreAddress(bag.end_address, { what: "end_address" }) : undefined;
|
|
2303
|
+
const type = bag.type !== undefined ? assertCommentType(bag.type) : undefined;
|
|
2304
|
+
|
|
2305
|
+
const all: CommentRow[] = listComments(handle);
|
|
2306
|
+
const matched = all.filter((row) => {
|
|
2307
|
+
if (wanted !== undefined && !wanted.has(row.address)) return false;
|
|
2308
|
+
if (start !== undefined && row.address < start) return false;
|
|
2309
|
+
if (end !== undefined && row.address > end) return false;
|
|
2310
|
+
if (type !== undefined && row.commentType !== type) return false;
|
|
2311
|
+
return true;
|
|
2312
|
+
});
|
|
2313
|
+
const comments = matched.slice(0, maxResults);
|
|
2314
|
+
return { store: handle.path, comments, returned: comments.length, matched: matched.length, truncated: matched.length > comments.length };
|
|
2315
|
+
}
|
|
2316
|
+
|
|
2317
|
+
function dispatchGetBlocks(handle: AnnoStoreHandle, args: unknown): unknown {
|
|
2318
|
+
const maxResults = assertMaxResults("anno_get_blocks", args);
|
|
2319
|
+
const bag = argBag(args);
|
|
2320
|
+
const blockType = bag.block_type !== undefined ? assertDataType(bag.block_type) : undefined;
|
|
2321
|
+
const include = new Set((Array.isArray(bag.include) ? bag.include : []) as string[]);
|
|
2322
|
+
|
|
2323
|
+
const matched = listRanges(handle).filter((row) => blockType === undefined || row.dataType === blockType);
|
|
2324
|
+
const blocks = matched.slice(0, maxResults);
|
|
2325
|
+
return {
|
|
2326
|
+
store: handle.path,
|
|
2327
|
+
blocks,
|
|
2328
|
+
returned: blocks.length,
|
|
2329
|
+
matched: matched.length,
|
|
2330
|
+
truncated: matched.length > blocks.length,
|
|
2331
|
+
...(include.has("scopes") ? { scopes: listScopes(handle) } : {}),
|
|
2332
|
+
...(include.has("enums") ? { enums: listProjectEnums(handle) } : {}),
|
|
2333
|
+
...(include.has("enum_usage") ? { enum_usage: listEnumUsage(handle) } : {}),
|
|
2334
|
+
};
|
|
2335
|
+
}
|
|
2336
|
+
|
|
2337
|
+
function dispatchCreateProjectEnum(handle: AnnoStoreHandle, args: unknown): unknown {
|
|
2338
|
+
const bag = argBag(args);
|
|
2339
|
+
const written = createProjectEnum(handle, {
|
|
2340
|
+
name: bag.name,
|
|
2341
|
+
variants: bag.variants,
|
|
2342
|
+
description: bag.description,
|
|
2343
|
+
baseRevision: assertBaseRevisionArg("anno_create_project_enum", args),
|
|
2344
|
+
});
|
|
2345
|
+
return { store: handle.path, name: bag.name, ...written, enums: listProjectEnums(handle) };
|
|
2346
|
+
}
|
|
2347
|
+
|
|
2348
|
+
function dispatchUpdateProjectEnum(handle: AnnoStoreHandle, args: unknown): unknown {
|
|
2349
|
+
const bag = argBag(args);
|
|
2350
|
+
const written = updateProjectEnum(handle, {
|
|
2351
|
+
name: bag.name,
|
|
2352
|
+
newName: bag.new_name,
|
|
2353
|
+
variants: bag.variants,
|
|
2354
|
+
description: bag.description,
|
|
2355
|
+
baseRevision: assertBaseRevisionArg("anno_update_project_enum", args),
|
|
2356
|
+
});
|
|
2357
|
+
return { store: handle.path, name: bag.new_name ?? bag.name, ...written, enums: listProjectEnums(handle) };
|
|
2358
|
+
}
|
|
2359
|
+
|
|
2360
|
+
function dispatchApplyEnumUsage(handle: AnnoStoreHandle, args: unknown): unknown {
|
|
2361
|
+
const bag = argBag(args);
|
|
2362
|
+
const baseRevision = assertBaseRevisionArg("anno_apply_enum_usage", args);
|
|
2363
|
+
const cleared = isEnumUsageClear(args);
|
|
2364
|
+
const written = cleared
|
|
2365
|
+
? clearEnumUsage(handle, { address: bag.address as number | string, baseRevision })
|
|
2366
|
+
: applyEnumUsage(handle, { address: bag.address as number | string, name: bag.name, baseRevision });
|
|
2367
|
+
return {
|
|
2368
|
+
store: handle.path,
|
|
2369
|
+
address: parseStoreAddress(bag.address, { what: "address" }),
|
|
2370
|
+
name: cleared ? null : bag.name,
|
|
2371
|
+
cleared,
|
|
2372
|
+
...written,
|
|
2373
|
+
enum_usage: listEnumUsage(handle),
|
|
2374
|
+
};
|
|
2375
|
+
}
|
|
2376
|
+
|
|
2377
|
+
/** THE HONEST SAVE. It opens (through the runner), reads the revision, and
|
|
2378
|
+
* closes. It writes NOTHING, and the body says so in its own words rather than
|
|
2379
|
+
* leaving the caller to infer durability from an empty success. `curated` in
|
|
2380
|
+
* the manifest means a route is required; returning `{available:false}` was
|
|
2381
|
+
* rejected, because a permanent refusal for a curated disposition is what the
|
|
2382
|
+
* `omit` disposition is for and the manifest does not say `omit`.
|
|
2383
|
+
*
|
|
2384
|
+
* THE REVISION IS READ EXACTLY ONCE, into a `const`, and that single value
|
|
2385
|
+
* feeds both the returned field and the note's prose. This is the one verb
|
|
2386
|
+
* whose output a caller is TOLD to use as a `base_revision` compare-and-swap
|
|
2387
|
+
* guard, so a field and a prose that could name different revisions is a guard
|
|
2388
|
+
* built on a number its own note contradicts -- and a guard nobody can trust is
|
|
2389
|
+
* worse than no guard, because it is acted on (WR-10). Two reads agreeing is an
|
|
2390
|
+
* accident of when they ran; one read agreeing with itself is a property. */
|
|
2391
|
+
function dispatchSaveProject(handle: AnnoStoreHandle): unknown {
|
|
2392
|
+
const revision = currentRevision(handle);
|
|
2393
|
+
return {
|
|
2394
|
+
store: handle.path,
|
|
2395
|
+
revision,
|
|
2396
|
+
wrote: false,
|
|
2397
|
+
note:
|
|
2398
|
+
"This verb performed NO write. Every mutating verb on this surface commits and fsyncs its own write before it " +
|
|
2399
|
+
"returns, so the store was already durable at revision " +
|
|
2400
|
+
String(revision) +
|
|
2401
|
+
" when this call arrived and there was nothing for an explicit save to flush. The revision is reported so it can " +
|
|
2402
|
+
"be used as a base_revision compare-and-swap guard on a later write.",
|
|
2403
|
+
};
|
|
2404
|
+
}
|
|
2405
|
+
|
|
2406
|
+
/** Enforces `base_revision` as a whole-call precondition rather than
|
|
2407
|
+
* threading it through each of the many writes `importGhidraExport()` and
|
|
2408
|
+
* `runMemmapJoin()` may issue: both verbs commit several writes per call, and
|
|
2409
|
+
* a single up-front comparison against the revision the caller computed its
|
|
2410
|
+
* batch against is the coherent point to apply an optimistic-concurrency
|
|
2411
|
+
* guard for a multi-write verb -- checked BEFORE anything is written, exactly
|
|
2412
|
+
* like every other refusal on this surface. */
|
|
2413
|
+
function assertNotStale(name: string, handle: AnnoStoreHandle, baseRevision: number | undefined): void {
|
|
2414
|
+
if (baseRevision === undefined) return;
|
|
2415
|
+
const rev = currentRevision(handle);
|
|
2416
|
+
if (baseRevision !== rev) {
|
|
2417
|
+
throw new AnnoStoreStaleRevisionError(
|
|
2418
|
+
`${name} refused: base revision ${baseRevision} is not the current on-disk revision ${rev}. Nothing was written.`,
|
|
2419
|
+
{ baseRevision, currentRevision: rev },
|
|
2420
|
+
);
|
|
2421
|
+
}
|
|
2422
|
+
}
|
|
2423
|
+
|
|
2424
|
+
function dispatchImportGhidraExport(handle: AnnoStoreHandle, args: unknown): unknown {
|
|
2425
|
+
const bag = argBag(args);
|
|
2426
|
+
const baseRevision = assertBaseRevisionArg("anno_import_ghidra_export", args);
|
|
2427
|
+
assertNotStale("anno_import_ghidra_export", handle, baseRevision);
|
|
2428
|
+
const exportPath = resolveExportPathArg(bag.export_path as string);
|
|
2429
|
+
return importGhidraExport(handle, {
|
|
2430
|
+
exportPath,
|
|
2431
|
+
expectedSha256: bag.sha256 as string | undefined,
|
|
2432
|
+
});
|
|
2433
|
+
}
|
|
2434
|
+
|
|
2435
|
+
/** `resolveWorkspacePath()` itself, never a second hand-rolled resolve-and-
|
|
2436
|
+
* prefix-test (T-37-01) -- but its underlying `AnnoStorePathError` message
|
|
2437
|
+
* says "store path ... is outside the workspace root", unaware of which
|
|
2438
|
+
* higher-level argument it was protecting, because `store` and `image` both
|
|
2439
|
+
* reuse the same generic wording. Wrapped here so a refusal on `export_path`
|
|
2440
|
+
* NAMES the argument rather than reading identically to a `store` refusal. */
|
|
2441
|
+
function resolveExportPathArg(raw: string): string {
|
|
2442
|
+
try {
|
|
2443
|
+
return resolveWorkspacePath(raw);
|
|
2444
|
+
} catch (err) {
|
|
2445
|
+
if (err instanceof AnnoStorePathError) {
|
|
2446
|
+
throw new AnnoStorePathError(`anno_import_ghidra_export refused: export_path ${err.message}`, {
|
|
2447
|
+
path: err.path,
|
|
2448
|
+
workspaceRoot: err.workspaceRoot,
|
|
2449
|
+
});
|
|
2450
|
+
}
|
|
2451
|
+
throw err;
|
|
2452
|
+
}
|
|
2453
|
+
}
|
|
2454
|
+
|
|
2455
|
+
function dispatchJoinMemmap(handle: AnnoStoreHandle, args: unknown): unknown {
|
|
2456
|
+
const baseRevision = assertBaseRevisionArg("anno_join_memmap", args);
|
|
2457
|
+
assertNotStale("anno_join_memmap", handle, baseRevision);
|
|
2458
|
+
const image = loadImage("anno_join_memmap", args);
|
|
2459
|
+
// CR-01 fix: `const_writes`/`graphics_map_index` are threaded into
|
|
2460
|
+
// `runMemmapJoin()` exactly as its own `RunMemmapJoinArgs` documents --
|
|
2461
|
+
// OMISSION (not `[]`) is what keeps every pre-existing call (no
|
|
2462
|
+
// const_writes at all) a byte-identical no-op for the bank-state and
|
|
2463
|
+
// graphics machinery.
|
|
2464
|
+
const constWrites = assertConstWritesArg("anno_join_memmap", args);
|
|
2465
|
+
const graphicsMapIndex = assertGraphicsMapIndexArg("anno_join_memmap", args);
|
|
2466
|
+
return runMemmapJoin(handle, {
|
|
2467
|
+
imageOrigin: image.origin,
|
|
2468
|
+
imageByteLength: image.body.length,
|
|
2469
|
+
...(constWrites !== undefined ? { constWrites } : {}),
|
|
2470
|
+
...(graphicsMapIndex !== undefined ? { graphicsMapIndex } : {}),
|
|
2471
|
+
});
|
|
2472
|
+
}
|
|
2473
|
+
|
|
2474
|
+
/**
|
|
2475
|
+
* `anno_evid_ingest`'s dispatch arm (EVID-01, EVID-04, plan 43-05). Calls
|
|
2476
|
+
* `parseAccessMap()` -- THE ONE PARSE -- then `ingestAccessMap()` from
|
|
2477
|
+
* `evid-ingest.ts`; on a refusal it throws inside the `ViceError` family
|
|
2478
|
+
* (never absorbs a drifted reply, T-43-22); on success it writes the WHOLE
|
|
2479
|
+
* observation array through ONE `insertExecObservations()` call, so the
|
|
2480
|
+
* write is one transaction through the store's single commit site
|
|
2481
|
+
* (T-43-26). `observationsWritten` is `insertExecObservations()`'s own
|
|
2482
|
+
* `insertedCount` (WR-02) -- counted row-by-row INSIDE that same
|
|
2483
|
+
* transaction, never from a separate pre-write read -- not the size of the
|
|
2484
|
+
* array handed in: re-ingesting the identical reply must report
|
|
2485
|
+
* `observationsWritten: 0` even though the same-shaped array was passed
|
|
2486
|
+
* again.
|
|
2487
|
+
*
|
|
2488
|
+
* `denominator` travels beside every count this answer reports
|
|
2489
|
+
* (`addressesQueried`, the parsed map's own projection) -- a bare
|
|
2490
|
+
* `observationsWritten` would invite the reading "the rest is data", which
|
|
2491
|
+
* is why the denominator is never omitted. No percentage is ever formed
|
|
2492
|
+
* here.
|
|
2493
|
+
*/
|
|
2494
|
+
function dispatchEvidIngest(handle: AnnoStoreHandle, args: unknown): unknown {
|
|
2495
|
+
const bag = argBag(args);
|
|
2496
|
+
const baseRevision = assertBaseRevisionArg("anno_evid_ingest", args);
|
|
2497
|
+
|
|
2498
|
+
const parsed = parseAccessMap(bag.memmap_text as string);
|
|
2499
|
+
const identity: IngestRunIdentity = {
|
|
2500
|
+
imageSha256: bag.image_sha256 as string,
|
|
2501
|
+
argv: bag.argv as string[],
|
|
2502
|
+
seed: bag.seed as string,
|
|
2503
|
+
};
|
|
2504
|
+
const ingested = ingestAccessMap(parsed, identity);
|
|
2505
|
+
if (!ingested.ok) {
|
|
2506
|
+
throw new AnnoToolArgumentError(`anno_evid_ingest refused: ${ingested.message}`, {
|
|
2507
|
+
toolName: "anno_evid_ingest",
|
|
2508
|
+
argument: "memmap_text",
|
|
2509
|
+
});
|
|
2510
|
+
}
|
|
2511
|
+
|
|
2512
|
+
const ranges = parsed.ok ? accessMapRanges(parsed.value) : undefined;
|
|
2513
|
+
const addressesWithRecordedAccess = ranges?.addressesWithRecordedAccess ?? 0;
|
|
2514
|
+
const addressesQueried = ranges?.addressesQueried ?? 0;
|
|
2515
|
+
|
|
2516
|
+
// A reply that recorded no execution anywhere is a real, legitimate
|
|
2517
|
+
// answer -- not an error -- but `insertExecObservations` refuses an EMPTY
|
|
2518
|
+
// observations array, so that zero-write case is reported directly here
|
|
2519
|
+
// rather than calling a store function built to refuse it. WR-01: it is
|
|
2520
|
+
// still routed through `applyWrite()` with a no-op mutator (rather than
|
|
2521
|
+
// returning early on `currentRevision(handle)` alone) so a stale
|
|
2522
|
+
// `base_revision` is refused on THIS path exactly as it would be on the
|
|
2523
|
+
// non-empty path below -- every other write verb in this store enforces
|
|
2524
|
+
// staleness through `applyWrite()`'s own check, and a caller relying on
|
|
2525
|
+
// that contract must not get a silent success here instead.
|
|
2526
|
+
if (ingested.observations.length === 0) {
|
|
2527
|
+
const { revision } = applyWrite(handle, () => false, { baseRevision });
|
|
2528
|
+
return {
|
|
2529
|
+
store: handle.path,
|
|
2530
|
+
revision,
|
|
2531
|
+
changed: false,
|
|
2532
|
+
observationsWritten: 0,
|
|
2533
|
+
addressesWithRecordedAccess,
|
|
2534
|
+
addressesQueried,
|
|
2535
|
+
denominator: addressesQueried,
|
|
2536
|
+
};
|
|
2537
|
+
}
|
|
2538
|
+
|
|
2539
|
+
// WR-02: `observationsWritten` is the COUNT `insertExecObservations()`
|
|
2540
|
+
// itself returns, counted row-by-row INSIDE its own `applyWrite`
|
|
2541
|
+
// transaction -- never a `listExecObservations()` read taken before that
|
|
2542
|
+
// transaction opens. A separately-derived pre-read can be overtaken by a
|
|
2543
|
+
// concurrent writer to the same run identity between the read and this
|
|
2544
|
+
// call's own commit, overstating how many rows THIS call actually added;
|
|
2545
|
+
// counting inside the transaction that performs the insert is the one
|
|
2546
|
+
// place this number can be exact.
|
|
2547
|
+
const written = insertExecObservations(handle, {
|
|
2548
|
+
imageSha256: ingested.runIdentity.imageSha256,
|
|
2549
|
+
argvDigest: ingested.runIdentity.argvDigest,
|
|
2550
|
+
seed: ingested.runIdentity.seed,
|
|
2551
|
+
observations: ingested.observations.map((o) => ({ address: o.address, sourceBank: o.sourceBank })),
|
|
2552
|
+
baseRevision,
|
|
2553
|
+
});
|
|
2554
|
+
|
|
2555
|
+
return {
|
|
2556
|
+
store: handle.path,
|
|
2557
|
+
revision: written.revision,
|
|
2558
|
+
changed: written.changed,
|
|
2559
|
+
observationsWritten: written.insertedCount,
|
|
2560
|
+
addressesWithRecordedAccess,
|
|
2561
|
+
addressesQueried,
|
|
2562
|
+
denominator: addressesQueried,
|
|
2563
|
+
};
|
|
2564
|
+
}
|
|
2565
|
+
|
|
2566
|
+
/**
|
|
2567
|
+
* `anno_evid_disagreements`'s dispatch arm (EVID-03/EVID-04, plan 43-06).
|
|
2568
|
+
* Fetches BOTH sides HERE -- `listExecObservations()` and `listRanges()` --
|
|
2569
|
+
* so `reconcileObservedExecution()` (`evid-reconcile.ts`) is never handed a
|
|
2570
|
+
* store to open itself; that pure module's own header states it must never
|
|
2571
|
+
* fetch either side.
|
|
2572
|
+
*
|
|
2573
|
+
* The byte-derived ranges are mapped through `blocksFromStore()`, reached by
|
|
2574
|
+
* a LAZY `await import("./anno-cli.ts")` -- the same lazy pattern
|
|
2575
|
+
* `vice-proxy.ts:307` already uses to reach `runAnnoCli`, so this file's own
|
|
2576
|
+
* static import graph (and therefore the MCP server's startup cost) is
|
|
2577
|
+
* unchanged: `anno-cli.ts` drags in `anno-coverage.ts`, `anno-memmap-render.ts`
|
|
2578
|
+
* and `anno-export-asm.ts`, none of which this verb needs. The mapping
|
|
2579
|
+
* itself is NOT re-implemented here: a second `RangeRow` -> `BlockEntry` site
|
|
2580
|
+
* would be a second answer to "what class is this address", which is
|
|
2581
|
+
* exactly the boundary `block-class.ts` (and `blocksFromStore()`'s own
|
|
2582
|
+
* comment) exists to keep at one.
|
|
2583
|
+
*
|
|
2584
|
+
* `max_results` (optional, `assertOptionalMaxResults`) bounds the RETURNED
|
|
2585
|
+
* `disagreements` array only -- `agreementCount` and every other bucket are
|
|
2586
|
+
* already counts, never rows, so there is nothing else to truncate.
|
|
2587
|
+
* `reconciliation`'s own key order is preserved by spreading it before
|
|
2588
|
+
* re-assigning `disagreements`: JS does not move an existing key to the end
|
|
2589
|
+
* of an object literal on reassignment, so `disagreements` stays the FIRST
|
|
2590
|
+
* key after `store` (EVID-03).
|
|
2591
|
+
*/
|
|
2592
|
+
async function dispatchEvidDisagreements(handle: AnnoStoreHandle, args: unknown): Promise<unknown> {
|
|
2593
|
+
const maxResults = assertOptionalMaxResults("anno_evid_disagreements", args);
|
|
2594
|
+
const bag = argBag(args);
|
|
2595
|
+
const hasRunFilter = bag.image_sha256 !== undefined;
|
|
2596
|
+
const observations = listExecObservations(
|
|
2597
|
+
handle,
|
|
2598
|
+
hasRunFilter ? { imageSha256: bag.image_sha256, argvDigest: bag.argv_digest, seed: bag.seed } : {},
|
|
2599
|
+
);
|
|
2600
|
+
// Lazy, deliberately: see this function's own doc comment above for why a
|
|
2601
|
+
// static top-level import of anno-cli.ts must never appear in this file.
|
|
2602
|
+
const { blocksFromStore } = await import("./anno-cli.ts");
|
|
2603
|
+
const blocks = blocksFromStore(listRanges(handle));
|
|
2604
|
+
const reconciliation = reconcileObservedExecution({ blocks, observations });
|
|
2605
|
+
const disagreements = maxResults === undefined ? reconciliation.disagreements : reconciliation.disagreements.slice(0, maxResults);
|
|
2606
|
+
return {
|
|
2607
|
+
store: handle.path,
|
|
2608
|
+
...reconciliation,
|
|
2609
|
+
disagreements,
|
|
2610
|
+
returned: disagreements.length,
|
|
2611
|
+
matched: reconciliation.disagreements.length,
|
|
2612
|
+
truncated: reconciliation.disagreements.length > disagreements.length,
|
|
2613
|
+
};
|
|
2614
|
+
}
|
|
2615
|
+
|
|
2616
|
+
/** `anno_evid_runs`'s dispatch arm (plan 43-06): `listObservedRuns()`'s own
|
|
2617
|
+
* answer, carried through UNCHANGED beside `store` -- its `denominator` is
|
|
2618
|
+
* reported exactly as that function computed it, never re-derived here. */
|
|
2619
|
+
function dispatchEvidRuns(handle: AnnoStoreHandle, args: unknown): unknown {
|
|
2620
|
+
void args; // this verb takes no argument beyond the universal `store`
|
|
2621
|
+
return { store: handle.path, ...listObservedRuns(handle) };
|
|
2622
|
+
}
|
|
2623
|
+
|
|
2624
|
+
/**
|
|
2625
|
+
* `anno_evid_reset`'s dispatch arm (EVID-05, plan 43-06): the store-side
|
|
2626
|
+
* half of a bracket reset, beside plan 43-03's emulator-side
|
|
2627
|
+
* `vice_memmap_zap`. Derives the run identity through `runIdentityFrom()`
|
|
2628
|
+
* from `evid-ingest.ts` -- the SAME single digest site `anno_evid_ingest`
|
|
2629
|
+
* uses -- never a second hashing site here, and never a caller-supplied
|
|
2630
|
+
* digest. `observationsRemoved` is read from a `listExecObservations()`
|
|
2631
|
+
* query taken BEFORE the delete, so the answer names exactly how many rows
|
|
2632
|
+
* this call removed rather than leaving a caller to infer it from `changed`
|
|
2633
|
+
* alone. `baseRevision` is threaded straight into
|
|
2634
|
+
* `deleteExecObservationsForRun()`, which enforces staleness itself through
|
|
2635
|
+
* `applyWrite()` -- the same "let the store's own write sequence check it"
|
|
2636
|
+
* discipline `dispatchEvidIngest()` above already uses, so there is no
|
|
2637
|
+
* second, redundant `assertNotStale()` call here.
|
|
2638
|
+
*/
|
|
2639
|
+
function dispatchEvidReset(handle: AnnoStoreHandle, args: unknown): unknown {
|
|
2640
|
+
const bag = argBag(args);
|
|
2641
|
+
const baseRevision = assertBaseRevisionArg("anno_evid_reset", args);
|
|
2642
|
+
const identity = runIdentityFrom({
|
|
2643
|
+
imageSha256: bag.image_sha256 as string,
|
|
2644
|
+
argv: bag.argv as string[],
|
|
2645
|
+
seed: bag.seed as string,
|
|
2646
|
+
});
|
|
2647
|
+
const existing = listExecObservations(handle, {
|
|
2648
|
+
imageSha256: identity.imageSha256,
|
|
2649
|
+
argvDigest: identity.argvDigest,
|
|
2650
|
+
seed: identity.seed,
|
|
2651
|
+
});
|
|
2652
|
+
const written = deleteExecObservationsForRun(handle, {
|
|
2653
|
+
imageSha256: identity.imageSha256,
|
|
2654
|
+
argvDigest: identity.argvDigest,
|
|
2655
|
+
seed: identity.seed,
|
|
2656
|
+
baseRevision,
|
|
2657
|
+
});
|
|
2658
|
+
return {
|
|
2659
|
+
store: handle.path,
|
|
2660
|
+
revision: written.revision,
|
|
2661
|
+
changed: written.changed,
|
|
2662
|
+
observationsRemoved: existing.length,
|
|
2663
|
+
// `denominator` travels beside `observationsRemoved` for the same reason
|
|
2664
|
+
// it travels beside every other count this evidence layer reports
|
|
2665
|
+
// (EVID-04, plan 43-07's own structural guard): a bare count invites the
|
|
2666
|
+
// reading "the rest is data". The bracket this call reset held exactly
|
|
2667
|
+
// `existing.length` rows before the delete, so that is what
|
|
2668
|
+
// `observationsRemoved` is a fraction of -- a full reset makes the two
|
|
2669
|
+
// numbers equal, but the field is never omitted just because it agrees.
|
|
2670
|
+
denominator: existing.length,
|
|
2671
|
+
};
|
|
2672
|
+
}
|
|
2673
|
+
|
|
2674
|
+
// ---------------------------------------------------------------------------
|
|
2675
|
+
// The image loader (D-07). The store holds annotations and never bytes, so
|
|
2676
|
+
// every derived read names its own image and this function is the ONE place
|
|
2677
|
+
// that turns that name into bytes plus an origin.
|
|
2678
|
+
//
|
|
2679
|
+
// DISPATCH IS BY EXTENSION FIRST, NEVER BY BYTE LENGTH. The branch order below
|
|
2680
|
+
// was copied from the CLI's own bootstrap dispatch rather than re-derived; that
|
|
2681
|
+
// verb was removed on 2026-08-29 when the CLI narrowed to two (D-14), so THIS
|
|
2682
|
+
// is now the only implementation of the order and the citation that named the
|
|
2683
|
+
// CLI's line range is deliberately gone rather than left dangling. The
|
|
2684
|
+
// incident it encodes (WR-07): a 4096-byte flat `.raw` capture fell through to
|
|
2685
|
+
// the `.prg` parser, whose first two bytes become the load address, so a
|
|
2686
|
+
// truncated capture silently "bootstrapped" with an origin read backwards out
|
|
2687
|
+
// of its own payload bytes and exited zero -- every downstream address wrong,
|
|
2688
|
+
// no diagnostic. The extension check runs BEFORE any length check so
|
|
2689
|
+
// `flatImageOrigin()`'s own named refusal stays reachable for those two
|
|
2690
|
+
// extensions.
|
|
2691
|
+
// ---------------------------------------------------------------------------
|
|
2692
|
+
|
|
2693
|
+
interface LoadedImage {
|
|
2694
|
+
path: string;
|
|
2695
|
+
kind: "prg" | "flat";
|
|
2696
|
+
origin: number;
|
|
2697
|
+
body: Uint8Array;
|
|
2698
|
+
totalBytes: number;
|
|
2699
|
+
}
|
|
2700
|
+
|
|
2701
|
+
function loadImage(name: string, args: unknown): LoadedImage {
|
|
2702
|
+
const raw = assertImageArg(name, args);
|
|
2703
|
+
const path = resolveWorkspacePath(raw);
|
|
2704
|
+
if (!existsSync(path)) {
|
|
2705
|
+
throw new AnnoStorePathError(
|
|
2706
|
+
`${name} refused: no image exists at ${JSON.stringify(path)} -- a derived read names the bytes it derives from (D-07), ` +
|
|
2707
|
+
"and an image that is not there is a different fact from an image with nothing in it.",
|
|
2708
|
+
{ path },
|
|
2709
|
+
);
|
|
2710
|
+
}
|
|
2711
|
+
const bytes = new Uint8Array(readFileSync(path));
|
|
2712
|
+
const ext = extname(path).toLowerCase();
|
|
2713
|
+
try {
|
|
2714
|
+
if (ext === ".raw" || ext === ".bin") {
|
|
2715
|
+
return { path, kind: "flat", origin: flatImageOrigin(bytes), body: bytes, totalBytes: bytes.length };
|
|
2716
|
+
}
|
|
2717
|
+
if (ext !== ".prg" && bytes.length === 65536) {
|
|
2718
|
+
return { path, kind: "flat", origin: flatImageOrigin(bytes), body: bytes, totalBytes: bytes.length };
|
|
2719
|
+
}
|
|
2720
|
+
const { origin, body } = parsePrg(bytes);
|
|
2721
|
+
return { path, kind: "prg", origin, body, totalBytes: bytes.length };
|
|
2722
|
+
} catch (err) {
|
|
2723
|
+
// `prg-image.ts` throws a bare `Error` by design -- it is a pure
|
|
2724
|
+
// byte-layout module with no error family of its own. Wrapped here so the
|
|
2725
|
+
// never-throw boundary can still name a class, and so the message carries
|
|
2726
|
+
// the caller's own vocabulary (the image path) rather than only the
|
|
2727
|
+
// internal function name.
|
|
2728
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
2729
|
+
throw new AnnoToolArgumentError(
|
|
2730
|
+
`${name} refused: ${JSON.stringify(path)} is not an image this surface can read (${reason}). Supply a .prg (a 2-byte ` +
|
|
2731
|
+
"little-endian load address plus a payload) or an exactly-65536-byte flat capture.",
|
|
2732
|
+
{ toolName: name, argument: "image" },
|
|
2733
|
+
);
|
|
2734
|
+
}
|
|
2735
|
+
}
|
|
2736
|
+
|
|
2737
|
+
/** Shannon entropy of `bytes`, in bits per byte. Above roughly 7.5 the image is
|
|
2738
|
+
* very likely compressed or packed, and nothing in it will decode sensibly
|
|
2739
|
+
* until it is depacked -- which is why this is REPORTED rather than left for a
|
|
2740
|
+
* caller to wonder about after a disassembly comes back as noise. */
|
|
2741
|
+
function shannonEntropy(bytes: Uint8Array): number {
|
|
2742
|
+
if (bytes.length === 0) return 0;
|
|
2743
|
+
const histogram = new Uint32Array(256);
|
|
2744
|
+
for (const byte of bytes) histogram[byte] += 1;
|
|
2745
|
+
let entropy = 0;
|
|
2746
|
+
for (const count of histogram) {
|
|
2747
|
+
if (count === 0) continue;
|
|
2748
|
+
const p = count / bytes.length;
|
|
2749
|
+
entropy -= p * Math.log2(p);
|
|
2750
|
+
}
|
|
2751
|
+
return Math.round(entropy * 1000) / 1000;
|
|
2752
|
+
}
|
|
2753
|
+
|
|
2754
|
+
/** The slice of `image` covering the inclusive span, or `null` when the span
|
|
2755
|
+
* falls outside the bytes the image actually holds. `null` rather than a short
|
|
2756
|
+
* slice: a partial answer to a range question reads as a complete answer to a
|
|
2757
|
+
* smaller one.
|
|
2758
|
+
*
|
|
2759
|
+
* TOTAL OVER EVERY (start, end) PAIR, and that is three cases, not two. Below
|
|
2760
|
+
* the origin and past the last byte are the obvious two. The third is an
|
|
2761
|
+
* INVERTED span -- a resolved `from` past its own `to` -- which passes both
|
|
2762
|
+
* bound checks while covering no bytes at all, and which `subarray()` would
|
|
2763
|
+
* hand back as a zero-length success. That is the same failure as a short
|
|
2764
|
+
* slice wearing a smaller hat: answering a question about no bytes with an
|
|
2765
|
+
* empty result reads as a complete answer to a smaller question, which is the
|
|
2766
|
+
* very thing this `null` return exists against (CR-01). */
|
|
2767
|
+
function sliceSpan(image: LoadedImage, start: number, end: number): Uint8Array | null {
|
|
2768
|
+
const from = start - image.origin;
|
|
2769
|
+
const to = end - image.origin;
|
|
2770
|
+
if (from < 0 || to >= image.body.length || from > to) return null;
|
|
2771
|
+
return image.body.subarray(from, to + 1);
|
|
2772
|
+
}
|
|
2773
|
+
|
|
2774
|
+
/** The ONE refusal builder both read verbs report through. `anno_disassemble`
|
|
2775
|
+
* and `anno_read_region` each call `sliceSpan()` exactly once, over the span
|
|
2776
|
+
* their own answer would have reported -- the span the CALLER can see -- and
|
|
2777
|
+
* each reaches this builder from that one verdict. Their AGREEMENT is the
|
|
2778
|
+
* property CR-01 was reported against: the defect was `anno_disassemble`
|
|
2779
|
+
* narrowing the requested end down to the image's last address BEFORE slicing,
|
|
2780
|
+
* so an out-of-image start produced an empty slice instead of the `null` that
|
|
2781
|
+
* reaches here, and the caller got `instructions:0` with an `end_address`
|
|
2782
|
+
* numerically below the `address` asked about. Do not reintroduce a per-verb
|
|
2783
|
+
* narrowing: it makes the two verbs disagree about the same bytes. */
|
|
2784
|
+
function outsideImage(name: string, image: LoadedImage, start: number, end: number): Record<string, unknown> {
|
|
2785
|
+
const last = image.origin + image.body.length - 1;
|
|
2786
|
+
return {
|
|
2787
|
+
available: false,
|
|
2788
|
+
reason:
|
|
2789
|
+
`${name} was asked for $${start.toString(16).padStart(4, "0")}..$${end.toString(16).padStart(4, "0")}, which is not ` +
|
|
2790
|
+
`entirely inside the image: ${JSON.stringify(image.path)} loads at $${image.origin.toString(16).padStart(4, "0")} and ` +
|
|
2791
|
+
`ends at $${last.toString(16).padStart(4, "0")}. Reported as unanswerable rather than served as a short slice, because a ` +
|
|
2792
|
+
"partial answer to a range question reads as a complete answer to a smaller one. Narrow the range, or name the image that " +
|
|
2793
|
+
"actually covers those addresses.",
|
|
2794
|
+
};
|
|
2795
|
+
}
|
|
2796
|
+
|
|
2797
|
+
function hexdump(bytes: Uint8Array, start: number): string[] {
|
|
2798
|
+
const lines: string[] = [];
|
|
2799
|
+
for (let offset = 0; offset < bytes.length; offset += 16) {
|
|
2800
|
+
const chunk = bytes.subarray(offset, offset + 16);
|
|
2801
|
+
const hex = [...chunk].map((b) => b.toString(16).padStart(2, "0")).join(" ");
|
|
2802
|
+
lines.push(`$${(start + offset).toString(16).padStart(4, "0")} ${hex}`);
|
|
2803
|
+
}
|
|
2804
|
+
return lines;
|
|
2805
|
+
}
|
|
2806
|
+
|
|
2807
|
+
/** The SAME shape `registerKeyFor().slice(1)` produces (uppercase, exactly
|
|
2808
|
+
* four hex digits) -- `anno-export-asm.ts`'s own `REGISTER_ENUM_NAME_RE`
|
|
2809
|
+
* comment explains why an enum usage is only a CANDIDATE for the decoder
|
|
2810
|
+
* when its name has this shape, and why that check is not centralised: two
|
|
2811
|
+
* renderers, two small local copies of this one shape predicate, one shared
|
|
2812
|
+
* decoder. Kept in sync by inspection (both are one line) rather than by
|
|
2813
|
+
* import, per D-16's own "two renderers" design.
|
|
2814
|
+
*
|
|
2815
|
+
* SHAPE ALONE IS NOT ENOUGH (45-REVIEW CR-01, fixed 2026-09-11): the call
|
|
2816
|
+
* site below also requires `hasRegBitsEntry()` -- imported from
|
|
2817
|
+
* `anno-enum-gen.ts` above, the ONE shared membership predicate, NOT a third
|
|
2818
|
+
* local copy -- to confirm `anno-regbits.json` actually covers the register
|
|
2819
|
+
* before attempting the decoder at all. */
|
|
2820
|
+
const REGISTER_ENUM_NAME_RE = /^[0-9A-F]{4}$/;
|
|
2821
|
+
|
|
2822
|
+
/** `#$XX` -> `#<replacement>` on the ASSEMBLER-VISIBLE half of `line`, the
|
|
2823
|
+
* same confinement `anno-export-asm.ts`'s `substituteImmediateEnum()` uses
|
|
2824
|
+
* (never rewriting inside a trailing `;` comment, where a renderer's own
|
|
2825
|
+
* NOTE text could coincidentally contain the same hex digits). A rendered
|
|
2826
|
+
* line that does not carry the expected literal is a disagreement between
|
|
2827
|
+
* this function and `disasm-renderer.ts`, and it is refused rather than
|
|
2828
|
+
* silently left unchanged. */
|
|
2829
|
+
function substituteReadableImmediate(line: string, value: number, replacement: string, address: number): string {
|
|
2830
|
+
const literal = `#$${(value & 0xff).toString(16).padStart(2, "0")}`;
|
|
2831
|
+
const separatorIndex = line.indexOf(" ; ");
|
|
2832
|
+
const directiveHalf = separatorIndex >= 0 ? line.slice(0, separatorIndex) : line;
|
|
2833
|
+
const commentHalf = separatorIndex >= 0 ? line.slice(separatorIndex) : "";
|
|
2834
|
+
const at = directiveHalf.indexOf(literal);
|
|
2835
|
+
if (at < 0) {
|
|
2836
|
+
throw new AnnoStoreError(
|
|
2837
|
+
`anno_disassemble: the instruction at $${address.toString(16).padStart(4, "0")} carries an enum usage, but its rendered line does ` +
|
|
2838
|
+
`not contain the immediate literal ${literal} this renderer expected to replace. Refusing rather than emitting a line whose ` +
|
|
2839
|
+
"substitution silently did nothing.",
|
|
2840
|
+
);
|
|
2841
|
+
}
|
|
2842
|
+
return `${directiveHalf.slice(0, at)}#${replacement}${directiveHalf.slice(at + literal.length)}${commentHalf}`;
|
|
2843
|
+
}
|
|
2844
|
+
|
|
2845
|
+
/** Appends `comment` as a trailing `;`-comment on `line`, joining it with any
|
|
2846
|
+
* EXISTING trailing comment (a `disasm-renderer.ts` note, e.g. an NMOS
|
|
2847
|
+
* page-wrap warning) via `" | "` -- the SAME separator `formatNotesComment()`
|
|
2848
|
+
* already uses to join multiple notes on one instruction, so a line with
|
|
2849
|
+
* both a note and a decoded register comment reads as one vocabulary rather
|
|
2850
|
+
* than two different join styles on one line. */
|
|
2851
|
+
function appendReadableComment(line: string, comment: string): string {
|
|
2852
|
+
const separatorIndex = line.indexOf(" ; ");
|
|
2853
|
+
if (separatorIndex < 0) return `${line} ; ${comment}`;
|
|
2854
|
+
return `${line} | ${comment}`;
|
|
2855
|
+
}
|
|
2856
|
+
|
|
2857
|
+
/**
|
|
2858
|
+
* D-16's SECOND renderer (plan 45-05): the READABILITY half. `anno-export-
|
|
2859
|
+
* asm.ts` carries the proof (a real-ACME byte-diff oracle); this is what a
|
|
2860
|
+
* Claude session actually reads. Calls `decomposeRegisterValue()` -- the ONE
|
|
2861
|
+
* owning decoder -- for exactly the same reason: this function decodes
|
|
2862
|
+
* NOTHING itself.
|
|
2863
|
+
*
|
|
2864
|
+
* BYTE-IDENTICAL TO `render()`'S OWN OUTPUT when the store carries no enum
|
|
2865
|
+
* usage inside the decoded range at all (the fast-path return below), and
|
|
2866
|
+
* for every instruction `usageByAddress` does not cover even when it does --
|
|
2867
|
+
* D-16 widens what a bound instruction shows; it does not touch anything
|
|
2868
|
+
* else `render()` already produces.
|
|
2869
|
+
*
|
|
2870
|
+
* THE LINE-INDEX MAPPING THIS RELIES ON: `render(instructions, { origin })`
|
|
2871
|
+
* is called here WITHOUT `showSymbols`, so `resolveSymbol()` (`disasm-
|
|
2872
|
+
* renderer.ts`) always returns `undefined` and its own symbol-header loop
|
|
2873
|
+
* never emits a line -- the header is EXACTLY `"!cpu 6510"` then `"* =
|
|
2874
|
+
* $XXXX"`, two lines, and `instructions[i]` maps to `lines[HEADER_LINES +
|
|
2875
|
+
* i]` with no other possible offset. A future caller of this function that
|
|
2876
|
+
* ever passes `showSymbols: true` would break that mapping silently; this
|
|
2877
|
+
* function does not, and does not need to for the readability job D-16 gives
|
|
2878
|
+
* it.
|
|
2879
|
+
*/
|
|
2880
|
+
function renderDisassembleListing(handle: AnnoStoreHandle, instructions: readonly Instruction[], origin: number): string {
|
|
2881
|
+
const baseListing = render(instructions as Instruction[], { origin });
|
|
2882
|
+
|
|
2883
|
+
const usageByAddress = new Map<number, EnumUsageRow>();
|
|
2884
|
+
for (const row of listEnumUsage(handle)) usageByAddress.set(row.address, row);
|
|
2885
|
+
if (usageByAddress.size === 0) return baseListing;
|
|
2886
|
+
|
|
2887
|
+
const enumsByName = new Map<string, ProjectEnumRow>();
|
|
2888
|
+
for (const row of listProjectEnums(handle)) enumsByName.set(row.name, row);
|
|
2889
|
+
|
|
2890
|
+
const HEADER_LINES = 2;
|
|
2891
|
+
const lines = baseListing.split("\n");
|
|
2892
|
+
|
|
2893
|
+
instructions.forEach((instr, index) => {
|
|
2894
|
+
const usage = usageByAddress.get(instr.address);
|
|
2895
|
+
if (usage === undefined) return;
|
|
2896
|
+
|
|
2897
|
+
// THE SAME REFUSAL SHAPE THE EXPORT BOUNDARY RAISES (`anno-export-
|
|
2898
|
+
// asm.ts`'s own enum-substitution block) for the same conditions, not a
|
|
2899
|
+
// silently plain listing for a store row this readable surface cannot
|
|
2900
|
+
// honour.
|
|
2901
|
+
const project = enumsByName.get(usage.enumName);
|
|
2902
|
+
if (project === undefined) {
|
|
2903
|
+
throw new AnnoStoreError(
|
|
2904
|
+
`anno_disassemble: the enum usage at $${instr.address.toString(16).padStart(4, "0")} names enum ${JSON.stringify(usage.enumName)}, ` +
|
|
2905
|
+
"which the store holds no definition for. Refusing to render a readable operand whose vocabulary is missing.",
|
|
2906
|
+
);
|
|
2907
|
+
}
|
|
2908
|
+
const role = instr.operand?.role;
|
|
2909
|
+
if (role !== "immediate" || !instr.acmeExpressible) {
|
|
2910
|
+
throw new AnnoStoreError(
|
|
2911
|
+
`anno_disassemble: the enum usage at $${instr.address.toString(16).padStart(4, "0")} names enum ${JSON.stringify(usage.enumName)}, but ` +
|
|
2912
|
+
"the instruction there is not an assembler-visible IMMEDIATE operand -- an enum renders on the immediate operand only. Refusing " +
|
|
2913
|
+
"rather than rendering a readable line with no substitution.",
|
|
2914
|
+
);
|
|
2915
|
+
}
|
|
2916
|
+
|
|
2917
|
+
// D-16: attempted ONLY when BOTH (45-REVIEW CR-01, fixed 2026-09-11) the
|
|
2918
|
+
// enum's name has the register-key shape -- see `REGISTER_ENUM_NAME_RE`'s
|
|
2919
|
+
// own comment for why a name that does not (e.g. a hand-authored
|
|
2920
|
+
// `viccolor`) is never a candidate -- AND `anno-regbits.json` actually
|
|
2921
|
+
// has a table entry for it (`hasRegBitsEntry()`). A register-shaped name
|
|
2922
|
+
// for a register the table does not cover (e.g. `D020`) is not a
|
|
2923
|
+
// decomposition failure; it falls through to the single-symbol shape
|
|
2924
|
+
// below with no decomposition attempted at all.
|
|
2925
|
+
let decomposition: RegisterDecomposition | undefined;
|
|
2926
|
+
if (REGISTER_ENUM_NAME_RE.test(usage.enumName) && hasRegBitsEntry(`$${usage.enumName}`)) {
|
|
2927
|
+
try {
|
|
2928
|
+
// `Number("0x...")`, never `parseInt()` -- this file's own guard
|
|
2929
|
+
// (anno-tools.test.ts) forbids a second, divergent numeric-parsing
|
|
2930
|
+
// rule beside the store's own. `usage.enumName` is already proven
|
|
2931
|
+
// to match REGISTER_ENUM_NAME_RE (four hex digits) above.
|
|
2932
|
+
decomposition = decomposeRegisterValue(Number(`0x${usage.enumName}`), instr.operand!.value);
|
|
2933
|
+
} catch (err) {
|
|
2934
|
+
throw new AnnoStoreError(
|
|
2935
|
+
`anno_disassemble: decomposing the enum usage at $${instr.address.toString(16).padStart(4, "0")} (enum ` +
|
|
2936
|
+
`${JSON.stringify(usage.enumName)}) against its bit-name table failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
2937
|
+
);
|
|
2938
|
+
}
|
|
2939
|
+
}
|
|
2940
|
+
|
|
2941
|
+
const lineIndex = HEADER_LINES + index;
|
|
2942
|
+
const currentLine = lines[lineIndex]!;
|
|
2943
|
+
|
|
2944
|
+
if (decomposition !== undefined && decomposition.multiField) {
|
|
2945
|
+
// D-17: OR-ed named constants AND the decoded comment -- both, exactly
|
|
2946
|
+
// as the export renders them, so a Claude session reading this listing
|
|
2947
|
+
// sees what the export proves.
|
|
2948
|
+
const orExpression = decomposition.terms.map((term) => term.name).join(" | ");
|
|
2949
|
+
const substituted = substituteReadableImmediate(currentLine, instr.operand!.value, orExpression, instr.address);
|
|
2950
|
+
lines[lineIndex] = appendReadableComment(substituted, decomposition.comment);
|
|
2951
|
+
return;
|
|
2952
|
+
}
|
|
2953
|
+
|
|
2954
|
+
// THE EXISTING SINGLE-SYMBOL SHAPE (D-16: not replaced) -- a single-field
|
|
2955
|
+
// register, an enum usage whose name is not register-shaped at all, OR
|
|
2956
|
+
// (45-REVIEW CR-01) a register-shaped name for a register
|
|
2957
|
+
// `anno-regbits.json` has no entry for (e.g. `D020`).
|
|
2958
|
+
let matched: string | undefined;
|
|
2959
|
+
for (const [key, variantName] of Object.entries(project.variants)) {
|
|
2960
|
+
if (parseVariantKey(key) === instr.operand!.value) matched = variantName;
|
|
2961
|
+
}
|
|
2962
|
+
if (matched === undefined) {
|
|
2963
|
+
throw new AnnoStoreError(
|
|
2964
|
+
`anno_disassemble: enum ${JSON.stringify(usage.enumName)} is bound to the immediate operand at ` +
|
|
2965
|
+
`$${instr.address.toString(16).padStart(4, "0")}, whose value is $${(instr.operand!.value & 0xff).toString(16).padStart(2, "0")}, ` +
|
|
2966
|
+
"and the enum has no variant for that value.",
|
|
2967
|
+
);
|
|
2968
|
+
}
|
|
2969
|
+
lines[lineIndex] = substituteReadableImmediate(currentLine, instr.operand!.value, `${usage.enumName}_${matched}`, instr.address);
|
|
2970
|
+
});
|
|
2971
|
+
|
|
2972
|
+
return lines.join("\n");
|
|
2973
|
+
}
|
|
2974
|
+
|
|
2975
|
+
function dispatchDisassemble(handle: AnnoStoreHandle, args: unknown): unknown {
|
|
2976
|
+
const image = loadImage("anno_disassemble", args);
|
|
2977
|
+
const bag = argBag(args);
|
|
2978
|
+
const start = parseStoreAddress(bag.address, { what: "address" });
|
|
2979
|
+
const cap = currentReadRegionMaxBytes();
|
|
2980
|
+
const last = image.origin + image.body.length - 1;
|
|
2981
|
+
// An omitted end is the CAP, not the whole image: the default has to be the
|
|
2982
|
+
// bound, or the default is the hazard.
|
|
2983
|
+
const requestedEnd = bag.end_address !== undefined ? parseStoreAddress(bag.end_address, { what: "end_address" }) : Math.min(start + cap - 1, last);
|
|
2984
|
+
if (bag.end_address !== undefined) assertWithinRegionCap("anno_disassemble", start, requestedEnd, undefined);
|
|
2985
|
+
// Sliced on the span the CALLER named, never on one narrowed down to the
|
|
2986
|
+
// image's last address first. The narrowing used to happen here, and it is
|
|
2987
|
+
// what made this verb disagree with `anno_read_region` (CR-01) -- see
|
|
2988
|
+
// `outsideImage()`. Note what is NOT lost: an omitted `end_address` derives
|
|
2989
|
+
// `requestedEnd` from the image's own last address above, so it is inside
|
|
2990
|
+
// the image by construction and nothing a caller named is narrowed away.
|
|
2991
|
+
const slice = sliceSpan(image, start, requestedEnd);
|
|
2992
|
+
if (slice === null) return outsideImage("anno_disassemble", image, start, requestedEnd);
|
|
2993
|
+
|
|
2994
|
+
const instructions = decode(slice, start, { end: requestedEnd });
|
|
2995
|
+
return {
|
|
2996
|
+
image: image.path,
|
|
2997
|
+
origin: image.origin,
|
|
2998
|
+
address: start,
|
|
2999
|
+
end_address: requestedEnd,
|
|
3000
|
+
instructions: instructions.length,
|
|
3001
|
+
listing: renderDisassembleListing(handle, instructions, start),
|
|
3002
|
+
};
|
|
3003
|
+
}
|
|
3004
|
+
|
|
3005
|
+
/**
|
|
3006
|
+
* `anno_hazard_report`'s dispatch arm. Fetches EVERY input here -- the
|
|
3007
|
+
* byte-derived ranges, labels, comments, cross-references, execution
|
|
3008
|
+
* observations and the image bytes -- and hands them to `buildHazardReport()`
|
|
3009
|
+
* exactly once; the pure module itself never fetches any of it (see its own
|
|
3010
|
+
* header). The byte-derived ranges are mapped through `blocksFromStore()`,
|
|
3011
|
+
* reached by the SAME lazy `await import("./anno-cli.ts")`
|
|
3012
|
+
* `dispatchEvidDisagreements()` already uses above, so this file's own static
|
|
3013
|
+
* import graph -- and therefore the MCP server's startup cost -- stays
|
|
3014
|
+
* unchanged: `anno-cli.ts` drags in `anno-coverage.ts`, `anno-memmap-render.ts`
|
|
3015
|
+
* and `anno-export-asm.ts`, none of which this verb needs either. The mapping
|
|
3016
|
+
* itself is NOT re-implemented here, for the same reason `dispatchEvidDisagreements`
|
|
3017
|
+
* states for itself.
|
|
3018
|
+
*/
|
|
3019
|
+
async function dispatchHazardReport(handle: AnnoStoreHandle, args: unknown): Promise<unknown> {
|
|
3020
|
+
const maxResults = assertOptionalMaxResults("anno_hazard_report", args);
|
|
3021
|
+
const image = loadImage("anno_hazard_report", args);
|
|
3022
|
+
const { blocksFromStore } = await import("./anno-cli.ts");
|
|
3023
|
+
const ranges = blocksFromStore(listRanges(handle));
|
|
3024
|
+
const symbols = listLabels(handle);
|
|
3025
|
+
const comments = listComments(handle);
|
|
3026
|
+
const xrefs = listXrefs(handle);
|
|
3027
|
+
const execObservations = listExecObservations(handle);
|
|
3028
|
+
const report = buildHazardReport({
|
|
3029
|
+
bytes: image.body,
|
|
3030
|
+
origin: image.origin,
|
|
3031
|
+
symbols,
|
|
3032
|
+
comments,
|
|
3033
|
+
ranges,
|
|
3034
|
+
xrefs,
|
|
3035
|
+
execObservations,
|
|
3036
|
+
});
|
|
3037
|
+
const findings = maxResults === undefined ? report.findings : report.findings.slice(0, maxResults);
|
|
3038
|
+
return {
|
|
3039
|
+
store: handle.path,
|
|
3040
|
+
image: image.path,
|
|
3041
|
+
...report,
|
|
3042
|
+
findings,
|
|
3043
|
+
returned: findings.length,
|
|
3044
|
+
matched: report.findings.length,
|
|
3045
|
+
truncated: report.truncated || report.findings.length > findings.length,
|
|
3046
|
+
};
|
|
3047
|
+
}
|
|
3048
|
+
|
|
3049
|
+
function dispatchReadRegion(args: unknown): unknown {
|
|
3050
|
+
const image = loadImage("anno_read_region", args);
|
|
3051
|
+
const bag = argBag(args);
|
|
3052
|
+
const start = parseStoreAddress(bag.start_address, { what: "start_address" });
|
|
3053
|
+
const end = parseStoreAddress(bag.end_address, { what: "end_address" });
|
|
3054
|
+
const view = bag.view === "hexdump" ? "hexdump" : "disasm";
|
|
3055
|
+
const slice = sliceSpan(image, start, end);
|
|
3056
|
+
if (slice === null) return outsideImage("anno_read_region", image, start, end);
|
|
3057
|
+
|
|
3058
|
+
if (view === "hexdump") {
|
|
3059
|
+
return { image: image.path, origin: image.origin, start_address: start, end_address: end, view, bytes: slice.length, hexdump: hexdump(slice, start).join("\n") };
|
|
3060
|
+
}
|
|
3061
|
+
const instructions = decode(slice, start, { end });
|
|
3062
|
+
return {
|
|
3063
|
+
image: image.path,
|
|
3064
|
+
origin: image.origin,
|
|
3065
|
+
start_address: start,
|
|
3066
|
+
end_address: end,
|
|
3067
|
+
view,
|
|
3068
|
+
bytes: slice.length,
|
|
3069
|
+
instructions: instructions.length,
|
|
3070
|
+
listing: render(instructions, { origin: start }),
|
|
3071
|
+
};
|
|
3072
|
+
}
|
|
3073
|
+
|
|
3074
|
+
function dispatchBinaryInfo(args: unknown): unknown {
|
|
3075
|
+
const image = loadImage("anno_get_binary_info", args);
|
|
3076
|
+
const entropy = shannonEntropy(image.body);
|
|
3077
|
+
return {
|
|
3078
|
+
image: image.path,
|
|
3079
|
+
kind: image.kind,
|
|
3080
|
+
origin: image.origin,
|
|
3081
|
+
total_bytes: image.totalBytes,
|
|
3082
|
+
body_bytes: image.body.length,
|
|
3083
|
+
last_address: image.origin + image.body.length - 1,
|
|
3084
|
+
entropy,
|
|
3085
|
+
likely_packed: entropy > 7.5,
|
|
3086
|
+
};
|
|
3087
|
+
}
|
|
3088
|
+
|
|
3089
|
+
function dispatchCrossReferences(handle: AnnoStoreHandle, args: unknown): unknown {
|
|
3090
|
+
const image = loadImage("anno_get_cross_references", args);
|
|
3091
|
+
const maxResults = assertMaxResults("anno_get_cross_references", args);
|
|
3092
|
+
const bag = argBag(args);
|
|
3093
|
+
const union = crossReferencesTo(handle, image.body, image.origin, bag.address as number | string);
|
|
3094
|
+
const callers = union.callers.slice(0, maxResults);
|
|
3095
|
+
return {
|
|
3096
|
+
store: handle.path,
|
|
3097
|
+
image: image.path,
|
|
3098
|
+
to: union.to,
|
|
3099
|
+
callers,
|
|
3100
|
+
returned: callers.length,
|
|
3101
|
+
total: union.count,
|
|
3102
|
+
truncated: union.count > callers.length,
|
|
3103
|
+
};
|
|
3104
|
+
}
|
|
3105
|
+
|
|
3106
|
+
function dispatchSearch(handle: AnnoStoreHandle, args: unknown): unknown {
|
|
3107
|
+
const image = loadImage("anno_search", args);
|
|
3108
|
+
const bag = argBag(args);
|
|
3109
|
+
// THE CALLER'S OWN BAG IS PASSED THROUGH, not reconstructed from the three
|
|
3110
|
+
// keys this layer knows about. `searchAnnotations` detects a corpus this
|
|
3111
|
+
// surface does not have by scanning for `search_<name>` keys it does not
|
|
3112
|
+
// recognise, so rebuilding the request here would silently DROP exactly the
|
|
3113
|
+
// signal the unanswerable-corpus report depends on -- and the caller would
|
|
3114
|
+
// get a clean, plausible, wrong hit list for a corpus that was never
|
|
3115
|
+
// searched. `query` and `max_results` are re-stated last so the validated
|
|
3116
|
+
// values win over whatever shape arrived.
|
|
3117
|
+
const result = searchAnnotations(handle, image.body, image.origin, {
|
|
3118
|
+
...bag,
|
|
3119
|
+
query: bag.query as string,
|
|
3120
|
+
max_results: assertMaxResults("anno_search", args),
|
|
3121
|
+
});
|
|
3122
|
+
|
|
3123
|
+
const unanswerable = Object.keys(result.unavailable);
|
|
3124
|
+
if (unanswerable.length > 0) {
|
|
3125
|
+
// THE WHOLE CALL IS ANSWERED AS UNANSWERABLE, not served as a partial
|
|
3126
|
+
// result set with a footnote. The request named a corpus this surface does
|
|
3127
|
+
// not have, so any hit list returned beside that would look like the
|
|
3128
|
+
// complete answer to the question actually asked -- which is the
|
|
3129
|
+
// plausible-looking zero this shape exists against. `isError` stays FALSE:
|
|
3130
|
+
// the request was well-formed and the answer is "no".
|
|
3131
|
+
return {
|
|
3132
|
+
available: false,
|
|
3133
|
+
reason: unanswerable.map((corpus) => result.unavailable[corpus]!.reason).join(" "),
|
|
3134
|
+
unanswerable_corpora: unanswerable,
|
|
3135
|
+
corpora: result.corpora,
|
|
3136
|
+
};
|
|
3137
|
+
}
|
|
3138
|
+
return { store: handle.path, image: image.path, ...result };
|
|
3139
|
+
}
|
|
3140
|
+
|
|
3141
|
+
function dispatchAddressDetails(handle: AnnoStoreHandle, args: unknown): unknown {
|
|
3142
|
+
const image = loadImage("anno_get_address_details", args);
|
|
3143
|
+
const bag = argBag(args);
|
|
3144
|
+
return { store: handle.path, image: image.path, ...composeAddressDetails(handle, image.body, image.origin, bag.address as number | string) };
|
|
3145
|
+
}
|
|
3146
|
+
|
|
3147
|
+
|
|
3148
|
+
/** PHASE TWO. Runs every entry against the ONE already-open handle, to
|
|
3149
|
+
* COMPLETION, pushing a per-entry status and never aborting on the first
|
|
3150
|
+
* failure. Pre-validation has already refused every batch that should not have
|
|
3151
|
+
* been sent, so a failure here is genuinely about one call rather than about
|
|
3152
|
+
* the payload. */
|
|
3153
|
+
async function dispatchBatchExecute(handle: AnnoStoreHandle, args: unknown): Promise<unknown> {
|
|
3154
|
+
const bag = argBag(args);
|
|
3155
|
+
const calls = bag.calls as Record<string, unknown>[];
|
|
3156
|
+
const results: Record<string, unknown>[] = [];
|
|
3157
|
+
for (const [index, call] of calls.entries()) {
|
|
3158
|
+
const name = call.name as string;
|
|
3159
|
+
const innerArgs = batchArgumentsFor(bag, call);
|
|
3160
|
+
try {
|
|
3161
|
+
const value = name === "anno_batch_execute" ? await dispatchBatchExecute(handle, innerArgs) : await dispatch(name, innerArgs, handle);
|
|
3162
|
+
results.push({ index, name, status: "success", result: value });
|
|
3163
|
+
} catch (err) {
|
|
3164
|
+
// NAMED BY CLASS, exactly as the outer boundary names it, so a per-item
|
|
3165
|
+
// failure is as diagnosable as a whole-call one.
|
|
3166
|
+
const errName = err instanceof Error ? err.name : "Error";
|
|
3167
|
+
const errMessage = err instanceof Error ? err.message : String(err);
|
|
3168
|
+
results.push({ index, name, status: "error", error: `[${errName}] ${errMessage}` });
|
|
3169
|
+
}
|
|
3170
|
+
}
|
|
3171
|
+
const failed = results.filter((entry) => entry.status === "error").length;
|
|
3172
|
+
return {
|
|
3173
|
+
store: handle.path,
|
|
3174
|
+
results,
|
|
3175
|
+
executed: results.length,
|
|
3176
|
+
succeeded: results.length - failed,
|
|
3177
|
+
failed,
|
|
3178
|
+
note:
|
|
3179
|
+
"Every entry ran: this loop does not abort on the first failure, so an error entry here means THAT CALL did not work, " +
|
|
3180
|
+
"not that the batch should not have been sent. A batch that should not have been sent is refused WHOLE before anything " +
|
|
3181
|
+
"is opened, and arrives as isError:true instead of as a per-item status.",
|
|
3182
|
+
};
|
|
3183
|
+
}
|
|
3184
|
+
|
|
3185
|
+
async function dispatch(name: string, args: unknown, handle: AnnoStoreHandle): Promise<unknown> {
|
|
3186
|
+
if (name === "anno_get_symbols") return dispatchGetSymbols(handle, args);
|
|
3187
|
+
if (name === "anno_set_label_name") return dispatchSetLabelName(handle, args);
|
|
3188
|
+
if (name === "anno_set_comment") return dispatchSetComment(handle, args);
|
|
3189
|
+
if (name === "anno_set_data_type") return dispatchSetDataType(handle, args);
|
|
3190
|
+
if (name === "anno_add_scope" || name === "anno_remove_scope") return dispatchScope(name, handle, args);
|
|
3191
|
+
if (name === "anno_exclude_range" || name === "anno_include_range") return dispatchExcludedRange(name, handle, args);
|
|
3192
|
+
if (name === "anno_get_comments") return dispatchGetComments(handle, args);
|
|
3193
|
+
if (name === "anno_get_blocks") return dispatchGetBlocks(handle, args);
|
|
3194
|
+
if (name === "anno_create_project_enum") return dispatchCreateProjectEnum(handle, args);
|
|
3195
|
+
if (name === "anno_update_project_enum") return dispatchUpdateProjectEnum(handle, args);
|
|
3196
|
+
if (name === "anno_apply_enum_usage") return dispatchApplyEnumUsage(handle, args);
|
|
3197
|
+
if (name === "anno_save_project") return dispatchSaveProject(handle);
|
|
3198
|
+
if (name === "anno_import_ghidra_export") return dispatchImportGhidraExport(handle, args);
|
|
3199
|
+
if (name === "anno_join_memmap") return dispatchJoinMemmap(handle, args);
|
|
3200
|
+
if (name === "anno_evid_ingest") return dispatchEvidIngest(handle, args);
|
|
3201
|
+
if (name === "anno_evid_disagreements") return dispatchEvidDisagreements(handle, args);
|
|
3202
|
+
if (name === "anno_evid_runs") return dispatchEvidRuns(handle, args);
|
|
3203
|
+
if (name === "anno_evid_reset") return dispatchEvidReset(handle, args);
|
|
3204
|
+
if (name === "anno_disassemble") return dispatchDisassemble(handle, args);
|
|
3205
|
+
if (name === "anno_hazard_report") return dispatchHazardReport(handle, args);
|
|
3206
|
+
if (name === "anno_read_region") return dispatchReadRegion(args);
|
|
3207
|
+
if (name === "anno_get_binary_info") return dispatchBinaryInfo(args);
|
|
3208
|
+
if (name === "anno_get_cross_references") return dispatchCrossReferences(handle, args);
|
|
3209
|
+
if (name === "anno_search") return dispatchSearch(handle, args);
|
|
3210
|
+
if (name === "anno_get_address_details") return dispatchAddressDetails(handle, args);
|
|
3211
|
+
if (name === "anno_batch_execute") return dispatchBatchExecute(handle, args);
|
|
3212
|
+
// Unreachable: `assertAnnoTool()` above has already refused every name
|
|
3213
|
+
// outside `CURATED_ANNO_TOOLS`, and every curated name has an arm here. It
|
|
3214
|
+
// refuses BY NAME anyway rather than returning a plausible-looking empty
|
|
3215
|
+
// answer -- a curated name with no dispatch arm is a bug in this file, and
|
|
3216
|
+
// saying so is cheaper than a silent `{}` somebody has to trace back.
|
|
3217
|
+
throw new AnnoUncuratedToolError(
|
|
3218
|
+
`"${name}" is curated but has no dispatch arm in anno-tools.ts. Resolution routes: implement it and ` +
|
|
3219
|
+
"add it to ANNO_TOOL_DEFINITIONS with a named criterion, or remove the caller reference.",
|
|
3220
|
+
{ toolName: name },
|
|
3221
|
+
);
|
|
3222
|
+
}
|
|
3223
|
+
|
|
3224
|
+
/**
|
|
3225
|
+
* Runs one curated `anno_*` tool call. THE NEVER-THROW BOUNDARY: every failure
|
|
3226
|
+
* -- an uncurated name, a malformed argument, a path outside the workspace, a
|
|
3227
|
+
* corrupt store, a bug in a dispatcher -- resolves as `{isError:true}` text
|
|
3228
|
+
* naming the error CLASS. Nothing rejects the returned promise.
|
|
3229
|
+
*
|
|
3230
|
+
* `assertAnnoTool` is INSIDE the `try`, deliberately and unlike
|
|
3231
|
+
* `anno-tools.ts`'s `runAnnoTool`, whose gate sits outside it so a refusal
|
|
3232
|
+
* REJECTS instead of resolving. That asymmetry is WR-02, recorded as out of
|
|
3233
|
+
* scope at `anno-tools.ts:772-774`; it is closed here.
|
|
3234
|
+
*
|
|
3235
|
+
* NO VERB EVER CREATES THE STORE IT WAS ASKED TO USE (D-06): "the annotations
|
|
3236
|
+
* are gone" and "there are no annotations" must not read the same. A read-only
|
|
3237
|
+
* verb gets that through `openStore`'s own `mustExist`; a writing verb gets it
|
|
3238
|
+
* through `assertStorePresent()` plus the inode guard above, because
|
|
3239
|
+
* `mustExist` also forces a read-only connection and there is no third state to
|
|
3240
|
+
* ask for. `closeStore` runs in a `finally`, so the handle is released on the
|
|
3241
|
+
* throwing path exactly as on the succeeding one (T-29-03).
|
|
3242
|
+
*/
|
|
3243
|
+
export async function runAnnoTool(name: string, args: unknown): Promise<ToolCallResult> {
|
|
3244
|
+
try {
|
|
3245
|
+
assertAnnoTool(name, args);
|
|
3246
|
+
const storePath = resolveStoreArg(name, args);
|
|
3247
|
+
const inodeBefore = assertStorePresent(name, storePath);
|
|
3248
|
+
const handle = openStore(storePath, { workspaceRoot: repoRoot(), mustExist: READ_ONLY_ANNO_VERBS.includes(name) });
|
|
3249
|
+
try {
|
|
3250
|
+
assertSameFile(name, storePath, inodeBefore);
|
|
3251
|
+
return okText(JSON.stringify(await dispatch(name, args, handle)));
|
|
3252
|
+
} finally {
|
|
3253
|
+
closeStore(handle);
|
|
3254
|
+
}
|
|
3255
|
+
} catch (err) {
|
|
3256
|
+
// Named by class (D18-12: a mid-window failure must surface a named,
|
|
3257
|
+
// distinguishable error, never a silent success) -- a caller can tell
|
|
3258
|
+
// AnnoStoreCorruptError apart from AnnoStorePathError etc. from this text
|
|
3259
|
+
// alone, without re-parsing loose message wording.
|
|
3260
|
+
const errName = err instanceof Error ? err.name : "Error";
|
|
3261
|
+
const errMessage = err instanceof Error ? err.message : String(err);
|
|
3262
|
+
return errText(`${name} failed: [${errName}] ${errMessage}`);
|
|
3263
|
+
}
|
|
3264
|
+
}
|