@clear-capabilities/agentic-security-scanner 0.171.0 → 0.172.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/CHANGELOG.md +29 -0
- package/bin/agentic-security.js +6 -0
- package/dist/1336.index.js +891 -719
- package/dist/550.index.js +548 -0
- package/dist/7405.index.js +885 -0
- package/dist/9382.index.js +4386 -0
- package/dist/9579.index.js +1197 -0
- package/dist/agentic-security.mjs +4 -4
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +4 -4
- package/src/context-intelligence/CLAUDE.md +25 -0
- package/src/context-intelligence/adapters/existing-ir.js +14 -0
- package/src/context-intelligence/adapters/haskell-hie.js +165 -0
- package/src/context-intelligence/build.js +68 -0
- package/src/context-intelligence/candidates.js +115 -0
- package/src/context-intelligence/canonical.js +120 -0
- package/src/context-intelligence/capabilities.js +33 -0
- package/src/context-intelligence/cli.js +426 -0
- package/src/context-intelligence/config.js +91 -0
- package/src/context-intelligence/controls.js +244 -0
- package/src/context-intelligence/diff.js +97 -0
- package/src/context-intelligence/entrypoints.js +336 -0
- package/src/context-intelligence/evaluation/index.js +235 -0
- package/src/context-intelligence/evaluation/runner.js +139 -0
- package/src/context-intelligence/evaluation/scripted-reader.js +289 -0
- package/src/context-intelligence/files.js +80 -0
- package/src/context-intelligence/graph-schema.js +227 -0
- package/src/context-intelligence/imports.js +123 -0
- package/src/context-intelligence/manifest.js +99 -0
- package/src/context-intelligence/packer.js +224 -0
- package/src/context-intelligence/persist.js +40 -0
- package/src/context-intelligence/prompts/packs.js +82 -0
- package/src/context-intelligence/queue.js +355 -0
- package/src/context-intelligence/reachability.js +188 -0
- package/src/context-intelligence/repo-info.js +16 -0
- package/src/context-intelligence/review.js +370 -0
- package/src/context-intelligence/spans.js +147 -0
- package/src/context-intelligence/store.js +203 -0
- package/src/posture/artifact-registry.js +2 -0
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Durable, bounded review queue.
|
|
3
|
+
//
|
|
4
|
+
// Every job ends in exactly one terminal state, with a reason, and the run reports both how many jobs were ELIGIBLE and
|
|
5
|
+
// how many were ATTEMPTED, so a run that stopped early can never read as a clean one:
|
|
6
|
+
//
|
|
7
|
+
// succeeded a verified answer was obtained
|
|
8
|
+
// partial the job ran but the answer was declined (bad citation, malformed, context unavailable)
|
|
9
|
+
// failed the model call failed and retries were exhausted or the failure is not retryable
|
|
10
|
+
// timed_out the job-level deadline passed
|
|
11
|
+
// skipped_by_policy egress policy, redaction, a size limit or an exhausted budget stopped it BEFORE any call
|
|
12
|
+
// cancelled the run was cancelled before the job finished
|
|
13
|
+
//
|
|
14
|
+
// Nothing here talks to a provider: the invoker comes from `review.resolveInvoker`, which goes through the governed
|
|
15
|
+
// discovery caller. Local-only policy therefore holds by construction, and there is no fallback to a different provider.
|
|
16
|
+
//
|
|
17
|
+
// Bounds are absolute: jobs, concurrency, model calls (retries count), tokens per job, estimated dollars (only when a
|
|
18
|
+
// price is supplied), and wall time. No unbounded queue growth, no infinite retry.
|
|
19
|
+
import { hashOf, canonicalJson, sha256 } from './canonical.js';
|
|
20
|
+
import { packRef, REVIEW_SCHEMA_VERSION } from './prompts/packs.js';
|
|
21
|
+
import { runReviewOnce, verifyReview } from './review.js';
|
|
22
|
+
import { readStoreJson, writeStoreJson, writeStoreFile } from './store.js';
|
|
23
|
+
|
|
24
|
+
export const TERMINAL_STATES = Object.freeze(['succeeded', 'partial', 'failed', 'timed_out', 'skipped_by_policy', 'cancelled']);
|
|
25
|
+
export const QUEUE_SCHEMA_VERSION = 'review-queue/1';
|
|
26
|
+
export const HARD_LIMITS = Object.freeze({ maxJobs: 20000, maxConcurrency: 16, maxCalls: 5000, maxRetries: 3, maxWallSeconds: 86400 });
|
|
27
|
+
const MAX_GROUP = 6;
|
|
28
|
+
|
|
29
|
+
/** Group entry points that operate on the same kind of object (the static segment before the last path parameter). */
|
|
30
|
+
export function resourceKeyOf(pathTemplate) {
|
|
31
|
+
const segs = String(pathTemplate || '').split('/').filter(Boolean);
|
|
32
|
+
let lastParam = -1;
|
|
33
|
+
segs.forEach((s, i) => { if (/^[:{<]/.test(s) || /[}>]$/.test(s)) lastParam = i; });
|
|
34
|
+
for (let i = lastParam - 1; i >= 0; i--) if (!/^[:{<]/.test(segs[i])) return segs[i];
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function jobKeyOf({ entrypointIds, contextHashes, packageIndex, pack, model }) {
|
|
39
|
+
return hashOf({
|
|
40
|
+
schema: REVIEW_SCHEMA_VERSION,
|
|
41
|
+
entrypoints: entrypointIds,
|
|
42
|
+
contexts: contextHashes,
|
|
43
|
+
packageIndex,
|
|
44
|
+
pack: packRef(pack),
|
|
45
|
+
model: { provider: model.provider || null, model: model.model || null },
|
|
46
|
+
settings: {},
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Plan jobs. `bundles` are build results (`{entrypoint, manifest}`). Excluded entry points get no job; they are counted
|
|
52
|
+
* by the caller from the entry-point dispositions.
|
|
53
|
+
*/
|
|
54
|
+
export function planJobs({ bundles, packs, model, maxJobs = HARD_LIMITS.maxJobs }) {
|
|
55
|
+
const live = bundles.filter(b => b.manifest);
|
|
56
|
+
const jobs = [];
|
|
57
|
+
for (const pack of packs) {
|
|
58
|
+
if (pack.multiBundle) {
|
|
59
|
+
const groups = new Map();
|
|
60
|
+
for (const b of live) {
|
|
61
|
+
if (b.manifest.context.integrity === 'unavailable') continue;
|
|
62
|
+
const k = resourceKeyOf(b.entrypoint.pathTemplate);
|
|
63
|
+
if (!k) continue;
|
|
64
|
+
if (!groups.has(k)) groups.set(k, []);
|
|
65
|
+
groups.get(k).push(b);
|
|
66
|
+
}
|
|
67
|
+
for (const [k, members] of [...groups.entries()].sort((a, b) => (a[0] < b[0] ? -1 : 1))) {
|
|
68
|
+
if (members.length < 2) continue;
|
|
69
|
+
const sorted = members.sort((a, b) => (a.entrypoint.id < b.entrypoint.id ? -1 : 1));
|
|
70
|
+
const used = sorted.slice(0, MAX_GROUP);
|
|
71
|
+
jobs.push({
|
|
72
|
+
key: jobKeyOf({ entrypointIds: used.map(m => m.entrypoint.id), contextHashes: used.map(m => m.manifest.contextHash), packageIndex: 1, pack, model }),
|
|
73
|
+
packId: pack.id, packRef: packRef(pack), label: `${pack.id}: ${k} (${used.length} routes)`, entrypointIds: used.map(m => m.entrypoint.id),
|
|
74
|
+
contextHashes: used.map(m => m.manifest.contextHash), packageIndexes: used.map(() => 1), groupTruncated: sorted.length > used.length ? sorted.length - used.length : 0,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
} else {
|
|
78
|
+
for (const b of live) {
|
|
79
|
+
for (const pk of b.manifest.context.packages.length ? b.manifest.context.packages : [{ index: 1 }]) {
|
|
80
|
+
jobs.push({
|
|
81
|
+
key: jobKeyOf({ entrypointIds: [b.entrypoint.id], contextHashes: [b.manifest.contextHash], packageIndex: pk.index, pack, model }),
|
|
82
|
+
packId: pack.id, packRef: packRef(pack), label: `${pack.id}: ${b.entrypoint.label}${b.manifest.context.packages.length > 1 ? ` [package ${pk.index}/${pk.total}]` : ''}`,
|
|
83
|
+
entrypointIds: [b.entrypoint.id], contextHashes: [b.manifest.contextHash], packageIndexes: [pk.index], groupTruncated: 0,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
jobs.sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
|
|
90
|
+
const dropped = Math.max(0, jobs.length - maxJobs);
|
|
91
|
+
return { jobs: jobs.slice(0, maxJobs), droppedByJobLimit: dropped };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ---- queue state ----------------------------------------------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
export function runIdOf(jobs, limits) { return hashOf({ jobs: jobs.map(j => j.key), limits: { calls: limits.maxCalls } }).slice(0, 24); }
|
|
97
|
+
|
|
98
|
+
function sealState(state) {
|
|
99
|
+
const body = { ...state };
|
|
100
|
+
delete body.stateHash;
|
|
101
|
+
return { ...body, stateHash: sha256(canonicalJson(body)) };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function loadState(root, runId) {
|
|
105
|
+
const r = readStoreJson(root, `queues/${runId}.json`);
|
|
106
|
+
if (!r.ok) return { ok: false, corrupt: r.code === 'corrupt-json' };
|
|
107
|
+
const { stateHash, ...body } = r.value;
|
|
108
|
+
if (r.value.schemaVersion !== QUEUE_SCHEMA_VERSION || sha256(canonicalJson(body)) !== stateHash) return { ok: false, corrupt: true };
|
|
109
|
+
return { ok: true, state: r.value };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function reviewPath(key) { return `reviews/${key}.json`; }
|
|
113
|
+
|
|
114
|
+
/** A stored review is reusable only if it still verifies as a review of exactly this job. */
|
|
115
|
+
function loadCachedReview(root, job) {
|
|
116
|
+
const r = readStoreJson(root, reviewPath(job.key));
|
|
117
|
+
if (!r.ok) return null;
|
|
118
|
+
const v = r.value;
|
|
119
|
+
if (!v || v.jobKey !== job.key || !v.review || v.review.schemaVersion !== REVIEW_SCHEMA_VERSION) return null;
|
|
120
|
+
if (sha256(canonicalJson(v.review)) !== v.reviewHash) return null;
|
|
121
|
+
return v;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ---- run --------------------------------------------------------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
const defaultSleep = (ms) => new Promise(r => setTimeout(r, ms));
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Run a planned job list.
|
|
130
|
+
* ctx.root, ctx.loadJob(job) -> { pack, entrypoints, manifests, packageTexts } | { error }
|
|
131
|
+
* ctx.fileContents, ctx.invoker ({ok,invoke}|{ok:false,reason,denied}), ctx.model
|
|
132
|
+
* ctx.limits { concurrency, maxCalls, maxWallSeconds, maxTokensPerJob, maxRetries, jobTimeoutMs, maxCostUsd, costPerCallUsd }
|
|
133
|
+
* ctx.signal (AbortSignal), ctx.sleep, ctx.random, ctx.now -- injected so tests are deterministic
|
|
134
|
+
* ctx.retryFailed (re-run jobs that ended failed/timed_out), ctx.onEvent
|
|
135
|
+
*/
|
|
136
|
+
export async function runQueue(jobs, ctx) {
|
|
137
|
+
const now = ctx.now || (() => Date.now());
|
|
138
|
+
const sleep = ctx.sleep || defaultSleep;
|
|
139
|
+
const random = ctx.random || Math.random;
|
|
140
|
+
const emit = typeof ctx.onEvent === 'function' ? ctx.onEvent : () => {};
|
|
141
|
+
const L = {
|
|
142
|
+
concurrency: Math.min(HARD_LIMITS.maxConcurrency, Math.max(1, ctx.limits.concurrency || 1)),
|
|
143
|
+
maxCalls: Math.min(HARD_LIMITS.maxCalls, Math.max(1, ctx.limits.maxCalls || 100)),
|
|
144
|
+
maxWallMs: Math.min(HARD_LIMITS.maxWallSeconds, Math.max(1, ctx.limits.maxWallSeconds || 900)) * 1000,
|
|
145
|
+
maxTokensPerJob: ctx.limits.maxTokensPerJob || 40000,
|
|
146
|
+
maxRetries: Math.min(HARD_LIMITS.maxRetries, Math.max(0, ctx.limits.maxRetries ?? 3)),
|
|
147
|
+
jobTimeoutMs: ctx.limits.jobTimeoutMs || 120000,
|
|
148
|
+
maxCostUsd: Number.isFinite(ctx.limits.maxCostUsd) ? ctx.limits.maxCostUsd : null,
|
|
149
|
+
costPerCallUsd: Number.isFinite(ctx.limits.costPerCallUsd) ? ctx.limits.costPerCallUsd : null,
|
|
150
|
+
};
|
|
151
|
+
const runId = runIdOf(jobs, L);
|
|
152
|
+
const started = now();
|
|
153
|
+
const loaded = loadState(ctx.root, runId);
|
|
154
|
+
const prior = loaded.ok ? loaded.state.jobs : {};
|
|
155
|
+
const state = { schemaVersion: QUEUE_SCHEMA_VERSION, runId, jobs: {} };
|
|
156
|
+
const results = new Map();
|
|
157
|
+
let calls = 0;
|
|
158
|
+
let estCost = 0;
|
|
159
|
+
let cooldownUntil = 0;
|
|
160
|
+
let budgetStop = null;
|
|
161
|
+
let persistChain = Promise.resolve();
|
|
162
|
+
const persist = () => {
|
|
163
|
+
persistChain = persistChain.then(() => { writeStoreJson(ctx.root, `queues/${runId}.json`, sealState(state)); });
|
|
164
|
+
return persistChain;
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
const settle = async (job, outcome) => {
|
|
168
|
+
state.jobs[job.key] = { state: outcome.state, reason: outcome.reason || null, attempts: outcome.attempts || 0, label: job.label };
|
|
169
|
+
results.set(job.key, { job, ...outcome });
|
|
170
|
+
if (outcome.review) {
|
|
171
|
+
const reviewRecord = { schemaVersion: 'review-result/1', jobKey: job.key, questionPack: job.packRef, entrypointIds: job.entrypointIds, contextHashes: job.contextHashes, state: outcome.state, review: outcome.review, reviewHash: sha256(canonicalJson(outcome.review)) };
|
|
172
|
+
writeStoreJson(ctx.root, reviewPath(job.key), reviewRecord);
|
|
173
|
+
}
|
|
174
|
+
await persist();
|
|
175
|
+
emit({ event: 'job', key: job.key, state: outcome.state, reason: outcome.reason || null });
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
// classify jobs: cached / settled-before / to run
|
|
179
|
+
const todo = [];
|
|
180
|
+
for (const job of jobs) {
|
|
181
|
+
const p = prior[job.key];
|
|
182
|
+
const cached = loadCachedReview(ctx.root, job);
|
|
183
|
+
if (cached && cached.state === 'succeeded') {
|
|
184
|
+
results.set(job.key, { job, state: 'succeeded', reason: null, review: cached.review, cached: true, attempts: 0 });
|
|
185
|
+
state.jobs[job.key] = { state: 'succeeded', reason: null, attempts: 0, label: job.label, cached: true };
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
// A policy skip, a cancellation and an interrupted job say nothing about the model, so they are always re-evaluated:
|
|
189
|
+
// the policy or the endpoint may be different now. Answers (even declined ones) and real failures are kept so a
|
|
190
|
+
// resume does not pay twice, unless the operator asks to retry them.
|
|
191
|
+
const resettable = !p || p.state === 'skipped_by_policy' || p.state === 'cancelled' || p.state === 'running' || p.state === 'pending' || (ctx.retryFailed && (p.state === 'failed' || p.state === 'timed_out'));
|
|
192
|
+
if (!resettable && ['partial', 'failed', 'timed_out', 'succeeded'].includes(p.state)) {
|
|
193
|
+
state.jobs[job.key] = { ...p, resumed: true };
|
|
194
|
+
results.set(job.key, { job, state: p.state, reason: p.reason, attempts: p.attempts, resumed: true, review: cached ? cached.review : undefined });
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
state.jobs[job.key] = { state: 'pending', reason: null, attempts: 0, label: job.label };
|
|
198
|
+
todo.push(job);
|
|
199
|
+
}
|
|
200
|
+
await persist();
|
|
201
|
+
|
|
202
|
+
const gate = async () => { const wait = cooldownUntil - now(); if (wait > 0) await sleep(wait); };
|
|
203
|
+
|
|
204
|
+
const budgetReason = () => {
|
|
205
|
+
if (ctx.signal && ctx.signal.aborted) return 'cancelled';
|
|
206
|
+
if (now() - started >= L.maxWallMs) return 'budget-exhausted:wall-time';
|
|
207
|
+
if (calls >= L.maxCalls) return 'budget-exhausted:max-calls';
|
|
208
|
+
if (L.maxCostUsd !== null && L.costPerCallUsd !== null && estCost + L.costPerCallUsd > L.maxCostUsd) return 'budget-exhausted:max-cost';
|
|
209
|
+
return null;
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
const runOne = async (job) => {
|
|
213
|
+
const stop = budgetReason();
|
|
214
|
+
if (stop) { return settle(job, stop === 'cancelled' ? { state: 'cancelled', reason: 'run cancelled before this job started' } : { state: 'skipped_by_policy', reason: stop }); }
|
|
215
|
+
if (!ctx.invoker || !ctx.invoker.ok) return settle(job, { state: 'skipped_by_policy', reason: (ctx.invoker && ctx.invoker.reason) || 'no model endpoint is configured' });
|
|
216
|
+
|
|
217
|
+
const loadedJob = ctx.loadJob(job);
|
|
218
|
+
if (loadedJob.error) return settle(job, { state: 'failed', reason: loadedJob.error, attempts: 0 });
|
|
219
|
+
|
|
220
|
+
let attempts = 0;
|
|
221
|
+
for (;;) {
|
|
222
|
+
const s2 = budgetReason();
|
|
223
|
+
if (s2) return settle(job, s2 === 'cancelled' ? { state: 'cancelled', reason: 'run cancelled', attempts } : { state: 'skipped_by_policy', reason: s2, attempts });
|
|
224
|
+
await gate();
|
|
225
|
+
attempts++;
|
|
226
|
+
calls++;
|
|
227
|
+
if (L.costPerCallUsd !== null) estCost += L.costPerCallUsd;
|
|
228
|
+
state.jobs[job.key] = { state: 'running', reason: null, attempts, label: job.label };
|
|
229
|
+
const out = await runReviewOnce({
|
|
230
|
+
pack: loadedJob.pack, entrypoints: loadedJob.entrypoints, manifests: loadedJob.manifests, packageIndexes: job.packageIndexes, packageTexts: loadedJob.packageTexts,
|
|
231
|
+
fileContents: ctx.fileContents, scanRoot: ctx.root, invoker: ctx.invoker, model: ctx.model, limits: { maxTokensPerJob: L.maxTokensPerJob, jobTimeoutMs: L.jobTimeoutMs },
|
|
232
|
+
});
|
|
233
|
+
if (out.state === 'skipped_by_policy') calls--; // nothing was sent
|
|
234
|
+
if (out.state === 'failed' && out.retryable && attempts <= L.maxRetries) {
|
|
235
|
+
// exponential backoff with jitter; the shared cooldown slows every worker, which is what a 429 storm needs
|
|
236
|
+
const backoff = Math.min(30000, 500 * 2 ** (attempts - 1)) + Math.floor(random() * 250);
|
|
237
|
+
cooldownUntil = Math.max(cooldownUntil, now() + backoff);
|
|
238
|
+
emit({ event: 'retry', key: job.key, attempt: attempts, backoffMs: backoff, reason: out.reason });
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
return settle(job, { ...out, attempts });
|
|
242
|
+
}
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
let next = 0;
|
|
246
|
+
const worker = async () => {
|
|
247
|
+
for (;;) {
|
|
248
|
+
const idx = next++;
|
|
249
|
+
if (idx >= todo.length) return;
|
|
250
|
+
try { await runOne(todo[idx]); } catch (err) { await settle(todo[idx], { state: 'failed', reason: `internal error: ${String((err && err.message) || err).slice(0, 120)}` }); }
|
|
251
|
+
}
|
|
252
|
+
};
|
|
253
|
+
const poolSize = Math.min(L.concurrency, Math.max(1, todo.length));
|
|
254
|
+
await Promise.all(Array.from({ length: poolSize }, worker));
|
|
255
|
+
await persistChain;
|
|
256
|
+
|
|
257
|
+
// anything still pending/running was never finished: it is cancelled, not clean
|
|
258
|
+
for (const job of todo) {
|
|
259
|
+
const s = state.jobs[job.key];
|
|
260
|
+
if (s && (s.state === 'pending' || s.state === 'running')) await settle(job, { state: 'cancelled', reason: 'run ended before this job finished', attempts: s.attempts });
|
|
261
|
+
}
|
|
262
|
+
budgetStop = budgetReason();
|
|
263
|
+
return { runId, results, coverage: coverageOf(jobs, results, { calls, estCost: L.costPerCallUsd !== null ? estCost : null, costBasis: L.costPerCallUsd !== null ? 'estimate: operator-supplied per-call price' : 'unknown: no price supplied, dollar cap not enforceable', limits: L, wallMs: now() - started, budgetStop, queueStateCorrupt: loaded.corrupt === true }) };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export function coverageOf(jobs, results, extra = {}) {
|
|
267
|
+
const c = { eligible: jobs.length, attempted: 0, succeeded: 0, partial: 0, failed: 0, timed_out: 0, skipped_by_policy: 0, cancelled: 0, cached: 0, resumed: 0, modelCalls: extra.calls || 0, estimatedCostUsd: extra.estCost ?? null, costBasis: extra.costBasis || null, wallMs: extra.wallMs || 0, budgetStop: extra.budgetStop || null, queueStateCorrupt: !!extra.queueStateCorrupt, reasons: {} };
|
|
268
|
+
for (const job of jobs) {
|
|
269
|
+
const r = results.get(job.key);
|
|
270
|
+
if (!r) { c.cancelled++; continue; }
|
|
271
|
+
c[r.state] = (c[r.state] || 0) + 1;
|
|
272
|
+
if (r.cached) c.cached++;
|
|
273
|
+
if (r.resumed) c.resumed++;
|
|
274
|
+
if (r.state !== 'skipped_by_policy' && r.state !== 'cancelled') c.attempted++;
|
|
275
|
+
if (r.reason) c.reasons[r.reason] = (c.reasons[r.reason] || 0) + 1;
|
|
276
|
+
}
|
|
277
|
+
// only a succeeded job with a conclusion is a reviewed route; everything else is missing coverage, never a clean pass
|
|
278
|
+
c.reviewed = c.succeeded;
|
|
279
|
+
c.missingCoverage = c.eligible - c.succeeded;
|
|
280
|
+
return c;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// ---- offline batch: export a request file, import a result file --------------------------------------------------
|
|
284
|
+
|
|
285
|
+
export function batchIdOf(jobs) { return hashOf(jobs.map(j => j.key)).slice(0, 24); }
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Write a provider-agnostic batch: a request file (one JSON line per job, with the redacted prompt) and a manifest that
|
|
289
|
+
* binds every request to its job key, context hash and prompt hash. No provider API is called: uploading the file and
|
|
290
|
+
* downloading results is the operator's step, and the contract of any particular provider's batch API is NOT assumed.
|
|
291
|
+
*/
|
|
292
|
+
export function exportBatch(root, jobs, loadJob, ctx) {
|
|
293
|
+
const { buildReviewPrompt, redactPackageText, checkEgressForFiles } = ctx.review;
|
|
294
|
+
const lines = [];
|
|
295
|
+
const entries = [];
|
|
296
|
+
const skipped = [];
|
|
297
|
+
for (const job of jobs) {
|
|
298
|
+
const lj = loadJob(job);
|
|
299
|
+
if (lj.error) { skipped.push({ key: job.key, reason: lj.error }); continue; }
|
|
300
|
+
const files = [...new Set(lj.manifests.flatMap((m, i) => (m.context.packages.find(p => p.index === job.packageIndexes[i]) || { segments: [] }).segments.map(s => s.file)))];
|
|
301
|
+
const eg = checkEgressForFiles(files, { scanRoot: root, model: ctx.model });
|
|
302
|
+
if (!eg.allowed) { skipped.push({ key: job.key, reason: `egress policy denied ${eg.file}: ${eg.reason}` }); continue; }
|
|
303
|
+
const red = lj.packageTexts.map(t => redactPackageText(t, { scanRoot: root }));
|
|
304
|
+
const bad = red.find(r => !r.ok);
|
|
305
|
+
if (bad) { skipped.push({ key: job.key, reason: `redaction refused: ${bad.reason}` }); continue; }
|
|
306
|
+
const { prompt, promptHash } = buildReviewPrompt({ pack: lj.pack, entrypoints: lj.entrypoints, packageTexts: red.map(r => r.text), contextHash: lj.manifests[0].contextHash });
|
|
307
|
+
lines.push(JSON.stringify({ custom_id: job.key, prompt }));
|
|
308
|
+
entries.push({ key: job.key, packRef: job.packRef, entrypointIds: job.entrypointIds, contextHashes: job.contextHashes, packageIndexes: job.packageIndexes, promptHash });
|
|
309
|
+
}
|
|
310
|
+
const batchId = batchIdOf(jobs);
|
|
311
|
+
const manifest = { schemaVersion: 'review-batch/1', batchId, jobs: entries, skipped, note: 'Provider batch upload and download are performed by the operator. Results are imported with `context review --import-batch`.' };
|
|
312
|
+
const w1 = writeStoreFile(root, `batches/${batchId}/requests.jsonl`, lines.join('\n') + (lines.length ? '\n' : ''));
|
|
313
|
+
if (!w1.ok) return w1;
|
|
314
|
+
const w2 = writeStoreJson(root, `batches/${batchId}/manifest.json`, manifest);
|
|
315
|
+
if (!w2.ok) return w2;
|
|
316
|
+
return { ok: true, batchId, requests: entries.length, skipped, requestsFile: w1.path };
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Import results for a batch. Each result line is `{ custom_id, response }` where `response` is the model text.
|
|
321
|
+
* A result is REJECTED when it names an unknown job, repeats a job already settled (first wins), or was produced for a
|
|
322
|
+
* context that is no longer the current one (stale or out-of-order). Import is idempotent.
|
|
323
|
+
*/
|
|
324
|
+
export function importBatch(root, batchId, resultsText, ctx) {
|
|
325
|
+
const m = readStoreJson(root, `batches/${batchId}/manifest.json`);
|
|
326
|
+
if (!m.ok) return { ok: false, reason: `batch manifest ${m.code}` };
|
|
327
|
+
const byKey = new Map(m.value.jobs.map(j => [j.key, j]));
|
|
328
|
+
const seen = new Set();
|
|
329
|
+
const report = { accepted: 0, declined: 0, rejected: [], duplicates: 0 };
|
|
330
|
+
for (const lineRaw of String(resultsText).split('\n')) {
|
|
331
|
+
const line = lineRaw.trim();
|
|
332
|
+
if (!line) continue;
|
|
333
|
+
let rec;
|
|
334
|
+
try { rec = JSON.parse(line); } catch { report.rejected.push({ reason: 'not-json' }); continue; }
|
|
335
|
+
const key = rec && rec.custom_id;
|
|
336
|
+
const entry = byKey.get(key);
|
|
337
|
+
if (!entry) { report.rejected.push({ key: key || null, reason: 'unknown-job' }); continue; }
|
|
338
|
+
const settled = loadCachedReview(root, { key });
|
|
339
|
+
if (seen.has(key) || (settled && settled.state === 'succeeded')) { report.duplicates++; continue; }
|
|
340
|
+
seen.add(key);
|
|
341
|
+
const job = ctx.jobs.find(j => j.key === key);
|
|
342
|
+
if (!job) { report.rejected.push({ key, reason: 'job-not-in-current-plan' }); continue; }
|
|
343
|
+
const lj = ctx.loadJob(job);
|
|
344
|
+
if (lj.error) { report.rejected.push({ key, reason: lj.error }); continue; }
|
|
345
|
+
if (lj.manifests.some((mm, i) => mm.contextHash !== entry.contextHashes[i])) { report.rejected.push({ key, reason: 'stale-context' }); continue; }
|
|
346
|
+
const promptHash = entry.promptHash;
|
|
347
|
+
const review = verifyReview({ raw: typeof rec.response === 'string' ? rec.response : '', pack: lj.pack, entrypoints: lj.entrypoints, manifests: lj.manifests, packageIndexes: job.packageIndexes, fileContents: ctx.fileContents, promptHash });
|
|
348
|
+
const state = review.status === 'completed' ? 'succeeded' : 'partial';
|
|
349
|
+
const record = { schemaVersion: 'review-result/1', jobKey: key, questionPack: job.packRef, entrypointIds: job.entrypointIds, contextHashes: job.contextHashes, state, review, reviewHash: sha256(canonicalJson(review)), via: 'batch-import' };
|
|
350
|
+
writeStoreJson(root, reviewPath(key), record);
|
|
351
|
+
if (state === 'succeeded') report.accepted++; else report.declined++;
|
|
352
|
+
}
|
|
353
|
+
return { ok: true, ...report };
|
|
354
|
+
}
|
|
355
|
+
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Deterministic, bounded reachability from an entry point's handler.
|
|
3
|
+
//
|
|
4
|
+
// Breadth-first, with a stable tie-break (call line, then callee name, then target id), so the same graph always
|
|
5
|
+
// yields the same visit order and therefore the same bundle bytes. Three things this module refuses to do:
|
|
6
|
+
//
|
|
7
|
+
// * claim exhaustiveness. A call that could not be resolved to a declaration is recorded as an explicit unknown.
|
|
8
|
+
// Only the ones that COULD hide first-party code (the callee's name matches a first-party declaration, or the
|
|
9
|
+
// callee is dynamic) are `material`; a call into a library or a response helper is a boundary, listed but not
|
|
10
|
+
// blocking, because nothing first-party can be hiding behind it.
|
|
11
|
+
// * hide a limit. Hitting the symbol or depth cap marks the walk `capped` and lists what was left unvisited.
|
|
12
|
+
// * lose a control. Middleware and every callee are walked, and each visited symbol's controls are collected by the
|
|
13
|
+
// same classifier the packer uses to refuse pruning.
|
|
14
|
+
import { analyzeSymbolText } from './controls.js';
|
|
15
|
+
import { cmpCodeUnit } from './canonical.js';
|
|
16
|
+
import { MATERIAL_REASONS } from './graph-schema.js';
|
|
17
|
+
|
|
18
|
+
export const DEFAULT_LIMITS = Object.freeze({ maxSymbols: 60, maxDepth: 12 });
|
|
19
|
+
|
|
20
|
+
function indexGraph(graph) {
|
|
21
|
+
const byId = new Map();
|
|
22
|
+
const edgesFrom = new Map();
|
|
23
|
+
for (const s of graph.symbols) byId.set(s.id, s);
|
|
24
|
+
for (const e of graph.edges) {
|
|
25
|
+
if (!edgesFrom.has(e.from)) edgesFrom.set(e.from, []);
|
|
26
|
+
edgesFrom.get(e.from).push(e);
|
|
27
|
+
}
|
|
28
|
+
return { byId, edgesFrom };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Walk the graph from an entry point.
|
|
33
|
+
* Returns { root, visited, facts, unknowns, boundaries, capped, unvisited, cycles, limits }.
|
|
34
|
+
*/
|
|
35
|
+
export function reachFrom({ graph, fileContents, entrypoint, limits = {} }) {
|
|
36
|
+
const lim = { ...DEFAULT_LIMITS, ...limits };
|
|
37
|
+
const { byId, edgesFrom } = indexGraph(graph);
|
|
38
|
+
const linesCache = new Map();
|
|
39
|
+
const linesOf = (f) => { if (!linesCache.has(f)) linesCache.set(f, (fileContents[f] || '').split('\n')); return linesCache.get(f); };
|
|
40
|
+
|
|
41
|
+
const visited = [];
|
|
42
|
+
const seen = new Set();
|
|
43
|
+
const unknowns = [];
|
|
44
|
+
const boundaryMap = new Map();
|
|
45
|
+
let cycles = 0;
|
|
46
|
+
let capped = false;
|
|
47
|
+
const queue = [];
|
|
48
|
+
|
|
49
|
+
const inlineRoot = entrypoint.handlerKind === 'inline' && entrypoint.rootSpan && entrypoint.language === 'haskell';
|
|
50
|
+
const rootSymbol = entrypoint.handlerSymbol ? byId.get(entrypoint.handlerSymbol) : null;
|
|
51
|
+
|
|
52
|
+
// the "root view": either the handler declaration or, for an inline handler inside a larger declaration, only the
|
|
53
|
+
// registration block's lines
|
|
54
|
+
let root = null;
|
|
55
|
+
if (inlineRoot && rootSymbol) {
|
|
56
|
+
root = { kind: 'inline-block', symbolId: rootSymbol.id, file: entrypoint.rootSpan.file, startLine: entrypoint.rootSpan.startLine, endLine: entrypoint.rootSpan.endLine, declLine: entrypoint.rootSpan.startLine, spanPrecision: entrypoint.rootSpan.precision };
|
|
57
|
+
} else if (rootSymbol) {
|
|
58
|
+
root = { kind: 'declaration', symbolId: rootSymbol.id, file: rootSymbol.file, startLine: rootSymbol.startLine, endLine: rootSymbol.endLine, declLine: rootSymbol.declLine, spanPrecision: rootSymbol.spanPrecision };
|
|
59
|
+
} else if (entrypoint.rootSpan) {
|
|
60
|
+
root = { kind: 'registration-only', symbolId: null, file: entrypoint.rootSpan.file, startLine: entrypoint.rootSpan.startLine, endLine: entrypoint.rootSpan.endLine, declLine: entrypoint.rootSpan.startLine, spanPrecision: entrypoint.rootSpan.precision };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const enqueue = (symbolId, depth, reason, viaLine, path, confidence) => {
|
|
64
|
+
if (seen.has(symbolId)) { cycles++; return; }
|
|
65
|
+
seen.add(symbolId);
|
|
66
|
+
queue.push({ symbolId, depth, reason, viaLine, path, confidence });
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
if (!root) {
|
|
70
|
+
return { root: null, visited: [], facts: [], unknowns: [{ type: 'no-handler', site: `${entrypoint.registrationSite.file}:${entrypoint.registrationSite.line}`, reason: 'entry point has no resolvable handler or registration block', material: true }], boundaries: [], capped: false, unvisited: [], cycles: 0, limits: lim };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// middleware runs before the handler: it is enqueued first and ordered at line -1
|
|
74
|
+
for (const mw of [...entrypoint.middleware].sort((a, b) => (a.line - b.line) || cmpCodeUnit(a.name, b.name))) {
|
|
75
|
+
if (mw.symbolId && byId.has(mw.symbolId)) enqueue(mw.symbolId, 0, mw.inherited ? 'inherited-middleware' : 'middleware', -1, [mw.symbolId], mw.resolution === 'resolved' ? 'exact' : 'name-match');
|
|
76
|
+
}
|
|
77
|
+
if (root.symbolId) {
|
|
78
|
+
if (!seen.has(root.symbolId)) { seen.add(root.symbolId); }
|
|
79
|
+
queue.unshift({ symbolId: root.symbolId, depth: 0, reason: 'handler', viaLine: null, path: [root.symbolId], confidence: entrypoint.handlerConfidence, isRoot: true });
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const emitted = [];
|
|
83
|
+
while (queue.length) {
|
|
84
|
+
const cur = queue.shift();
|
|
85
|
+
const sym = byId.get(cur.symbolId);
|
|
86
|
+
if (!sym) continue;
|
|
87
|
+
if (emitted.length >= lim.maxSymbols) { capped = true; queue.unshift(cur); break; }
|
|
88
|
+
emitted.push(cur);
|
|
89
|
+
visited.push({ symbolId: cur.symbolId, depth: cur.depth, reason: cur.reason, viaLine: cur.viaLine, path: cur.path, confidence: cur.confidence });
|
|
90
|
+
|
|
91
|
+
const restrictTo = cur.isRoot && root.kind === 'inline-block' ? [root.startLine, root.endLine] : null;
|
|
92
|
+
const out = (edgesFrom.get(cur.symbolId) || [])
|
|
93
|
+
.filter(e => !e.superseded)
|
|
94
|
+
.filter(e => !restrictTo || (e.line >= restrictTo[0] && e.line <= restrictTo[1]))
|
|
95
|
+
.sort((a, b) => (a.line - b.line) || (a.calleeName < b.calleeName ? -1 : a.calleeName > b.calleeName ? 1 : 0) || ((a.to || '') < (b.to || '') ? -1 : 1));
|
|
96
|
+
for (const e of out) {
|
|
97
|
+
// The root-level line a chain started on: callees of the handler are ordered by the line of the call that led to them.
|
|
98
|
+
const via = cur.isRoot ? e.line : cur.viaLine;
|
|
99
|
+
if (e.conflict) {
|
|
100
|
+
// the compiler and the static pass disagree about this call: follow both, and say the bundle is not settled
|
|
101
|
+
unknowns.push({ type: 'compiler-static-conflict', site: `${sym.file}:${e.line}`, from: cur.symbolId, calleeName: e.calleeName, reason: 'compiler-and-static-analysis-disagree', material: true, candidates: [] });
|
|
102
|
+
}
|
|
103
|
+
if (e.to) {
|
|
104
|
+
if (cur.depth + 1 > lim.maxDepth) { capped = true; continue; }
|
|
105
|
+
enqueue(e.to, cur.depth + 1, 'callee', via, [...cur.path, e.to], 'exact');
|
|
106
|
+
} else if (MATERIAL_REASONS.has(e.unresolvedReason)) {
|
|
107
|
+
unknowns.push({ type: e.kind, site: `${sym.file}:${e.line}`, from: cur.symbolId, calleeName: e.calleeName, reason: e.unresolvedReason, material: true, candidates: e.candidates });
|
|
108
|
+
// Candidates are shown to the model as CANDIDATES (low confidence); the unknown stays material regardless.
|
|
109
|
+
if (e.candidates.length && e.candidates.length <= 3 && cur.depth + 1 <= lim.maxDepth) {
|
|
110
|
+
for (const c of e.candidates) enqueue(c, cur.depth + 1, 'name-match-candidate', via, [...cur.path, c], 'name-match');
|
|
111
|
+
}
|
|
112
|
+
} else {
|
|
113
|
+
const key = `${e.calleeName}|${e.unresolvedReason}`;
|
|
114
|
+
const b = boundaryMap.get(key) || { calleeName: e.calleeName, reason: e.unresolvedReason, count: 0, firstSite: `${sym.file}:${e.line}` };
|
|
115
|
+
b.count++;
|
|
116
|
+
boundaryMap.set(key, b);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
const unvisited = queue.map(q => q.symbolId).sort();
|
|
121
|
+
|
|
122
|
+
// facts: classified once per visited symbol, with the effective ordering line attached
|
|
123
|
+
const facts = [];
|
|
124
|
+
for (const v of visited) {
|
|
125
|
+
const sym = byId.get(v.symbolId);
|
|
126
|
+
const isInlineRoot = v.reason === 'handler' && root.kind === 'inline-block';
|
|
127
|
+
const view = isInlineRoot ? { ...sym, startLine: root.startLine, endLine: root.endLine, declLine: root.startLine } : sym;
|
|
128
|
+
const hs = /\.l?hs$/i.test(sym.file);
|
|
129
|
+
for (const f of analyzeSymbolText(view, linesOf(sym.file), { haskell: hs })) {
|
|
130
|
+
let effLine;
|
|
131
|
+
if (v.reason === 'handler') effLine = f.line;
|
|
132
|
+
else if (v.reason === 'middleware' || v.reason === 'inherited-middleware') effLine = -1;
|
|
133
|
+
else effLine = v.viaLine === null || v.viaLine === undefined ? f.line : v.viaLine;
|
|
134
|
+
facts.push({ ...f, file: sym.file, effLine, via: v.reason });
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
// registration-only roots have no symbol: still classify the registration block itself
|
|
138
|
+
if (!root.symbolId) {
|
|
139
|
+
const view = { id: `${root.file}::<registration>@${root.startLine}`, file: root.file, startLine: root.startLine, endLine: root.endLine, declLine: root.startLine };
|
|
140
|
+
for (const f of analyzeSymbolText(view, linesOf(root.file), { haskell: /\.l?hs$/i.test(root.file) })) facts.push({ ...f, file: root.file, effLine: f.line, via: 'handler' });
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
unknowns.sort((a, b) => (a.site < b.site ? -1 : a.site > b.site ? 1 : 0) || (a.calleeName < b.calleeName ? -1 : 1));
|
|
144
|
+
const boundaries = [...boundaryMap.values()].sort((a, b) => (a.calleeName < b.calleeName ? -1 : a.calleeName > b.calleeName ? 1 : 0));
|
|
145
|
+
return { root, visited, facts, unknowns, boundaries, capped, unvisited, cycles, limits: lim };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Protection state per dimension. The five non-present states are deliberately distinct, because collapsing them is
|
|
150
|
+
* how "I did not see a check" turns into "there is no check":
|
|
151
|
+
* present an observed or named control precedes the first sensitive operation
|
|
152
|
+
* evaluated_and_ineffective a control exists but runs AFTER the first sensitive operation (a late guard)
|
|
153
|
+
* not_present the context is complete and no control of this kind is in it
|
|
154
|
+
* not_resolved a material unknown edge could hide one
|
|
155
|
+
* omitted_due_to_budget a symbol that holds a control was left out for size
|
|
156
|
+
* not_seen no sensitive operation was seen, so there was nothing for a control to protect
|
|
157
|
+
*/
|
|
158
|
+
export const PROTECTION_DIMENSIONS = Object.freeze(['authentication', 'authorization-role', 'ownership']);
|
|
159
|
+
|
|
160
|
+
export function evaluateProtection({ facts, unknowns, omittedWithControls = false, capped = false }) {
|
|
161
|
+
const sens = facts.filter(f => f.category === 'sensitive');
|
|
162
|
+
const firstSens = sens.length ? Math.min(...sens.map(f => f.effLine)) : null;
|
|
163
|
+
const dims = {};
|
|
164
|
+
for (const dim of PROTECTION_DIMENSIONS) {
|
|
165
|
+
const kinds = dim === 'ownership' ? ['ownership', 'tenant-scope'] : [dim];
|
|
166
|
+
const ctl = facts.filter(f => f.category === 'control' && kinds.includes(f.kind));
|
|
167
|
+
// A control whose answer is visibly thrown away (a predicate called as a bare statement) guards nothing.
|
|
168
|
+
const effective = (f) => f.rejectionSeen !== false;
|
|
169
|
+
const before = ctl.filter(f => effective(f) && (firstSens === null || f.effLine < firstSens || (f.basis === 'query-filter' && f.effLine <= firstSens)));
|
|
170
|
+
const after = ctl.filter(f => firstSens !== null && f.effLine > firstSens);
|
|
171
|
+
let state; let why;
|
|
172
|
+
if (before.length) { state = 'present'; why = 'a control precedes the first sensitive operation'; }
|
|
173
|
+
else if (after.length) { state = 'evaluated_and_ineffective'; why = 'every control of this kind runs after the first sensitive operation'; }
|
|
174
|
+
else if (ctl.length && ctl.every(f => !effective(f))) { state = 'evaluated_and_ineffective'; why = 'a check is called but nothing in view acts on its answer'; }
|
|
175
|
+
else if (ctl.length) { state = 'evaluated_and_ineffective'; why = 'a control is present but is not ordered before the sensitive operation'; }
|
|
176
|
+
else if (omittedWithControls) { state = 'omitted_due_to_budget'; why = 'a symbol holding a control was left out for size'; }
|
|
177
|
+
else if (unknowns.some(u => u.material) || capped) { state = 'not_resolved'; why = 'an unresolved call or a traversal limit could hide a control'; }
|
|
178
|
+
else if (firstSens === null) { state = 'not_seen'; why = 'no sensitive operation was seen in the resolved context'; }
|
|
179
|
+
else { state = 'not_present'; why = 'the resolved context is complete and contains no control of this kind'; }
|
|
180
|
+
dims[dim] = {
|
|
181
|
+
state, why, firstSensitiveLine: firstSens,
|
|
182
|
+
// How the control was established: 'observed' = the text shows it, 'inferred' = only a name suggests it. Only meaningful for `present`.
|
|
183
|
+
strength: state === 'present' ? (before.some(f => f.evidence === 'observed') ? 'observed' : 'inferred') : null,
|
|
184
|
+
evidence: ctl.slice(0, 6).map(f => ({ file: f.file, line: f.line, kind: f.kind, evidence: f.evidence, basis: f.basis, order: firstSens === null ? 'no-sensitive-op' : f.effLine < firstSens ? 'before-sensitive' : f.effLine > firstSens ? 'after-sensitive' : 'same-step', rejectionSeen: f.rejectionSeen })),
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
return dims;
|
|
188
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Best-effort git identity of the checkout. Absent git is not an error: the fields are null and the snapshot hash
|
|
3
|
+
// (content-based) still identifies the analysed bytes exactly.
|
|
4
|
+
import { execFileSync } from 'node:child_process';
|
|
5
|
+
|
|
6
|
+
function git(root, args) {
|
|
7
|
+
try {
|
|
8
|
+
return execFileSync('git', ['-C', root, ...args], { encoding: 'utf8', timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 1024 * 1024 }).trim();
|
|
9
|
+
} catch { return null; }
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function repositoryInfo(root, snapshotHash) {
|
|
13
|
+
const commit = git(root, ['rev-parse', 'HEAD']);
|
|
14
|
+
const status = commit ? git(root, ['status', '--porcelain']) : null;
|
|
15
|
+
return { commit: commit || null, dirty: status === null ? null : status.length > 0, snapshotHash };
|
|
16
|
+
}
|