bootproof 0.1.0
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/LICENSE +201 -0
- package/README.md +265 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +402 -0
- package/dist/diagnosis.d.ts +7 -0
- package/dist/diagnosis.js +139 -0
- package/dist/exec.d.ts +29 -0
- package/dist/exec.js +125 -0
- package/dist/infer.d.ts +4 -0
- package/dist/infer.js +432 -0
- package/dist/plan.d.ts +7 -0
- package/dist/plan.js +94 -0
- package/dist/platform.d.ts +3 -0
- package/dist/platform.js +23 -0
- package/dist/proof.d.ts +24 -0
- package/dist/proof.js +104 -0
- package/dist/redact.d.ts +4 -0
- package/dist/redact.js +31 -0
- package/dist/registry.d.ts +38 -0
- package/dist/registry.js +70 -0
- package/dist/remote.d.ts +13 -0
- package/dist/remote.js +73 -0
- package/dist/run.d.ts +23 -0
- package/dist/run.js +198 -0
- package/dist/taxonomy.d.ts +6 -0
- package/dist/taxonomy.js +68 -0
- package/dist/types.d.ts +117 -0
- package/dist/types.js +7 -0
- package/docs/CI_ACTION.md +75 -0
- package/docs/FAILURE_TAXONOMY.md +55 -0
- package/docs/HONESTY_CONTRACT.md +86 -0
- package/docs/REAL_REPO_EVIDENCE.md +72 -0
- package/docs/REGISTRY.md +49 -0
- package/docs/RELEASE_CHECKLIST.md +74 -0
- package/package.json +52 -0
package/dist/platform.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export function normalizeDockerBindPath(p, platform) {
|
|
2
|
+
if (platform === "wsl2" && /^\/mnt\/[^/]+\//.test(p))
|
|
3
|
+
return p;
|
|
4
|
+
if (platform === "windows") {
|
|
5
|
+
const m = p.match(/^([A-Za-z]):[\\/](.*)$/);
|
|
6
|
+
if (m)
|
|
7
|
+
return `/${m[1].toLowerCase()}/${m[2].replace(/\\/g, "/")}`;
|
|
8
|
+
}
|
|
9
|
+
return p;
|
|
10
|
+
}
|
|
11
|
+
export function detectHostPlatform() {
|
|
12
|
+
if (process.platform === "win32")
|
|
13
|
+
return "windows";
|
|
14
|
+
if (process.platform === "darwin")
|
|
15
|
+
return "macos";
|
|
16
|
+
try {
|
|
17
|
+
const fs = require("node:fs");
|
|
18
|
+
if (/microsoft/i.test(fs.readFileSync("/proc/version", "utf8")))
|
|
19
|
+
return "wsl2";
|
|
20
|
+
}
|
|
21
|
+
catch { /* not linux-with-procfs */ }
|
|
22
|
+
return "linux";
|
|
23
|
+
}
|
package/dist/proof.d.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { Attestation, ObservedStep, RunPlan, FailureClass } from "./types.js";
|
|
2
|
+
export declare const TOOL_ID = "bootproof@0.1.0";
|
|
3
|
+
export declare function gitInfo(repo: string): Attestation["repo"];
|
|
4
|
+
export declare function buildAttestation(input: {
|
|
5
|
+
repo: string;
|
|
6
|
+
plan: RunPlan;
|
|
7
|
+
observed: ObservedStep[];
|
|
8
|
+
startedAt: string;
|
|
9
|
+
booted: boolean;
|
|
10
|
+
healthVerified: boolean;
|
|
11
|
+
healthObservation: string | null;
|
|
12
|
+
observedHealthCandidates?: string[];
|
|
13
|
+
failureClass: FailureClass | null;
|
|
14
|
+
failureEvidence: string | null;
|
|
15
|
+
explanation: string;
|
|
16
|
+
}): Attestation;
|
|
17
|
+
export declare function signDetached(body: Buffer): {
|
|
18
|
+
signature: string;
|
|
19
|
+
publicKeyPem: string;
|
|
20
|
+
};
|
|
21
|
+
export declare function verifyDetached(body: Buffer, signature: string, publicKeyPem: string): boolean;
|
|
22
|
+
export declare function verifySignature(att: Attestation): boolean;
|
|
23
|
+
export declare function attestationPath(repo: string): string;
|
|
24
|
+
export declare function writeAttestation(repo: string, att: Attestation): string;
|
package/dist/proof.js
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { execFileSync } from "node:child_process";
|
|
6
|
+
export const TOOL_ID = "bootproof@0.1.0";
|
|
7
|
+
export function gitInfo(repo) {
|
|
8
|
+
const git = (...args) => {
|
|
9
|
+
try {
|
|
10
|
+
return execFileSync("git", ["-C", repo, ...args], { encoding: "utf8" }).trim();
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
if (!fs.existsSync(path.join(repo, ".git")))
|
|
17
|
+
return { path: repo, remote: null, commit: null, dirty: null };
|
|
18
|
+
const status = git("status", "--porcelain");
|
|
19
|
+
return {
|
|
20
|
+
path: repo,
|
|
21
|
+
remote: git("remote", "get-url", "origin"),
|
|
22
|
+
commit: git("rev-parse", "HEAD"),
|
|
23
|
+
dirty: status === null ? null : status.length > 0,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
function signerKeyPath() {
|
|
27
|
+
return path.join(os.homedir(), ".bootproof", "signer.json");
|
|
28
|
+
}
|
|
29
|
+
function loadOrCreateSigner() {
|
|
30
|
+
const p = signerKeyPath();
|
|
31
|
+
if (fs.existsSync(p)) {
|
|
32
|
+
const saved = JSON.parse(fs.readFileSync(p, "utf8"));
|
|
33
|
+
return { privateKey: crypto.createPrivateKey(saved.privateKeyPem), publicKeyPem: saved.publicKeyPem };
|
|
34
|
+
}
|
|
35
|
+
const { privateKey, publicKey } = crypto.generateKeyPairSync("ed25519");
|
|
36
|
+
const privateKeyPem = privateKey.export({ type: "pkcs8", format: "pem" }).toString();
|
|
37
|
+
const publicKeyPem = publicKey.export({ type: "spki", format: "pem" }).toString();
|
|
38
|
+
fs.mkdirSync(path.dirname(p), { recursive: true, mode: 0o700 });
|
|
39
|
+
fs.writeFileSync(p, JSON.stringify({ privateKeyPem, publicKeyPem }), { mode: 0o600 });
|
|
40
|
+
return { privateKey: crypto.createPrivateKey(privateKeyPem), publicKeyPem };
|
|
41
|
+
}
|
|
42
|
+
function canonicalBody(att) {
|
|
43
|
+
const { signature: _s, signer: _k, ...body } = att;
|
|
44
|
+
return Buffer.from(JSON.stringify(body));
|
|
45
|
+
}
|
|
46
|
+
export function buildAttestation(input) {
|
|
47
|
+
const att = {
|
|
48
|
+
schema: "bootproof/attestation/v1",
|
|
49
|
+
tool: TOOL_ID,
|
|
50
|
+
repo: gitInfo(input.repo),
|
|
51
|
+
environment: { os: `${os.platform()} ${os.release()}`, arch: os.arch(), node: process.version },
|
|
52
|
+
trust: { level: "local_developer_signed", signer: "local_ed25519", oidc: null },
|
|
53
|
+
plan: input.plan,
|
|
54
|
+
observed: input.observed,
|
|
55
|
+
result: {
|
|
56
|
+
booted: input.booted,
|
|
57
|
+
healthVerified: input.healthVerified,
|
|
58
|
+
healthObservation: input.healthObservation,
|
|
59
|
+
observedHealthCandidates: input.observedHealthCandidates ?? [],
|
|
60
|
+
failureClass: input.failureClass,
|
|
61
|
+
failureEvidence: input.failureEvidence,
|
|
62
|
+
explanation: input.explanation,
|
|
63
|
+
},
|
|
64
|
+
startedAt: input.startedAt,
|
|
65
|
+
finishedAt: new Date().toISOString(),
|
|
66
|
+
signer: null,
|
|
67
|
+
signature: null,
|
|
68
|
+
};
|
|
69
|
+
const { privateKey, publicKeyPem } = loadOrCreateSigner();
|
|
70
|
+
att.signature = crypto.sign(null, canonicalBody(att), privateKey).toString("base64");
|
|
71
|
+
att.signer = { publicKey: publicKeyPem, algorithm: "ed25519" };
|
|
72
|
+
return att;
|
|
73
|
+
}
|
|
74
|
+
export function signDetached(body) {
|
|
75
|
+
const { privateKey, publicKeyPem } = loadOrCreateSigner();
|
|
76
|
+
return { signature: crypto.sign(null, body, privateKey).toString("base64"), publicKeyPem };
|
|
77
|
+
}
|
|
78
|
+
export function verifyDetached(body, signature, publicKeyPem) {
|
|
79
|
+
try {
|
|
80
|
+
return crypto.verify(null, body, crypto.createPublicKey(publicKeyPem), Buffer.from(signature, "base64"));
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
export function verifySignature(att) {
|
|
87
|
+
if (!att.signature || !att.signer)
|
|
88
|
+
return false;
|
|
89
|
+
try {
|
|
90
|
+
return crypto.verify(null, canonicalBody(att), crypto.createPublicKey(att.signer.publicKey), Buffer.from(att.signature, "base64"));
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
export function attestationPath(repo) {
|
|
97
|
+
return path.join(repo, ".bootproof", "attestation.json");
|
|
98
|
+
}
|
|
99
|
+
export function writeAttestation(repo, att) {
|
|
100
|
+
const p = attestationPath(repo);
|
|
101
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
102
|
+
fs.writeFileSync(p, JSON.stringify(att, null, 2) + "\n");
|
|
103
|
+
return p;
|
|
104
|
+
}
|
package/dist/redact.d.ts
ADDED
package/dist/redact.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// Redaction for shareable artifacts. Rule: anything that leaves the machine goes
|
|
2
|
+
// through here first, and the user is shown the exact redacted output before sharing.
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
const PATTERNS = [
|
|
5
|
+
{ name: "env assignment secrets", re: /\b([A-Z][A-Z0-9_]*(?:SECRET|TOKEN|PASSWORD|PASSWD|API_KEY|PRIVATE_KEY|ACCESS_KEY)[A-Z0-9_]*)=([^\s'"]+)/g, replace: "$1=[redacted]" },
|
|
6
|
+
{ name: "url credentials", re: /\b([a-z][a-z0-9+.-]*:\/\/)([^\s:@\/]+):([^\s@\/]+)@/g, replace: "$1[redacted]:[redacted]@" },
|
|
7
|
+
{ name: "bearer tokens", re: /\b(Bearer|token)\s+[A-Za-z0-9\-._~+/]{16,}={0,2}/g, replace: "$1 [redacted]" },
|
|
8
|
+
{ name: "github tokens", re: /\bgh[pousr]_[A-Za-z0-9]{20,}\b/g, replace: "[redacted-github-token]" },
|
|
9
|
+
{ name: "aws access keys", re: /\bAKIA[0-9A-Z]{16}\b/g, replace: "[redacted-aws-key]" },
|
|
10
|
+
{ name: "jwt-like", re: /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g, replace: "[redacted-jwt]" },
|
|
11
|
+
{ name: "long hex secrets", re: /\b[0-9a-f]{40,}\b/gi, replace: "[redacted-hex]" },
|
|
12
|
+
];
|
|
13
|
+
export function redactText(input) {
|
|
14
|
+
let text = input;
|
|
15
|
+
const applied = [];
|
|
16
|
+
for (const p of PATTERNS) {
|
|
17
|
+
if (p.re.test(text)) {
|
|
18
|
+
applied.push(p.name);
|
|
19
|
+
text = text.replace(p.re, p.replace);
|
|
20
|
+
}
|
|
21
|
+
p.re.lastIndex = 0;
|
|
22
|
+
}
|
|
23
|
+
// machine-identifying paths
|
|
24
|
+
const home = os.homedir();
|
|
25
|
+
if (home && text.includes(home)) {
|
|
26
|
+
text = text.split(home).join("~");
|
|
27
|
+
applied.push("home directory path");
|
|
28
|
+
}
|
|
29
|
+
text = text.replace(/\/(?:Users|home)\/[^/\s]+/g, "~");
|
|
30
|
+
return { text, applied };
|
|
31
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { Attestation } from "./types.js";
|
|
2
|
+
export interface RegistryEntry {
|
|
3
|
+
schema: "bootproof/registry-entry/v1";
|
|
4
|
+
tool: string;
|
|
5
|
+
repo: {
|
|
6
|
+
remote: string | null;
|
|
7
|
+
commit: string | null;
|
|
8
|
+
dirty: boolean | null;
|
|
9
|
+
};
|
|
10
|
+
environment: Attestation["environment"];
|
|
11
|
+
plan: {
|
|
12
|
+
provider: string;
|
|
13
|
+
healthUrl: string;
|
|
14
|
+
steps: {
|
|
15
|
+
kind: string;
|
|
16
|
+
command?: string;
|
|
17
|
+
}[];
|
|
18
|
+
};
|
|
19
|
+
result: {
|
|
20
|
+
booted: boolean;
|
|
21
|
+
healthVerified: boolean;
|
|
22
|
+
healthObservation: string | null;
|
|
23
|
+
failureClass: string | null;
|
|
24
|
+
redactedEvidence: string | null;
|
|
25
|
+
};
|
|
26
|
+
redactionsApplied: string[];
|
|
27
|
+
attestationSha256: string;
|
|
28
|
+
attestedAt: string;
|
|
29
|
+
signer: {
|
|
30
|
+
publicKey: string;
|
|
31
|
+
algorithm: "ed25519";
|
|
32
|
+
} | null;
|
|
33
|
+
signature: string | null;
|
|
34
|
+
}
|
|
35
|
+
export declare function buildRegistryEntry(att: Attestation): RegistryEntry;
|
|
36
|
+
export declare function verifyRegistryEntry(entry: RegistryEntry): boolean;
|
|
37
|
+
export declare function registryEntryPath(repo: string): string;
|
|
38
|
+
export declare function writeRegistryEntry(repo: string, entry: RegistryEntry): string;
|
package/dist/registry.js
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// The proposed BootProof registry is federated by design (docs/REGISTRY.md):
|
|
2
|
+
// WRITE PATH: developers may commit signed proof to their own repositories.
|
|
3
|
+
// FUTURE READ PATH: an index could discover public artifacts and verify every signature.
|
|
4
|
+
// No public index is operated today. The CLI never uploads evidence. `attest export` produces a
|
|
5
|
+
// redacted, re-signed local entry; sharing it remains a deliberate git/PR action.
|
|
6
|
+
import crypto from "node:crypto";
|
|
7
|
+
import fs from "node:fs";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import { redactText } from "./redact.js";
|
|
10
|
+
import { signDetached, verifyDetached } from "./proof.js";
|
|
11
|
+
export function buildRegistryEntry(att) {
|
|
12
|
+
const redactions = new Set();
|
|
13
|
+
let redactedEvidence = null;
|
|
14
|
+
if (att.result.failureEvidence) {
|
|
15
|
+
const r = redactText(att.result.failureEvidence);
|
|
16
|
+
redactedEvidence = r.text;
|
|
17
|
+
r.applied.forEach(a => redactions.add(a));
|
|
18
|
+
}
|
|
19
|
+
const fullHash = crypto.createHash("sha256").update(JSON.stringify(att)).digest("hex");
|
|
20
|
+
const entry = {
|
|
21
|
+
schema: "bootproof/registry-entry/v1",
|
|
22
|
+
tool: att.tool,
|
|
23
|
+
repo: { remote: att.repo.remote, commit: att.repo.commit, dirty: att.repo.dirty },
|
|
24
|
+
environment: att.environment,
|
|
25
|
+
plan: {
|
|
26
|
+
provider: att.plan.provider,
|
|
27
|
+
healthUrl: att.plan.healthUrl,
|
|
28
|
+
steps: att.plan.steps.map(s => {
|
|
29
|
+
const r = s.command ? redactText(s.command) : null;
|
|
30
|
+
if (r)
|
|
31
|
+
r.applied.forEach(a => redactions.add(a));
|
|
32
|
+
return { kind: s.kind, command: r?.text };
|
|
33
|
+
}),
|
|
34
|
+
},
|
|
35
|
+
result: {
|
|
36
|
+
booted: att.result.booted,
|
|
37
|
+
healthVerified: att.result.healthVerified,
|
|
38
|
+
healthObservation: att.result.healthObservation,
|
|
39
|
+
failureClass: att.result.failureClass,
|
|
40
|
+
redactedEvidence,
|
|
41
|
+
},
|
|
42
|
+
redactionsApplied: [...redactions],
|
|
43
|
+
attestationSha256: fullHash,
|
|
44
|
+
attestedAt: att.finishedAt,
|
|
45
|
+
signer: null,
|
|
46
|
+
signature: null,
|
|
47
|
+
};
|
|
48
|
+
const signed = signDetached(canonical(entry));
|
|
49
|
+
entry.signature = signed.signature;
|
|
50
|
+
entry.signer = { publicKey: signed.publicKeyPem, algorithm: "ed25519" };
|
|
51
|
+
return entry;
|
|
52
|
+
}
|
|
53
|
+
function canonical(entry) {
|
|
54
|
+
const { signature: _s, signer: _k, ...body } = entry;
|
|
55
|
+
return Buffer.from(JSON.stringify(body));
|
|
56
|
+
}
|
|
57
|
+
export function verifyRegistryEntry(entry) {
|
|
58
|
+
if (!entry.signature || !entry.signer)
|
|
59
|
+
return false;
|
|
60
|
+
return verifyDetached(canonical(entry), entry.signature, entry.signer.publicKey);
|
|
61
|
+
}
|
|
62
|
+
export function registryEntryPath(repo) {
|
|
63
|
+
return path.join(repo, ".bootproof", "registry-entry.json");
|
|
64
|
+
}
|
|
65
|
+
export function writeRegistryEntry(repo, entry) {
|
|
66
|
+
const p = registryEntryPath(repo);
|
|
67
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
68
|
+
fs.writeFileSync(p, JSON.stringify(entry, null, 2) + "\n");
|
|
69
|
+
return p;
|
|
70
|
+
}
|
package/dist/remote.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export interface GithubRemote {
|
|
2
|
+
originalUrl: string;
|
|
3
|
+
canonicalUrl: string;
|
|
4
|
+
owner: string;
|
|
5
|
+
repo: string;
|
|
6
|
+
}
|
|
7
|
+
export interface RemoteClone extends GithubRemote {
|
|
8
|
+
repoPath: string;
|
|
9
|
+
}
|
|
10
|
+
export declare function isRemoteTarget(value: string): boolean;
|
|
11
|
+
export declare function parseGithubRemote(value: string): GithubRemote;
|
|
12
|
+
export declare function cloneGithubRemote(value: string, cwd: string): RemoteClone;
|
|
13
|
+
export declare function managedRemoteSource(repoPath: string): string | null;
|
package/dist/remote.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { execFileSync } from "node:child_process";
|
|
4
|
+
export function isRemoteTarget(value) {
|
|
5
|
+
return /^[a-z][a-z0-9+.-]*:\/\//i.test(value) || /^git@/i.test(value);
|
|
6
|
+
}
|
|
7
|
+
export function parseGithubRemote(value) {
|
|
8
|
+
let url;
|
|
9
|
+
try {
|
|
10
|
+
url = new URL(value);
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
throw new Error("Remote targets must be full HTTPS GitHub repository URLs.");
|
|
14
|
+
}
|
|
15
|
+
if (url.protocol !== "https:" || url.hostname.toLowerCase() !== "github.com") {
|
|
16
|
+
throw new Error("Remote mode currently accepts only public HTTPS GitHub repository URLs.");
|
|
17
|
+
}
|
|
18
|
+
if (url.username || url.password || url.port || url.search || url.hash) {
|
|
19
|
+
throw new Error("Remote URLs must not contain credentials, custom ports, query strings, or fragments.");
|
|
20
|
+
}
|
|
21
|
+
const parts = url.pathname.split("/").filter(Boolean);
|
|
22
|
+
if (parts.length !== 2) {
|
|
23
|
+
throw new Error("Remote GitHub URLs must identify exactly one repository: https://github.com/owner/repo.");
|
|
24
|
+
}
|
|
25
|
+
const owner = parts[0];
|
|
26
|
+
const repo = parts[1].replace(/\.git$/i, "");
|
|
27
|
+
const safeSegment = /^[A-Za-z0-9_.-]+$/;
|
|
28
|
+
if (!safeSegment.test(owner) || !safeSegment.test(repo) || owner === "." || owner === ".." || repo === "." || repo === "..") {
|
|
29
|
+
throw new Error("Remote GitHub owner and repository names contain unsupported characters.");
|
|
30
|
+
}
|
|
31
|
+
return {
|
|
32
|
+
originalUrl: value,
|
|
33
|
+
canonicalUrl: `https://github.com/${owner}/${repo}.git`,
|
|
34
|
+
owner,
|
|
35
|
+
repo,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
export function cloneGithubRemote(value, cwd) {
|
|
39
|
+
const remote = parseGithubRemote(value);
|
|
40
|
+
const ownerRoot = path.join(cwd, ".bootproof", "remotes", "github.com", remote.owner);
|
|
41
|
+
fs.mkdirSync(ownerRoot, { recursive: true });
|
|
42
|
+
const runRoot = fs.mkdtempSync(path.join(ownerRoot, `${remote.repo}-`));
|
|
43
|
+
const repoPath = path.join(runRoot, "repo");
|
|
44
|
+
try {
|
|
45
|
+
execFileSync("git", ["clone", "--depth", "1", "--single-branch", "--no-tags", "--", remote.canonicalUrl, repoPath], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
|
|
46
|
+
const marker = {
|
|
47
|
+
schema: "bootproof/remote-source/v1",
|
|
48
|
+
canonicalUrl: remote.canonicalUrl,
|
|
49
|
+
repoDirectory: "repo",
|
|
50
|
+
};
|
|
51
|
+
fs.writeFileSync(path.join(runRoot, "source.json"), JSON.stringify(marker, null, 2) + "\n");
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
fs.rmSync(runRoot, { recursive: true, force: true });
|
|
55
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
56
|
+
throw new Error(`Could not clone ${remote.canonicalUrl}: ${detail}`);
|
|
57
|
+
}
|
|
58
|
+
return { ...remote, repoPath };
|
|
59
|
+
}
|
|
60
|
+
export function managedRemoteSource(repoPath) {
|
|
61
|
+
const markerPath = path.join(path.dirname(path.resolve(repoPath)), "source.json");
|
|
62
|
+
try {
|
|
63
|
+
const marker = JSON.parse(fs.readFileSync(markerPath, "utf8"));
|
|
64
|
+
return marker.schema === "bootproof/remote-source/v1"
|
|
65
|
+
&& marker.repoDirectory === path.basename(path.resolve(repoPath))
|
|
66
|
+
&& typeof marker.canonicalUrl === "string"
|
|
67
|
+
? marker.canonicalUrl
|
|
68
|
+
: null;
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
}
|
package/dist/run.d.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { Inference, RunPlan, FailureClass, Attestation } from "./types.js";
|
|
2
|
+
export interface UpOptions {
|
|
3
|
+
provider: "docker" | "local";
|
|
4
|
+
unsafeLocal: boolean;
|
|
5
|
+
dryRun: boolean;
|
|
6
|
+
remoteSource?: string;
|
|
7
|
+
workspace?: string;
|
|
8
|
+
timeoutMs: number;
|
|
9
|
+
install: boolean;
|
|
10
|
+
port?: number;
|
|
11
|
+
}
|
|
12
|
+
export interface UpOutcome {
|
|
13
|
+
inference: Inference;
|
|
14
|
+
plan: RunPlan;
|
|
15
|
+
attestation: Attestation | null;
|
|
16
|
+
refusal: {
|
|
17
|
+
failureClass: FailureClass;
|
|
18
|
+
explanation: string;
|
|
19
|
+
} | null;
|
|
20
|
+
writtenFiles: string[];
|
|
21
|
+
}
|
|
22
|
+
export declare function packageManagerVersionMatches(expected: string, actual: string): boolean;
|
|
23
|
+
export declare function up(repoPath: string, opts: UpOptions): Promise<UpOutcome>;
|
package/dist/run.js
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { inferRepo } from "./infer.js";
|
|
3
|
+
import { buildPlan, writePlanFiles } from "./plan.js";
|
|
4
|
+
import { runToCompletion, superviseApp, pollHealthCandidates, minimalEnv } from "./exec.js";
|
|
5
|
+
import { classifyFailure } from "./taxonomy.js";
|
|
6
|
+
import { buildAttestation, writeAttestation } from "./proof.js";
|
|
7
|
+
function classifyHealthFailure(evidence) {
|
|
8
|
+
if (/(only HTTP 5\d\d observed|HTTP 5\d\d|status\s*5\d\d|returned 5\d\d)/i.test(evidence)) {
|
|
9
|
+
return "health_http_error";
|
|
10
|
+
}
|
|
11
|
+
return "health_check_timeout";
|
|
12
|
+
}
|
|
13
|
+
function step(id, kind, command, startedAt, exitCode, ok, observation, evidenceTail) {
|
|
14
|
+
return { id, kind, command, startedAt, finishedAt: new Date().toISOString(), exitCode, ok, observation, evidenceTail };
|
|
15
|
+
}
|
|
16
|
+
export function packageManagerVersionMatches(expected, actual) {
|
|
17
|
+
const expectedMatch = expected.trim().match(/^v?(\d+)(?:\.(\d+))?(?:\.(\d+))?$/);
|
|
18
|
+
const actualMatch = actual.trim().match(/^v?(\d+)(?:\.(\d+))?(?:\.(\d+))?/);
|
|
19
|
+
if (!expectedMatch || !actualMatch)
|
|
20
|
+
return true;
|
|
21
|
+
const expectedParts = expectedMatch.slice(1).filter((part) => part !== undefined);
|
|
22
|
+
const actualParts = actualMatch.slice(1, 1 + expectedParts.length);
|
|
23
|
+
return expectedParts.every((part, index) => part === actualParts[index]);
|
|
24
|
+
}
|
|
25
|
+
function packageManagerVersionEvidence(inference) {
|
|
26
|
+
if (inference.packageManager === "unknown" || !inference.packageManagerVersion)
|
|
27
|
+
return null;
|
|
28
|
+
try {
|
|
29
|
+
const actual = execFileSync(inference.packageManager, ["--version"], { cwd: inference.repoPath, encoding: "utf8" }).trim();
|
|
30
|
+
if (packageManagerVersionMatches(inference.packageManagerVersion, actual))
|
|
31
|
+
return null;
|
|
32
|
+
return `packageManager field or engines.${inference.packageManager} expected version: ${inference.packageManagerVersion}\nGot: ${actual}`;
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
export async function up(repoPath, opts) {
|
|
39
|
+
const startedAt = new Date().toISOString();
|
|
40
|
+
const inference = inferRepo(repoPath, { workspace: opts.workspace });
|
|
41
|
+
if (opts.port) {
|
|
42
|
+
inference.port = opts.port;
|
|
43
|
+
inference.portEvidence = "set by --port flag";
|
|
44
|
+
inference.healthCandidates = inference.healthCandidates.map(candidate => candidate.replace(/:\d{2,5}(?=\/)/, `:${opts.port}`));
|
|
45
|
+
}
|
|
46
|
+
const plan = buildPlan(inference, opts.provider);
|
|
47
|
+
const base = { inference, plan, writtenFiles: [] };
|
|
48
|
+
const refuse = (failureClass, explanation, observed = [], failureEvidence = explanation) => {
|
|
49
|
+
const refusal = { failureClass, explanation };
|
|
50
|
+
if (opts.dryRun)
|
|
51
|
+
return { ...base, attestation: null, refusal };
|
|
52
|
+
const ungeneratedPaths = plan.generatedFiles.map(file => file.path);
|
|
53
|
+
const refusalPlan = {
|
|
54
|
+
...plan,
|
|
55
|
+
steps: plan.steps.filter(planned => !ungeneratedPaths.some(file => planned.command?.includes(file))),
|
|
56
|
+
generatedFiles: [],
|
|
57
|
+
};
|
|
58
|
+
const attestation = buildAttestation({
|
|
59
|
+
repo: inference.repoPath,
|
|
60
|
+
plan: refusalPlan,
|
|
61
|
+
observed,
|
|
62
|
+
startedAt,
|
|
63
|
+
booted: false,
|
|
64
|
+
healthVerified: false,
|
|
65
|
+
healthObservation: null,
|
|
66
|
+
observedHealthCandidates: [],
|
|
67
|
+
failureClass,
|
|
68
|
+
failureEvidence: failureEvidence.slice(-2000),
|
|
69
|
+
explanation,
|
|
70
|
+
});
|
|
71
|
+
writeAttestation(inference.repoPath, attestation);
|
|
72
|
+
return { inference, plan: refusalPlan, writtenFiles: [], attestation, refusal };
|
|
73
|
+
};
|
|
74
|
+
if (!inference.isApplication) {
|
|
75
|
+
return refuse("not_an_application", inference.notAppReason);
|
|
76
|
+
}
|
|
77
|
+
if (inference.stack.includes("python-backend") && inference.stack.includes("flask") && inference.setupSteps.length > 0) {
|
|
78
|
+
return refuse("python_flask_setup_required", "BootProof detected a Python/Flask + React application with setup steps. This repository requires database migration/init and service orchestration before it can be verified.");
|
|
79
|
+
}
|
|
80
|
+
if (!opts.workspace && inference.workspaces.length > 1 && !inference.appCommand) {
|
|
81
|
+
return refuse("workspace_ambiguous", `This is a monorepo with ${inference.workspaces.length} workspace candidates. Choose one with --workspace <dir> instead of letting bootproof guess.`);
|
|
82
|
+
}
|
|
83
|
+
if (opts.remoteSource && !opts.dryRun && (opts.provider !== "local" || !opts.unsafeLocal)) {
|
|
84
|
+
return refuse("unknown_failure", `BootProof cloned ${opts.remoteSource} for inspection but will not execute remote repository code without --provider local --unsafe-local.`);
|
|
85
|
+
}
|
|
86
|
+
if (opts.provider === "local" && !opts.unsafeLocal) {
|
|
87
|
+
return refuse("unknown_failure", "Local provider runs repository code directly on your machine. Re-run with --unsafe-local to acknowledge this, or use --provider docker.");
|
|
88
|
+
}
|
|
89
|
+
if (opts.dryRun)
|
|
90
|
+
return { ...base, attestation: null, refusal: null };
|
|
91
|
+
if (inference.dependencyInstallRequired && !opts.install) {
|
|
92
|
+
const skipped = step("install", "install", inference.installCommand ?? undefined, new Date().toISOString(), null, false, "skipped by default — dependency-backed application was not started; pass --install to run dependency installation");
|
|
93
|
+
return refuse("dependency_install_skipped", "The inferred application command depends on project packages, but dependency installation was not requested. BootProof did not start the partial application pipeline.", [skipped]);
|
|
94
|
+
}
|
|
95
|
+
if (opts.install) {
|
|
96
|
+
const versionEvidence = packageManagerVersionEvidence(inference);
|
|
97
|
+
if (versionEvidence) {
|
|
98
|
+
const observed = step("package-manager-version", "install", `${inference.packageManager} --version`, new Date().toISOString(), 0, false, `declared ${inference.packageManager} ${inference.packageManagerVersion}, but found ${versionEvidence.split("Got: ")[1]}`, versionEvidence);
|
|
99
|
+
return refuse("package_manager_version_mismatch", "The repository declares a package manager version that does not match the version available in the current environment. Enable Corepack or install the required package manager version before rerunning BootProof.", [observed], versionEvidence);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
if (inference.multiAppCommand) {
|
|
103
|
+
return refuse("workspace_ambiguous", "BootProof detected a root command that starts multiple workspaces in parallel. Choose a specific application with --workspace <dir>; one responding workspace is not proof that the whole repository booted.");
|
|
104
|
+
}
|
|
105
|
+
if (inference.incompleteAppCommand) {
|
|
106
|
+
return refuse("unknown_failure", "BootProof detected a hybrid backend/frontend repository, but the inferred command starts only the frontend development pipeline. Complete application orchestration is not implemented, so no boot was attempted.");
|
|
107
|
+
}
|
|
108
|
+
const writtenFiles = writePlanFiles(inference, inference.repoPath);
|
|
109
|
+
const observed = [];
|
|
110
|
+
const env = minimalEnv({ PORT: String(inference.port) });
|
|
111
|
+
const fail = (failureClass, evidence, explanation) => {
|
|
112
|
+
const att = buildAttestation({ repo: inference.repoPath, plan, observed, startedAt, booted: false, healthVerified: false, healthObservation: null, observedHealthCandidates: [], failureClass, failureEvidence: evidence.slice(-2000), explanation });
|
|
113
|
+
writeAttestation(inference.repoPath, att);
|
|
114
|
+
return { inference, plan, writtenFiles, attestation: att, refusal: null };
|
|
115
|
+
};
|
|
116
|
+
for (const planned of plan.steps) {
|
|
117
|
+
if (planned.kind === "service" && planned.command) {
|
|
118
|
+
const t = new Date().toISOString();
|
|
119
|
+
const r = await runToCompletion(planned.command, inference.repoPath, 120_000, env);
|
|
120
|
+
const ok = r.exitCode === 0;
|
|
121
|
+
observed.push(step(planned.id, "service", planned.command, t, r.exitCode, ok, ok ? "services started (docker compose exit 0)" : "docker compose failed", r.stderr || r.stdout));
|
|
122
|
+
if (!ok) {
|
|
123
|
+
const c = classifyFailure(r.stderr + r.stdout);
|
|
124
|
+
return fail(c.class, r.stderr + r.stdout, c.explanation);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
if (planned.kind === "install" && planned.command) {
|
|
128
|
+
if (!opts.install) {
|
|
129
|
+
observed.push(step(planned.id, "install", planned.command, new Date().toISOString(), null, false, "skipped by default — optional install was not needed for the observed boot"));
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
const t = new Date().toISOString();
|
|
133
|
+
const r = await runToCompletion(planned.command, inference.repoPath, 600_000, env);
|
|
134
|
+
const ok = r.exitCode === 0 && !r.timedOut;
|
|
135
|
+
observed.push(step(planned.id, "install", planned.command, t, r.exitCode, ok, ok ? "dependencies installed (exit 0)" : r.timedOut ? "install timed out" : `install failed (exit ${r.exitCode})`, ok ? undefined : r.stderr || r.stdout));
|
|
136
|
+
if (!ok) {
|
|
137
|
+
const c = classifyFailure(r.stderr + r.stdout);
|
|
138
|
+
return fail(c.class === "unknown_failure" ? "install_failed" : c.class, r.stderr + r.stdout, c.explanation);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
if (planned.kind === "start-app" && planned.command) {
|
|
142
|
+
const t = new Date().toISOString();
|
|
143
|
+
const app = superviseApp(planned.command, inference.repoPath, env);
|
|
144
|
+
const health = await pollHealthCandidates(plan.healthCandidates, opts.timeoutMs, app.output);
|
|
145
|
+
plan.healthCandidates = health.candidates;
|
|
146
|
+
if (health.url)
|
|
147
|
+
plan.healthUrl = health.url;
|
|
148
|
+
const exit = app.exited();
|
|
149
|
+
if (exit && !health.responded) {
|
|
150
|
+
observed.push(step(planned.id, "start-app", planned.command, t, exit.code, false, `app process exited (code ${exit.code}) before responding`, app.output()));
|
|
151
|
+
const c = classifyFailure(app.output());
|
|
152
|
+
await app.stop();
|
|
153
|
+
return fail(c.class === "unknown_failure" ? "app_exited_early" : c.class, app.output(), c.explanation);
|
|
154
|
+
}
|
|
155
|
+
observed.push(step(planned.id, "start-app", planned.command, t, null, true, "app process started and was supervised"));
|
|
156
|
+
const ht = new Date().toISOString();
|
|
157
|
+
if (health.responded && health.status !== null && health.status < 500) {
|
|
158
|
+
const observedUrl = health.url ?? plan.healthUrl;
|
|
159
|
+
observed.push(step("health", "health", undefined, ht, null, true, `observed HTTP ${health.status} at ${observedUrl} after ${health.elapsedMs}ms (${health.attempts} attempts)`));
|
|
160
|
+
await app.stop();
|
|
161
|
+
const att = buildAttestation({ repo: inference.repoPath, plan, observed, startedAt, booted: true, healthVerified: true, healthObservation: `HTTP ${health.status} at ${observedUrl}`, observedHealthCandidates: health.discoveredCandidates, failureClass: null, failureEvidence: null, explanation: `Verified: ${observedUrl} responded HTTP ${health.status}. This attestation records what was observed, not a guarantee the app is fully functional.` });
|
|
162
|
+
writeAttestation(inference.repoPath, att);
|
|
163
|
+
return { inference, plan, writtenFiles, attestation: att, refusal: null };
|
|
164
|
+
}
|
|
165
|
+
const evidence = app.output();
|
|
166
|
+
const healthFailureMessage = health.responded
|
|
167
|
+
? `only HTTP ${health.status} observed at ${health.url ?? plan.healthUrl}`
|
|
168
|
+
: `no HTTP response at candidates ${health.candidates.join(", ")} within ${opts.timeoutMs}ms`;
|
|
169
|
+
observed.push(step("health", "health", undefined, ht, null, false, healthFailureMessage, evidence));
|
|
170
|
+
const c = classifyFailure(`${healthFailureMessage}\n${evidence}`);
|
|
171
|
+
const healthClass = health.responded && health.status !== null && health.status >= 500
|
|
172
|
+
? "health_http_error"
|
|
173
|
+
: c.class === "unknown_failure"
|
|
174
|
+
? classifyHealthFailure(healthFailureMessage)
|
|
175
|
+
: c.class;
|
|
176
|
+
const healthExplanation = healthClass === "health_http_error"
|
|
177
|
+
? "The app responded on the configured health URL, but returned HTTP 5xx. BootProof observed a running server, but not a verified healthy boot."
|
|
178
|
+
: c.explanation;
|
|
179
|
+
await app.stop();
|
|
180
|
+
const att = buildAttestation({
|
|
181
|
+
repo: inference.repoPath,
|
|
182
|
+
plan,
|
|
183
|
+
observed,
|
|
184
|
+
startedAt,
|
|
185
|
+
booted: false,
|
|
186
|
+
healthVerified: false,
|
|
187
|
+
healthObservation: null,
|
|
188
|
+
observedHealthCandidates: health.discoveredCandidates,
|
|
189
|
+
failureClass: healthClass,
|
|
190
|
+
failureEvidence: `${healthFailureMessage}\n${evidence}`.slice(-2000),
|
|
191
|
+
explanation: healthExplanation,
|
|
192
|
+
});
|
|
193
|
+
writeAttestation(inference.repoPath, att);
|
|
194
|
+
return { inference, plan, writtenFiles, attestation: att, refusal: null };
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return fail("not_an_application", "", "Plan contained no runnable app step.");
|
|
198
|
+
}
|