@markstanden/opencode-test-container 0.0.0-stage → 0.1.1
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/LICENSE +21 -0
- package/README.md +263 -2
- package/container/Containerfile +24 -0
- package/container/README.md +29 -0
- package/dist/adapters/opencode/events.d.ts +90 -0
- package/dist/adapters/opencode/events.js +213 -0
- package/dist/adapters/opencode/runtime.d.ts +87 -0
- package/dist/adapters/opencode/runtime.js +136 -0
- package/dist/adapters/podman/container.d.ts +62 -0
- package/dist/adapters/podman/container.js +119 -0
- package/dist/adapters/podman/image.d.ts +50 -0
- package/dist/adapters/podman/image.js +124 -0
- package/dist/adapters/process.d.ts +38 -0
- package/dist/adapters/process.js +71 -0
- package/dist/campaign/assess.d.ts +45 -0
- package/dist/campaign/assess.js +208 -0
- package/dist/campaign/close.d.ts +17 -0
- package/dist/campaign/close.js +112 -0
- package/dist/campaign/evidence-guard.d.ts +24 -0
- package/dist/campaign/evidence-guard.js +49 -0
- package/dist/campaign/freeze.d.ts +94 -0
- package/dist/campaign/freeze.js +297 -0
- package/dist/campaign/inventory.d.ts +62 -0
- package/dist/campaign/inventory.js +192 -0
- package/dist/campaign/judge.d.ts +209 -0
- package/dist/campaign/judge.js +1285 -0
- package/dist/campaign/ledger.d.ts +64 -0
- package/dist/campaign/ledger.js +149 -0
- package/dist/campaign/prepare.d.ts +129 -0
- package/dist/campaign/prepare.js +483 -0
- package/dist/campaign/primary.d.ts +96 -0
- package/dist/campaign/primary.js +189 -0
- package/dist/campaign/run.d.ts +151 -0
- package/dist/campaign/run.js +562 -0
- package/dist/campaign/status.d.ts +76 -0
- package/dist/campaign/status.js +237 -0
- package/dist/cli/otc.d.ts +51 -0
- package/dist/cli/otc.js +435 -0
- package/dist/contracts/canonical.d.ts +21 -0
- package/dist/contracts/canonical.js +44 -0
- package/dist/contracts/types.d.ts +261 -0
- package/dist/contracts/types.js +9 -0
- package/dist/contracts/validate.d.ts +72 -0
- package/dist/contracts/validate.js +1021 -0
- package/dist/contracts/version.d.ts +44 -0
- package/dist/contracts/version.js +147 -0
- package/dist/evaluators/docs/analysis/accounting.d.ts +140 -0
- package/dist/evaluators/docs/analysis/accounting.js +237 -0
- package/dist/evaluators/docs/analysis/blinding.d.ts +40 -0
- package/dist/evaluators/docs/analysis/blinding.js +80 -0
- package/dist/evaluators/docs/analysis/bounds.d.ts +40 -0
- package/dist/evaluators/docs/analysis/bounds.js +31 -0
- package/dist/evaluators/docs/analysis/budget.d.ts +137 -0
- package/dist/evaluators/docs/analysis/budget.js +305 -0
- package/dist/evaluators/docs/analysis/closure.d.ts +175 -0
- package/dist/evaluators/docs/analysis/closure.js +251 -0
- package/dist/evaluators/docs/analysis/decision.d.ts +59 -0
- package/dist/evaluators/docs/analysis/decision.js +161 -0
- package/dist/evaluators/docs/analysis/freeze.d.ts +201 -0
- package/dist/evaluators/docs/analysis/freeze.js +395 -0
- package/dist/evaluators/docs/analysis/guardrails.d.ts +51 -0
- package/dist/evaluators/docs/analysis/guardrails.js +194 -0
- package/dist/evaluators/docs/analysis/journal.d.ts +150 -0
- package/dist/evaluators/docs/analysis/journal.js +475 -0
- package/dist/evaluators/docs/analysis/ledger.d.ts +78 -0
- package/dist/evaluators/docs/analysis/ledger.js +145 -0
- package/dist/evaluators/docs/analysis/measures.d.ts +302 -0
- package/dist/evaluators/docs/analysis/measures.js +1069 -0
- package/dist/evaluators/docs/analysis/model.d.ts +236 -0
- package/dist/evaluators/docs/analysis/model.js +740 -0
- package/dist/evaluators/docs/analysis/pairing.d.ts +68 -0
- package/dist/evaluators/docs/analysis/pairing.js +95 -0
- package/dist/evaluators/docs/analysis/records.d.ts +229 -0
- package/dist/evaluators/docs/analysis/records.js +935 -0
- package/dist/evaluators/docs/analysis/recovery.d.ts +225 -0
- package/dist/evaluators/docs/analysis/recovery.js +507 -0
- package/dist/evaluators/docs/analysis/render.d.ts +235 -0
- package/dist/evaluators/docs/analysis/render.js +566 -0
- package/dist/evaluators/docs/analysis/replay.d.ts +15 -0
- package/dist/evaluators/docs/analysis/replay.js +1272 -0
- package/dist/evaluators/docs/analysis/screening/inventory.d.ts +136 -0
- package/dist/evaluators/docs/analysis/screening/inventory.js +307 -0
- package/dist/evaluators/docs/analysis/screening/noteVersion.d.ts +15 -0
- package/dist/evaluators/docs/analysis/screening/noteVersion.js +46 -0
- package/dist/evaluators/docs/analysis/screening/reconcile.d.ts +38 -0
- package/dist/evaluators/docs/analysis/screening/reconcile.js +40 -0
- package/dist/evaluators/docs/analysis/screening/rules.d.ts +163 -0
- package/dist/evaluators/docs/analysis/screening/rules.js +330 -0
- package/dist/evaluators/docs/analysis/screening/scan.d.ts +72 -0
- package/dist/evaluators/docs/analysis/screening/scan.js +362 -0
- package/dist/evaluators/docs/analysis/selector.d.ts +68 -0
- package/dist/evaluators/docs/analysis/selector.js +123 -0
- package/dist/evaluators/docs/assess.d.ts +129 -0
- package/dist/evaluators/docs/assess.js +433 -0
- package/dist/evaluators/docs/campaign.d.ts +133 -0
- package/dist/evaluators/docs/campaign.js +222 -0
- package/dist/evaluators/docs/index.d.ts +25 -0
- package/dist/evaluators/docs/index.js +25 -0
- package/dist/evaluators/docs/journal.d.ts +150 -0
- package/dist/evaluators/docs/journal.js +475 -0
- package/dist/evaluators/docs/scoring.d.ts +367 -0
- package/dist/evaluators/docs/scoring.js +662 -0
- package/dist/evaluators/skills/assess.d.ts +94 -0
- package/dist/evaluators/skills/assess.js +207 -0
- package/dist/index.d.ts +46 -0
- package/dist/index.js +29 -0
- package/dist/legacy/prod014.d.ts +86 -0
- package/dist/legacy/prod014.js +209 -0
- package/dist/reporting/report.d.ts +79 -0
- package/dist/reporting/report.js +242 -0
- package/docs/architecture.md +106 -0
- package/docs/capability-matrix.md +84 -0
- package/docs/offline-rehearsal.md +75 -0
- package/docs/parity-record.md +81 -0
- package/docs/pr-description.md +103 -0
- package/docs/release-0.1.0.md +52 -0
- package/docs/releasing.md +82 -0
- package/docs/schema.md +97 -0
- package/examples/skills/README.md +13 -0
- package/examples/skills/campaign.json +73 -0
- package/examples/skills/skills/otel-helper/SKILL.md +10 -0
- package/examples/skills/suite.json +29 -0
- package/package.json +45 -5
|
@@ -0,0 +1,475 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one checked evidence boundary for the plugin's operation journal.
|
|
3
|
+
*
|
|
4
|
+
* The plugin can write an opt-in, HMAC-signed journal of served docs
|
|
5
|
+
* operations. That journal is the only record of a nested Code Mode call, so
|
|
6
|
+
* it is also the easiest evidence to forge. This module decides what the
|
|
7
|
+
* journal proves, and nothing else in the harness or analyser may credit a
|
|
8
|
+
* journal line it did not verify.
|
|
9
|
+
*
|
|
10
|
+
* Trust boundary: the signing key and the journal are both harness output,
|
|
11
|
+
* copied after the trial process exits. A trial tool cannot acquire the key
|
|
12
|
+
* during the run, so a correctly signed line with this run's session, message
|
|
13
|
+
* and execute identities, matching a host-recorded dispatch one-to-one, is
|
|
14
|
+
* evidence the plugin served that call. The key is held locally beside the
|
|
15
|
+
* evidence; possession of that key is possession of the ability to forge, so
|
|
16
|
+
* the verification is tamper-evident, not tamper-proof.
|
|
17
|
+
*/
|
|
18
|
+
import { createHash, createHmac, timingSafeEqual } from "node:crypto";
|
|
19
|
+
import { isDeepStrictEqual } from "node:util";
|
|
20
|
+
/** Whether a value is a plain JSON object. */
|
|
21
|
+
function isRecord(value) {
|
|
22
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
23
|
+
}
|
|
24
|
+
/** Parse one journal line, or undefined when it is not a JSON object. */
|
|
25
|
+
function parseLine(line) {
|
|
26
|
+
try {
|
|
27
|
+
const value = JSON.parse(line);
|
|
28
|
+
return isRecord(value) ? value : undefined;
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/** Whether a record names a served docs call on either registration surface. */
|
|
35
|
+
export function isServedRecord(record) {
|
|
36
|
+
if (record.schemaVersion !== 1)
|
|
37
|
+
return false;
|
|
38
|
+
if (record.tool !== "docs" && record.tool !== "docs_search")
|
|
39
|
+
return false;
|
|
40
|
+
if (record.surface !== "native" && record.surface !== "codemode-twin") {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
return isRecord(record.input);
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Whether a record's MAC matches the given key. The MAC covers every field
|
|
47
|
+
* except `mac`, in the record's own member order, exactly as the plugin writes
|
|
48
|
+
* it.
|
|
49
|
+
* @param record The journal record.
|
|
50
|
+
* @param key The run's signing key.
|
|
51
|
+
* @returns True when the MAC is well formed and matches.
|
|
52
|
+
*/
|
|
53
|
+
export function journalMacValid(record, key) {
|
|
54
|
+
const mac = record.mac;
|
|
55
|
+
if (typeof mac !== "string")
|
|
56
|
+
return false;
|
|
57
|
+
const body = Object.fromEntries(Object.entries(record).filter(([field]) => field !== "mac"));
|
|
58
|
+
const expected = createHmac("sha256", key)
|
|
59
|
+
.update(JSON.stringify(body), "utf8")
|
|
60
|
+
.digest("hex");
|
|
61
|
+
const left = Buffer.from(expected, "hex");
|
|
62
|
+
const right = Buffer.from(mac, "hex");
|
|
63
|
+
return left.length === right.length && timingSafeEqual(left, right);
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Whether a record carries this run's observed identity and names an execute
|
|
67
|
+
* call present in its trail.
|
|
68
|
+
* @param record The journal record.
|
|
69
|
+
* @param identity The run's observed session/message ids.
|
|
70
|
+
* @param callIds The execute call ids in the run's trail.
|
|
71
|
+
* @returns True when every identity matches exactly.
|
|
72
|
+
*/
|
|
73
|
+
export function journalRecordAttributed(record, identity, callIds) {
|
|
74
|
+
if (typeof record.sessionID !== "string" ||
|
|
75
|
+
!identity.sessions.has(record.sessionID)) {
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
if (typeof record.messageID !== "string" ||
|
|
79
|
+
!identity.messages.has(record.messageID)) {
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
return typeof record.callID === "string" && callIds.has(record.callID);
|
|
83
|
+
}
|
|
84
|
+
/** The signed result's metadata, when it is present as an object. */
|
|
85
|
+
function resultMetadataOf(record) {
|
|
86
|
+
const result = record.result;
|
|
87
|
+
if (!isRecord(result))
|
|
88
|
+
return undefined;
|
|
89
|
+
return isRecord(result.metadata) ? result.metadata : undefined;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* The signed add result's write fact, or null when it does not establish one.
|
|
93
|
+
* @param record The signed journal record.
|
|
94
|
+
* @returns Whether a write happened and the lake it names, or null.
|
|
95
|
+
*/
|
|
96
|
+
function addWriteOf(record) {
|
|
97
|
+
const metadata = resultMetadataOf(record);
|
|
98
|
+
if (metadata === undefined)
|
|
99
|
+
return null;
|
|
100
|
+
if (metadata.written !== true && metadata.written !== false)
|
|
101
|
+
return null;
|
|
102
|
+
return {
|
|
103
|
+
written: metadata.written,
|
|
104
|
+
lake: typeof metadata.lake === "string" ? metadata.lake : null,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Whether the signed result permits removing the nested lake. A refused add
|
|
109
|
+
* wrote nothing; a successful write must name the host's top-level lake.
|
|
110
|
+
* @param record The signed journal record.
|
|
111
|
+
* @param hostInput The host input, with a string-form add already decoded.
|
|
112
|
+
* @returns True when the write facts make the removal safe.
|
|
113
|
+
*/
|
|
114
|
+
function writeAllowsUndeliveredAddLake(record, hostInput) {
|
|
115
|
+
const write = addWriteOf(record);
|
|
116
|
+
if (write === null)
|
|
117
|
+
return false;
|
|
118
|
+
if (!write.written)
|
|
119
|
+
return true;
|
|
120
|
+
return (typeof hostInput.lake === "string" &&
|
|
121
|
+
write.lake !== null &&
|
|
122
|
+
hostInput.lake === write.lake);
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Remove only the nested `add.lake` the runtime discards before execute.
|
|
126
|
+
*
|
|
127
|
+
* The tool schema declares `lake` at the top level; Effect Schema's default
|
|
128
|
+
* excess-property handling drops the undeclared nested field. A successful
|
|
129
|
+
* write therefore binds only when the host's top-level selector is present and
|
|
130
|
+
* equals the lake the signed result says was written. A refused add wrote
|
|
131
|
+
* nothing, so it binds without a destination. Every other shape refuses: the
|
|
132
|
+
* comparator never drops another field, and the nested value never becomes the
|
|
133
|
+
* credited destination.
|
|
134
|
+
* @param record The signed journal record.
|
|
135
|
+
* @param candidate The decoded host input and its two add objects.
|
|
136
|
+
* @returns The comparable host input, or null when this shape is not safe.
|
|
137
|
+
*/
|
|
138
|
+
function withoutUndeliveredAddLake(record, candidate) {
|
|
139
|
+
if (!Object.hasOwn(candidate.hostAdd, "lake"))
|
|
140
|
+
return null;
|
|
141
|
+
if (Object.hasOwn(candidate.recordAdd, "lake"))
|
|
142
|
+
return null;
|
|
143
|
+
if (!writeAllowsUndeliveredAddLake(record, candidate.hostInput)) {
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
const add = { ...candidate.hostAdd };
|
|
147
|
+
delete add.lake;
|
|
148
|
+
return { ...candidate.hostInput, add };
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* The host input with a string-form add decoded once, when this is a native
|
|
152
|
+
* add shape. A non-add call, a malformed string, a double-encoded string or a
|
|
153
|
+
* non-object add returns null.
|
|
154
|
+
*/
|
|
155
|
+
function decodedHostInput(record, hostInput) {
|
|
156
|
+
if (!isRecord(record.input) || !isRecord(record.input.add))
|
|
157
|
+
return null;
|
|
158
|
+
if (typeof hostInput.add === "string") {
|
|
159
|
+
let decoded;
|
|
160
|
+
try {
|
|
161
|
+
decoded = JSON.parse(hostInput.add);
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
if (!isRecord(decoded))
|
|
167
|
+
return null;
|
|
168
|
+
return {
|
|
169
|
+
hostInput: { ...hostInput, add: decoded },
|
|
170
|
+
recordAdd: record.input.add,
|
|
171
|
+
hostAdd: decoded,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
if (!isRecord(hostInput.add))
|
|
175
|
+
return null;
|
|
176
|
+
return {
|
|
177
|
+
hostInput,
|
|
178
|
+
recordAdd: record.input.add,
|
|
179
|
+
hostAdd: hostInput.add,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Whether one host dispatch carries the signed input.
|
|
184
|
+
*
|
|
185
|
+
* Exact equality is the primary match. A native docs call can also carry
|
|
186
|
+
* `add` as a JSON string: the runtime decodes it into the signed struct before
|
|
187
|
+
* the plugin executes it, so only the raw string reaches the host trail.
|
|
188
|
+
* Decode that one field once and require the whole resulting input to be
|
|
189
|
+
* deeply equal to the signed input. The runtime also discards an undeclared
|
|
190
|
+
* `add.lake` before execute; that one field is removed under the signed
|
|
191
|
+
* result's write facts, never generally. A malformed, double-encoded, one-way
|
|
192
|
+
* or otherwise differing value still refuses. The signed record is never
|
|
193
|
+
* rewritten: its bytes are the MAC-covered evidence.
|
|
194
|
+
* @param record The signed journal record.
|
|
195
|
+
* @param dispatch The host-recorded dispatch.
|
|
196
|
+
* @returns True when the host input matches the signed input, exactly or
|
|
197
|
+
* through the one decoded-string/undelivered-nested-field exception.
|
|
198
|
+
*/
|
|
199
|
+
function hostInputMatches(record, dispatch) {
|
|
200
|
+
if (isDeepStrictEqual(dispatch.input, record.input))
|
|
201
|
+
return true;
|
|
202
|
+
if (record.surface !== "native" || record.tool !== "docs")
|
|
203
|
+
return false;
|
|
204
|
+
if (!isRecord(dispatch.input) || !isRecord(record.input))
|
|
205
|
+
return false;
|
|
206
|
+
const candidate = decodedHostInput(record, dispatch.input);
|
|
207
|
+
if (candidate === null)
|
|
208
|
+
return false;
|
|
209
|
+
if (isDeepStrictEqual(candidate.hostInput, record.input))
|
|
210
|
+
return true;
|
|
211
|
+
const normalized = withoutUndeliveredAddLake(record, candidate);
|
|
212
|
+
return normalized !== null && isDeepStrictEqual(normalized, record.input);
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Consume one matching host dispatch, so two journal lines can never both
|
|
216
|
+
* claim one dispatch.
|
|
217
|
+
* @param host The remaining host calls and dispatches.
|
|
218
|
+
* @param record The journal record.
|
|
219
|
+
* @returns True when one dispatch was consumed.
|
|
220
|
+
*/
|
|
221
|
+
function consumeDispatch(host, record) {
|
|
222
|
+
const native = record.surface === "native";
|
|
223
|
+
const candidates = native ? host.nativeCalls : host.dispatches;
|
|
224
|
+
const match = candidates.findIndex((dispatch) => dispatch.callId === record.callID &&
|
|
225
|
+
dispatch.tool === record.tool &&
|
|
226
|
+
dispatch.status === record.status &&
|
|
227
|
+
hostInputMatches(record, dispatch));
|
|
228
|
+
if (match < 0)
|
|
229
|
+
return false;
|
|
230
|
+
candidates.splice(match, 1);
|
|
231
|
+
return true;
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Verify one captured journal against this run's identity and host dispatches.
|
|
235
|
+
*
|
|
236
|
+
* Order matters: shape, then MAC, then identity, then one-to-one dispatch
|
|
237
|
+
* matching. A line failing any step is rejected with its reason and is never
|
|
238
|
+
* credited. If a journal exists but no usable key does, nothing is credited
|
|
239
|
+
* and the state says why.
|
|
240
|
+
* @param input The captured lines, key and run observations.
|
|
241
|
+
* @param input.lines The captured journal lines, or undefined when absent.
|
|
242
|
+
* @param input.key The run's signing key, or null when unavailable.
|
|
243
|
+
* @param input.identity The run's observed session and message ids.
|
|
244
|
+
* @param input.callIds The execute call ids in the run's trail.
|
|
245
|
+
* @param input.nativeCalls The docs calls the host recorded directly.
|
|
246
|
+
* @param input.dispatches The nested docs dispatches the host recorded.
|
|
247
|
+
* @returns The explicit verification result.
|
|
248
|
+
*/
|
|
249
|
+
export function verifyOperationsJournal(input) {
|
|
250
|
+
if (input.lines === undefined) {
|
|
251
|
+
return {
|
|
252
|
+
state: "absent",
|
|
253
|
+
credited: [],
|
|
254
|
+
rejected: [],
|
|
255
|
+
problems: [],
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
const key = typeof input.key === "string" && input.key.trim() !== ""
|
|
259
|
+
? input.key.trim()
|
|
260
|
+
: null;
|
|
261
|
+
if (key === null) {
|
|
262
|
+
return {
|
|
263
|
+
state: "unverifiable-key",
|
|
264
|
+
credited: [],
|
|
265
|
+
rejected: input.lines.flatMap((line, index) => line.trim() === ""
|
|
266
|
+
? []
|
|
267
|
+
: [{ index, reason: "not-json" }]),
|
|
268
|
+
problems: [
|
|
269
|
+
"a docs-operation journal exists without a usable signing key; no journal line is credited",
|
|
270
|
+
],
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
const host = {
|
|
274
|
+
callIds: new Set(input.callIds),
|
|
275
|
+
nativeCalls: input.nativeCalls ? [...input.nativeCalls] : [],
|
|
276
|
+
dispatches: [...input.dispatches],
|
|
277
|
+
};
|
|
278
|
+
const credited = [];
|
|
279
|
+
const rejected = [];
|
|
280
|
+
for (const [index, line] of input.lines.entries()) {
|
|
281
|
+
const outcome = verifyLine(line, index, key, input, host);
|
|
282
|
+
if (outcome.rejected !== undefined)
|
|
283
|
+
rejected.push(outcome.rejected);
|
|
284
|
+
if (outcome.credited !== undefined)
|
|
285
|
+
credited.push(outcome.credited);
|
|
286
|
+
}
|
|
287
|
+
return { state: "verified", credited, rejected, problems: [] };
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* Verify one journal line and consume its host dispatch when it is credited.
|
|
291
|
+
* @param line The raw line.
|
|
292
|
+
* @param index The zero-based line index.
|
|
293
|
+
* @param key The run's signing key.
|
|
294
|
+
* @param input The run's observed identity and call ids.
|
|
295
|
+
* @param input.identity The run's observed session/message ids.
|
|
296
|
+
* @param input.callIds The execute call ids in the trail.
|
|
297
|
+
* @param host The mutable host calls and dispatches.
|
|
298
|
+
* @returns The credited record or the rejection.
|
|
299
|
+
*/
|
|
300
|
+
function verifyLine(line, index, key, input, host) {
|
|
301
|
+
if (line.trim() === "")
|
|
302
|
+
return {};
|
|
303
|
+
const record = parseLine(line);
|
|
304
|
+
if (record === undefined)
|
|
305
|
+
return { rejected: { index, reason: "not-json" } };
|
|
306
|
+
if (!isServedRecord(record)) {
|
|
307
|
+
return { rejected: { index, reason: "not-served-twin" } };
|
|
308
|
+
}
|
|
309
|
+
if (!journalMacValid(record, key)) {
|
|
310
|
+
return { rejected: { index, reason: "bad-mac" } };
|
|
311
|
+
}
|
|
312
|
+
if (!journalRecordAttributed(record, input.identity, input.callIds)) {
|
|
313
|
+
return { rejected: { index, reason: "unattributed" } };
|
|
314
|
+
}
|
|
315
|
+
if (!consumeDispatch(host, record)) {
|
|
316
|
+
return { rejected: { index, reason: "no-host-dispatch" } };
|
|
317
|
+
}
|
|
318
|
+
return { credited: { index, record } };
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
321
|
+
* Collect the session and message identities a saved CLI trail observed.
|
|
322
|
+
* @param stdoutText The saved `stdout.jsonl` bytes.
|
|
323
|
+
* @returns The identity sets; empty when the trail is unreadable.
|
|
324
|
+
*/
|
|
325
|
+
export function journalIdentityFromTrail(stdoutText) {
|
|
326
|
+
const identity = {
|
|
327
|
+
sessions: new Set(),
|
|
328
|
+
messages: new Set(),
|
|
329
|
+
};
|
|
330
|
+
for (const line of stdoutText.split("\n")) {
|
|
331
|
+
if (line.trim() === "")
|
|
332
|
+
continue;
|
|
333
|
+
let event;
|
|
334
|
+
try {
|
|
335
|
+
event = JSON.parse(line);
|
|
336
|
+
}
|
|
337
|
+
catch {
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
340
|
+
if (isRecord(event))
|
|
341
|
+
addEventIdentity(event, identity);
|
|
342
|
+
}
|
|
343
|
+
return identity;
|
|
344
|
+
}
|
|
345
|
+
/**
|
|
346
|
+
* Add one event's session and message ids, including a nested part's.
|
|
347
|
+
* @param event The parsed event.
|
|
348
|
+
* @param identity The accumulating identity sets.
|
|
349
|
+
* @param identity.sessions The observed session ids.
|
|
350
|
+
* @param identity.messages The observed message ids.
|
|
351
|
+
*/
|
|
352
|
+
function addEventIdentity(event, identity) {
|
|
353
|
+
if (typeof event.sessionID === "string") {
|
|
354
|
+
identity.sessions.add(event.sessionID);
|
|
355
|
+
}
|
|
356
|
+
if (typeof event.messageID === "string") {
|
|
357
|
+
identity.messages.add(event.messageID);
|
|
358
|
+
}
|
|
359
|
+
const part = isRecord(event.part) ? event.part : undefined;
|
|
360
|
+
if (part === undefined)
|
|
361
|
+
return;
|
|
362
|
+
if (typeof part.sessionID === "string") {
|
|
363
|
+
identity.sessions.add(part.sessionID);
|
|
364
|
+
}
|
|
365
|
+
if (typeof part.messageID === "string") {
|
|
366
|
+
identity.messages.add(part.messageID);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
/** The journal tool name for one host dispatch name. */
|
|
370
|
+
function dispatchTool(name) {
|
|
371
|
+
if (name === "docs.docs_search")
|
|
372
|
+
return "docs_search";
|
|
373
|
+
if (name === "docs")
|
|
374
|
+
return "docs";
|
|
375
|
+
return null;
|
|
376
|
+
}
|
|
377
|
+
/**
|
|
378
|
+
* Extract the host dispatches and execute call ids from a saved `tools.jsonl`.
|
|
379
|
+
* @param toolsText The saved tools trail bytes.
|
|
380
|
+
* @returns The host half of attribution; empty when the trail is unreadable.
|
|
381
|
+
*/
|
|
382
|
+
export function hostDispatchesFromTrail(toolsText) {
|
|
383
|
+
const result = {
|
|
384
|
+
callIds: new Set(),
|
|
385
|
+
nativeCalls: [],
|
|
386
|
+
dispatches: [],
|
|
387
|
+
};
|
|
388
|
+
for (const line of toolsText.split("\n")) {
|
|
389
|
+
if (line.trim() === "")
|
|
390
|
+
continue;
|
|
391
|
+
let entry;
|
|
392
|
+
try {
|
|
393
|
+
entry = JSON.parse(line);
|
|
394
|
+
}
|
|
395
|
+
catch {
|
|
396
|
+
continue;
|
|
397
|
+
}
|
|
398
|
+
if (isRecord(entry))
|
|
399
|
+
addHostDispatches(entry, result);
|
|
400
|
+
}
|
|
401
|
+
return result;
|
|
402
|
+
}
|
|
403
|
+
/**
|
|
404
|
+
* Add one tool-trail entry's execute id and docs dispatches.
|
|
405
|
+
* @param entry The parsed trail entry.
|
|
406
|
+
* @param result The accumulating host result.
|
|
407
|
+
*/
|
|
408
|
+
function addHostDispatches(entry, result) {
|
|
409
|
+
if (typeof entry.id === "string") {
|
|
410
|
+
const tool = dispatchTool(entry.tool);
|
|
411
|
+
if (tool !== null) {
|
|
412
|
+
result.callIds.add(entry.id);
|
|
413
|
+
result.nativeCalls.push({
|
|
414
|
+
tool,
|
|
415
|
+
status: typeof entry.status === "string" ? entry.status : "unknown",
|
|
416
|
+
input: entry.input,
|
|
417
|
+
callId: entry.id,
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
if (entry.tool === "execute")
|
|
421
|
+
result.callIds.add(entry.id);
|
|
422
|
+
}
|
|
423
|
+
if (entry.tool !== "execute" || !isRecord(entry.metadata))
|
|
424
|
+
return;
|
|
425
|
+
const envelope = isRecord(entry.metadata.metadata)
|
|
426
|
+
? entry.metadata.metadata
|
|
427
|
+
: entry.metadata;
|
|
428
|
+
const calls = Array.isArray(envelope.toolCalls) ? envelope.toolCalls : [];
|
|
429
|
+
appendDispatches(calls, result.dispatches, typeof entry.id === "string" ? entry.id : null);
|
|
430
|
+
}
|
|
431
|
+
/**
|
|
432
|
+
* Append the docs dispatches one execute result recorded.
|
|
433
|
+
* @param calls The result's recorded tool calls.
|
|
434
|
+
* @param dispatches The accumulating dispatches.
|
|
435
|
+
*/
|
|
436
|
+
function appendDispatches(calls, dispatches, parentCallId) {
|
|
437
|
+
for (const call of calls) {
|
|
438
|
+
if (!isRecord(call))
|
|
439
|
+
continue;
|
|
440
|
+
const tool = dispatchTool(call.tool);
|
|
441
|
+
if (tool === null)
|
|
442
|
+
continue;
|
|
443
|
+
dispatches.push({
|
|
444
|
+
tool,
|
|
445
|
+
status: typeof call.status === "string" ? call.status : "unknown",
|
|
446
|
+
input: call.input,
|
|
447
|
+
callId: parentCallId,
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
/**
|
|
452
|
+
* Check a captured answer against the producer's `meta.answerHash`.
|
|
453
|
+
*
|
|
454
|
+
* The harness hashes the exact final-answer text as bytes (`sha256(text)`),
|
|
455
|
+
* so the analyser must hash the recorded `final.md` bytes, never a normalised
|
|
456
|
+
* rendering. A missing hash leaves the answer unbound rather than trusted.
|
|
457
|
+
* @param answer The recorded answer bytes.
|
|
458
|
+
* @param answerHash The producer's recorded hash, when present.
|
|
459
|
+
* @param settings The optional binding settings.
|
|
460
|
+
* @param settings.allowUnbound Explicit read-only coverage for legacy evidence
|
|
461
|
+
* written before the producer recorded a hash.
|
|
462
|
+
* @returns A problem, or null when the answer is bound.
|
|
463
|
+
*/
|
|
464
|
+
export function answerIdentityProblem(answer, answerHash, settings = {}) {
|
|
465
|
+
if (typeof answerHash !== "string" || answerHash === "") {
|
|
466
|
+
return settings.allowUnbound
|
|
467
|
+
? null
|
|
468
|
+
: "no recorded answer hash; the answer cannot be bound to its trial";
|
|
469
|
+
}
|
|
470
|
+
const actual = createHash("sha256").update(answer, "utf8").digest("hex");
|
|
471
|
+
if (actual !== answerHash) {
|
|
472
|
+
return "recorded answer does not match meta.answerHash; the answer was altered after capture";
|
|
473
|
+
}
|
|
474
|
+
return null;
|
|
475
|
+
}
|