@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,319 @@
|
|
|
1
|
+
// modules/security/artifact-scan/floor.js — the zero-LLM deterministic floor
|
|
2
|
+
// (task 1002544, goal 1000055).
|
|
3
|
+
//
|
|
4
|
+
// The floor's whole purpose is that a successful prompt injection cannot erase
|
|
5
|
+
// its own evidence: this report is computed by CODE and written to disk BEFORE
|
|
6
|
+
// any model reads a byte of the artifact. A panel can add findings on top; the
|
|
7
|
+
// reducer takes the MAXIMUM of this floor and everything downstream, so
|
|
8
|
+
// nothing a model says can lower what the detectors already proved.
|
|
9
|
+
//
|
|
10
|
+
// The floor reads the PRISTINE copy, not the mirror. Code is immune to
|
|
11
|
+
// persuasion, so the injection-safety argument for the mirror does not apply —
|
|
12
|
+
// and the mirror is scrubbed, so codepoint evidence EXISTS only in pristine.
|
|
13
|
+
// Reads follow the same confinement discipline as mirror.js: manifest-driven,
|
|
14
|
+
// lexically checked before any fs call, realpath-verified inside the pen.
|
|
15
|
+
//
|
|
16
|
+
// Verdict arithmetic (monotone by construction):
|
|
17
|
+
// any critical finding → 'dangerous'
|
|
18
|
+
// any finding at all → 'caution'
|
|
19
|
+
// no findings → 'clean'
|
|
20
|
+
// A detector that crashes contributes a finding (never silence), so a broken
|
|
21
|
+
// detector degrades the verdict toward caution rather than toward clean.
|
|
22
|
+
|
|
23
|
+
'use strict';
|
|
24
|
+
|
|
25
|
+
const fsp = require('node:fs/promises');
|
|
26
|
+
const path = require('node:path');
|
|
27
|
+
const { TextDecoder } = require('node:util');
|
|
28
|
+
|
|
29
|
+
const { isInside } = require('./quarantine');
|
|
30
|
+
const { MIRROR_CAPS } = require('./mirror');
|
|
31
|
+
const { scrubUntrusted } = require('./fence-local');
|
|
32
|
+
const { finding, isSensitivePath, SEVERITIES, excerpt } = require('./detectors/shared');
|
|
33
|
+
|
|
34
|
+
const npmLifecycle = require('./detectors/npm-lifecycle');
|
|
35
|
+
const hooks = require('./detectors/hooks');
|
|
36
|
+
const mcp = require('./detectors/mcp');
|
|
37
|
+
const permissions = require('./detectors/permissions');
|
|
38
|
+
const credentials = require('./detectors/credentials');
|
|
39
|
+
const network = require('./detectors/network');
|
|
40
|
+
const injection = require('./detectors/injection');
|
|
41
|
+
const dependencies = require('./detectors/dependencies');
|
|
42
|
+
const configObfuscation = require('./detectors/config-obfuscation');
|
|
43
|
+
const capabilities = require('./detectors/capabilities');
|
|
44
|
+
|
|
45
|
+
const FLOOR_VERSION = '1.0.0';
|
|
46
|
+
const REPORT_BASENAME = 'floor-report.json';
|
|
47
|
+
|
|
48
|
+
// Bounds on the fetch-stage fold (see runFloor's extraFindings).
|
|
49
|
+
const MAX_EXTRA_FINDINGS = 500;
|
|
50
|
+
const CODE_MAX_CHARS = 64;
|
|
51
|
+
|
|
52
|
+
// Shared with the reducer (task 1002545): the max() it takes is over this rank.
|
|
53
|
+
const VERDICT_RANK = Object.freeze({ clean: 0, caution: 1, dangerous: 2 });
|
|
54
|
+
|
|
55
|
+
const UTF8_STRICT = new TextDecoder('utf-8', { fatal: true });
|
|
56
|
+
|
|
57
|
+
const DETECTORS = Object.freeze([
|
|
58
|
+
['npm-lifecycle', npmLifecycle.detect],
|
|
59
|
+
['claude-hooks', hooks.detect],
|
|
60
|
+
['mcp-servers', mcp.detect],
|
|
61
|
+
['permissions', permissions.detect],
|
|
62
|
+
['credential-stores', credentials.detect],
|
|
63
|
+
['network-sinks', network.detect],
|
|
64
|
+
['injection-text', injection.detect],
|
|
65
|
+
['dependencies', dependencies.detect],
|
|
66
|
+
['config-obfuscation', configObfuscation.detect],
|
|
67
|
+
]);
|
|
68
|
+
|
|
69
|
+
// Per-chunk parallel-read width. Bounded so a huge artifact cannot exhaust
|
|
70
|
+
// file descriptors; chunked (not a free-running pool) so files[]/skipped[]
|
|
71
|
+
// keep deterministic manifest order.
|
|
72
|
+
const READ_CONCURRENCY = 16;
|
|
73
|
+
|
|
74
|
+
// Manifest-driven confined read of every renderable text file. Anything the
|
|
75
|
+
// floor cannot honestly read (binary, oversize, escaping, unreadable) becomes
|
|
76
|
+
// a skipped record — counted, never silently dropped — because "I did not read
|
|
77
|
+
// it" must always be visible to the reducer's coverage cap.
|
|
78
|
+
async function buildCorpus({ pristineDir, quarantineRoot, entries, caps = {}, realQuarantine: realQuarantineIn = null }) {
|
|
79
|
+
const maxFileBytes = caps.maxFileBytes ?? MIRROR_CAPS.maxFileBytes;
|
|
80
|
+
const realQuarantine = realQuarantineIn || await fsp.realpath(quarantineRoot);
|
|
81
|
+
|
|
82
|
+
// One entry → { file } | { skip }, so chunked parallel reads reassemble in
|
|
83
|
+
// manifest order below. The RAW rel is used for every filesystem operation;
|
|
84
|
+
// the STORED path is scrubbed at this chokepoint, because manifest paths are
|
|
85
|
+
// artifact-controlled and corpus records flow into the report (coverage,
|
|
86
|
+
// capability inventory) — the report must never become a carrier.
|
|
87
|
+
const readOne = async (entry) => {
|
|
88
|
+
const rel = entry.path;
|
|
89
|
+
const safeRel = scrubUntrusted(rel);
|
|
90
|
+
const skip = (reason, bytes) => ({ skip: { path: safeRel, reason, bytes: bytes || 0 } });
|
|
91
|
+
if (entry.kind !== 'file') return skip(entry.kind === 'symlink' ? 'symlink' : 'special', entry.size);
|
|
92
|
+
if (typeof rel !== 'string' || rel.length === 0 || path.isAbsolute(rel)
|
|
93
|
+
|| !isInside(pristineDir, path.resolve(pristineDir, ...rel.split('/')))) {
|
|
94
|
+
return skip('path_escape', entry.size);
|
|
95
|
+
}
|
|
96
|
+
if (entry.size > maxFileBytes) return skip('oversize', entry.size);
|
|
97
|
+
|
|
98
|
+
const abs = path.resolve(pristineDir, ...rel.split('/'));
|
|
99
|
+
let buf;
|
|
100
|
+
try {
|
|
101
|
+
const real = await fsp.realpath(abs);
|
|
102
|
+
if (!isInside(realQuarantine, real)) return skip('path_escape', entry.size);
|
|
103
|
+
const st = await fsp.lstat(abs);
|
|
104
|
+
if (st.isSymbolicLink()) return skip('path_escape', entry.size);
|
|
105
|
+
// Read the verified resolved path — same TOCTOU close as mirror.js.
|
|
106
|
+
buf = await fsp.readFile(real);
|
|
107
|
+
} catch {
|
|
108
|
+
return skip('unreadable', entry.size);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
try {
|
|
112
|
+
return { file: { path: safeRel, size: buf.length, text: UTF8_STRICT.decode(buf) } };
|
|
113
|
+
} catch {
|
|
114
|
+
return skip('non_utf8', buf.length);
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const files = [];
|
|
119
|
+
const skipped = [];
|
|
120
|
+
for (let i = 0; i < entries.length; i += READ_CONCURRENCY) {
|
|
121
|
+
const chunk = await Promise.all(entries.slice(i, i + READ_CONCURRENCY).map(readOne));
|
|
122
|
+
for (const r of chunk) {
|
|
123
|
+
if (r.file) files.push(r.file);
|
|
124
|
+
else skipped.push(r.skip);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return { files, skipped };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function computeFloorVerdict(findings) {
|
|
131
|
+
if (findings.some((f) => f.severity === 'critical')) return 'dangerous';
|
|
132
|
+
return findings.length > 0 ? 'caution' : 'clean';
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function countBySeverity(findings) {
|
|
136
|
+
const counts = { critical: 0, high: 0, medium: 0, low: 0 };
|
|
137
|
+
for (const f of findings) counts[f.severity] = (counts[f.severity] || 0) + 1;
|
|
138
|
+
return counts;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// The same-file join: a file that both names a credential store AND carries a
|
|
142
|
+
// network sink. Labeled exactly "same-file co-occurrence" — the floor has no
|
|
143
|
+
// dataflow analysis and must never imply one; co-location is opportunity, and
|
|
144
|
+
// opportunity inside one file is the strongest static exfil signal we can
|
|
145
|
+
// honestly claim.
|
|
146
|
+
function coOccurrence(corpus) {
|
|
147
|
+
const creds = credentials.perFileHits(corpus);
|
|
148
|
+
const sinks = network.perFileHits(corpus);
|
|
149
|
+
const findings = [];
|
|
150
|
+
for (const [file, credHits] of creds) {
|
|
151
|
+
const sinkHits = sinks.get(file);
|
|
152
|
+
if (!sinkHits) continue;
|
|
153
|
+
// Only FILE-class credential hits (a concrete on-disk secret path) arm this
|
|
154
|
+
// critical. An env-var NAME next to a fetch() is the single most common
|
|
155
|
+
// shape in ordinary tooling, so it must not be the read half of a
|
|
156
|
+
// read-then-send accusation — it still stands as its own medium finding.
|
|
157
|
+
const fileCreds = credHits.filter((h) => h.klass === 'file');
|
|
158
|
+
if (fileCreds.length === 0) continue;
|
|
159
|
+
findings.push(finding('read_then_send_same_file', 'critical', file,
|
|
160
|
+
`Same-file co-occurrence: names ${fileCreds[0].label} (line ${fileCreds[0].line}) AND carries ${sinkHits[0].label} (line ${sinkHits[0].line}). Co-location, not proven dataflow.`,
|
|
161
|
+
{
|
|
162
|
+
credential_stores: fileCreds.map((h) => h.label),
|
|
163
|
+
network_sinks: sinkHits.map((h) => h.label),
|
|
164
|
+
claimed_as: 'same-file co-occurrence, not dataflow',
|
|
165
|
+
}));
|
|
166
|
+
}
|
|
167
|
+
return findings;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Coverage findings: a skip is only honest if it cannot resolve to "clean". A
|
|
171
|
+
// skipped SENSITIVE file (a config that changes what the harness/installer
|
|
172
|
+
// does) becomes a finding; an artifact NONE of whose bytes were readable
|
|
173
|
+
// becomes a finding. Both keep the floor's own verdict off 'clean' when
|
|
174
|
+
// executable/config content went unreviewed — the header's promise that
|
|
175
|
+
// unreviewed never reads as clean, enforced in the floor itself and not left to
|
|
176
|
+
// the downstream reducer alone.
|
|
177
|
+
function coverageFindings(corpus) {
|
|
178
|
+
const findings = [];
|
|
179
|
+
for (const s of corpus.skipped) {
|
|
180
|
+
if (isSensitivePath(s.path)) {
|
|
181
|
+
findings.push(finding('unreviewed_sensitive_file', 'high', s.path,
|
|
182
|
+
`A security-relevant file was skipped (${s.reason}) and never reviewed — the installer/harness can still read it.`,
|
|
183
|
+
{ reason: s.reason, bytes: s.bytes || 0 }));
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
if (corpus.files.length === 0 && corpus.skipped.length > 0) {
|
|
187
|
+
findings.push(finding('nothing_readable', 'high', '(floor)',
|
|
188
|
+
`None of the artifact's ${corpus.skipped.length} file(s) were readable as text — the floor reviewed nothing.`,
|
|
189
|
+
{ skipped: corpus.skipped.length }));
|
|
190
|
+
}
|
|
191
|
+
return findings;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Run every detector crash-isolated: a detector that throws yields a finding,
|
|
195
|
+
// never a hole in the report. `detectors` is injectable so the crash-isolation
|
|
196
|
+
// contract is unit-testable with a deliberately-throwing detector.
|
|
197
|
+
function runDetectors(corpus, detectors = DETECTORS) {
|
|
198
|
+
const findings = [];
|
|
199
|
+
const errors = [];
|
|
200
|
+
for (const [name, detect] of detectors) {
|
|
201
|
+
try {
|
|
202
|
+
findings.push(...detect(corpus));
|
|
203
|
+
} catch (err) {
|
|
204
|
+
errors.push({ detector: name, error: String(err && err.message ? err.message : err) });
|
|
205
|
+
findings.push(finding('detector_error', 'high', '(floor)',
|
|
206
|
+
`Detector "${name}" crashed — its surface is UNREVIEWED and counts against coverage, not toward clean.`,
|
|
207
|
+
{ detector: name }));
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return { findings, errors };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Build + persist the floor report. Returns the report object; the file at
|
|
214
|
+
// <quarantineRoot>/floor-report.json is on disk before this resolves — callers
|
|
215
|
+
// (the skill) MUST await this before any model reads a byte.
|
|
216
|
+
//
|
|
217
|
+
// `extraFindings` carries code-computed evidence produced BEFORE the floor ran
|
|
218
|
+
// — fetchArtifact's findings (unfetched submodules, surviving symlinks,
|
|
219
|
+
// non-regular nodes). They belong in the persisted report for the same reason
|
|
220
|
+
// every detector finding does: they are arithmetic, not opinion, and the
|
|
221
|
+
// reducer takes its floor component from this report. Routing them only to a
|
|
222
|
+
// chat summary let a repo whose submodule was never fetched reduce to CLEAN
|
|
223
|
+
// (review finding) — an artifact with unreviewed code must never read as clean.
|
|
224
|
+
async function runFloor({ pristineDir, quarantineRoot, entries, sha = null, contentHash = null, caps = {}, extraFindings = [] }) {
|
|
225
|
+
// Resolved once, shared by the corpus reads and the persist below. Allowed to
|
|
226
|
+
// throw: an unresolvable pen means no report can honestly land anywhere.
|
|
227
|
+
const realQuarantine = await fsp.realpath(quarantineRoot);
|
|
228
|
+
let corpus = { files: [], skipped: [] };
|
|
229
|
+
let corpusError = null;
|
|
230
|
+
try {
|
|
231
|
+
corpus = await buildCorpus({ pristineDir, quarantineRoot, entries, caps, realQuarantine });
|
|
232
|
+
} catch (err) {
|
|
233
|
+
corpusError = String(err && err.message ? err.message : err);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const { findings, errors } = runDetectors(corpus);
|
|
237
|
+
// Each post-detector step is crash-isolated too: the whole point of the floor
|
|
238
|
+
// is that a report ALWAYS lands, so a throw in co-occurrence, the coverage
|
|
239
|
+
// pass, or the capability inventory must degrade to a finding, never abort
|
|
240
|
+
// the persist. isolate() runs a step, appends its result, and on a throw
|
|
241
|
+
// records a detector_error (which contributes caution) instead of propagating.
|
|
242
|
+
const isolate = (name, fn) => {
|
|
243
|
+
try { return fn(); }
|
|
244
|
+
catch (err) {
|
|
245
|
+
errors.push({ detector: name, error: String(err && err.message ? err.message : err) });
|
|
246
|
+
findings.push(finding('detector_error', 'high', '(floor)',
|
|
247
|
+
`Step "${name}" crashed — its surface is UNREVIEWED and counts against coverage, not toward clean.`, { detector: name }));
|
|
248
|
+
return null;
|
|
249
|
+
}
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
findings.push(...(isolate('co-occurrence', () => coOccurrence(corpus)) || []));
|
|
253
|
+
findings.push(...(isolate('coverage', () => coverageFindings(corpus)) || []));
|
|
254
|
+
// Re-built through finding() so fetch-stage strings get the same scrub + cap
|
|
255
|
+
// every other finding gets — a caller's array is untrusted input here too.
|
|
256
|
+
// `code` does NOT pass through finding()'s chokepoint (it is a label, not a
|
|
257
|
+
// quoted string), so it is scrubbed + capped explicitly here; and the array
|
|
258
|
+
// is length-capped, because an unbounded fold would let one stage balloon
|
|
259
|
+
// the persisted report.
|
|
260
|
+
findings.push(...(isolate('fetch-stage', () => (Array.isArray(extraFindings) ? extraFindings : [])
|
|
261
|
+
.filter((f) => f && typeof f === 'object')
|
|
262
|
+
.slice(0, MAX_EXTRA_FINDINGS)
|
|
263
|
+
.map((f) => finding(
|
|
264
|
+
excerpt(String(f.code || 'fetch_finding'), CODE_MAX_CHARS),
|
|
265
|
+
SEVERITIES.includes(f.severity) ? f.severity : 'high',
|
|
266
|
+
String(f.file || '(fetch)'),
|
|
267
|
+
String(f.message || ''),
|
|
268
|
+
f.detail ?? null
|
|
269
|
+
))) || []));
|
|
270
|
+
const capabilityInventory = isolate('capabilities', () => capabilities.inventory(corpus)) || [];
|
|
271
|
+
if (corpusError) {
|
|
272
|
+
findings.push(finding('corpus_error', 'high', '(floor)',
|
|
273
|
+
'The floor could not read the artifact tree at all — nothing here was reviewed.', { error: corpusError }));
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const report = {
|
|
277
|
+
floor_version: FLOOR_VERSION,
|
|
278
|
+
detectors_run: DETECTORS.map(([name]) => name),
|
|
279
|
+
detector_errors: errors,
|
|
280
|
+
generated_at: new Date().toISOString(),
|
|
281
|
+
target: { sha, content_hash: contentHash },
|
|
282
|
+
findings,
|
|
283
|
+
counts: countBySeverity(findings),
|
|
284
|
+
floor_verdict: computeFloorVerdict(findings),
|
|
285
|
+
// Printed, never scored — the reader's power budget for the artifact.
|
|
286
|
+
capabilities: capabilityInventory,
|
|
287
|
+
coverage: {
|
|
288
|
+
files_read: corpus.files.length,
|
|
289
|
+
bytes_read: corpus.files.reduce((sum, f) => sum + f.size, 0),
|
|
290
|
+
files_skipped: corpus.skipped,
|
|
291
|
+
bytes_skipped: corpus.skipped.reduce((sum, s) => sum + (s.bytes || 0), 0),
|
|
292
|
+
},
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
// Atomic persist inside the pen: write-then-rename so a crash mid-write can
|
|
296
|
+
// never leave a half-valid report a later stage might trust. Persist is the
|
|
297
|
+
// one step that MUST be allowed to throw — a report we could not write to disk
|
|
298
|
+
// is not a report, and the caller needs to know the floor did not land.
|
|
299
|
+
const outPath = path.join(realQuarantine, REPORT_BASENAME);
|
|
300
|
+
const tmpPath = `${outPath}.tmp`;
|
|
301
|
+
await fsp.writeFile(tmpPath, JSON.stringify(report, null, 2), 'utf8');
|
|
302
|
+
await fsp.rename(tmpPath, outPath);
|
|
303
|
+
|
|
304
|
+
return { report, reportPath: outPath };
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
module.exports = {
|
|
308
|
+
FLOOR_VERSION,
|
|
309
|
+
REPORT_BASENAME,
|
|
310
|
+
VERDICT_RANK,
|
|
311
|
+
DETECTORS,
|
|
312
|
+
buildCorpus,
|
|
313
|
+
computeFloorVerdict,
|
|
314
|
+
countBySeverity,
|
|
315
|
+
coOccurrence,
|
|
316
|
+
coverageFindings,
|
|
317
|
+
runDetectors,
|
|
318
|
+
runFloor,
|
|
319
|
+
};
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// modules/security/artifact-scan/limits.js — the honest-limits text
|
|
2
|
+
// (task 1002545, goal 1000055).
|
|
3
|
+
//
|
|
4
|
+
// This file OWNS the caveat language. The reducer computes the verdict and the
|
|
5
|
+
// assembler renders the report, but the sentences that admit what the scan
|
|
6
|
+
// could not see live here, as fixed templates with slots filled from real
|
|
7
|
+
// counts — so a steered panel can bias findings but can never soften the
|
|
8
|
+
// caveats, because no model-authored text is ever spliced into them.
|
|
9
|
+
|
|
10
|
+
'use strict';
|
|
11
|
+
|
|
12
|
+
const { excerpt } = require('./detectors/shared');
|
|
13
|
+
|
|
14
|
+
// THE PASSING WORDING — the most important string in the feature. Emitted
|
|
15
|
+
// verbatim (slots filled) whenever a scan's computed verdict is 'clean'.
|
|
16
|
+
// Do not reword: the sentence is the product of an owner decision that a
|
|
17
|
+
// "clean" scan must still say exactly what it cannot promise.
|
|
18
|
+
const PASSING_WORDING =
|
|
19
|
+
'No findings at the {tier} tier for commit {sha} — that is the absence of ' +
|
|
20
|
+
'evidence, not proof this is harmless. I read the code but never ran it, so ' +
|
|
21
|
+
'anything that only happens at runtime was invisible to me; {N} of {M} files ' +
|
|
22
|
+
'could not be read at all and are listed in the report; the dependencies it ' +
|
|
23
|
+
'declares and any server its config points at were never fetched or checked; ' +
|
|
24
|
+
'and this covers only these exact bytes — one new commit voids it. If you go ' +
|
|
25
|
+
'ahead, install this exact commit, from the copy I already read.';
|
|
26
|
+
|
|
27
|
+
// Fill the passing wording. tier and sha are artifact-adjacent strings, so
|
|
28
|
+
// they are scrubbed + capped HERE — the paragraph that carries the honesty
|
|
29
|
+
// promise must not depend on every caller remembering to sanitize. One GLOBAL
|
|
30
|
+
// pass with a replacer FUNCTION: a `$`-pattern in a value can never expand
|
|
31
|
+
// into the template, and a slot token inside a filled value is never
|
|
32
|
+
// re-scanned — so fill order cannot matter and a value containing "{sha}"
|
|
33
|
+
// stays literal (task 1002545 grader nit, fixed under task 1002548).
|
|
34
|
+
function passingWording({ tier, sha, filesUnread, filesTotal }) {
|
|
35
|
+
const slots = {
|
|
36
|
+
tier: () => excerpt(String(tier), 16),
|
|
37
|
+
sha: () => excerpt(String(sha), 64),
|
|
38
|
+
N: () => String(Number(filesUnread) || 0),
|
|
39
|
+
M: () => String(Number(filesTotal) || 0),
|
|
40
|
+
};
|
|
41
|
+
return PASSING_WORDING.replace(/\{(tier|sha|N|M)\}/g, (_, name) => slots[name]());
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// The honest-limits lines for EVERY verdict (clean gets PASSING_WORDING on
|
|
45
|
+
// top). Same principle: fixed sentences, slots from counts.
|
|
46
|
+
function limitLines({ filesRead, filesUnread, declaredDeps, unscannable, lensProblems }) {
|
|
47
|
+
const lines = [
|
|
48
|
+
'This was a static read of the exact bytes at one commit. Nothing was executed, installed, or fetched.',
|
|
49
|
+
];
|
|
50
|
+
if (filesUnread > 0) {
|
|
51
|
+
lines.push(`${filesUnread} file(s) could not be read (binary, oversize, or unreadable) — their contents are UNREVIEWED.`);
|
|
52
|
+
}
|
|
53
|
+
if (declaredDeps > 0) {
|
|
54
|
+
lines.push(`${declaredDeps} declared dependencies were never fetched — installing would pull code nobody here read.`);
|
|
55
|
+
}
|
|
56
|
+
if (unscannable > 0) {
|
|
57
|
+
lines.push(`${unscannable} finding(s) point at code that lives outside these bytes (MCP commands, remote servers) and could not be scanned.`);
|
|
58
|
+
}
|
|
59
|
+
if (lensProblems > 0) {
|
|
60
|
+
lines.push(`${lensProblems} review lens(es) failed to complete — their surface counts as unreviewed, not as clean.`);
|
|
61
|
+
}
|
|
62
|
+
lines.push(`${filesRead} file(s) were actually read; the verdict covers those bytes only, and a new commit voids it.`);
|
|
63
|
+
return lines;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
module.exports = { PASSING_WORDING, passingWording, limitLines };
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
// modules/security/artifact-scan/manifest.js — the deterministic inventory of
|
|
2
|
+
// what was fetched.
|
|
3
|
+
//
|
|
4
|
+
// The manifest is the scan's anchor. Three downstream promises rest on it being
|
|
5
|
+
// exact and reproducible:
|
|
6
|
+
// * "install this exact commit, from the copy I already read" — the install
|
|
7
|
+
// offer re-hashes and compares before printing a command;
|
|
8
|
+
// * "{N} of {M} files could not be read" — the honest-limits sentence quotes
|
|
9
|
+
// real counts, so unreadable entries are recorded, never skipped;
|
|
10
|
+
// * "we already scanned these exact bytes" — content_hash is the GDS key.
|
|
11
|
+
//
|
|
12
|
+
// Cross-platform determinism is therefore a correctness requirement: paths are
|
|
13
|
+
// normalized to forward slashes, entries sort by UTF-8 BYTES (localeCompare
|
|
14
|
+
// would drift by machine locale), and the hashed form is JSON-per-line so a
|
|
15
|
+
// path containing a quote, newline, or tab cannot forge a field boundary.
|
|
16
|
+
//
|
|
17
|
+
// Symlinks are never followed — lstat throughout. The hardened clone sets
|
|
18
|
+
// core.symlinks=false so a symlink arrives as inert text, but where that does
|
|
19
|
+
// not bite, a real symlink would otherwise let the walk read a file outside the
|
|
20
|
+
// quarantine. It is recorded by its link text and its target is never opened.
|
|
21
|
+
//
|
|
22
|
+
// Fully async: every filesystem call goes through fs.promises. A sync walk at
|
|
23
|
+
// CAPS limits (5,000 files / 64 MiB) blocks for seconds, and being called from
|
|
24
|
+
// an async function would only hide that behind an await.
|
|
25
|
+
|
|
26
|
+
'use strict';
|
|
27
|
+
|
|
28
|
+
const crypto = require('node:crypto');
|
|
29
|
+
const fsp = require('node:fs/promises');
|
|
30
|
+
const path = require('node:path');
|
|
31
|
+
|
|
32
|
+
// Sibling within this module (intra-module requires are fine; a module may not
|
|
33
|
+
// require another MODULE or a core internal). CAPS lives with the quarantine
|
|
34
|
+
// because the budget is a property of the holding pen, not of the hasher.
|
|
35
|
+
const { CAPS } = require('./quarantine');
|
|
36
|
+
|
|
37
|
+
const KIND = Object.freeze({
|
|
38
|
+
FILE: 'file',
|
|
39
|
+
SYMLINK: 'symlink',
|
|
40
|
+
OTHER: 'other', // fifo, socket, block/char device — never opened
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const HASH_CHUNK_BYTES = 1024 * 1024;
|
|
44
|
+
|
|
45
|
+
function toPosix(rel) {
|
|
46
|
+
return rel.split(path.sep).join('/');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Pre-keyed so the comparator allocates nothing — encoding inside a comparator
|
|
50
|
+
// would allocate O(n log n) throwaway Buffers.
|
|
51
|
+
function sortByBytes(items, keyOf) {
|
|
52
|
+
return items
|
|
53
|
+
.map((item) => ({ item, key: Buffer.from(keyOf(item), 'utf8') }))
|
|
54
|
+
.sort((a, b) => Buffer.compare(a.key, b.key))
|
|
55
|
+
.map((w) => w.item);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Hash in fixed chunks so peak memory stays flat regardless of file size. The
|
|
59
|
+
// buffer is owned by one buildManifest call and threaded down, not module-level:
|
|
60
|
+
// two concurrent manifests would otherwise share it and corrupt each other's
|
|
61
|
+
// digests, which async made possible.
|
|
62
|
+
async function sha256File(abs, buf) {
|
|
63
|
+
const h = crypto.createHash('sha256');
|
|
64
|
+
const fh = await fsp.open(abs, 'r');
|
|
65
|
+
try {
|
|
66
|
+
for (;;) {
|
|
67
|
+
const { bytesRead } = await fh.read(buf, 0, buf.length, null);
|
|
68
|
+
if (bytesRead <= 0) break;
|
|
69
|
+
h.update(buf.subarray(0, bytesRead));
|
|
70
|
+
}
|
|
71
|
+
} finally {
|
|
72
|
+
await fh.close();
|
|
73
|
+
}
|
|
74
|
+
return h.digest('hex');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Thrown when a cap trips. Carries the finding the caller records — the abort is
|
|
78
|
+
// fail-closed and always surfaces, never a silent truncation.
|
|
79
|
+
class CapExceeded extends Error {
|
|
80
|
+
constructor(code, message, detail) {
|
|
81
|
+
super(message);
|
|
82
|
+
this.name = 'CapExceeded';
|
|
83
|
+
this.code = code;
|
|
84
|
+
this.detail = detail ?? null;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function walk(rootDir, { maxFiles, maxBytes, skipNames, buf }) {
|
|
89
|
+
const entries = [];
|
|
90
|
+
let totalBytes = 0;
|
|
91
|
+
|
|
92
|
+
// Every recorded node counts against maxFiles, not just regular files: a tree
|
|
93
|
+
// of 10,000 symlinks is as much of a resource problem as 10,000 files, and a
|
|
94
|
+
// cap counting only the latter would let a crafted artifact walk past it.
|
|
95
|
+
const push = (entry) => {
|
|
96
|
+
if (entries.length + 1 > maxFiles) {
|
|
97
|
+
throw new CapExceeded('cap_max_files', `Artifact exceeds the ${maxFiles}-entry cap.`, { maxFiles });
|
|
98
|
+
}
|
|
99
|
+
entries.push(entry);
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const visit = async (absDir) => {
|
|
103
|
+
const names = sortByBytes(await fsp.readdir(absDir), (n) => n);
|
|
104
|
+
for (const name of names) {
|
|
105
|
+
const abs = path.join(absDir, name);
|
|
106
|
+
const rel = toPosix(path.relative(rootDir, abs));
|
|
107
|
+
if (skipNames.has(name) && path.dirname(rel) === '.') continue;
|
|
108
|
+
|
|
109
|
+
const st = await fsp.lstat(abs); // lstat: never resolves a symlink
|
|
110
|
+
if (st.isSymbolicLink()) {
|
|
111
|
+
push({
|
|
112
|
+
path: rel,
|
|
113
|
+
kind: KIND.SYMLINK,
|
|
114
|
+
size: 0,
|
|
115
|
+
sha256: null,
|
|
116
|
+
// The link text, recorded as inert data. We do NOT open the target.
|
|
117
|
+
linkTarget: await fsp.readlink(abs),
|
|
118
|
+
});
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
if (st.isDirectory()) {
|
|
122
|
+
await visit(abs);
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
if (!st.isFile()) {
|
|
126
|
+
push({ path: rel, kind: KIND.OTHER, size: 0, sha256: null });
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
totalBytes += st.size;
|
|
131
|
+
if (totalBytes > maxBytes) {
|
|
132
|
+
throw new CapExceeded('cap_max_bytes', `Artifact exceeds the ${maxBytes}-byte cap.`, { maxBytes, totalBytes });
|
|
133
|
+
}
|
|
134
|
+
push({ path: rel, kind: KIND.FILE, size: st.size, sha256: await sha256File(abs, buf) });
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
await visit(rootDir);
|
|
139
|
+
return { entries: sortByBytes(entries, (e) => e.path), totalBytes };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// The exact bytes content_hash is taken over. One JSON array per entry, newline
|
|
143
|
+
// separated: unambiguous under any path content, stable across platforms.
|
|
144
|
+
function serialize(entries) {
|
|
145
|
+
return entries
|
|
146
|
+
.map((e) => JSON.stringify([e.path, e.kind, e.size, e.sha256, e.linkTarget ?? null]))
|
|
147
|
+
.join('\n');
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Build the manifest for a fetched tree.
|
|
151
|
+
// rootDir — the pristine directory (its .git should already be removed)
|
|
152
|
+
// skipNames — top-level names to ignore (default: .git, if still present)
|
|
153
|
+
//
|
|
154
|
+
// Returns { entries, fileCount, symlinkCount, otherCount, totalBytes, contentHash }.
|
|
155
|
+
// Throws CapExceeded when a budget trips.
|
|
156
|
+
async function buildManifest(rootDir, { maxFiles, maxBytes, skipNames = ['.git'] } = {}) {
|
|
157
|
+
if (!Number.isFinite(maxFiles) || !Number.isFinite(maxBytes)) {
|
|
158
|
+
throw new Error('buildManifest requires numeric maxFiles and maxBytes (fail-closed: no implicit unlimited).');
|
|
159
|
+
}
|
|
160
|
+
const buf = Buffer.allocUnsafe(HASH_CHUNK_BYTES);
|
|
161
|
+
const { entries, totalBytes } = await walk(rootDir, { maxFiles, maxBytes, skipNames: new Set(skipNames), buf });
|
|
162
|
+
|
|
163
|
+
let fileCount = 0;
|
|
164
|
+
let symlinkCount = 0;
|
|
165
|
+
let otherCount = 0;
|
|
166
|
+
for (const e of entries) {
|
|
167
|
+
if (e.kind === KIND.FILE) fileCount++;
|
|
168
|
+
else if (e.kind === KIND.SYMLINK) symlinkCount++;
|
|
169
|
+
else otherCount++;
|
|
170
|
+
}
|
|
171
|
+
return {
|
|
172
|
+
entries,
|
|
173
|
+
fileCount,
|
|
174
|
+
symlinkCount,
|
|
175
|
+
otherCount,
|
|
176
|
+
totalBytes,
|
|
177
|
+
contentHash: crypto.createHash('sha256').update(serialize(entries), 'utf8').digest('hex'),
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Re-hash a tree and compare it to a stored manifest. This is what stands
|
|
182
|
+
// between "the scan passed" and "here is the install command" — if the bytes
|
|
183
|
+
// moved underneath us, the offer must be withdrawn rather than honoured.
|
|
184
|
+
//
|
|
185
|
+
// A cap trip here means the tree GREW past the budget it was scanned under,
|
|
186
|
+
// which is itself proof it changed — so it resolves to ok:false rather than
|
|
187
|
+
// propagating. Answering "is this still what I read?" must never throw on the
|
|
188
|
+
// very tampering it exists to detect.
|
|
189
|
+
async function verifyUnchanged(rootDir, manifest, opts = {}) {
|
|
190
|
+
const maxFiles = opts.maxFiles ?? CAPS.maxFiles;
|
|
191
|
+
const maxBytes = opts.maxBytes ?? CAPS.maxBytes;
|
|
192
|
+
let fresh;
|
|
193
|
+
try {
|
|
194
|
+
fresh = await buildManifest(rootDir, { maxFiles, maxBytes });
|
|
195
|
+
} catch (err) {
|
|
196
|
+
if (err instanceof CapExceeded) return { ok: false, contentHash: null, reason: err.code };
|
|
197
|
+
throw err;
|
|
198
|
+
}
|
|
199
|
+
const ok = fresh.contentHash === manifest.contentHash;
|
|
200
|
+
return { ok, contentHash: fresh.contentHash, reason: ok ? null : 'content_hash_mismatch' };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
module.exports = { KIND, CapExceeded, buildManifest, verifyUnchanged };
|