@bongos/core 1.18.18 → 1.19.2
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 +515 -195
- 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/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/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/README.md +8 -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 +179 -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 +2 -0
- 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/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 +16 -16
- 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/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 +70 -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 +99 -14
- 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 +86 -1
- 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_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/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 +128 -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 +124 -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/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,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 };
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
// modules/security/artifact-scan/sanitize.js — the sanitize landing
|
|
2
|
+
// (task 1002548, goal 1000055).
|
|
3
|
+
//
|
|
4
|
+
// The sanitize offer is the narrowest of the three landings, and every one of
|
|
5
|
+
// its gates is ARITHMETIC over recorded evidence — the same rule as the
|
|
6
|
+
// reducer. planSanitize() decides eligibility from the reducer's components
|
|
7
|
+
// and the findings themselves; no model opinion, no narrative, no override.
|
|
8
|
+
// The owner's four gates, verbatim from the task:
|
|
9
|
+
//
|
|
10
|
+
// 1. NO MALICE INDICATOR — any critical finding anywhere (floor or panel),
|
|
11
|
+
// or a dangerous floor/verdict, kills the offer. A sanitized copy of a
|
|
12
|
+
// malicious artifact is a laundered artifact.
|
|
13
|
+
// 2. WHOLE-NODE REMOVALS ONLY — every finding must name a concrete manifest
|
|
14
|
+
// file that can be removed entire. We never edit inside a file: a
|
|
15
|
+
// line-level "fix" of hostile code is a claim of understanding the scan
|
|
16
|
+
// cannot honestly make.
|
|
17
|
+
// 3. FULL COVERAGE — nothing skipped, nothing unscannable, no unfetched
|
|
18
|
+
// dependencies or submodules. Static analysis cannot vouch for a copy it
|
|
19
|
+
// never fully read. Read off the reducer's coverage_cap, which is why
|
|
20
|
+
// fetch-stage findings must reach the floor report (runFloor's
|
|
21
|
+
// extraFindings) — routed anywhere else, this gate cannot see them.
|
|
22
|
+
// 4. CLEAN RE-SCAN BY FRESH SUBAGENTS — enforced by the SKILL, which
|
|
23
|
+
// re-runs the whole pipeline (manifest → mirror → floor → panel) over
|
|
24
|
+
// the sanitized copy in a SECOND quarantine with fresh contexts. This
|
|
25
|
+
// module only produces the copy; it never blesses it.
|
|
26
|
+
//
|
|
27
|
+
// buildSanitizedCopy() writes ONLY inside the quarantine — the sanitized tree
|
|
28
|
+
// lands at <quarantine>/sanitized/ and the diff at <quarantine>/sanitize-diff.md.
|
|
29
|
+
// The sanitized copy is PRISTINE-CLASS: raw artifact bytes (it must stay
|
|
30
|
+
// byte-identical to install), so no model may read it — the re-scan mirrors
|
|
31
|
+
// it first, same as any fetch. File reads follow the mirror.js confinement
|
|
32
|
+
// discipline: lexical check before any fs call, realpath verified inside the
|
|
33
|
+
// pen, read the verified resolved path.
|
|
34
|
+
|
|
35
|
+
'use strict';
|
|
36
|
+
|
|
37
|
+
const crypto = require('node:crypto');
|
|
38
|
+
const fsp = require('node:fs/promises');
|
|
39
|
+
const path = require('node:path');
|
|
40
|
+
|
|
41
|
+
const { isInside } = require('./quarantine');
|
|
42
|
+
const { excerpt } = require('./detectors/shared');
|
|
43
|
+
|
|
44
|
+
const SANITIZED_DIRNAME = 'sanitized';
|
|
45
|
+
const DIFF_BASENAME = 'sanitize-diff.md';
|
|
46
|
+
|
|
47
|
+
// The cap detectors/shared.finding() applies to a finding's `file`. A finding
|
|
48
|
+
// carries the SCRUBBED, capped form of a manifest path; the manifest carries
|
|
49
|
+
// the raw bytes. Matching one against the other directly is the collision bug
|
|
50
|
+
// below — this constant is what makes the two comparable.
|
|
51
|
+
const PATH_MAX_CHARS = 300;
|
|
52
|
+
const CODE_MAX_CHARS = 64;
|
|
53
|
+
const MAX_REASON_CODES = 20;
|
|
54
|
+
|
|
55
|
+
function allFindings(floorReport = {}, lenses = []) {
|
|
56
|
+
const floor = Array.isArray(floorReport.findings) ? floorReport.findings : [];
|
|
57
|
+
const panel = [];
|
|
58
|
+
for (const lens of lenses) {
|
|
59
|
+
for (const f of (Array.isArray(lens && lens.findings) ? lens.findings : [])) panel.push(f);
|
|
60
|
+
}
|
|
61
|
+
return { floor, panel };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Gate 1 as its own testable piece: is there any indicator of actual malice?
|
|
65
|
+
// Arithmetic on purpose — any critical-severity finding, from the floor or
|
|
66
|
+
// from any lens, or a dangerous floor verdict. Severity is the one field the
|
|
67
|
+
// pipeline already treats as evidence; a separate "looks malicious" judgment
|
|
68
|
+
// would reintroduce the opinion the reducer exists to exclude.
|
|
69
|
+
function hasMaliceIndicator(floorReport = {}, lenses = []) {
|
|
70
|
+
const { floor, panel } = allFindings(floorReport, lenses);
|
|
71
|
+
// Codes are model-authored on panel findings and artifact-derived on floor
|
|
72
|
+
// ones, and they flow into reasons[] which the skill relays to chat — so they
|
|
73
|
+
// pass the same scrub + one-line + cap every report string does.
|
|
74
|
+
const codes = [...floor, ...panel]
|
|
75
|
+
.filter((f) => f && f.severity === 'critical')
|
|
76
|
+
.map((f) => excerpt(String(f.code || 'unlabeled'), CODE_MAX_CHARS));
|
|
77
|
+
if (floorReport.floor_verdict === 'dangerous' && codes.length === 0) codes.push('floor_verdict_dangerous');
|
|
78
|
+
return { malicious: codes.length > 0, codes };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// The eligibility decision. Takes the REDUCER RESULT (never a narrative) plus
|
|
82
|
+
// the raw findings and the manifest entries, and returns
|
|
83
|
+
// { eligible, reasons, removals, unremovable, provenance }. `removals` is the
|
|
84
|
+
// sorted set of RAW manifest paths whose removal addresses every finding; a
|
|
85
|
+
// finding that names no concrete manifest file (e.g. file '(floor)', an
|
|
86
|
+
// off-box surface, or a path the manifest does not carry) is unremovable and
|
|
87
|
+
// kills the offer — fail closed, gate 2.
|
|
88
|
+
//
|
|
89
|
+
// PATH IDENTITY (review finding). A finding's `file` has been through
|
|
90
|
+
// scrubUntrusted + whitespace-collapse + a 300-char cap; a manifest path has
|
|
91
|
+
// not. Joining them directly let an artifact ship two files whose names differ
|
|
92
|
+
// only by a scrubbed codepoint — the removal set then named the benign twin
|
|
93
|
+
// while the flagged file was copied into sanitized/. So the join runs on the
|
|
94
|
+
// SCRUBBED form of both sides, and a scrubbed name that maps to more than one
|
|
95
|
+
// raw manifest entry is AMBIGUOUS: it becomes unremovable rather than a guess,
|
|
96
|
+
// because deleting the wrong file is exactly the failure this gate exists to
|
|
97
|
+
// prevent.
|
|
98
|
+
//
|
|
99
|
+
// PROVENANCE (review finding, split verdict). A panel finding's `file` is
|
|
100
|
+
// model-authored text, so it is both evidence AND a destructive instruction.
|
|
101
|
+
// The gates cannot verify it, so they LABEL it: `provenance` reports which
|
|
102
|
+
// removals a code-computed floor finding backs and which rest on panel text
|
|
103
|
+
// alone. The blast radius stays bounded by three things that do not trust the
|
|
104
|
+
// panel — removals are whole files inside the quarantine only, the human reads
|
|
105
|
+
// the diff, and the copy must pass a fresh-subagent re-scan (gate 4).
|
|
106
|
+
function planSanitize({ reduced, floorReport = {}, lenses = [], entries = [] } = {}) {
|
|
107
|
+
if (!reduced || !reduced.components) {
|
|
108
|
+
throw new Error('planSanitize requires the reducer result — sanitize eligibility is arithmetic, never a narrative');
|
|
109
|
+
}
|
|
110
|
+
const reasons = [];
|
|
111
|
+
|
|
112
|
+
const malice = hasMaliceIndicator(floorReport, lenses);
|
|
113
|
+
if (malice.malicious) reasons.push(`malice_indicator: ${malice.codes.join(', ')}`);
|
|
114
|
+
if (reduced.verdict === 'dangerous') reasons.push('verdict_dangerous');
|
|
115
|
+
|
|
116
|
+
if (reduced.components.coverage_cap !== 'clean') {
|
|
117
|
+
reasons.push(`coverage_incomplete: ${(reduced.coverage_reasons || []).join('; ') || 'coverage cap held'}`);
|
|
118
|
+
}
|
|
119
|
+
// Gates 1–3 are about what THIS scan proved, so the scan must have been a
|
|
120
|
+
// full one: a panel-bearing tier (low never ran a real panel) and a panel
|
|
121
|
+
// that completed healthy. The clean re-scan (gate 4) is the skill's job.
|
|
122
|
+
if (reduced.components.tier_clamp !== 'clean') reasons.push('tier_ran_no_full_panel');
|
|
123
|
+
if (reduced.components.lens_health !== 'clean') reasons.push('panel_unhealthy_or_absent');
|
|
124
|
+
|
|
125
|
+
const { floor, panel } = allFindings(floorReport, lenses);
|
|
126
|
+
const findings = [...floor.map((f) => ({ f, from: 'floor' })), ...panel.map((f) => ({ f, from: 'panel' }))];
|
|
127
|
+
if (findings.length === 0) reasons.push('nothing_to_remove');
|
|
128
|
+
|
|
129
|
+
// Scrubbed manifest name → the entries that render to it. EVERY kind is
|
|
130
|
+
// indexed, not just files: a symlink whose scrubbed name collides with a
|
|
131
|
+
// real file must make that name ambiguous, or a finding about the symlink
|
|
132
|
+
// schedules the innocent file for deletion (verification finding).
|
|
133
|
+
const byScrubbed = new Map();
|
|
134
|
+
for (const e of entries) {
|
|
135
|
+
if (!e || typeof e.path !== 'string') continue;
|
|
136
|
+
const key = excerpt(e.path, PATH_MAX_CHARS);
|
|
137
|
+
// A name that scrubs away to nothing (all-whitespace, all-zero-width) can
|
|
138
|
+
// never identify anything. Indexing it would let it match every finding
|
|
139
|
+
// that carries no file at all — the fail-open this gate exists to prevent.
|
|
140
|
+
if (key === '') continue;
|
|
141
|
+
if (!byScrubbed.has(key)) byScrubbed.set(key, []);
|
|
142
|
+
byScrubbed.get(key).push(e);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const removals = new Set();
|
|
146
|
+
const backedByFloor = new Set();
|
|
147
|
+
const unremovable = [];
|
|
148
|
+
for (const { f, from } of findings) {
|
|
149
|
+
const claimed = f && typeof f.file === 'string' ? excerpt(f.file, PATH_MAX_CHARS) : '';
|
|
150
|
+
const matches = claimed === '' ? [] : (byScrubbed.get(claimed) || []);
|
|
151
|
+
if (matches.length === 1 && matches[0].kind === 'file') {
|
|
152
|
+
removals.add(matches[0].path);
|
|
153
|
+
if (from === 'floor') backedByFloor.add(matches[0].path);
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
let reason = 'no_manifest_file';
|
|
157
|
+
if (matches.length > 1) reason = 'ambiguous_path';
|
|
158
|
+
else if (matches.length === 1) reason = 'not_a_regular_file';
|
|
159
|
+
unremovable.push({
|
|
160
|
+
code: excerpt(String((f && f.code) || 'unlabeled'), CODE_MAX_CHARS),
|
|
161
|
+
file: claimed,
|
|
162
|
+
reason,
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
if (unremovable.length > 0) {
|
|
166
|
+
// Count-capped as well as per-item capped: a hostile panel returning
|
|
167
|
+
// thousands of findings must not build one unbounded reason string.
|
|
168
|
+
const codes = unremovable.slice(0, MAX_REASON_CODES).map((u) => u.code).join(', ');
|
|
169
|
+
const more = unremovable.length > MAX_REASON_CODES ? ` (+${unremovable.length - MAX_REASON_CODES} more)` : '';
|
|
170
|
+
reasons.push(`not_whole_node_removable: ${codes}${more}`);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// removals[] carries RAW manifest paths because buildSanitizedCopy needs the
|
|
174
|
+
// real bytes to find the file on disk — so it is NOT display-safe and must
|
|
175
|
+
// never be printed. provenance carries the scrubbed rendering of the same
|
|
176
|
+
// paths and is what a human is shown (verification finding: a bidi override
|
|
177
|
+
// or an embedded newline in a filename otherwise forges the approval
|
|
178
|
+
// display at the exact moment deletions are approved).
|
|
179
|
+
const sorted = [...removals].sort();
|
|
180
|
+
const display = (p) => excerpt(p, PATH_MAX_CHARS);
|
|
181
|
+
return {
|
|
182
|
+
eligible: reasons.length === 0,
|
|
183
|
+
reasons,
|
|
184
|
+
removals: sorted,
|
|
185
|
+
unremovable,
|
|
186
|
+
provenance: {
|
|
187
|
+
floor_backed: sorted.filter((p) => backedByFloor.has(p)).map(display),
|
|
188
|
+
panel_only: sorted.filter((p) => !backedByFloor.has(p)).map(display),
|
|
189
|
+
},
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Copy the pristine tree minus the removals into <quarantine>/sanitized/, and
|
|
194
|
+
// write the diff (whole files removed — nothing else changed) next to it.
|
|
195
|
+
// Writes ONLY inside the quarantine; kept files are copied byte-identical.
|
|
196
|
+
// Refuses stale state (an existing sanitized/ dir) and any removal path the
|
|
197
|
+
// manifest does not carry — a typo'd removal must never silently keep a file
|
|
198
|
+
// the plan meant to drop.
|
|
199
|
+
//
|
|
200
|
+
// Every kept file is re-hashed against its manifest sha256 before it is
|
|
201
|
+
// written (review finding): the diff's promise that kept files are
|
|
202
|
+
// "byte-identical to the scanned pristine copy" was previously prose the code
|
|
203
|
+
// never tested, so anything that changed the pen between the scan and the
|
|
204
|
+
// sanitize — a concurrent process, another tool, a shared build box — was
|
|
205
|
+
// copied in under a provenance claim. Now a mismatch aborts.
|
|
206
|
+
//
|
|
207
|
+
// On ANY failure the partial sanitized/ tree is removed before the error
|
|
208
|
+
// propagates: a directory that looks like a finished sanitized copy but is
|
|
209
|
+
// silently missing files, with no diff beside it, is worse than no directory
|
|
210
|
+
// (and the non-recursive mkdir would otherwise refuse every retry).
|
|
211
|
+
async function buildSanitizedCopy({ pristineDir, quarantineRoot, entries, removals } = {}) {
|
|
212
|
+
if (!pristineDir || !quarantineRoot || !Array.isArray(entries) || !Array.isArray(removals) || removals.length === 0) {
|
|
213
|
+
throw new Error('buildSanitizedCopy requires pristineDir, quarantineRoot, manifest entries[], and a non-empty removals[].');
|
|
214
|
+
}
|
|
215
|
+
const fileEntries = new Map(entries.filter((e) => e && e.kind === 'file').map((e) => [e.path, e]));
|
|
216
|
+
for (const r of removals) {
|
|
217
|
+
if (!fileEntries.has(r)) throw new Error(`Removal "${excerpt(String(r), 300)}" is not a manifest file — refusing a plan the manifest does not vouch for.`);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const realQuarantine = await fsp.realpath(quarantineRoot);
|
|
221
|
+
const sanitizedDir = path.join(realQuarantine, SANITIZED_DIRNAME);
|
|
222
|
+
await fsp.mkdir(sanitizedDir); // non-recursive: an existing sanitized/ is stale state, refuse
|
|
223
|
+
|
|
224
|
+
const remove = new Set(removals);
|
|
225
|
+
const removed = [];
|
|
226
|
+
const skippedNonFile = [];
|
|
227
|
+
let copied = 0;
|
|
228
|
+
const madeDirs = new Set();
|
|
229
|
+
|
|
230
|
+
try {
|
|
231
|
+
for (const entry of entries) {
|
|
232
|
+
if (!entry || entry.kind !== 'file') {
|
|
233
|
+
if (entry) skippedNonFile.push(entry.path);
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
const rel = entry.path;
|
|
237
|
+
if (remove.has(rel)) {
|
|
238
|
+
removed.push({ path: rel, size: entry.size, sha256: entry.sha256 || null });
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Confinement, same discipline as mirror.js: lexical first, then physical.
|
|
243
|
+
if (typeof rel !== 'string' || rel.length === 0 || path.isAbsolute(rel)
|
|
244
|
+
|| !isInside(pristineDir, path.resolve(pristineDir, ...rel.split('/')))) {
|
|
245
|
+
throw new Error(`Manifest path "${excerpt(String(rel), 300)}" escapes the pristine tree — refusing to build a sanitized copy over it.`);
|
|
246
|
+
}
|
|
247
|
+
const abs = path.resolve(pristineDir, ...rel.split('/'));
|
|
248
|
+
const real = await fsp.realpath(abs);
|
|
249
|
+
if (!isInside(realQuarantine, real)) {
|
|
250
|
+
throw new Error(`"${excerpt(rel, 300)}" resolves outside the quarantine — refusing.`);
|
|
251
|
+
}
|
|
252
|
+
const st = await fsp.lstat(abs);
|
|
253
|
+
if (st.isSymbolicLink()) {
|
|
254
|
+
throw new Error(`"${excerpt(rel, 300)}" is a symlink on disk though the manifest calls it a file — refusing.`);
|
|
255
|
+
}
|
|
256
|
+
const buf = await fsp.readFile(real); // the verified resolved path, not abs
|
|
257
|
+
|
|
258
|
+
// The diff claims every kept file is byte-identical to what was scanned.
|
|
259
|
+
// Prove it against the manifest hash rather than asserting it in prose.
|
|
260
|
+
if (!entry.sha256) {
|
|
261
|
+
throw new Error(`"${excerpt(rel, 300)}" has no manifest sha256 — refusing to claim byte-identity the manifest cannot vouch for.`);
|
|
262
|
+
}
|
|
263
|
+
const fresh = crypto.createHash('sha256').update(buf).digest('hex');
|
|
264
|
+
if (fresh !== entry.sha256) {
|
|
265
|
+
throw new Error(`"${excerpt(rel, 300)}" changed since the scan (manifest sha256 ${excerpt(String(entry.sha256), 64)}, on disk ${fresh}) — refusing to copy bytes nobody reviewed.`);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const dest = path.resolve(sanitizedDir, ...rel.split('/'));
|
|
269
|
+
if (!isInside(sanitizedDir, dest)) {
|
|
270
|
+
throw new Error(`"${excerpt(rel, 300)}" would land outside sanitized/ — refusing.`);
|
|
271
|
+
}
|
|
272
|
+
const destDir = path.dirname(dest);
|
|
273
|
+
if (!madeDirs.has(destDir)) {
|
|
274
|
+
await fsp.mkdir(destDir, { recursive: true });
|
|
275
|
+
madeDirs.add(destDir);
|
|
276
|
+
}
|
|
277
|
+
await fsp.writeFile(dest, buf);
|
|
278
|
+
copied++;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// The diff: whole files removed, nothing else touched. Paths and hashes are
|
|
282
|
+
// artifact-controlled strings, so they pass through the same scrub+cap every
|
|
283
|
+
// report string does.
|
|
284
|
+
const diffLines = [
|
|
285
|
+
'# Sanitize diff',
|
|
286
|
+
'',
|
|
287
|
+
'Whole-file removals only — every kept file was re-hashed against its manifest sha256 before being copied.',
|
|
288
|
+
`Removed ${removed.length} file(s); copied ${copied} file(s) into ${SANITIZED_DIRNAME}/.`,
|
|
289
|
+
'',
|
|
290
|
+
];
|
|
291
|
+
for (const r of removed) {
|
|
292
|
+
diffLines.push(`- REMOVED ${excerpt(r.path, 300)} (${Number(r.size) || 0} bytes, sha256 ${excerpt(String(r.sha256 || '(none)'), 64)})`);
|
|
293
|
+
}
|
|
294
|
+
if (skippedNonFile.length > 0) {
|
|
295
|
+
diffLines.push('');
|
|
296
|
+
diffLines.push(`Not copied (non-file manifest entries, never reviewed): ${skippedNonFile.map((p) => excerpt(String(p), 120)).join(', ')}`);
|
|
297
|
+
}
|
|
298
|
+
const diff = diffLines.join('\n');
|
|
299
|
+
const diffPath = path.join(realQuarantine, DIFF_BASENAME);
|
|
300
|
+
await fsp.writeFile(diffPath, diff, 'utf8');
|
|
301
|
+
|
|
302
|
+
return { sanitizedDir, diffPath, diff, copied, removed, skippedNonFile };
|
|
303
|
+
} catch (err) {
|
|
304
|
+
// No half-built tree survives a failure — see the header. Best-effort so a
|
|
305
|
+
// cleanup problem can never mask the real error.
|
|
306
|
+
await fsp.rm(sanitizedDir, { recursive: true, force: true }).catch(() => {});
|
|
307
|
+
throw err;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
module.exports = {
|
|
312
|
+
SANITIZED_DIRNAME,
|
|
313
|
+
DIFF_BASENAME,
|
|
314
|
+
hasMaliceIndicator,
|
|
315
|
+
planSanitize,
|
|
316
|
+
buildSanitizedCopy,
|
|
317
|
+
};
|