@bongos/core 1.18.19 → 1.19.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.bongos-core.json +547 -197
- package/.claude/skills/builder-box/SKILL.md +1 -1
- package/.claude/skills/builder-claim/SKILL.md +1 -1
- package/.claude/skills/goal-create/SKILL.md +1 -1
- package/.claude/skills/idea-triage/SKILL.md +1 -1
- package/.claude/skills/planning-session/SKILL.md +5 -5
- package/.claude/skills/scan-before-install/SKILL.md +165 -0
- package/bin/bongos.js +33 -7
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +63 -41
- package/clients/bongos-client/index.cjs +63 -41
- package/clients/bongos-client/index.d.ts +78 -44
- package/clients/bongos-client/index.mjs +63 -41
- package/docs/adr/0020-security-model-public-stub.md +1 -1
- package/docs/adr/0097-one-active-claim-per-session-and-worktree-binding.md +2 -0
- package/docs/adr/0137-upstream-publish-policy-manual-owner-gated.md +1 -1
- package/docs/adr/0144-devbox-rehome-onto-cloudbongos-plane.md +1 -1
- package/docs/adr/0151-governance-permissions-as-atom-ranks-as-roles.md +2 -1
- package/docs/adr/0155-adopt-private-repo-widen-oauth-scope.md +51 -0
- package/docs/adr/0156-user-set-display-name-wins.md +62 -0
- package/docs/adr/0157-archon-is-rank-and-identity-only.md +93 -0
- package/docs/adr/0158-choke-point-verdict-taxonomy.md +174 -0
- package/docs/adr/0159-merge-gate-reconciliation.md +88 -0
- package/docs/adr/0160-main-audit-repo-verifiable-caller.md +77 -0
- package/docs/adr/0161-publish-on-merge.md +34 -0
- package/docs/adr/README.md +9 -0
- package/docs/api/openapi.json +1209 -233
- package/docs/api-reference.md +61 -46
- package/docs/architecture.md +1 -1
- package/docs/canonical-permissions.md +61 -16
- package/docs/design/vanilla-hall-ui-redesign-scope.md +1 -1
- package/docs/file-map.md +4 -1
- package/docs/onboarding/diagrams/assertions.json +3 -3
- package/docs/recipes/core-release-pipeline.md +181 -0
- package/docs/recipes/packaging-the-core.md +2 -0
- package/docs/recipes/parallel-goal-waves.md +1 -3
- package/docs/recipes/private-npm-distribution.md +6 -2
- package/docs/recipes/upgrading-the-core.md +2 -0
- package/migrations/core_197_display_name_source.sql +56 -0
- package/migrations/core_204_rekey_active_claim_per_session_worktree.sql +77 -0
- package/migrations/core_205_versions_track_default.sql +28 -0
- package/modules/autonomy/routes/autonomy.js +1 -1
- package/modules/builder-settings/db.js +7 -1
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +63 -41
- package/modules/dev-box/routes/box.js +11 -9
- package/modules/discord/auth.js +3 -1
- package/modules/discord/routes/discord.js +8 -2
- package/modules/discord/ship-broadcast.js +48 -1
- package/modules/governance/catalog.js +70 -20
- package/modules/governance/db.js +102 -0
- package/modules/governance/migrations/governance_006_version_create.sql +38 -0
- package/modules/governance/migrations/governance_007_delegate_archon_ops_to_metic.sql +72 -0
- package/modules/governance/routes/governance.js +235 -6
- package/modules/grading/grader-workers/index.js +18 -9
- package/modules/hall-ui/public/gate.js +6 -2
- package/modules/hall-ui/public/goals-page.js +6 -2
- package/modules/hall-ui/public/governance.css +100 -0
- package/modules/hall-ui/public/governance.html +77 -0
- package/modules/hall-ui/public/governance.js +353 -0
- package/modules/hall-ui/public/harbor.js +14 -8
- package/modules/hall-ui/public/instances-panel.js +3 -1
- package/modules/hall-ui/public/new-project-wizard.js +6 -0
- package/modules/hall-ui/public/people.html +51 -0
- package/modules/hall-ui/public/people.js +100 -0
- package/modules/hall-ui/public/profile.html +2 -1
- package/modules/hall-ui/public/profile.js +9 -3
- package/modules/hall-ui/public/sessions.js +4 -2
- package/modules/hall-ui/public/shell.js +28 -7
- package/modules/hall-ui/public/task-detail.js +6 -3
- package/modules/hall-ui/public/watch.js +17 -9
- package/modules/ideas/blockers.js +44 -7
- package/modules/ideas/routes/blockers.js +12 -7
- package/modules/ideas/routes/inbox.js +2 -2
- package/modules/lifecycle/db.js +84 -17
- package/modules/lifecycle/github-push.js +37 -10
- package/modules/lifecycle/publish-reconciler.js +128 -31
- package/modules/lifecycle/routes/claims.js +10 -4
- package/modules/lifecycle/routes/dependencies.js +2 -2
- package/modules/lifecycle/routes/done-when.js +4 -4
- package/modules/lifecycle/routes/gate-approvals.js +9 -3
- package/modules/lifecycle/routes/goals.js +5 -5
- package/modules/lifecycle/routes/tasks.js +99 -69
- package/modules/lifecycle/routes/versions.js +13 -2
- package/modules/lifecycle/ship-card.js +38 -7
- package/modules/memory/routes/memory.js +2 -2
- package/modules/onboarding/routes/access-requests.js +3 -3
- package/modules/platform-identity/routes/projects.js +10 -6
- package/modules/platform-identity/routes/scouting.js +2 -2
- package/modules/provisioning/migrations/provisioning_004_onboard_mode.sql +30 -0
- package/modules/provisioning/provisioning.js +24 -6
- package/modules/provisioning/routes/provisioning.js +24 -6
- package/modules/security/artifact-scan/aggregate.js +133 -0
- package/modules/security/artifact-scan/detectors/capabilities.js +53 -0
- package/modules/security/artifact-scan/detectors/config-obfuscation.js +41 -0
- package/modules/security/artifact-scan/detectors/credentials.js +112 -0
- package/modules/security/artifact-scan/detectors/dependencies.js +115 -0
- package/modules/security/artifact-scan/detectors/hooks.js +116 -0
- package/modules/security/artifact-scan/detectors/injection.js +118 -0
- package/modules/security/artifact-scan/detectors/mcp.js +102 -0
- package/modules/security/artifact-scan/detectors/network.js +67 -0
- package/modules/security/artifact-scan/detectors/npm-lifecycle.js +67 -0
- package/modules/security/artifact-scan/detectors/permissions.js +91 -0
- package/modules/security/artifact-scan/detectors/shared.js +219 -0
- package/modules/security/artifact-scan/fence-local.js +99 -0
- package/modules/security/artifact-scan/fetch.js +330 -0
- package/modules/security/artifact-scan/floor.js +319 -0
- package/modules/security/artifact-scan/limits.js +66 -0
- package/modules/security/artifact-scan/manifest.js +203 -0
- package/modules/security/artifact-scan/mirror.js +158 -0
- package/modules/security/artifact-scan/quarantine.js +149 -0
- package/modules/security/artifact-scan/rebuild-spec.js +120 -0
- package/modules/security/artifact-scan/report.js +155 -0
- package/modules/security/artifact-scan/sanitize.js +317 -0
- package/modules/security/migrations/security_001_tool_scans.sql +145 -0
- package/modules/security/module.json +2 -2
- package/modules/security/routes/reports.js +11 -7
- package/modules/security/routes/scans.js +115 -0
- package/modules/security/scan-store.js +214 -0
- package/modules/security/tests/aggregate.mjs +283 -0
- package/modules/security/tests/artifact_scan.mjs +591 -0
- package/modules/security/tests/floor.mjs +549 -0
- package/modules/security/tests/mirror.mjs +350 -0
- package/modules/security/tests/sanitize_rebuild.mjs +499 -0
- package/modules/security/tests/scan_store.mjs +250 -0
- package/modules/security/tests/tool_scans_migration.mjs +116 -0
- package/modules/sessions/routes/sessions.js +6 -5
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/public-landing/create.html +13 -3
- package/scripts/gds/abandon.js +1 -1
- package/scripts/gds/audit-authorship.sh +6 -0
- package/scripts/gds/box.js +1 -1
- package/scripts/gds/bump-version.js +76 -0
- package/scripts/gds/claim.js +15 -0
- package/scripts/gds/cli-lib.js +16 -4
- package/scripts/gds/gating.js +28 -13
- package/scripts/gds/gen-api-docs.js +1 -1
- package/scripts/gds/goal.js +234 -0
- package/scripts/gds/init.js +77 -15
- package/scripts/gds/main-audit.js +80 -11
- package/scripts/gds/mirror-redact.js +6 -0
- package/scripts/gds/newcomer-floor.js +2 -2
- package/scripts/gds/provision.js +230 -94
- package/scripts/gds/run-unit-tests.js +9 -1
- package/scripts/gds/sandbox-stage.js +66 -9
- package/scripts/gds/search-ingest-md.js +1 -0
- package/scripts/gds/sequence.js +67 -8
- package/scripts/gds/ship.js +183 -50
- package/scripts/hall-preview/server.js +1 -1
- package/scripts/migrate.sh +5 -5
- package/src/bongos/auth.js +129 -27
- package/src/bongos/db-kernel.js +66 -5
- package/src/bongos/db.js +6 -7
- package/src/bongos/github-repos.js +19 -7
- package/src/bongos/goal-scope-check.js +1 -1
- package/src/bongos/module-scope-map.js +3 -1
- package/src/bongos/permission-path-check.js +1 -1
- package/src/bongos/route-rank-check.js +73 -30
- package/src/bongos/routes/audit-log.js +4 -2
- package/src/bongos/routes/auth.js +8 -4
- package/src/bongos/routes/backup.js +7 -4
- package/src/bongos/routes/builders.js +23 -11
- package/src/bongos/routes/github.js +15 -4
- package/src/bongos/routes/llm-cache.js +5 -2
- package/src/bongos/routes/modules.js +9 -5
- package/src/bongos/routes/security.js +21 -12
- package/src/bongos/serve-internal.js +22 -7
- package/src/module-api.js +96 -13
- package/src/platform-server.js +14 -0
- package/tests/api_client.mjs +18 -2
- package/tests/api_path_404.mjs +3 -0
- package/tests/art_stage.mjs +2 -1
- package/tests/atlas.mjs +32 -4
- package/tests/auth_admission_gate.mjs +17 -5
- package/tests/auth_error_brand.mjs +41 -21
- package/tests/auth_resolve_failure.mjs +155 -0
- package/tests/blocker_link.mjs +88 -0
- package/tests/bongos_cli.mjs +91 -0
- package/tests/bongos_downloads.mjs +2 -1
- package/tests/box_dns_repoint.mjs +2 -0
- package/tests/box_ship_permissions.mjs +2 -0
- package/tests/box_terminal_ssrf.mjs +23 -17
- package/tests/bump_version.mjs +71 -0
- package/tests/claim_error_surface.mjs +23 -0
- package/tests/claude_materialize.mjs +4 -0
- package/tests/cli_box_scope.mjs +40 -0
- package/tests/cloud_bongos_pack.mjs +34 -4
- package/tests/conductor.mjs +2 -1
- package/tests/criterion_authoring_gate.mjs +28 -5
- package/tests/currency_label.mjs +10 -1
- package/tests/deploy_instance.mjs +2 -0
- package/tests/devbox_downloads.mjs +2 -1
- package/tests/display_name.mjs +95 -6
- package/tests/eviction_policy.mjs +2 -1
- package/tests/file_map.mjs +10 -0
- package/tests/first_admin_bootstrap.mjs +10 -2
- package/tests/first_run_engine_check.mjs +2 -1
- package/tests/fitness.mjs +26 -6
- package/tests/gate_approvals.mjs +15 -2
- package/tests/github_push_land_proof.mjs +50 -4
- package/tests/github_repos_helpers.mjs +30 -5
- package/tests/github_token_store.mjs +23 -0
- package/tests/goal_abuse_matrix.mjs +13 -3
- package/tests/goal_cli.mjs +89 -0
- package/tests/governance_abuse_matrix.mjs +386 -0
- package/tests/governance_fold.mjs +156 -0
- package/tests/governance_fold_bench.mjs +50 -0
- package/tests/governance_live_proof.mjs +201 -0
- package/tests/governance_page_gates.mjs +21 -9
- package/tests/governance_parity.mjs +11 -2
- package/tests/governance_require_permission.mjs +125 -1
- package/tests/governance_routes.mjs +334 -0
- package/tests/grader.mjs +7 -3
- package/tests/hall_nav.mjs +14 -4
- package/tests/helpers.mjs +73 -0
- package/tests/hook_core_resolve.mjs +2 -1
- package/tests/host_topology_skips.mjs +97 -0
- package/tests/infra_exec_paths.mjs +14 -4
- package/tests/init.mjs +147 -0
- package/tests/known_failing_ratchet.mjs +89 -0
- package/tests/lifecycle-card.mjs +2 -1
- package/tests/main_audit.mjs +72 -1
- package/tests/memory_isolation.mjs +56 -9
- package/tests/migrate_root_resolution.mjs +1 -1
- package/tests/mirror_redact.mjs +19 -0
- package/tests/module_api.mjs +3 -1
- package/tests/module_loader.mjs +4 -0
- package/tests/modules.mjs +35 -7
- package/tests/modules_route.mjs +29 -5
- package/tests/onboarding_observability.mjs +49 -11
- package/tests/onboarding_route_signals.mjs +1 -0
- package/tests/one_active_claim_per_session.mjs +80 -0
- package/tests/patch_gate_ordering.mjs +102 -0
- package/tests/pre_compact.mjs +2 -1
- package/tests/preview_server.mjs +2 -1
- package/tests/profile_route.mjs +1 -0
- package/tests/provision.mjs +170 -0
- package/tests/provisioning_onboard_mode.mjs +180 -0
- package/tests/publish_manifest.mjs +16 -3
- package/tests/publish_reconciler.mjs +181 -10
- package/tests/rank_tier_single_source.mjs +205 -0
- package/tests/recall_hints.mjs +2 -1
- package/tests/repo_map.mjs +4 -0
- package/tests/route_permission_check.mjs +14 -5
- package/tests/rubric_fixtures.mjs +3 -0
- package/tests/run_routine_two_root.mjs +2 -0
- package/tests/sandbox_stage.mjs +69 -7
- package/tests/search_ingest.mjs +7 -0
- package/tests/security_docs.mjs +28 -6
- package/tests/sequence.mjs +64 -0
- package/tests/session-cards.mjs +2 -1
- package/tests/session_start_freshness.mjs +2 -1
- package/tests/ship_broadcast_server.mjs +63 -1
- package/tests/ship_card.mjs +66 -0
- package/tests/ship_ci_deploy.mjs +66 -1
- package/tests/ship_smoke_gate.mjs +76 -0
- package/tests/versions_route.mjs +15 -0
- package/tests/workflow_exec_paths.mjs +16 -4
- package/tests/worktree_guard.mjs +2 -1
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// modules/security/artifact-scan/mirror.js — the reading-room copy of a
|
|
2
|
+
// quarantined artifact (task 1002543, goal 1000055).
|
|
3
|
+
//
|
|
4
|
+
// Two copies of a fetched artifact exist in the quarantine: the PRISTINE copy
|
|
5
|
+
// no model is ever allowed to read, and this mirror — where every readable
|
|
6
|
+
// file has already been scrubbed of control/zero-width/bidi characters and
|
|
7
|
+
// wrapped in an on-disk fence before any agent can open it. The defense is
|
|
8
|
+
// structural: even a raw Read call against mirror/ returns data-never-
|
|
9
|
+
// instructions, with no dependence on any agent's discipline.
|
|
10
|
+
//
|
|
11
|
+
// What does NOT get mirrored becomes a STUB RECORD instead: symlinks (never
|
|
12
|
+
// followed), non-UTF8/binary files, oversize files, special files, and any
|
|
13
|
+
// path that escapes the pen lexically or physically. Stub bytes are counted as
|
|
14
|
+
// UNREVIEWED — downstream the reducer caps the verdict at caution over them,
|
|
15
|
+
// because static analysis cannot clear what it never read.
|
|
16
|
+
//
|
|
17
|
+
// The walk is driven by the MANIFEST entries, not a fresh readdir: the mirror
|
|
18
|
+
// renders exactly the inventory that was hashed, so "what we read" and "what
|
|
19
|
+
// we fingerprinted" can never drift apart.
|
|
20
|
+
//
|
|
21
|
+
// Confinement is checked twice per file, fail-closed:
|
|
22
|
+
// 1. lexically — a manifest path that is absolute or resolves above the
|
|
23
|
+
// pristine root is refused before any fs call touches it;
|
|
24
|
+
// 2. physically — the file's realpath must live under the quarantine root,
|
|
25
|
+
// so a real on-disk symlink smuggled past kind detection still cannot
|
|
26
|
+
// pull outside bytes into the mirror.
|
|
27
|
+
//
|
|
28
|
+
// Fully async (fs.promises) for the same reason as manifest.js: a sync walk at
|
|
29
|
+
// cap limits blocks the event loop for seconds.
|
|
30
|
+
|
|
31
|
+
'use strict';
|
|
32
|
+
|
|
33
|
+
const fsp = require('node:fs/promises');
|
|
34
|
+
const path = require('node:path');
|
|
35
|
+
const { TextDecoder } = require('node:util');
|
|
36
|
+
|
|
37
|
+
// Intra-module siblings (a module may require its own files, never another
|
|
38
|
+
// module or a core internal).
|
|
39
|
+
const { isInside } = require('./quarantine');
|
|
40
|
+
const { newFenceDelim, fenceFile } = require('./fence-local');
|
|
41
|
+
|
|
42
|
+
// Per-file readability budget. A "text" file past this size is recorded as a
|
|
43
|
+
// stub rather than rendered readable — a 40 MB minified blob is unreviewable
|
|
44
|
+
// text in practice, and unreviewable must always surface as unreviewed.
|
|
45
|
+
const MIRROR_CAPS = Object.freeze({
|
|
46
|
+
maxFileBytes: 1024 * 1024, // 1 MiB per mirrored file
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const INDEX_BASENAME = 'mirror-index.json';
|
|
50
|
+
|
|
51
|
+
// Lexical containment for a manifest-relative path: must be relative, and must
|
|
52
|
+
// stay under root after resolution. Directional — anything else is an escape.
|
|
53
|
+
function relEscapes(rootDir, rel) {
|
|
54
|
+
if (typeof rel !== 'string' || rel.length === 0) return true;
|
|
55
|
+
if (path.isAbsolute(rel)) return true;
|
|
56
|
+
return !isInside(rootDir, path.resolve(rootDir, ...rel.split('/')));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Decode strictly as UTF-8. A single invalid sequence means "not text we can
|
|
60
|
+
// honestly render" — the file becomes a stub, never a lossily-decoded mirror.
|
|
61
|
+
// One shared decoder: non-streaming decode() keeps no state between calls.
|
|
62
|
+
const UTF8_STRICT = new TextDecoder('utf-8', { fatal: true });
|
|
63
|
+
function strictUtf8(buf) {
|
|
64
|
+
try {
|
|
65
|
+
return UTF8_STRICT.decode(buf);
|
|
66
|
+
} catch {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Build the reading-room mirror for one scan.
|
|
72
|
+
// pristineDir — the pristine snapshot (never modified here)
|
|
73
|
+
// mirrorDir — where scrubbed+fenced copies are written
|
|
74
|
+
// quarantineRoot — the pen; every realpath must resolve under it
|
|
75
|
+
// entries — manifest entries ({ path, kind, size, ... }) from buildManifest
|
|
76
|
+
// caps — { maxFileBytes } override (tests); defaults MIRROR_CAPS
|
|
77
|
+
//
|
|
78
|
+
// Returns { mirrored, stubs, unreviewedBytes, fenceDelim, indexPath }. A stub
|
|
79
|
+
// is { path, reason: symlink|special|oversize|non_utf8|path_escape|unreadable,
|
|
80
|
+
// bytes }. Never throws on a per-file problem — a file the mirror cannot
|
|
81
|
+
// honestly render is a recorded stub, not a crashed scan.
|
|
82
|
+
async function buildMirror({ pristineDir, mirrorDir, quarantineRoot, entries, caps = {} }) {
|
|
83
|
+
if (!pristineDir || !mirrorDir || !quarantineRoot || !Array.isArray(entries)) {
|
|
84
|
+
throw new Error('buildMirror requires pristineDir, mirrorDir, quarantineRoot and manifest entries[].');
|
|
85
|
+
}
|
|
86
|
+
const maxFileBytes = caps.maxFileBytes ?? MIRROR_CAPS.maxFileBytes;
|
|
87
|
+
const realQuarantine = await fsp.realpath(quarantineRoot);
|
|
88
|
+
const fenceDelim = newFenceDelim();
|
|
89
|
+
|
|
90
|
+
const mirrored = [];
|
|
91
|
+
const stubs = [];
|
|
92
|
+
const madeDirs = new Set();
|
|
93
|
+
const stub = (rel, reason, bytes) => stubs.push({ path: rel, reason, bytes });
|
|
94
|
+
|
|
95
|
+
for (const entry of entries) {
|
|
96
|
+
const rel = entry.path;
|
|
97
|
+
|
|
98
|
+
if (entry.kind === 'symlink') { stub(rel, 'symlink', entry.size || 0); continue; }
|
|
99
|
+
if (entry.kind !== 'file') { stub(rel, 'special', entry.size || 0); continue; }
|
|
100
|
+
|
|
101
|
+
// 1. Lexical confinement — before any fs call touches the path.
|
|
102
|
+
if (relEscapes(pristineDir, rel)) { stub(rel, 'path_escape', entry.size || 0); continue; }
|
|
103
|
+
const abs = path.resolve(pristineDir, ...rel.split('/'));
|
|
104
|
+
|
|
105
|
+
if (entry.size > maxFileBytes) { stub(rel, 'oversize', entry.size); continue; }
|
|
106
|
+
|
|
107
|
+
// 2. Physical confinement — the realpath must stay inside the pen, so a
|
|
108
|
+
// real symlink mislabeled "file" cannot pull outside bytes into the mirror.
|
|
109
|
+
let buf;
|
|
110
|
+
try {
|
|
111
|
+
const real = await fsp.realpath(abs);
|
|
112
|
+
if (!isInside(realQuarantine, real)) { stub(rel, 'path_escape', entry.size || 0); continue; }
|
|
113
|
+
const st = await fsp.lstat(abs);
|
|
114
|
+
if (st.isSymbolicLink()) { stub(rel, 'path_escape', entry.size || 0); continue; }
|
|
115
|
+
// Read the VERIFIED resolved path, not the original — reading `abs` would
|
|
116
|
+
// reopen the race the realpath check just closed (TOCTOU: a swap between
|
|
117
|
+
// lstat and read would be followed silently).
|
|
118
|
+
buf = await fsp.readFile(real);
|
|
119
|
+
} catch {
|
|
120
|
+
// Missing/unreadable on disk though present in the manifest — that
|
|
121
|
+
// disagreement is itself worth surfacing, as unreviewed.
|
|
122
|
+
stub(rel, 'unreadable', entry.size || 0);
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const text = strictUtf8(buf);
|
|
127
|
+
if (text === null) { stub(rel, 'non_utf8', buf.length); continue; }
|
|
128
|
+
|
|
129
|
+
const dest = path.resolve(mirrorDir, ...rel.split('/'));
|
|
130
|
+
if (!isInside(mirrorDir, dest)) { stub(rel, 'path_escape', buf.length); continue; }
|
|
131
|
+
const destDir = path.dirname(dest);
|
|
132
|
+
if (!madeDirs.has(destDir)) {
|
|
133
|
+
await fsp.mkdir(destDir, { recursive: true });
|
|
134
|
+
madeDirs.add(destDir);
|
|
135
|
+
}
|
|
136
|
+
await fsp.writeFile(dest, fenceFile(fenceDelim, rel, text), 'utf8');
|
|
137
|
+
mirrored.push({ path: rel, bytes: buf.length });
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const unreviewedBytes = stubs.reduce((sum, s) => sum + (s.bytes || 0), 0);
|
|
141
|
+
|
|
142
|
+
// The index is the trusted record downstream stages (floor, reducer) read:
|
|
143
|
+
// which files are renderable, which bytes were never reviewed, and the fence
|
|
144
|
+
// delimiter the panel should expect. Written inside the pen, nowhere else.
|
|
145
|
+
const indexPath = path.join(realQuarantine, INDEX_BASENAME);
|
|
146
|
+
const index = {
|
|
147
|
+
fenceDelim,
|
|
148
|
+
mirrored,
|
|
149
|
+
stubs,
|
|
150
|
+
unreviewedBytes,
|
|
151
|
+
counts: { mirrored: mirrored.length, stubs: stubs.length },
|
|
152
|
+
};
|
|
153
|
+
await fsp.writeFile(indexPath, JSON.stringify(index, null, 2), 'utf8');
|
|
154
|
+
|
|
155
|
+
return { mirrored, stubs, unreviewedBytes, fenceDelim, indexPath };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
module.exports = { buildMirror, MIRROR_CAPS, INDEX_BASENAME };
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// modules/security/artifact-scan/quarantine.js — the holding pen an untrusted
|
|
2
|
+
// artifact is fetched into.
|
|
3
|
+
//
|
|
4
|
+
// The scan engine's first rule is that a suspect artifact never gets a chance to
|
|
5
|
+
// act. Everything downstream (the mirror, the detectors, the panel) assumes the
|
|
6
|
+
// bytes it reads live somewhere that satisfies three location properties:
|
|
7
|
+
//
|
|
8
|
+
// 1. OUTSIDE THE REPO — so a scanned artifact can never be mistaken for our
|
|
9
|
+
// own source, land in a diff, or be picked up by a fitness/lint sweep.
|
|
10
|
+
// 2. OUTSIDE ONEDRIVE (or any sync root) — a synced folder uploads whatever
|
|
11
|
+
// lands in it. Fetching malware into a synced directory copies it to the
|
|
12
|
+
// builder's cloud storage and every other machine on that account.
|
|
13
|
+
// 3. OUTSIDE EVERY WORKSPACE ROOT — this is the subtle one. A Claude session
|
|
14
|
+
// auto-loads CLAUDE.md and .claude/ from directories inside its workspace.
|
|
15
|
+
// An artifact carrying its own CLAUDE.md, dropped inside a workspace root,
|
|
16
|
+
// is prompt injection delivered through the harness itself rather than
|
|
17
|
+
// through any file an agent chose to read. Callers pass their workspace
|
|
18
|
+
// roots in `forbiddenRoots`; we refuse to build a quarantine under one.
|
|
19
|
+
//
|
|
20
|
+
// The pen also holds the three neutralizing fixtures the hardened clone needs:
|
|
21
|
+
// an EMPTY REAL hooks directory (git's core.hooksPath must be a real directory
|
|
22
|
+
// — /dev/null works on macOS and is not a directory on Windows), and an empty
|
|
23
|
+
// gitconfig used for both GIT_CONFIG_GLOBAL and GIT_CONFIG_SYSTEM so the
|
|
24
|
+
// builder's own git config (insteadOf URL rewrites, a global hooksPath, a
|
|
25
|
+
// credential helper) cannot influence the fetch.
|
|
26
|
+
//
|
|
27
|
+
// Pure-ish: filesystem only. No DB, no network, no doorway — so it is unit
|
|
28
|
+
// testable and safe to require from a CLI.
|
|
29
|
+
|
|
30
|
+
'use strict';
|
|
31
|
+
|
|
32
|
+
const fs = require('node:fs');
|
|
33
|
+
const os = require('node:os');
|
|
34
|
+
const path = require('node:path');
|
|
35
|
+
|
|
36
|
+
// modules/security/artifact-scan/ -> modules/security/ -> modules/ -> repo root
|
|
37
|
+
const REPO_ROOT = path.resolve(__dirname, '..', '..', '..');
|
|
38
|
+
|
|
39
|
+
// Fail-closed budget. Exceeding any of these aborts the scan and records a
|
|
40
|
+
// finding — it never silently truncates, because a scan that quietly stopped
|
|
41
|
+
// reading at 80% of a repo and still says "no findings" is a lie.
|
|
42
|
+
const CAPS = Object.freeze({
|
|
43
|
+
maxFiles: 5000,
|
|
44
|
+
maxBytes: 64 * 1024 * 1024, // 64 MiB across the whole artifact
|
|
45
|
+
cloneTimeoutMs: 120_000,
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// A directory is "sync-backed" if any segment names a known consumer-sync root.
|
|
49
|
+
// Deliberately a name test rather than a path test: OneDrive/Dropbox relocate,
|
|
50
|
+
// and the property we care about ("anything written here is uploaded") travels
|
|
51
|
+
// with the folder, not with one machine's absolute path.
|
|
52
|
+
const SYNC_SEGMENTS = ['onedrive', 'dropbox', 'google drive', 'icloud drive'];
|
|
53
|
+
|
|
54
|
+
function isSyncBacked(p) {
|
|
55
|
+
return path
|
|
56
|
+
.resolve(p)
|
|
57
|
+
.split(/[\\/]/)
|
|
58
|
+
.some((seg) => SYNC_SEGMENTS.includes(seg.toLowerCase()));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Lexical containment: is `candidate` equal to, or under, `root`? Directional on
|
|
62
|
+
// purpose — path.relative escaping upward ('..') or returning an absolute path
|
|
63
|
+
// both mean "outside". Used to reject a quarantine site, and by callers to keep
|
|
64
|
+
// derived paths inside the pen.
|
|
65
|
+
function isInside(root, candidate) {
|
|
66
|
+
const rel = path.relative(path.resolve(root), path.resolve(candidate));
|
|
67
|
+
if (rel === '') return true;
|
|
68
|
+
return !rel.startsWith('..') && !path.isAbsolute(rel);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Why a chosen site is unusable, or null when it is fine. Split out from
|
|
72
|
+
// createQuarantine so the rule set is directly testable without touching disk.
|
|
73
|
+
function siteRefusal(site, forbiddenRoots) {
|
|
74
|
+
if (isInside(REPO_ROOT, site)) return `inside the repository (${REPO_ROOT})`;
|
|
75
|
+
if (isSyncBacked(site)) return 'inside a cloud-sync folder (it would upload the artifact)';
|
|
76
|
+
for (const root of forbiddenRoots) {
|
|
77
|
+
if (!root) continue;
|
|
78
|
+
if (isInside(root, site)) return `inside a workspace root (${root}) where its CLAUDE.md could auto-load`;
|
|
79
|
+
}
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Build a fresh, uniquely-named pen.
|
|
84
|
+
//
|
|
85
|
+
// `forbiddenRoots` is REQUIRED, not optional-with-an-empty-default. Workspace
|
|
86
|
+
// isolation is the primary defense against an artifact's own CLAUDE.md being
|
|
87
|
+
// auto-loaded as instructions, and a defense that silently does nothing when a
|
|
88
|
+
// caller forgets a parameter is not a defense. Callers with genuinely no
|
|
89
|
+
// workspace roots must say so by passing [] explicitly. The repo root and sync
|
|
90
|
+
// folders are refused unconditionally regardless.
|
|
91
|
+
//
|
|
92
|
+
// Returns { root, pristineDir, mirrorDir, hooksDir, gitConfigFile, cleanup() }.
|
|
93
|
+
// pristineDir holds the bytes no model is ever allowed to read; mirrorDir is
|
|
94
|
+
// where the scrubbed reading-room copy is written (task 1002543).
|
|
95
|
+
function createQuarantine({ tmpRoot = os.tmpdir(), forbiddenRoots } = {}) {
|
|
96
|
+
if (!Array.isArray(forbiddenRoots)) {
|
|
97
|
+
throw new Error(
|
|
98
|
+
'createQuarantine requires forbiddenRoots: string[] — the workspace roots a quarantine must never live under. ' +
|
|
99
|
+
'Pass [] explicitly if this caller genuinely has none.'
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
const refusal = siteRefusal(tmpRoot, forbiddenRoots);
|
|
103
|
+
if (refusal) {
|
|
104
|
+
throw new Error(
|
|
105
|
+
`Refusing to create a quarantine under ${tmpRoot} — it is ${refusal}. ` +
|
|
106
|
+
'Pass a different tmpRoot outside the repo, outside any sync folder, and outside every workspace root.'
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const root = fs.mkdtempSync(path.join(tmpRoot, 'bongos-scan-'));
|
|
111
|
+
// mkdtemp resolves through whatever tmpRoot really is (on macOS /tmp is a
|
|
112
|
+
// symlink to /private/tmp), so re-check the REAL location before we trust it.
|
|
113
|
+
const realRoot = fs.realpathSync(root);
|
|
114
|
+
const realRefusal = siteRefusal(realRoot, forbiddenRoots);
|
|
115
|
+
if (realRefusal) {
|
|
116
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
117
|
+
throw new Error(`Refusing to use quarantine ${realRoot} — it resolves ${realRefusal}.`);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const pristineDir = path.join(realRoot, 'pristine');
|
|
121
|
+
const mirrorDir = path.join(realRoot, 'mirror');
|
|
122
|
+
const hooksDir = path.join(realRoot, 'empty-hooks');
|
|
123
|
+
const gitConfigFile = path.join(realRoot, 'empty-gitconfig');
|
|
124
|
+
|
|
125
|
+
fs.mkdirSync(mirrorDir);
|
|
126
|
+
fs.mkdirSync(hooksDir); // must exist and be EMPTY: git's core.hooksPath target
|
|
127
|
+
fs.writeFileSync(gitConfigFile, '', 'utf8');
|
|
128
|
+
// pristineDir is deliberately NOT created — `git clone` wants to create it.
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
root: realRoot,
|
|
132
|
+
pristineDir,
|
|
133
|
+
mirrorDir,
|
|
134
|
+
hooksDir,
|
|
135
|
+
gitConfigFile,
|
|
136
|
+
cleanup() {
|
|
137
|
+
fs.rmSync(realRoot, { recursive: true, force: true });
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
module.exports = {
|
|
143
|
+
CAPS,
|
|
144
|
+
REPO_ROOT,
|
|
145
|
+
createQuarantine,
|
|
146
|
+
siteRefusal,
|
|
147
|
+
isSyncBacked,
|
|
148
|
+
isInside,
|
|
149
|
+
};
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// modules/security/artifact-scan/rebuild-spec.js — the beyond-repair landing
|
|
2
|
+
// (task 1002548, goal 1000055).
|
|
3
|
+
//
|
|
4
|
+
// When an artifact is dangerous, or sanitize's gates fail, the honest offer
|
|
5
|
+
// left is: rebuild the FUNCTIONALITY from scratch, specified from the
|
|
6
|
+
// artifact's DOCUMENTATION ONLY. Two rules make that safe:
|
|
7
|
+
//
|
|
8
|
+
// * DOC FILES ONLY. selectDocFiles() names exactly which manifest entries a
|
|
9
|
+
// model may read for the spec (via the MIRROR, like every model read) —
|
|
10
|
+
// README/CHANGELOG/LICENSE-class basenames and doc extensions. Code files
|
|
11
|
+
// never qualify, so no hostile implementation detail rides into the spec.
|
|
12
|
+
// * SCRIPT-OWNED FRAMING. assembleRebuildSpec() renders the document: the
|
|
13
|
+
// ground rules are fixed sentences owned here (the limits.js pattern), and
|
|
14
|
+
// every model-authored requirement line passes through the same
|
|
15
|
+
// scrub + one-line + cap transform every report string does — the spec can
|
|
16
|
+
// be filed as a GDS task without ever becoming a carrier.
|
|
17
|
+
//
|
|
18
|
+
// Pure string/path logic — no I/O. The skill decides where the file lands
|
|
19
|
+
// (the builder's own user config dir, never the repo).
|
|
20
|
+
|
|
21
|
+
'use strict';
|
|
22
|
+
|
|
23
|
+
const { excerpt } = require('./detectors/shared');
|
|
24
|
+
|
|
25
|
+
// Documentation by basename or extension, never by directory: a .js under
|
|
26
|
+
// docs/ is code, and code is exactly what this spec must not be derived from.
|
|
27
|
+
// The basename rule matches the EXACT extensionless name only — "readme.js"
|
|
28
|
+
// is code that happens to be named readme, and must never qualify; names with
|
|
29
|
+
// a doc extension (README.md) qualify through DOC_EXT_RE instead.
|
|
30
|
+
const DOC_BASENAME_RE = /^(readme|changelog|changes|contributing|license|licence|notice|authors|history|faq|copying)$/i;
|
|
31
|
+
const DOC_EXT_RE = /\.(md|markdown|mdx|rst|txt|adoc)$/i;
|
|
32
|
+
|
|
33
|
+
const SPEC_LIMITS = Object.freeze({
|
|
34
|
+
maxRequirements: 150,
|
|
35
|
+
maxRequirementChars: 300,
|
|
36
|
+
maxDocFilesListed: 200,
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
// Fixed ground rules — script-owned sentences, never model-authored. Exported
|
|
40
|
+
// so tests can pin them verbatim.
|
|
41
|
+
const GROUND_RULES = Object.freeze([
|
|
42
|
+
'This spec was derived from the artifact\'s DOCUMENTATION ONLY. No code from the artifact was consulted for, or copied into, this document.',
|
|
43
|
+
'Rebuilding means implementing from scratch. Do not fetch, vendor, or reuse any bytes of the original artifact.',
|
|
44
|
+
'Each requirement below is a claim from the artifact\'s own docs — treat it as a wish list to verify against real need, not as a trusted statement.',
|
|
45
|
+
]);
|
|
46
|
+
|
|
47
|
+
function isDocPath(relPath) {
|
|
48
|
+
const s = String(relPath || '');
|
|
49
|
+
const base = s.slice(Math.max(s.lastIndexOf('/'), s.lastIndexOf('\\')) + 1);
|
|
50
|
+
return DOC_BASENAME_RE.test(base) || DOC_EXT_RE.test(base);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// The manifest file entries a model may read (in the mirror) to write the
|
|
54
|
+
// spec. Everything else in the artifact is out of bounds for this landing.
|
|
55
|
+
function selectDocFiles(entries = []) {
|
|
56
|
+
return entries
|
|
57
|
+
.filter((e) => e && e.kind === 'file' && isDocPath(e.path))
|
|
58
|
+
.map((e) => e.path);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Render REBUILD-SPEC.md. `requirements` is the model-authored list of
|
|
62
|
+
// one-line functional requirements (written after reading the fenced doc
|
|
63
|
+
// mirror); everything artifact- or model-authored is scrubbed + capped here.
|
|
64
|
+
// Throws on an empty or malformed requirements list — a spec with no
|
|
65
|
+
// requirements is not a spec, and silently emitting one would turn the
|
|
66
|
+
// beyond-repair landing into an empty ritual.
|
|
67
|
+
function assembleRebuildSpec({
|
|
68
|
+
target = {}, // { url, sha }
|
|
69
|
+
docPaths = [], // the selectDocFiles() output actually consulted
|
|
70
|
+
requirements, // string[] — one line each, model-authored
|
|
71
|
+
generatedAt = new Date().toISOString(),
|
|
72
|
+
} = {}) {
|
|
73
|
+
if (!Array.isArray(requirements) || requirements.length === 0) {
|
|
74
|
+
throw new Error('assembleRebuildSpec requires a non-empty requirements[] — a spec with no requirements is not a spec.');
|
|
75
|
+
}
|
|
76
|
+
if (requirements.length > SPEC_LIMITS.maxRequirements) {
|
|
77
|
+
throw new Error(`assembleRebuildSpec caps requirements at ${SPEC_LIMITS.maxRequirements} — split the spec instead.`);
|
|
78
|
+
}
|
|
79
|
+
for (const r of requirements) {
|
|
80
|
+
if (typeof r !== 'string' || r.trim().length === 0) {
|
|
81
|
+
throw new Error('assembleRebuildSpec requirements must be non-empty strings.');
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
if (!Array.isArray(docPaths)) throw new Error('assembleRebuildSpec requires docPaths[] (may be empty).');
|
|
85
|
+
|
|
86
|
+
const out = [];
|
|
87
|
+
out.push('# REBUILD-SPEC — rebuild this functionality from scratch');
|
|
88
|
+
out.push('');
|
|
89
|
+
out.push(`Original artifact: ${excerpt(String(target.url || '(unknown)'), 300)} @ \`${excerpt(String(target.sha || '(unknown)'), 64)}\` · ${generatedAt}`);
|
|
90
|
+
out.push('');
|
|
91
|
+
out.push('## Ground rules (fixed)');
|
|
92
|
+
out.push('');
|
|
93
|
+
for (const rule of GROUND_RULES) out.push(`- ${rule}`);
|
|
94
|
+
out.push('');
|
|
95
|
+
out.push('## Documentation consulted');
|
|
96
|
+
out.push('');
|
|
97
|
+
if (docPaths.length === 0) out.push('_None — the artifact shipped no readable documentation; the requirements below rest on its stated purpose only._');
|
|
98
|
+
for (const p of docPaths.slice(0, SPEC_LIMITS.maxDocFilesListed)) {
|
|
99
|
+
out.push(`- ${excerpt(String(p), 300)}`);
|
|
100
|
+
}
|
|
101
|
+
if (docPaths.length > SPEC_LIMITS.maxDocFilesListed) {
|
|
102
|
+
out.push(`- …and ${docPaths.length - SPEC_LIMITS.maxDocFilesListed} more`);
|
|
103
|
+
}
|
|
104
|
+
out.push('');
|
|
105
|
+
out.push('## Functional requirements (from the docs — verify before building)');
|
|
106
|
+
out.push('');
|
|
107
|
+
for (const r of requirements) {
|
|
108
|
+
out.push(`- ${excerpt(r, SPEC_LIMITS.maxRequirementChars)}`);
|
|
109
|
+
}
|
|
110
|
+
out.push('');
|
|
111
|
+
return out.join('\n');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
module.exports = {
|
|
115
|
+
SPEC_LIMITS,
|
|
116
|
+
GROUND_RULES,
|
|
117
|
+
isDocPath,
|
|
118
|
+
selectDocFiles,
|
|
119
|
+
assembleRebuildSpec,
|
|
120
|
+
};
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// modules/security/artifact-scan/report.js — the report assembler
|
|
2
|
+
// (task 1002545, goal 1000055).
|
|
3
|
+
//
|
|
4
|
+
// A SCRIPT assembles the report: the verdict headline and the honest-limits
|
|
5
|
+
// block are emitted here, from the reducer's arithmetic and limits.js's fixed
|
|
6
|
+
// sentences — so a steered panel can bias findings but can never soften the
|
|
7
|
+
// caveats or write the headline. Every quoted string that originates in the
|
|
8
|
+
// artifact or in a model-authored lens finding passes through the same
|
|
9
|
+
// scrub + single-line + cap transform the floor's findings use, so the report
|
|
10
|
+
// can quote what was found without ever re-delivering the payload.
|
|
11
|
+
|
|
12
|
+
'use strict';
|
|
13
|
+
|
|
14
|
+
const { excerpt } = require('./detectors/shared');
|
|
15
|
+
const { passingWording, limitLines } = require('./limits');
|
|
16
|
+
const { LENS_OK } = require('./aggregate');
|
|
17
|
+
|
|
18
|
+
const SEVERITY_ORDER = Object.freeze(['critical', 'high', 'medium', 'low']);
|
|
19
|
+
|
|
20
|
+
// One finding → one report line. Code/file/message are untrusted (artifact or
|
|
21
|
+
// model authored) — scrubbed + capped here regardless of who produced them.
|
|
22
|
+
function findingLine(f, source) {
|
|
23
|
+
const sev = SEVERITY_ORDER.includes(f && f.severity) ? f.severity : 'high';
|
|
24
|
+
const code = excerpt(String((f && f.code) || 'unlabeled'), 64);
|
|
25
|
+
const file = excerpt(String((f && f.file) || '(unknown)'), 300);
|
|
26
|
+
const message = excerpt(String((f && f.message) || ''), 300);
|
|
27
|
+
return `- [${sev}] \`${code}\` ${file} — ${message} _(${source})_`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const SEVERITY_PREFIXES = Object.freeze(SEVERITY_ORDER.map((s) => `- [${s}]`));
|
|
31
|
+
|
|
32
|
+
function sortBySeverity(lines) {
|
|
33
|
+
return lines.sort((a, b) => {
|
|
34
|
+
const ai = SEVERITY_PREFIXES.findIndex((p) => a.startsWith(p));
|
|
35
|
+
const bi = SEVERITY_PREFIXES.findIndex((p) => b.startsWith(p));
|
|
36
|
+
return ai - bi;
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Assemble the full markdown report. Pure string building — no I/O; the skill
|
|
41
|
+
// decides where the file lands (the builder's own user dir, never the repo).
|
|
42
|
+
function assembleReport({
|
|
43
|
+
target = {}, // { url, sha, contentHash, kind }
|
|
44
|
+
tier = 'low',
|
|
45
|
+
reduced, // reduceVerdict() result — REQUIRED, the script's arithmetic
|
|
46
|
+
floorReport = {},
|
|
47
|
+
lenses = [],
|
|
48
|
+
engineVersion = 'unknown',
|
|
49
|
+
generatedAt = new Date().toISOString(),
|
|
50
|
+
} = {}) {
|
|
51
|
+
if (!reduced || !reduced.verdict) {
|
|
52
|
+
throw new Error('assembleReport requires the reducer result — the report never invents a verdict');
|
|
53
|
+
}
|
|
54
|
+
const cov = floorReport.coverage || {};
|
|
55
|
+
const skipped = Array.isArray(cov.files_skipped) ? cov.files_skipped : [];
|
|
56
|
+
const filesRead = Number(cov.files_read) || 0;
|
|
57
|
+
const filesTotal = filesRead + skipped.length;
|
|
58
|
+
const floorFindings = Array.isArray(floorReport.findings) ? floorReport.findings : [];
|
|
59
|
+
const lensProblems = lenses.filter((l) => !l || l.status !== LENS_OK);
|
|
60
|
+
const declaredDeps = floorFindings
|
|
61
|
+
.filter((f) => f && f.code === 'dependencies_unfetched')
|
|
62
|
+
.reduce((sum, f) => sum + (Number(f.detail && f.detail.declared) || 0), 0);
|
|
63
|
+
const unscannable = floorFindings.filter((f) => f && f.detail && f.detail.unscannable === true).length;
|
|
64
|
+
const sha = target.sha || floorReport.target?.sha || '(unknown)';
|
|
65
|
+
|
|
66
|
+
const out = [];
|
|
67
|
+
|
|
68
|
+
// ---- Headline: the script's arithmetic, never a lens's prose ----
|
|
69
|
+
out.push(`# Scan verdict: ${reduced.verdict.toUpperCase()}`);
|
|
70
|
+
out.push('');
|
|
71
|
+
out.push(`Target: ${excerpt(String(target.url || '(unknown)'), 300)} @ \`${excerpt(String(sha), 64)}\` · tier: ${excerpt(String(tier), 16)} · ${generatedAt}`);
|
|
72
|
+
out.push('');
|
|
73
|
+
out.push('Verdict arithmetic (max of all components):');
|
|
74
|
+
for (const [name, v] of Object.entries(reduced.components)) {
|
|
75
|
+
out.push(`- ${name}: ${v}`);
|
|
76
|
+
}
|
|
77
|
+
if (reduced.coverage_reasons.length > 0) {
|
|
78
|
+
out.push(`- coverage cap held at caution because: ${reduced.coverage_reasons.join('; ')}`);
|
|
79
|
+
}
|
|
80
|
+
out.push('');
|
|
81
|
+
|
|
82
|
+
// ---- Honest limits: fixed sentences, slots from real counts ----
|
|
83
|
+
out.push('## Honest limits');
|
|
84
|
+
out.push('');
|
|
85
|
+
if (reduced.verdict === 'clean') {
|
|
86
|
+
out.push(passingWording({ tier, sha, filesUnread: skipped.length, filesTotal }));
|
|
87
|
+
out.push('');
|
|
88
|
+
}
|
|
89
|
+
for (const line of limitLines({
|
|
90
|
+
filesRead,
|
|
91
|
+
filesUnread: skipped.length,
|
|
92
|
+
declaredDeps,
|
|
93
|
+
unscannable,
|
|
94
|
+
lensProblems: lensProblems.length,
|
|
95
|
+
})) {
|
|
96
|
+
out.push(`- ${line}`);
|
|
97
|
+
}
|
|
98
|
+
out.push('');
|
|
99
|
+
|
|
100
|
+
// ---- Findings: floor first (code-computed), then each lens ----
|
|
101
|
+
out.push('## Findings');
|
|
102
|
+
out.push('');
|
|
103
|
+
const lines = floorFindings.map((f) => findingLine(f, 'floor'));
|
|
104
|
+
for (const lens of lenses) {
|
|
105
|
+
const name = excerpt(String((lens && lens.lens) || 'unnamed-lens'), 40);
|
|
106
|
+
for (const f of (Array.isArray(lens && lens.findings) ? lens.findings : [])) {
|
|
107
|
+
lines.push(findingLine(f, `lens:${name}`));
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
if (lines.length === 0) out.push('_No findings. See Honest limits above for what that does and does not mean._');
|
|
111
|
+
else out.push(...sortBySeverity(lines));
|
|
112
|
+
out.push('');
|
|
113
|
+
|
|
114
|
+
// ---- Capability inventory: printed, never scored ----
|
|
115
|
+
const capabilities = Array.isArray(floorReport.capabilities) ? floorReport.capabilities : [];
|
|
116
|
+
out.push('## Capability inventory (what this artifact CAN do — informational, not scored)');
|
|
117
|
+
out.push('');
|
|
118
|
+
if (capabilities.length === 0) out.push('_None detected._');
|
|
119
|
+
for (const cap of capabilities) {
|
|
120
|
+
const name = excerpt(String(cap.capability || ''), 40);
|
|
121
|
+
const files = (Array.isArray(cap.files) ? cap.files : []).map((p) => excerpt(String(p), 120)).join(', ');
|
|
122
|
+
out.push(`- ${name} (${Number(cap.count) || 0}): ${files}`);
|
|
123
|
+
}
|
|
124
|
+
out.push('');
|
|
125
|
+
|
|
126
|
+
// ---- What was NOT checked: skips + off-box surfaces + lens failures ----
|
|
127
|
+
out.push('## What was NOT checked');
|
|
128
|
+
out.push('');
|
|
129
|
+
if (lenses.length === 0) {
|
|
130
|
+
out.push('- the review panel produced NO lens results — the entire panel surface is UNREVIEWED');
|
|
131
|
+
}
|
|
132
|
+
if (skipped.length === 0 && unscannable === 0 && declaredDeps === 0 && lensProblems.length === 0 && lenses.length > 0) {
|
|
133
|
+
out.push('_Every file in the manifest was read; no off-box surfaces were declared._');
|
|
134
|
+
}
|
|
135
|
+
for (const s of skipped) {
|
|
136
|
+
out.push(`- ${excerpt(String(s.path), 300)} — skipped (${excerpt(String(s.reason), 40)}, ${Number(s.bytes) || 0} bytes) — UNREVIEWED`);
|
|
137
|
+
}
|
|
138
|
+
if (unscannable > 0) out.push(`- ${unscannable} unscannable surface(s): MCP commands / remote servers whose code is not in these bytes`);
|
|
139
|
+
if (declaredDeps > 0) out.push(`- ${declaredDeps} declared dependencies — never fetched, never read`);
|
|
140
|
+
for (const l of lensProblems) {
|
|
141
|
+
out.push(`- lens ${excerpt(String((l && l.lens) || 'unnamed'), 40)} did not complete (${excerpt(String((l && l.status) || 'missing'), 24)}) — its surface is UNREVIEWED`);
|
|
142
|
+
}
|
|
143
|
+
out.push('');
|
|
144
|
+
|
|
145
|
+
// ---- Scan facts ----
|
|
146
|
+
out.push('## Scan facts');
|
|
147
|
+
out.push('');
|
|
148
|
+
out.push(`- engine: ${excerpt(String(engineVersion), 64)}`);
|
|
149
|
+
out.push(`- content hash: \`${excerpt(String(target.contentHash || '(none)'), 64)}\``);
|
|
150
|
+
out.push(`- files read: ${filesRead} · skipped: ${skipped.length} · floor findings: ${floorFindings.length}`);
|
|
151
|
+
|
|
152
|
+
return out.join('\n');
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
module.exports = { assembleReport, findingLine };
|