@bongos/core 1.18.19 → 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 +514 -194
- 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 +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 +86 -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_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 +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/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,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 };
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// modules/security/artifact-scan/mirror.js — the reading-room copy of a
|
|
2
|
+
// quarantined artifact (task 1002543, goal 1000055).
|
|
3
|
+
//
|
|
4
|
+
// Two copies of a fetched artifact exist in the quarantine: the PRISTINE copy
|
|
5
|
+
// no model is ever allowed to read, and this mirror — where every readable
|
|
6
|
+
// file has already been scrubbed of control/zero-width/bidi characters and
|
|
7
|
+
// wrapped in an on-disk fence before any agent can open it. The defense is
|
|
8
|
+
// structural: even a raw Read call against mirror/ returns data-never-
|
|
9
|
+
// instructions, with no dependence on any agent's discipline.
|
|
10
|
+
//
|
|
11
|
+
// What does NOT get mirrored becomes a STUB RECORD instead: symlinks (never
|
|
12
|
+
// followed), non-UTF8/binary files, oversize files, special files, and any
|
|
13
|
+
// path that escapes the pen lexically or physically. Stub bytes are counted as
|
|
14
|
+
// UNREVIEWED — downstream the reducer caps the verdict at caution over them,
|
|
15
|
+
// because static analysis cannot clear what it never read.
|
|
16
|
+
//
|
|
17
|
+
// The walk is driven by the MANIFEST entries, not a fresh readdir: the mirror
|
|
18
|
+
// renders exactly the inventory that was hashed, so "what we read" and "what
|
|
19
|
+
// we fingerprinted" can never drift apart.
|
|
20
|
+
//
|
|
21
|
+
// Confinement is checked twice per file, fail-closed:
|
|
22
|
+
// 1. lexically — a manifest path that is absolute or resolves above the
|
|
23
|
+
// pristine root is refused before any fs call touches it;
|
|
24
|
+
// 2. physically — the file's realpath must live under the quarantine root,
|
|
25
|
+
// so a real on-disk symlink smuggled past kind detection still cannot
|
|
26
|
+
// pull outside bytes into the mirror.
|
|
27
|
+
//
|
|
28
|
+
// Fully async (fs.promises) for the same reason as manifest.js: a sync walk at
|
|
29
|
+
// cap limits blocks the event loop for seconds.
|
|
30
|
+
|
|
31
|
+
'use strict';
|
|
32
|
+
|
|
33
|
+
const fsp = require('node:fs/promises');
|
|
34
|
+
const path = require('node:path');
|
|
35
|
+
const { TextDecoder } = require('node:util');
|
|
36
|
+
|
|
37
|
+
// Intra-module siblings (a module may require its own files, never another
|
|
38
|
+
// module or a core internal).
|
|
39
|
+
const { isInside } = require('./quarantine');
|
|
40
|
+
const { newFenceDelim, fenceFile } = require('./fence-local');
|
|
41
|
+
|
|
42
|
+
// Per-file readability budget. A "text" file past this size is recorded as a
|
|
43
|
+
// stub rather than rendered readable — a 40 MB minified blob is unreviewable
|
|
44
|
+
// text in practice, and unreviewable must always surface as unreviewed.
|
|
45
|
+
const MIRROR_CAPS = Object.freeze({
|
|
46
|
+
maxFileBytes: 1024 * 1024, // 1 MiB per mirrored file
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const INDEX_BASENAME = 'mirror-index.json';
|
|
50
|
+
|
|
51
|
+
// Lexical containment for a manifest-relative path: must be relative, and must
|
|
52
|
+
// stay under root after resolution. Directional — anything else is an escape.
|
|
53
|
+
function relEscapes(rootDir, rel) {
|
|
54
|
+
if (typeof rel !== 'string' || rel.length === 0) return true;
|
|
55
|
+
if (path.isAbsolute(rel)) return true;
|
|
56
|
+
return !isInside(rootDir, path.resolve(rootDir, ...rel.split('/')));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Decode strictly as UTF-8. A single invalid sequence means "not text we can
|
|
60
|
+
// honestly render" — the file becomes a stub, never a lossily-decoded mirror.
|
|
61
|
+
// One shared decoder: non-streaming decode() keeps no state between calls.
|
|
62
|
+
const UTF8_STRICT = new TextDecoder('utf-8', { fatal: true });
|
|
63
|
+
function strictUtf8(buf) {
|
|
64
|
+
try {
|
|
65
|
+
return UTF8_STRICT.decode(buf);
|
|
66
|
+
} catch {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Build the reading-room mirror for one scan.
|
|
72
|
+
// pristineDir — the pristine snapshot (never modified here)
|
|
73
|
+
// mirrorDir — where scrubbed+fenced copies are written
|
|
74
|
+
// quarantineRoot — the pen; every realpath must resolve under it
|
|
75
|
+
// entries — manifest entries ({ path, kind, size, ... }) from buildManifest
|
|
76
|
+
// caps — { maxFileBytes } override (tests); defaults MIRROR_CAPS
|
|
77
|
+
//
|
|
78
|
+
// Returns { mirrored, stubs, unreviewedBytes, fenceDelim, indexPath }. A stub
|
|
79
|
+
// is { path, reason: symlink|special|oversize|non_utf8|path_escape|unreadable,
|
|
80
|
+
// bytes }. Never throws on a per-file problem — a file the mirror cannot
|
|
81
|
+
// honestly render is a recorded stub, not a crashed scan.
|
|
82
|
+
async function buildMirror({ pristineDir, mirrorDir, quarantineRoot, entries, caps = {} }) {
|
|
83
|
+
if (!pristineDir || !mirrorDir || !quarantineRoot || !Array.isArray(entries)) {
|
|
84
|
+
throw new Error('buildMirror requires pristineDir, mirrorDir, quarantineRoot and manifest entries[].');
|
|
85
|
+
}
|
|
86
|
+
const maxFileBytes = caps.maxFileBytes ?? MIRROR_CAPS.maxFileBytes;
|
|
87
|
+
const realQuarantine = await fsp.realpath(quarantineRoot);
|
|
88
|
+
const fenceDelim = newFenceDelim();
|
|
89
|
+
|
|
90
|
+
const mirrored = [];
|
|
91
|
+
const stubs = [];
|
|
92
|
+
const madeDirs = new Set();
|
|
93
|
+
const stub = (rel, reason, bytes) => stubs.push({ path: rel, reason, bytes });
|
|
94
|
+
|
|
95
|
+
for (const entry of entries) {
|
|
96
|
+
const rel = entry.path;
|
|
97
|
+
|
|
98
|
+
if (entry.kind === 'symlink') { stub(rel, 'symlink', entry.size || 0); continue; }
|
|
99
|
+
if (entry.kind !== 'file') { stub(rel, 'special', entry.size || 0); continue; }
|
|
100
|
+
|
|
101
|
+
// 1. Lexical confinement — before any fs call touches the path.
|
|
102
|
+
if (relEscapes(pristineDir, rel)) { stub(rel, 'path_escape', entry.size || 0); continue; }
|
|
103
|
+
const abs = path.resolve(pristineDir, ...rel.split('/'));
|
|
104
|
+
|
|
105
|
+
if (entry.size > maxFileBytes) { stub(rel, 'oversize', entry.size); continue; }
|
|
106
|
+
|
|
107
|
+
// 2. Physical confinement — the realpath must stay inside the pen, so a
|
|
108
|
+
// real symlink mislabeled "file" cannot pull outside bytes into the mirror.
|
|
109
|
+
let buf;
|
|
110
|
+
try {
|
|
111
|
+
const real = await fsp.realpath(abs);
|
|
112
|
+
if (!isInside(realQuarantine, real)) { stub(rel, 'path_escape', entry.size || 0); continue; }
|
|
113
|
+
const st = await fsp.lstat(abs);
|
|
114
|
+
if (st.isSymbolicLink()) { stub(rel, 'path_escape', entry.size || 0); continue; }
|
|
115
|
+
// Read the VERIFIED resolved path, not the original — reading `abs` would
|
|
116
|
+
// reopen the race the realpath check just closed (TOCTOU: a swap between
|
|
117
|
+
// lstat and read would be followed silently).
|
|
118
|
+
buf = await fsp.readFile(real);
|
|
119
|
+
} catch {
|
|
120
|
+
// Missing/unreadable on disk though present in the manifest — that
|
|
121
|
+
// disagreement is itself worth surfacing, as unreviewed.
|
|
122
|
+
stub(rel, 'unreadable', entry.size || 0);
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const text = strictUtf8(buf);
|
|
127
|
+
if (text === null) { stub(rel, 'non_utf8', buf.length); continue; }
|
|
128
|
+
|
|
129
|
+
const dest = path.resolve(mirrorDir, ...rel.split('/'));
|
|
130
|
+
if (!isInside(mirrorDir, dest)) { stub(rel, 'path_escape', buf.length); continue; }
|
|
131
|
+
const destDir = path.dirname(dest);
|
|
132
|
+
if (!madeDirs.has(destDir)) {
|
|
133
|
+
await fsp.mkdir(destDir, { recursive: true });
|
|
134
|
+
madeDirs.add(destDir);
|
|
135
|
+
}
|
|
136
|
+
await fsp.writeFile(dest, fenceFile(fenceDelim, rel, text), 'utf8');
|
|
137
|
+
mirrored.push({ path: rel, bytes: buf.length });
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const unreviewedBytes = stubs.reduce((sum, s) => sum + (s.bytes || 0), 0);
|
|
141
|
+
|
|
142
|
+
// The index is the trusted record downstream stages (floor, reducer) read:
|
|
143
|
+
// which files are renderable, which bytes were never reviewed, and the fence
|
|
144
|
+
// delimiter the panel should expect. Written inside the pen, nowhere else.
|
|
145
|
+
const indexPath = path.join(realQuarantine, INDEX_BASENAME);
|
|
146
|
+
const index = {
|
|
147
|
+
fenceDelim,
|
|
148
|
+
mirrored,
|
|
149
|
+
stubs,
|
|
150
|
+
unreviewedBytes,
|
|
151
|
+
counts: { mirrored: mirrored.length, stubs: stubs.length },
|
|
152
|
+
};
|
|
153
|
+
await fsp.writeFile(indexPath, JSON.stringify(index, null, 2), 'utf8');
|
|
154
|
+
|
|
155
|
+
return { mirrored, stubs, unreviewedBytes, fenceDelim, indexPath };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
module.exports = { buildMirror, MIRROR_CAPS, INDEX_BASENAME };
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// modules/security/artifact-scan/quarantine.js — the holding pen an untrusted
|
|
2
|
+
// artifact is fetched into.
|
|
3
|
+
//
|
|
4
|
+
// The scan engine's first rule is that a suspect artifact never gets a chance to
|
|
5
|
+
// act. Everything downstream (the mirror, the detectors, the panel) assumes the
|
|
6
|
+
// bytes it reads live somewhere that satisfies three location properties:
|
|
7
|
+
//
|
|
8
|
+
// 1. OUTSIDE THE REPO — so a scanned artifact can never be mistaken for our
|
|
9
|
+
// own source, land in a diff, or be picked up by a fitness/lint sweep.
|
|
10
|
+
// 2. OUTSIDE ONEDRIVE (or any sync root) — a synced folder uploads whatever
|
|
11
|
+
// lands in it. Fetching malware into a synced directory copies it to the
|
|
12
|
+
// builder's cloud storage and every other machine on that account.
|
|
13
|
+
// 3. OUTSIDE EVERY WORKSPACE ROOT — this is the subtle one. A Claude session
|
|
14
|
+
// auto-loads CLAUDE.md and .claude/ from directories inside its workspace.
|
|
15
|
+
// An artifact carrying its own CLAUDE.md, dropped inside a workspace root,
|
|
16
|
+
// is prompt injection delivered through the harness itself rather than
|
|
17
|
+
// through any file an agent chose to read. Callers pass their workspace
|
|
18
|
+
// roots in `forbiddenRoots`; we refuse to build a quarantine under one.
|
|
19
|
+
//
|
|
20
|
+
// The pen also holds the three neutralizing fixtures the hardened clone needs:
|
|
21
|
+
// an EMPTY REAL hooks directory (git's core.hooksPath must be a real directory
|
|
22
|
+
// — /dev/null works on macOS and is not a directory on Windows), and an empty
|
|
23
|
+
// gitconfig used for both GIT_CONFIG_GLOBAL and GIT_CONFIG_SYSTEM so the
|
|
24
|
+
// builder's own git config (insteadOf URL rewrites, a global hooksPath, a
|
|
25
|
+
// credential helper) cannot influence the fetch.
|
|
26
|
+
//
|
|
27
|
+
// Pure-ish: filesystem only. No DB, no network, no doorway — so it is unit
|
|
28
|
+
// testable and safe to require from a CLI.
|
|
29
|
+
|
|
30
|
+
'use strict';
|
|
31
|
+
|
|
32
|
+
const fs = require('node:fs');
|
|
33
|
+
const os = require('node:os');
|
|
34
|
+
const path = require('node:path');
|
|
35
|
+
|
|
36
|
+
// modules/security/artifact-scan/ -> modules/security/ -> modules/ -> repo root
|
|
37
|
+
const REPO_ROOT = path.resolve(__dirname, '..', '..', '..');
|
|
38
|
+
|
|
39
|
+
// Fail-closed budget. Exceeding any of these aborts the scan and records a
|
|
40
|
+
// finding — it never silently truncates, because a scan that quietly stopped
|
|
41
|
+
// reading at 80% of a repo and still says "no findings" is a lie.
|
|
42
|
+
const CAPS = Object.freeze({
|
|
43
|
+
maxFiles: 5000,
|
|
44
|
+
maxBytes: 64 * 1024 * 1024, // 64 MiB across the whole artifact
|
|
45
|
+
cloneTimeoutMs: 120_000,
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// A directory is "sync-backed" if any segment names a known consumer-sync root.
|
|
49
|
+
// Deliberately a name test rather than a path test: OneDrive/Dropbox relocate,
|
|
50
|
+
// and the property we care about ("anything written here is uploaded") travels
|
|
51
|
+
// with the folder, not with one machine's absolute path.
|
|
52
|
+
const SYNC_SEGMENTS = ['onedrive', 'dropbox', 'google drive', 'icloud drive'];
|
|
53
|
+
|
|
54
|
+
function isSyncBacked(p) {
|
|
55
|
+
return path
|
|
56
|
+
.resolve(p)
|
|
57
|
+
.split(/[\\/]/)
|
|
58
|
+
.some((seg) => SYNC_SEGMENTS.includes(seg.toLowerCase()));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Lexical containment: is `candidate` equal to, or under, `root`? Directional on
|
|
62
|
+
// purpose — path.relative escaping upward ('..') or returning an absolute path
|
|
63
|
+
// both mean "outside". Used to reject a quarantine site, and by callers to keep
|
|
64
|
+
// derived paths inside the pen.
|
|
65
|
+
function isInside(root, candidate) {
|
|
66
|
+
const rel = path.relative(path.resolve(root), path.resolve(candidate));
|
|
67
|
+
if (rel === '') return true;
|
|
68
|
+
return !rel.startsWith('..') && !path.isAbsolute(rel);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Why a chosen site is unusable, or null when it is fine. Split out from
|
|
72
|
+
// createQuarantine so the rule set is directly testable without touching disk.
|
|
73
|
+
function siteRefusal(site, forbiddenRoots) {
|
|
74
|
+
if (isInside(REPO_ROOT, site)) return `inside the repository (${REPO_ROOT})`;
|
|
75
|
+
if (isSyncBacked(site)) return 'inside a cloud-sync folder (it would upload the artifact)';
|
|
76
|
+
for (const root of forbiddenRoots) {
|
|
77
|
+
if (!root) continue;
|
|
78
|
+
if (isInside(root, site)) return `inside a workspace root (${root}) where its CLAUDE.md could auto-load`;
|
|
79
|
+
}
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Build a fresh, uniquely-named pen.
|
|
84
|
+
//
|
|
85
|
+
// `forbiddenRoots` is REQUIRED, not optional-with-an-empty-default. Workspace
|
|
86
|
+
// isolation is the primary defense against an artifact's own CLAUDE.md being
|
|
87
|
+
// auto-loaded as instructions, and a defense that silently does nothing when a
|
|
88
|
+
// caller forgets a parameter is not a defense. Callers with genuinely no
|
|
89
|
+
// workspace roots must say so by passing [] explicitly. The repo root and sync
|
|
90
|
+
// folders are refused unconditionally regardless.
|
|
91
|
+
//
|
|
92
|
+
// Returns { root, pristineDir, mirrorDir, hooksDir, gitConfigFile, cleanup() }.
|
|
93
|
+
// pristineDir holds the bytes no model is ever allowed to read; mirrorDir is
|
|
94
|
+
// where the scrubbed reading-room copy is written (task 1002543).
|
|
95
|
+
function createQuarantine({ tmpRoot = os.tmpdir(), forbiddenRoots } = {}) {
|
|
96
|
+
if (!Array.isArray(forbiddenRoots)) {
|
|
97
|
+
throw new Error(
|
|
98
|
+
'createQuarantine requires forbiddenRoots: string[] — the workspace roots a quarantine must never live under. ' +
|
|
99
|
+
'Pass [] explicitly if this caller genuinely has none.'
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
const refusal = siteRefusal(tmpRoot, forbiddenRoots);
|
|
103
|
+
if (refusal) {
|
|
104
|
+
throw new Error(
|
|
105
|
+
`Refusing to create a quarantine under ${tmpRoot} — it is ${refusal}. ` +
|
|
106
|
+
'Pass a different tmpRoot outside the repo, outside any sync folder, and outside every workspace root.'
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const root = fs.mkdtempSync(path.join(tmpRoot, 'bongos-scan-'));
|
|
111
|
+
// mkdtemp resolves through whatever tmpRoot really is (on macOS /tmp is a
|
|
112
|
+
// symlink to /private/tmp), so re-check the REAL location before we trust it.
|
|
113
|
+
const realRoot = fs.realpathSync(root);
|
|
114
|
+
const realRefusal = siteRefusal(realRoot, forbiddenRoots);
|
|
115
|
+
if (realRefusal) {
|
|
116
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
117
|
+
throw new Error(`Refusing to use quarantine ${realRoot} — it resolves ${realRefusal}.`);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const pristineDir = path.join(realRoot, 'pristine');
|
|
121
|
+
const mirrorDir = path.join(realRoot, 'mirror');
|
|
122
|
+
const hooksDir = path.join(realRoot, 'empty-hooks');
|
|
123
|
+
const gitConfigFile = path.join(realRoot, 'empty-gitconfig');
|
|
124
|
+
|
|
125
|
+
fs.mkdirSync(mirrorDir);
|
|
126
|
+
fs.mkdirSync(hooksDir); // must exist and be EMPTY: git's core.hooksPath target
|
|
127
|
+
fs.writeFileSync(gitConfigFile, '', 'utf8');
|
|
128
|
+
// pristineDir is deliberately NOT created — `git clone` wants to create it.
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
root: realRoot,
|
|
132
|
+
pristineDir,
|
|
133
|
+
mirrorDir,
|
|
134
|
+
hooksDir,
|
|
135
|
+
gitConfigFile,
|
|
136
|
+
cleanup() {
|
|
137
|
+
fs.rmSync(realRoot, { recursive: true, force: true });
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
module.exports = {
|
|
143
|
+
CAPS,
|
|
144
|
+
REPO_ROOT,
|
|
145
|
+
createQuarantine,
|
|
146
|
+
siteRefusal,
|
|
147
|
+
isSyncBacked,
|
|
148
|
+
isInside,
|
|
149
|
+
};
|