@event4u/agent-config 14.19.0 → 14.21.0
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/.augment-plugin/marketplace.json +3 -3
- package/.augment-plugin/plugin.json +1 -1
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +351 -156
- package/README.md +1 -1
- package/agents/templates/.ai-council.yml.example +10 -0
- package/dist/agent-src/commands/agent-handoff.md +42 -41
- package/dist/agent-src/commands/analyze/inbox.md +1 -1
- package/dist/agent-src/commands/analyze/{reference-repo.md → repo.md} +247 -32
- package/dist/agent-src/commands/analyze/roadmap-repos.md +129 -0
- package/dist/agent-src/commands/analyze.md +12 -7
- package/dist/agent-src/commands/feature/plan.md +55 -16
- package/dist/agent-src/commands/feature/refactor.md +8 -3
- package/dist/agent-src/commands/humanize.md +48 -4
- package/dist/agent-src/contexts/execution/contract-decision-sheet.md +22 -1
- package/dist/agent-src/contexts/execution/host-capability-manifest.md +11 -2
- package/dist/agent-src/contexts/execution/roadmap-process-loop.md +35 -2
- package/dist/agent-src/contexts/execution/terminal-states.md +4 -1
- package/dist/agent-src/rules/evaluator-independence.md +4 -4
- package/dist/agent-src/rules/external-reference-deep-dive.md +2 -2
- package/dist/agent-src/rules/untrusted-input-defense.md +0 -2
- package/dist/agent-src/skills/competitive-positioning/SKILL.md +2 -2
- package/dist/agent-src/skills/evaluate-llm-feature/SKILL.md +1 -1
- package/dist/agent-src/skills/humanizer/references/anti-aiisms.md +27 -11
- package/dist/agent-src/skills/learning-to-rule-or-skill/SKILL.md +1 -1
- package/dist/agent-src/skills/markitdown/SKILL.md +2 -2
- package/dist/agent-src/skills/repomix-packer/SKILL.md +1 -1
- package/dist/agent-src/skills/skill-improvement-pipeline/SKILL.md +28 -6
- package/dist/agent-src/skills/technical-specification/SKILL.md +4 -0
- package/dist/agent-src/skills/test-driven-development/SKILL.md +1 -3
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/agent-src/templates/features.md +5 -0
- package/dist/agent-src/templates/roadmaps.md +29 -1
- package/dist/agent-src/templates/scripts/telemetry/mcp_lite.ts +213 -0
- package/dist/agent-src/templates/scripts/telemetry/report_renderer.ts +16 -0
- package/dist/agent-src/templates/scripts/telemetry_report.ts +22 -2
- package/dist/cli/mcp/dispatch.js +10 -0
- package/dist/cli/mcp/stdio.js +33 -2
- package/dist/cli/mcp/telemetry.js +227 -0
- package/dist/cli-delegate/{chunk-KPBWFOPZ.js → chunk-2FTMENJL.js} +194 -37
- package/dist/cli-delegate/{chunk-PMEKGDED.js → chunk-2TAQ2K4V.js} +166 -1
- package/dist/cli-delegate/{chunk-GZTMRYCN.js → chunk-643Z7FIC.js} +79 -12
- package/dist/cli-delegate/{chunk-CYH5SP6M.js → chunk-I6RHFQEG.js} +151 -16
- package/dist/cli-delegate/{chunk-IDHUQIXP.js → chunk-J3RFNKG4.js} +41 -5
- package/dist/cli-delegate/{chunk-7PRE6ZV2.js → chunk-YS6XTLEK.js} +199 -4
- package/dist/cli-delegate/chunk-ZVBOHSVF.js +1109 -0
- package/dist/cli-delegate/cmd_conformance.js +88 -110
- package/dist/cli-delegate/cmd_doctor.js +3 -3
- package/dist/cli-delegate/cmd_export.js +2 -2
- package/dist/cli-delegate/cmd_handoff.js +42 -12
- package/dist/cli-delegate/cmd_memory_get.js +1 -1
- package/dist/cli-delegate/cmd_prune.js +2 -2
- package/dist/cli-delegate/cmd_route_audit.js +1 -1
- package/dist/cli-delegate/cmd_session_recycle.js +59 -902
- package/dist/cli-delegate/cmd_settings_migrate.js +2 -2
- package/dist/cli-delegate/cmd_sync.js +2 -2
- package/dist/cli-delegate/cmd_uninstall.js +2 -2
- package/dist/cli-delegate/cmd_validate.js +2 -2
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +62 -31
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +3 -3
- package/dist/discovery/dormancy-report.md +1 -1
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +7 -6
- package/dist/discovery/trust-report.md +2 -2
- package/dist/discovery/workspaces.json +6 -5
- package/dist/hooks/dispatch.js +3801 -2803
- package/dist/install/install.mjs +396 -168
- package/dist/mcp/registry-manifest.json +3 -3
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +809 -180
- package/dist/scripts/ai_council/content_ceiling.js +63 -0
- package/dist/scripts/ai_council/seat_policy.js +106 -0
- package/dist/scripts/ai_council/transport_resolver.js +21 -0
- package/dist/scripts/mcp_server/telemetry.js +148 -0
- package/docs/guidelines/agent-infra/naming.md +1 -1
- package/docs/guidelines/agent-infra/security-lint-containment.md +46 -6
- package/docs/guidelines/agent-infra/self-improvement-pipeline.md +4 -4
- package/docs/guidelines/agent-infra/untrusted-input-spotlighting.md +1 -1
- package/docs/guidelines/redundancy-taxonomy.md +28 -0
- package/llms.txt +14 -14
- package/package.json +1 -1
- package/src/agent-src/templates/scripts/telemetry/mcp_lite.ts +213 -0
- package/src/agent-src/templates/scripts/telemetry/report_renderer.ts +16 -0
- package/src/agent-src/templates/scripts/telemetry_report.ts +22 -2
- package/src/config/assurance-capability-registry.json +9 -0
- package/src/config/gate-coverage.yml +55 -0
- package/src/config/gate-violation-baselines.json +18 -6
- package/src/config/release-gate-locality.yml +4 -3
- package/src/scripts/_cli/cmd_session_recycle.ts +51 -3
- package/src/scripts/_lib/audit_field_provenance.ts +44 -3
- package/src/scripts/_lib/blocked_by_marker.ts +189 -0
- package/src/scripts/_lib/host_capability.ts +76 -1
- package/src/scripts/_lib/install_reach_checks.ts +33 -1
- package/src/scripts/_lib/mcp_bridge.ts +222 -10
- package/src/scripts/_lib/mcp_consent_residual.ts +173 -0
- package/src/scripts/_lib/orchestration_record.ts +23 -1
- package/src/scripts/_lib/recycle_envelope_paths.ts +190 -0
- package/src/scripts/_lib/release_highlights.ts +19 -211
- package/src/scripts/_lib/release_material.ts +27 -77
- package/src/scripts/_lib/release_position.ts +96 -0
- package/src/scripts/_lib/review_skipped_record.ts +17 -1
- package/src/scripts/_lib/security_lint.ts +179 -7
- package/src/scripts/_lib/self_repair.ts +141 -6
- package/src/scripts/_lib/self_repair_store.ts +273 -7
- package/src/scripts/_lib/structured_ask.ts +117 -0
- package/src/scripts/_lib/subagent_capsule.ts +155 -3
- package/src/scripts/_lib/tty_prompt.ts +76 -0
- package/src/scripts/ai_council/api_transport.ts +163 -0
- package/src/scripts/ai_council/clients.ts +58 -63
- package/src/scripts/ai_council/config.ts +13 -0
- package/src/scripts/ai_council/content_ceiling.ts +79 -0
- package/src/scripts/ai_council/jury_aggregate.ts +178 -0
- package/src/scripts/ai_council/seat_policy.ts +137 -0
- package/src/scripts/ai_council/status_surface.ts +79 -0
- package/src/scripts/ai_council/transport_resolver.ts +59 -1
- package/src/scripts/ai_tells_rules.ts +446 -5
- package/src/scripts/ask_block_census.ts +499 -0
- package/src/scripts/bench_humanizer_eval.ts +264 -40
- package/src/scripts/bench_prose_tells_fp.ts +198 -0
- package/src/scripts/build_rule_trigger_matrix.ts +5 -0
- package/src/scripts/check_branch_work_committed.ts +234 -0
- package/src/scripts/check_mcp_doc_drift.ts +217 -0
- package/src/scripts/check_pack_size.ts +321 -3
- package/src/scripts/check_release_highlights.ts +0 -157
- package/src/scripts/council_cli.ts +18 -27
- package/src/scripts/detect_ai_tells.ts +142 -20
- package/src/scripts/handoff_context_hook.ts +54 -7
- package/src/scripts/harvest_reference_tokens.ts +698 -0
- package/src/scripts/hook_manifest.json +1 -1
- package/src/scripts/hook_manifest.yaml +31 -1
- package/src/scripts/hooks/concern_registry.ts +2 -0
- package/src/scripts/hooks/delegation_nudge_hook.ts +45 -1
- package/src/scripts/hooks/one_question_per_ask_hook.ts +191 -0
- package/src/scripts/hooks/session_eol_hook.ts +18 -7
- package/src/scripts/install-hooks.sh +52 -0
- package/src/scripts/install.ts +4 -4
- package/src/scripts/lint_agent_security.ts +316 -25
- package/src/scripts/lint_confusables.ts +42 -6
- package/src/scripts/lint_handoffs.ts +0 -132
- package/src/scripts/lint_hidden_unicode.ts +49 -7
- package/src/scripts/lint_instruction_smuggling.ts +42 -6
- package/src/scripts/lint_mcp_config_security.ts +42 -6
- package/src/scripts/lint_roadmap_blockers.ts +51 -1
- package/src/scripts/lint_roadmap_later_disposition.ts +217 -27
- package/src/scripts/lint_skill_frontmatter_safety.ts +43 -7
- package/src/scripts/mcp_server/resources.ts +41 -0
- package/src/scripts/mcp_server/telemetry.ts +19 -4
- package/src/scripts/migrate_command_suggestions.ts +1 -1
- package/src/scripts/orchestration_record.ts +12 -1
- package/src/scripts/probe_unblocked_ask.ts +135 -2
- package/src/scripts/release.ts +50 -95
- package/src/scripts/release_drill.ts +6 -10
- package/src/scripts/release_env.ts +1 -0
- package/src/scripts/release_publication.ts +40 -31
- package/src/scripts/self_repair_cli.ts +31 -1
- package/src/scripts/self_review_gate.ts +409 -15
- package/src/scripts/skill_scout.ts +69 -0
- package/dist/cli-delegate/chunk-2GNWGMAL.js +0 -172
- package/dist/cli-delegate/chunk-LPPWO5CE.js +0 -161
|
@@ -100,7 +100,13 @@ export function isReviewablePath(p: string): boolean {
|
|
|
100
100
|
}
|
|
101
101
|
|
|
102
102
|
function changedFiles(baseRef: string, cwd: string = REPO_ROOT): string[] {
|
|
103
|
-
|
|
103
|
+
// `-c core.quotePath=false`: by default git renders a non-ASCII path as an
|
|
104
|
+
// escaped, QUOTED string (`"gr\303\274e.ts"`), which then matches no
|
|
105
|
+
// pathspec when fed back to `git diff`. The file was silently skipped by
|
|
106
|
+
// `perFileDiffs` AND counted as reviewed by the coverage arithmetic — an
|
|
107
|
+
// unreviewed file reported as read, which is the failure this whole change
|
|
108
|
+
// exists to prevent.
|
|
109
|
+
const r = spawnSync('git', ['-c', 'core.quotePath=false', 'diff', '--name-only', `${baseRef}...HEAD`], {
|
|
104
110
|
cwd,
|
|
105
111
|
encoding: 'utf8',
|
|
106
112
|
});
|
|
@@ -124,6 +130,32 @@ function diffText(baseRef: string, files: string[], cwd: string = REPO_ROOT): st
|
|
|
124
130
|
return (r.stdout ?? '').toString();
|
|
125
131
|
}
|
|
126
132
|
|
|
133
|
+
/**
|
|
134
|
+
* The diff split per file, so it can be packed into budgeted requests.
|
|
135
|
+
*
|
|
136
|
+
* One `git diff` per file rather than splitting the combined output, because
|
|
137
|
+
* packing needs a SIZE PER FILE before any text is assembled — a combined diff
|
|
138
|
+
* gives one blob and its per-file sizes only by re-deriving path boundaries
|
|
139
|
+
* from the text.
|
|
140
|
+
*
|
|
141
|
+
* The rationale this replaces claimed the combined form "breaks that parse
|
|
142
|
+
* silently" on separator-bearing paths. That was a straw man: the pre-change
|
|
143
|
+
* code never parsed the combined diff at all, it sent it whole.
|
|
144
|
+
*
|
|
145
|
+
* It is not free either. Each three-dot diff re-resolves the merge base at
|
|
146
|
+
* ~93 ms, so a 316-file span costs ~30 s. That is paid ONCE now — `buildPlan`
|
|
147
|
+
* stores the partition and `main` reuses it, where it used to recompute and
|
|
148
|
+
* pay twice.
|
|
149
|
+
*/
|
|
150
|
+
function perFileDiffs(baseRef: string, files: readonly string[], cwd: string = REPO_ROOT): FileDiff[] {
|
|
151
|
+
const out: FileDiff[] = [];
|
|
152
|
+
for (const path of files) {
|
|
153
|
+
const diff = diffText(baseRef, [path], cwd);
|
|
154
|
+
if (diff !== '') out.push({ path, diff });
|
|
155
|
+
}
|
|
156
|
+
return out;
|
|
157
|
+
}
|
|
158
|
+
|
|
127
159
|
/** Sum of added+deleted lines across `files` (binary files count 0). */
|
|
128
160
|
function changedLineCount(baseRef: string, files: string[], cwd: string = REPO_ROOT): number {
|
|
129
161
|
if (files.length === 0) return 0;
|
|
@@ -241,10 +273,196 @@ function buildSystemPrompt(release?: ReleaseInfo, baseRef?: string): string {
|
|
|
241
273
|
return release && baseRef ? base + releaseNoteText(release, baseRef) : base;
|
|
242
274
|
}
|
|
243
275
|
|
|
276
|
+
// Prompt budget and partitioning.
|
|
277
|
+
//
|
|
278
|
+
// Measured across FOUR consecutive releases: the live call returned
|
|
279
|
+
// `HTTP 400 prompt is too long` every time and the gate reviewed nothing. The
|
|
280
|
+
// release path sets `analysisBase` to the previous tag, so the whole release
|
|
281
|
+
// span goes into one request. ALL FOUR recorded a figure against the 200000
|
|
282
|
+
// cap — 235472 (14.17.0), 413191 (14.18.0), 450336 (14.19.0), 260998
|
|
283
|
+
// (14.20.0). The smallest exceeds the cap by 17.7 %.
|
|
284
|
+
//
|
|
285
|
+
// Corrected here: an earlier version of this comment said three releases
|
|
286
|
+
// recorded a figure and put the smallest at 30 % over. Both were wrong, and the
|
|
287
|
+
// omitted measurement was the one NEAREST the cap — the reading that most
|
|
288
|
+
// constrains the budget below. The conclusion is unchanged and does not depend
|
|
289
|
+
// on the error: every span observed exceeds the cap, so this is structural and
|
|
290
|
+
// waiting for smaller releases is not a fix.
|
|
291
|
+
//
|
|
292
|
+
// `promptChars` was already computed and only REPORTED. Nothing consulted it
|
|
293
|
+
// before spending the call.
|
|
294
|
+
//
|
|
295
|
+
// What this deliberately does NOT do is truncate. A silently shortened diff
|
|
296
|
+
// produces findings about a fragment while reading as a review of the whole
|
|
297
|
+
// change, which is a false green — the failure mode this repository's
|
|
298
|
+
// honest-null discipline exists to prevent. Instead the diff is partitioned per
|
|
299
|
+
// FILE, each chunk is reviewed, findings are merged, and whatever did not fit
|
|
300
|
+
// is NAMED as unreviewed.
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Chars per token the budget assumes, and the measurement behind it.
|
|
304
|
+
*
|
|
305
|
+
* A proxy, not a measurement of the real thing: the cap is enforced by the
|
|
306
|
+
* provider's tokenizer, which this repository cannot run — `js-tiktoken` is a
|
|
307
|
+
* DIFFERENT tokenizer, so agreeing with it would prove nothing.
|
|
308
|
+
*
|
|
309
|
+
* The ratio is DERIVED from the four failures, dividing measured diff chars by
|
|
310
|
+
* the token count the API itself reported:
|
|
311
|
+
*
|
|
312
|
+
* 14.19.0…14.20.0 817,805 chars / 260,998 tokens = 3.13
|
|
313
|
+
* 14.18.0…14.19.0 1,432,333 chars / 450,336 tokens = 3.18
|
|
314
|
+
*
|
|
315
|
+
* An earlier version of this constant used 3.0 and called it "deliberately
|
|
316
|
+
* pessimistic". It was not: against a measured 3.13 that is 4 % of margin, and
|
|
317
|
+
* the ratio is strongly content-dependent — cl100k over this repo's own diffs
|
|
318
|
+
* reads ~3.9 for `src/`, ~4.1 for `agents/` prose and ~2.75 for a lockfile,
|
|
319
|
+
* a 1.5x spread. Dense JSON, CSV and YAML paths are not excluded from the
|
|
320
|
+
* review scope, so a chunk of them tokenizes far worse than the average the
|
|
321
|
+
* two spans above describe. 2.4 covers the lockfile-shaped end with the same
|
|
322
|
+
* ~22 % gap those spans show between cl100k and the provider's count.
|
|
323
|
+
*
|
|
324
|
+
* A call that still overflows a chunk built under this budget falsifies this
|
|
325
|
+
* number, not the partitioning — and the failure is graceful either way: the
|
|
326
|
+
* chunk is reported unreviewed and the run continues.
|
|
327
|
+
*/
|
|
328
|
+
export const BUDGET_CHARS_PER_TOKEN = 2.4;
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Input budget for one request, in characters.
|
|
332
|
+
*
|
|
333
|
+
* 180000 rather than the full 200000 cap because the system prompt rides along
|
|
334
|
+
* with EVERY request and is not part of the chunk: the two review skill bodies
|
|
335
|
+
* are ~14000 chars, and in release mode `releaseNoteText` joins the entire
|
|
336
|
+
* packaging file list into it — ~12600 chars at 316 files. Both are charged per
|
|
337
|
+
* chunk, so the reservation has to cover them at the same pessimistic ratio.
|
|
338
|
+
*/
|
|
339
|
+
export const PROMPT_BUDGET_CHARS = Math.floor(180_000 * BUDGET_CHARS_PER_TOKEN);
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Cost ceiling, in requests per run.
|
|
343
|
+
*
|
|
344
|
+
* The gate is advisory and single-maintainer-funded, so an unbounded chunk
|
|
345
|
+
* count would turn one failed call into an arbitrary bill. A span that needs
|
|
346
|
+
* more is reviewed up to the ceiling and the remainder reported unreviewed — a
|
|
347
|
+
* partial review that says so, never a silent one.
|
|
348
|
+
*
|
|
349
|
+
* SIX, and the number moved for a measured reason rather than for comfort. It
|
|
350
|
+
* was four while the budget assumed 3.0 chars/token. Correcting that assumption
|
|
351
|
+
* to the measured 2.4 shrank each request, so the same span needs more of them:
|
|
352
|
+
* the live 14.19.0...HEAD span went to FIVE requests at the corrected budget and
|
|
353
|
+
* reported 82 files unreviewed. Buying tokenizer safety with coverage is not a
|
|
354
|
+
* trade this gate should make silently, so the ceiling absorbs it with one
|
|
355
|
+
* request of headroom above the largest span measured.
|
|
356
|
+
*
|
|
357
|
+
* The cost is real and is the reason this is a constant rather than unbounded:
|
|
358
|
+
* six requests of up to ~180000 input tokens is the worst case per run, and
|
|
359
|
+
* `--dry-run` prints the count before anything is spent.
|
|
360
|
+
*/
|
|
361
|
+
export const MAX_REVIEW_CHUNKS = 6;
|
|
362
|
+
|
|
363
|
+
/** How many unreviewed paths `--dry-run` names before collapsing to a count. */
|
|
364
|
+
export const DRY_RUN_UNREVIEWED_SAMPLE = 5;
|
|
365
|
+
|
|
366
|
+
export interface FileDiff {
|
|
367
|
+
path: string;
|
|
368
|
+
diff: string;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
export interface DiffChunk {
|
|
372
|
+
/** Concatenated diff text for one request. */
|
|
373
|
+
text: string;
|
|
374
|
+
/** The files this chunk carries — so coverage counts what was READ. */
|
|
375
|
+
files: string[];
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
export interface DiffPartition {
|
|
379
|
+
chunks: DiffChunk[];
|
|
380
|
+
/** Files no chunk carries, with the reason — rendered into the review. */
|
|
381
|
+
unreviewed: { path: string; reason: string }[];
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Pack per-file diffs into as few chunks as fit the budget.
|
|
386
|
+
*
|
|
387
|
+
* Deterministic: input order is preserved and the fill is greedy, so the same
|
|
388
|
+
* span always partitions the same way and a finding's chunk is reproducible.
|
|
389
|
+
*
|
|
390
|
+
* A single file larger than the whole budget is NOT split. A diff cut at an
|
|
391
|
+
* arbitrary byte offset produces half a hunk, and a reviewer reading half a
|
|
392
|
+
* hunk reports on code that does not exist — worse than not reading it, because
|
|
393
|
+
* the finding looks authoritative. Such a file is reported unreviewed instead.
|
|
394
|
+
*/
|
|
395
|
+
export function partitionDiff(
|
|
396
|
+
files: readonly FileDiff[],
|
|
397
|
+
budgetChars: number = PROMPT_BUDGET_CHARS,
|
|
398
|
+
maxChunks: number = MAX_REVIEW_CHUNKS,
|
|
399
|
+
): DiffPartition {
|
|
400
|
+
const ceiling = Math.max(0, maxChunks);
|
|
401
|
+
const chunks: DiffChunk[] = [];
|
|
402
|
+
const unreviewed: { path: string; reason: string }[] = [];
|
|
403
|
+
let current: DiffChunk = { text: '', files: [] };
|
|
404
|
+
|
|
405
|
+
for (const f of files) {
|
|
406
|
+
if (f.diff.length > budgetChars) {
|
|
407
|
+
unreviewed.push({
|
|
408
|
+
path: f.path,
|
|
409
|
+
reason:
|
|
410
|
+
`single-file diff of ${String(f.diff.length)} chars exceeds the ` +
|
|
411
|
+
`${String(budgetChars)}-char request budget; not split, because a diff cut ` +
|
|
412
|
+
'mid-hunk yields findings about code that does not exist',
|
|
413
|
+
});
|
|
414
|
+
continue;
|
|
415
|
+
}
|
|
416
|
+
if (current.text !== '' && current.text.length + f.diff.length > budgetChars) {
|
|
417
|
+
chunks.push(current);
|
|
418
|
+
current = { text: '', files: [] };
|
|
419
|
+
}
|
|
420
|
+
current.text += f.diff;
|
|
421
|
+
current.files.push(f.path);
|
|
422
|
+
}
|
|
423
|
+
if (current.text !== '') chunks.push(current);
|
|
424
|
+
|
|
425
|
+
if (chunks.length > ceiling) {
|
|
426
|
+
// Every dropped chunk names its own files. The first version packed to
|
|
427
|
+
// opaque strings and reported ONE aggregate entry for all of them, which
|
|
428
|
+
// made the coverage subtraction downstream count every dropped file as
|
|
429
|
+
// reviewed — 59 of 60 for a run that read 4. That was not an inherent
|
|
430
|
+
// limit of the problem, as its comment claimed; it was a consequence of
|
|
431
|
+
// throwing the file list away here.
|
|
432
|
+
const total = chunks.length;
|
|
433
|
+
for (const dropped of chunks.slice(ceiling)) {
|
|
434
|
+
for (const path of dropped.files) {
|
|
435
|
+
unreviewed.push({
|
|
436
|
+
path,
|
|
437
|
+
reason:
|
|
438
|
+
`the span needs ${String(total)} requests at this budget and the ` +
|
|
439
|
+
`per-run ceiling is ${String(ceiling)}; this file's chunk was not sent`,
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
chunks.length = ceiling;
|
|
444
|
+
}
|
|
445
|
+
return { chunks, unreviewed };
|
|
446
|
+
}
|
|
447
|
+
|
|
244
448
|
// ── Plan (dry-run) ────────────────────────────────────────────────────
|
|
245
449
|
export interface ReviewPlan {
|
|
246
450
|
skills: string[];
|
|
247
451
|
files: string[];
|
|
452
|
+
/**
|
|
453
|
+
* The partition the live run will send, computed ONCE.
|
|
454
|
+
*
|
|
455
|
+
* `main` used to call `partitionDiff(perFileDiffs(...))` again over the same
|
|
456
|
+
* span. Measured: `git diff <base>...HEAD -- <one file>` costs ~93 ms
|
|
457
|
+
* because every three-dot form re-resolves the merge base, so 316 files
|
|
458
|
+
* meant ~632 spawns and about a minute of wall clock per live run, half of
|
|
459
|
+
* it thrown away.
|
|
460
|
+
*/
|
|
461
|
+
partition: DiffPartition;
|
|
462
|
+
/** Model calls the live run will make — one per budgeted chunk. */
|
|
463
|
+
requests: number;
|
|
464
|
+
/** Files no request will carry, with the reason. */
|
|
465
|
+
unreviewed: { path: string; reason: string }[];
|
|
248
466
|
promptChars: number;
|
|
249
467
|
note: string;
|
|
250
468
|
escalation: string[];
|
|
@@ -277,7 +495,15 @@ export function buildPlan(baseRef: string, cwd: string = REPO_ROOT): ReviewPlan
|
|
|
277
495
|
const diff = diffText(analysisBase, files, cwd);
|
|
278
496
|
const escalation = escalationReasons(files, changedLineCount(analysisBase, files, cwd));
|
|
279
497
|
const systemPrompt = buildSystemPrompt(release, baseRef);
|
|
498
|
+
// The same partition the live path will use, so `--dry-run` previews the
|
|
499
|
+
// REQUEST COUNT rather than only a token estimate. This gate spends money
|
|
500
|
+
// per request, and a plan that reports "~N tokens" without saying how many
|
|
501
|
+
// calls that becomes hides the number the maintainer is deciding about.
|
|
502
|
+
const partition = partitionDiff(perFileDiffs(analysisBase, files, cwd));
|
|
280
503
|
return {
|
|
504
|
+
partition,
|
|
505
|
+
requests: partition.chunks.length,
|
|
506
|
+
unreviewed: partition.unreviewed,
|
|
281
507
|
skills: [...REVIEW_SKILLS],
|
|
282
508
|
files,
|
|
283
509
|
analysisBase,
|
|
@@ -288,8 +514,12 @@ export function buildPlan(baseRef: string, cwd: string = REPO_ROOT): ReviewPlan
|
|
|
288
514
|
files.length === 0
|
|
289
515
|
? 'No reviewable (non-generated) files changed — the live review would no-op.'
|
|
290
516
|
: `${files.length} reviewable file(s); live review would send ~${Math.ceil(
|
|
291
|
-
(systemPrompt.length + diff.length) /
|
|
292
|
-
|
|
517
|
+
(systemPrompt.length * Math.max(1, partition.chunks.length) + diff.length) /
|
|
518
|
+
BUDGET_CHARS_PER_TOKEN,
|
|
519
|
+
)} input tokens across ${partition.chunks.length} request(s)` +
|
|
520
|
+
(partition.unreviewed.length > 0
|
|
521
|
+
? `, leaving ${partition.unreviewed.length} path(s) UNREVIEWED.`
|
|
522
|
+
: '.'),
|
|
293
523
|
};
|
|
294
524
|
}
|
|
295
525
|
|
|
@@ -320,6 +550,28 @@ function parseFindings(text: string): Finding[] {
|
|
|
320
550
|
}));
|
|
321
551
|
}
|
|
322
552
|
|
|
553
|
+
/**
|
|
554
|
+
* Collapse findings that two chunks both reported.
|
|
555
|
+
*
|
|
556
|
+
* Chunks are disjoint by file, so a duplicate means the same defect was
|
|
557
|
+
* described from two files' diffs — a shared helper and one of its callers, for
|
|
558
|
+
* instance. `findingId` is sha256(kind|title|file), already the identity the
|
|
559
|
+
* ledger and the rendered table use, so deduplicating on it keeps the id in the
|
|
560
|
+
* comment matching the id in the artifact. First occurrence wins, which keeps
|
|
561
|
+
* the order deterministic for a given partition.
|
|
562
|
+
*/
|
|
563
|
+
export function dedupeFindings(findings: readonly Finding[]): Finding[] {
|
|
564
|
+
const seen = new Set<string>();
|
|
565
|
+
const out: Finding[] = [];
|
|
566
|
+
for (const f of findings) {
|
|
567
|
+
const id = findingId(f);
|
|
568
|
+
if (seen.has(id)) continue;
|
|
569
|
+
seen.add(id);
|
|
570
|
+
out.push(f);
|
|
571
|
+
}
|
|
572
|
+
return out;
|
|
573
|
+
}
|
|
574
|
+
|
|
323
575
|
/** Advisory escalation banner, appended when a diff warrants full ai-council. */
|
|
324
576
|
function escalationBlock(reasons: readonly string[]): string {
|
|
325
577
|
if (reasons.length === 0) return '';
|
|
@@ -332,14 +584,54 @@ function escalationBlock(reasons: readonly string[]): string {
|
|
|
332
584
|
);
|
|
333
585
|
}
|
|
334
586
|
|
|
335
|
-
|
|
587
|
+
/**
|
|
588
|
+
* What the review did NOT read, stated in the comment itself.
|
|
589
|
+
*
|
|
590
|
+
* Without this line a chunked review is indistinguishable from a whole-span
|
|
591
|
+
* one, which is the false green the partitioning exists to avoid — the reader
|
|
592
|
+
* of the PR comment is the person who has to know the coverage, and they never
|
|
593
|
+
* see the workflow log.
|
|
594
|
+
*/
|
|
595
|
+
export function coverageBlock(coverage?: ReviewCoverage): string {
|
|
596
|
+
if (!coverage) return '';
|
|
597
|
+
const parts = [
|
|
598
|
+
`\n\n**Coverage.** ${String(coverage.chunks)} request(s) over ` +
|
|
599
|
+
`${String(coverage.filesReviewed)} of ${String(coverage.filesTotal)} changed file(s).`,
|
|
600
|
+
];
|
|
601
|
+
if (coverage.unreviewed.length > 0) {
|
|
602
|
+
parts.push(
|
|
603
|
+
` **NOT reviewed (${String(coverage.unreviewed.length)}):**\n` +
|
|
604
|
+
coverage.unreviewed.map((u) => `- \`${u.path}\` — ${u.reason}`).join('\n') +
|
|
605
|
+
// Blank line, not a single newline: GFM treats one newline after
|
|
606
|
+
// a list item as lazy continuation, so the sentence rendered
|
|
607
|
+
// INSIDE the last bullet instead of standing on its own. The
|
|
608
|
+
// `toContain` assertion passed either way, which is why the
|
|
609
|
+
// reviewer had to render it to see it.
|
|
610
|
+
'\n\nFindings above cover the reviewed part only; absence of a finding for an ' +
|
|
611
|
+
'unreviewed path is not evidence about that path.',
|
|
612
|
+
);
|
|
613
|
+
}
|
|
614
|
+
return parts.join('');
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
export interface ReviewCoverage {
|
|
618
|
+
chunks: number;
|
|
619
|
+
/** Files carried by chunks whose call RETURNED. */
|
|
620
|
+
filesReviewed: number;
|
|
621
|
+
/** Files the span changed, so the reader sees the fraction, not a bare count. */
|
|
622
|
+
filesTotal: number;
|
|
623
|
+
unreviewed: { path: string; reason: string }[];
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
export function renderReview(findings: Finding[], enforce: boolean, escalation: readonly string[] = [], coverage?: ReviewCoverage): string {
|
|
336
627
|
const banner =
|
|
337
628
|
'> HUMAN REVIEW REQUIRED — dogfooded AI adversarial-review + security gate. ' +
|
|
338
629
|
'Findings are decision support, not a guarantee; detection is probabilistic. ' +
|
|
339
630
|
'This is a floor, **not** independent human review.';
|
|
340
631
|
const esc = escalationBlock(escalation);
|
|
632
|
+
const cov = coverageBlock(coverage);
|
|
341
633
|
if (findings.length === 0) {
|
|
342
|
-
return `${banner}\n\n✅ Self-review gate: no findings.${esc}`;
|
|
634
|
+
return `${banner}\n\n✅ Self-review gate: no findings.${cov}${esc}`;
|
|
343
635
|
}
|
|
344
636
|
const blocking = findings.filter(classifyBlocking);
|
|
345
637
|
// Each row carries an explicit (Blocking)/(Advisory) marker so a
|
|
@@ -372,7 +664,7 @@ export function renderReview(findings: Finding[], enforce: boolean, escalation:
|
|
|
372
664
|
const machine = `\n\n<!-- release-findings-json: ${JSON.stringify(
|
|
373
665
|
findings.map((f) => ({ finding_id: findingId(f), severity: f.severity, kind: f.kind, title: f.title, file: f.file ?? null })),
|
|
374
666
|
)} -->`;
|
|
375
|
-
return `${banner}\n\n${verdictLine}\n\n| id | severity | kind | file | finding |\n|---|---|---|---|---|\n${rows}${idNote}${esc}${machine}`;
|
|
667
|
+
return `${banner}\n\n${verdictLine}\n\n| id | severity | kind | file | finding |\n|---|---|---|---|---|\n${rows}${idNote}${cov}${esc}${machine}`;
|
|
376
668
|
}
|
|
377
669
|
|
|
378
670
|
function postReview(body: string): void {
|
|
@@ -417,6 +709,40 @@ export function main(argv: string[]): 0 | 2 {
|
|
|
417
709
|
`packaging diff ${plan.release.packagingFiles.length} file(s))\n`
|
|
418
710
|
: '') +
|
|
419
711
|
` files: ${plan.files.length}\n` +
|
|
712
|
+
` calls: ${plan.requests} (budget ${String(PROMPT_BUDGET_CHARS)} chars/request, ` +
|
|
713
|
+
`ceiling ${String(MAX_REVIEW_CHUNKS)})\n` +
|
|
714
|
+
(plan.unreviewed.length > 0
|
|
715
|
+
? ` UNREVIEWED (${String(plan.unreviewed.length)}): ` +
|
|
716
|
+
// Capped: a real run reported 82 paths, and 82 paths on one
|
|
717
|
+
// line is not a report anybody reads. The count is the
|
|
718
|
+
// decision-relevant number; the names are a sample.
|
|
719
|
+
`${plan.unreviewed
|
|
720
|
+
.slice(0, DRY_RUN_UNREVIEWED_SAMPLE)
|
|
721
|
+
.map((u) => u.path)
|
|
722
|
+
.join(', ')}` +
|
|
723
|
+
(plan.unreviewed.length > DRY_RUN_UNREVIEWED_SAMPLE
|
|
724
|
+
? ` … +${String(plan.unreviewed.length - DRY_RUN_UNREVIEWED_SAMPLE)} more`
|
|
725
|
+
: '') +
|
|
726
|
+
'\n' +
|
|
727
|
+
// The reason was computed and never printed, so the
|
|
728
|
+
// operator saw what WOULD be sent and never what the span
|
|
729
|
+
// needs.
|
|
730
|
+
` reason: ${plan.unreviewed[0]?.reason ?? 'unknown'}\n`
|
|
731
|
+
: '') +
|
|
732
|
+
// At the ceiling nothing is lost YET, and the next slightly
|
|
733
|
+
// larger span loses its remainder silently apart from the
|
|
734
|
+
// coverage line. Saying so while it is still a warning is the
|
|
735
|
+
// only cheap moment: the alternative is reading it in a
|
|
736
|
+
// published review's coverage block.
|
|
737
|
+
// Gated on the ceiling ALONE. The first version also required
|
|
738
|
+
// `unreviewed.length === 0`, which silenced the warning in the
|
|
739
|
+
// states nearest to loss: once a span has one over-budget file
|
|
740
|
+
// the two facts are independent, and conflating them hid the
|
|
741
|
+
// warning exactly when it mattered.
|
|
742
|
+
(plan.requests >= MAX_REVIEW_CHUNKS
|
|
743
|
+
? ` ⚠️ at the per-run ceiling (${String(MAX_REVIEW_CHUNKS)}) — a larger span ` +
|
|
744
|
+
'loses its remainder\n'
|
|
745
|
+
: '') +
|
|
420
746
|
` ${plan.note}\n` +
|
|
421
747
|
(plan.escalation.length
|
|
422
748
|
? ` escalation: ${plan.escalation.join('; ')} → recommend /council:pr (run-time authorized)\n`
|
|
@@ -455,15 +781,79 @@ export function main(argv: string[]): 0 | 2 {
|
|
|
455
781
|
// findings returns 2 — an error is not a finding.)
|
|
456
782
|
try {
|
|
457
783
|
const client = new AnthropicClient({ api_key: key });
|
|
458
|
-
const
|
|
459
|
-
|
|
784
|
+
const systemPrompt = buildSystemPrompt(plan.release, baseRef);
|
|
785
|
+
const partition = plan.partition;
|
|
786
|
+
|
|
787
|
+
if (partition.chunks.length === 0) {
|
|
788
|
+
// Reviewing nothing while saying nothing is the state four releases
|
|
789
|
+
// were already in, so this reports NEUTRAL with the reason. The
|
|
790
|
+
// reason is DERIVED rather than asserted: the first version said
|
|
791
|
+
// "every changed file exceeds the per-request budget", which is one
|
|
792
|
+
// of several ways to get here and was simply wrong for the others
|
|
793
|
+
// (an empty per-file diff, for instance).
|
|
794
|
+
const why =
|
|
795
|
+
partition.unreviewed.length > 0
|
|
796
|
+
? `${String(partition.unreviewed.length)} path(s) could not be placed in a ` +
|
|
797
|
+
`request: ${partition.unreviewed[0]?.reason ?? 'unknown'}`
|
|
798
|
+
: 'no file produced a diff to review';
|
|
460
799
|
process.stdout.write(
|
|
461
|
-
`::warning::self-review-gate NEUTRAL —
|
|
462
|
-
'
|
|
800
|
+
`::warning::self-review-gate NEUTRAL — ${why}; nothing was reviewed, not a ` +
|
|
801
|
+
'blocker.\n',
|
|
463
802
|
);
|
|
464
|
-
return 0;
|
|
803
|
+
return 0;
|
|
465
804
|
}
|
|
466
|
-
|
|
805
|
+
|
|
806
|
+
// One request per chunk, findings merged. A chunk that fails does NOT
|
|
807
|
+
// discard the chunks that succeeded: four releases produced no review at
|
|
808
|
+
// all because one failure was the whole review, and a partial review
|
|
809
|
+
// that names its coverage is worth more than another honest null.
|
|
810
|
+
const findings: Finding[] = [];
|
|
811
|
+
const unreviewed = [...partition.unreviewed];
|
|
812
|
+
let reviewedChunks = 0;
|
|
813
|
+
let filesRead = 0;
|
|
814
|
+
for (const [i, chunk] of partition.chunks.entries()) {
|
|
815
|
+
const resp = client.ask(systemPrompt, chunk.text, 4096);
|
|
816
|
+
if (resp.error) {
|
|
817
|
+
process.stdout.write(
|
|
818
|
+
`::warning::self-review-gate — chunk ${String(i + 1)}/` +
|
|
819
|
+
`${String(partition.chunks.length)} did not complete (${resp.error}); ` +
|
|
820
|
+
'continuing with the remaining chunks.\n',
|
|
821
|
+
);
|
|
822
|
+
// The chunk's OWN files, not a synthetic entry: a failed chunk
|
|
823
|
+
// used to be recorded as one unnamed row while the coverage
|
|
824
|
+
// count still credited all its files as read.
|
|
825
|
+
for (const path of chunk.files) {
|
|
826
|
+
unreviewed.push({
|
|
827
|
+
path,
|
|
828
|
+
reason: `the model call for this file's chunk did not complete: ${resp.error}`,
|
|
829
|
+
});
|
|
830
|
+
}
|
|
831
|
+
continue;
|
|
832
|
+
}
|
|
833
|
+
reviewedChunks += 1;
|
|
834
|
+
filesRead += chunk.files.length;
|
|
835
|
+
findings.push(...parseFindings(resp.text));
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
if (reviewedChunks === 0) {
|
|
839
|
+
process.stdout.write(
|
|
840
|
+
'::warning::self-review-gate NEUTRAL — no chunk completed, nothing was reviewed, ' +
|
|
841
|
+
'not a blocker.\n',
|
|
842
|
+
);
|
|
843
|
+
return 0;
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
// Counted from the chunks that actually returned, never derived by
|
|
847
|
+
// subtracting rows from the file total. The subtraction was wrong in
|
|
848
|
+
// both of this change's own new states: one aggregate row for N dropped
|
|
849
|
+
// chunks credited N-1 unread files as read, and per-chunk failures were
|
|
850
|
+
// appended AFTER the number was computed.
|
|
851
|
+
const coverage: ReviewCoverage = {
|
|
852
|
+
chunks: reviewedChunks,
|
|
853
|
+
filesReviewed: filesRead,
|
|
854
|
+
filesTotal: plan.files.length,
|
|
855
|
+
unreviewed,
|
|
856
|
+
};
|
|
467
857
|
const outIdx = argv.indexOf('--findings-out');
|
|
468
858
|
if (outIdx >= 0 && argv[outIdx + 1]) {
|
|
469
859
|
// Durable ingestion input for the disposition ledger (release-truth
|
|
@@ -478,17 +868,21 @@ export function main(argv: string[]): 0 | 2 {
|
|
|
478
868
|
{
|
|
479
869
|
schema_version: 1,
|
|
480
870
|
...independenceFields(['anthropic']),
|
|
481
|
-
|
|
871
|
+
coverage,
|
|
872
|
+
findings: dedupeFindings(findings).map((f) => ({
|
|
873
|
+
finding_id: findingId(f),
|
|
874
|
+
...f,
|
|
875
|
+
})),
|
|
482
876
|
},
|
|
483
877
|
null,
|
|
484
878
|
2,
|
|
485
879
|
) + '\n',
|
|
486
880
|
);
|
|
487
881
|
}
|
|
488
|
-
const body = renderReview(findings, enforce, plan.escalation);
|
|
882
|
+
const body = renderReview(dedupeFindings(findings), enforce, plan.escalation, coverage);
|
|
489
883
|
postReview(body);
|
|
490
884
|
|
|
491
|
-
const code = gateVerdict(findings, { enforce });
|
|
885
|
+
const code = gateVerdict(dedupeFindings(findings), { enforce });
|
|
492
886
|
if (code === 2) {
|
|
493
887
|
process.stdout.write('::error::self-review-gate — merge-blocking findings under enforce mode.\n');
|
|
494
888
|
}
|
|
@@ -48,6 +48,12 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
|
48
48
|
|
|
49
49
|
import { collect, _keyword_vector, _cosine } from './audit_skill_overlap.js';
|
|
50
50
|
import { reportScanned, DeadScopeError } from './_lib/scan_scope.js';
|
|
51
|
+
import * as sl from './_lib/security_lint.js';
|
|
52
|
+
import { _scan as _scanHiddenUnicode } from './lint_hidden_unicode.js';
|
|
53
|
+
import { _scan as _scanConfusables } from './lint_confusables.js';
|
|
54
|
+
import { _scan as _scanInstructionSmuggling } from './lint_instruction_smuggling.js';
|
|
55
|
+
import { _scan as _scanMcpConfigSecurity } from './lint_mcp_config_security.js';
|
|
56
|
+
import { _scan as _scanFrontmatterSafety } from './lint_skill_frontmatter_safety.js';
|
|
51
57
|
|
|
52
58
|
const _HERE = fileURLToPath(import.meta.url);
|
|
53
59
|
const _DEFAULT_ROOT = path.resolve(path.dirname(_HERE), '..', '..');
|
|
@@ -262,6 +268,60 @@ export function resolveCandidateDir(
|
|
|
262
268
|
return { dir, refusal: null };
|
|
263
269
|
}
|
|
264
270
|
|
|
271
|
+
/**
|
|
272
|
+
* The five content linters, by the check id each one reports under.
|
|
273
|
+
*
|
|
274
|
+
* The SAME functions the `lint_agent_security` umbrella spawns as processes.
|
|
275
|
+
* Called in-process here rather than shelled out: five `tsx` cold starts per
|
|
276
|
+
* candidate is a cost with no benefit, and the thing that must be identical is
|
|
277
|
+
* the DETECTION, which is these functions.
|
|
278
|
+
*/
|
|
279
|
+
const CONTENT_LINTERS: ReadonlyArray<readonly [string, (sf: sl.ScannedFile) => sl.Finding[]]> = [
|
|
280
|
+
['hidden-unicode', _scanHiddenUnicode],
|
|
281
|
+
['mixed-script-confusable', _scanConfusables],
|
|
282
|
+
['instruction-smuggling', _scanInstructionSmuggling],
|
|
283
|
+
['mcp-config-security', _scanMcpConfigSecurity],
|
|
284
|
+
['dangerous-frontmatter', _scanFrontmatterSafety],
|
|
285
|
+
];
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Read what a quarantined candidate SAYS, and refuse on a payload.
|
|
289
|
+
*
|
|
290
|
+
* `intake`'s other checks are properties of the file — symlink, extension, exec
|
|
291
|
+
* bit, size. Every one of them can be true of a file whose text tells an agent
|
|
292
|
+
* to hide its output from the user. "Inert" was a claim about the container that
|
|
293
|
+
* the gate read as a claim about the contents.
|
|
294
|
+
*
|
|
295
|
+
* TWO DELIBERATE DIFFERENCES FROM HOW THESE LINTERS RUN OVER THIS PACKAGE:
|
|
296
|
+
*
|
|
297
|
+
* 1. **Any HIGH refuses, whatever its weight.** The 0.25 confidence weighting is
|
|
298
|
+
* a false-positive containment device for THIS repository's own corpus, where
|
|
299
|
+
* a path containing `docs` or `examples` really is documentation somebody
|
|
300
|
+
* here wrote. A quarantined third-party candidate carries no such provenance,
|
|
301
|
+
* and a directory named `examples/` in it is an attacker's word for it. A
|
|
302
|
+
* refusal is also cheaper than a false pass in exactly this direction: the
|
|
303
|
+
* candidate is rejected and a human reads it.
|
|
304
|
+
* 2. **Every text extension is scanned, not just `.md`.** The candidate
|
|
305
|
+
* allow-list already bounds what may be present; a payload in the `.yml` a
|
|
306
|
+
* candidate ships is a payload.
|
|
307
|
+
*
|
|
308
|
+
* The refusal NAMES the linter, because "the content scan refused this" sends a
|
|
309
|
+
* reader to five detectors.
|
|
310
|
+
*/
|
|
311
|
+
export function scanCandidateContent(candidateDir: string): string[] {
|
|
312
|
+
const out: string[] = [];
|
|
313
|
+
for (const sf of sl.iter_corpus(['.'], [...ALLOWED_EXT], candidateDir)) {
|
|
314
|
+
for (const [check, scan] of CONTENT_LINTERS) {
|
|
315
|
+
for (const finding of scan(sf)) {
|
|
316
|
+
if (finding.severity !== 'HIGH') continue;
|
|
317
|
+
const loc = finding.line ? `${sf.rel}:${String(finding.line)}` : sf.rel;
|
|
318
|
+
out.push(`${loc}: flagged by ${check} — ${finding.message}`);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
return out;
|
|
323
|
+
}
|
|
324
|
+
|
|
265
325
|
/**
|
|
266
326
|
* Refuse a candidate that is not inert.
|
|
267
327
|
*
|
|
@@ -296,6 +356,15 @@ export function intake(candidateDir: string): IntakeResult {
|
|
|
296
356
|
if (files.length === 0) {
|
|
297
357
|
refusals.push('candidate directory is empty');
|
|
298
358
|
}
|
|
359
|
+
// Content, after the file properties. Ordering is not cosmetic: a candidate
|
|
360
|
+
// that fails an inertness check may not be safe to READ (a symlink out of
|
|
361
|
+
// quarantine is the case the read-time re-check exists for), so the bytes
|
|
362
|
+
// are only opened once the container has been judged.
|
|
363
|
+
if (refusals.length === 0) {
|
|
364
|
+
for (const flagged of scanCandidateContent(candidateDir)) {
|
|
365
|
+
refusals.push(flagged);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
299
368
|
return { accepted: refusals.length === 0, refusals, files_seen: files.length };
|
|
300
369
|
}
|
|
301
370
|
|