@awak-app/simy-cli 0.3.3 → 0.4.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/README.md +55 -0
- package/package.json +4 -2
- package/src/agent.js +18 -1
- package/src/cli-contract.js +1 -1
- package/src/execution-guardrail.js +2 -2
- package/src/index.js +14 -0
- package/src/local-task.js +51 -8
- package/src/orchestrator/audit.js +42 -1
- package/src/orchestrator/loop.js +29 -3
- package/src/runner.js +44 -0
- package/src/sqm/bundle-store.js +249 -0
- package/src/sqm/canonical.js +45 -0
- package/src/sqm/checkers.js +299 -0
- package/src/sqm/command.js +149 -0
- package/src/sqm/evidence-client.js +12 -0
- package/src/sqm/index.js +141 -0
- package/src/sqm/proof.js +154 -0
- package/src/sqm/repository.js +163 -0
- package/src/sqm/session.js +58 -0
- package/src/sqm/validation.js +305 -0
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
import { isSessionValid, readSession } from "../session-store.js";
|
|
6
|
+
import { resolveWebOrigin } from "../web-origin.js";
|
|
7
|
+
import { uploadRunEvidence } from "./evidence-client.js";
|
|
8
|
+
import { runSqmCheck, runSqmMinCheck } from "./index.js";
|
|
9
|
+
import { ensureSqmSession } from "./session.js";
|
|
10
|
+
|
|
11
|
+
export async function runSqmCommand(argv, { cliVersion }) {
|
|
12
|
+
if (argv.includes("--help") || argv.includes("-h") || !argv.length) {
|
|
13
|
+
printHelp();
|
|
14
|
+
return 0;
|
|
15
|
+
}
|
|
16
|
+
if (!["min-check", "check"].includes(argv[0])) throw new Error("Usage: simy sqm <min-check|check> [options]");
|
|
17
|
+
const webOrigin = resolveWebOrigin(option(argv, "--host"));
|
|
18
|
+
let session = await readSession(webOrigin);
|
|
19
|
+
let validSession = isSessionValid(session, Date.now(), webOrigin) ? session : null;
|
|
20
|
+
if (!validSession && !explicitSqmEnvironment(argv)) {
|
|
21
|
+
session = await ensureSqmSession(webOrigin, { noOpen: argv.includes("--no-open") });
|
|
22
|
+
validSession = session;
|
|
23
|
+
}
|
|
24
|
+
const organizationId = process.env.SIMY_SQM_ORGANIZATION_ID || validSession?.organization_id || validSession?.org_id;
|
|
25
|
+
const token = process.env.SIMY_SQM_TOKEN || process.env.SIMY_ACCESS_TOKEN || validSession?.token;
|
|
26
|
+
const accountId = process.env.SIMY_SQM_ACCOUNT_ID || validSession?.account_id || validSession?.auth_user_id || validSession?.device_id;
|
|
27
|
+
const deviceId = process.env.SIMY_SQM_DEVICE_ID || validSession?.device_id;
|
|
28
|
+
if (!organizationId) throw new Error(`SQM needs an organization-bound SIMY session. Run \`simy --host ${webOrigin}\` once, then retry.`);
|
|
29
|
+
const endpoint = process.env.SIMY_SQM_BUNDLE_URL || (validSession?.api_base_url ? new URL("sqm/knowledge-bundle", validSession.api_base_url).href : undefined);
|
|
30
|
+
const common = {
|
|
31
|
+
cwd: process.cwd(),
|
|
32
|
+
baseBranch: option(argv, "--base") || "dev",
|
|
33
|
+
cliVersion,
|
|
34
|
+
offline: argv.includes("--offline"),
|
|
35
|
+
refresh: argv.includes("--refresh"),
|
|
36
|
+
token,
|
|
37
|
+
endpoint,
|
|
38
|
+
accountId,
|
|
39
|
+
organizationId,
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
if (argv[0] === "min-check") {
|
|
43
|
+
const result = await runSqmMinCheck(common);
|
|
44
|
+
const output = option(argv, "--output") || defaultMinResultPath();
|
|
45
|
+
await atomicJson(output, result);
|
|
46
|
+
if (argv.includes("--json")) console.log(JSON.stringify(result, null, 2));
|
|
47
|
+
else printMin(result, output);
|
|
48
|
+
return result.status === "unavailable" ? 2 : result.status === "preliminary_failed" ? 1 : 0;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const minPath = option(argv, "--min-result") || defaultMinResultPath();
|
|
52
|
+
let minCheck;
|
|
53
|
+
try {
|
|
54
|
+
minCheck = JSON.parse(await readFile(minPath, "utf8"));
|
|
55
|
+
} catch (error) {
|
|
56
|
+
if (error?.code !== "ENOENT") throw error;
|
|
57
|
+
console.warn("No min-check result was found; running the deterministic min-check first.");
|
|
58
|
+
minCheck = await runSqmMinCheck(common);
|
|
59
|
+
await atomicJson(minPath, minCheck);
|
|
60
|
+
}
|
|
61
|
+
const result = await runSqmCheck({ ...common, minCheck, deviceId, signingKeyPath: option(argv, "--signing-key") || undefined });
|
|
62
|
+
const proofPath = path.resolve(option(argv, "--proof") || "proof.json");
|
|
63
|
+
const evidencePath = path.resolve(option(argv, "--signed-evidence") || `${proofPath}.signed.json`);
|
|
64
|
+
await Promise.all([atomicJson(proofPath, result.proof), atomicJson(evidencePath, result.signed_evidence)]);
|
|
65
|
+
let upload = { uploaded: false, reason: "disabled" };
|
|
66
|
+
if (!argv.includes("--no-upload")) {
|
|
67
|
+
const evidenceEndpoint = process.env.SIMY_SQM_EVIDENCE_URL || (validSession?.api_base_url ? new URL("sqm/run-evidence", validSession.api_base_url).href : undefined);
|
|
68
|
+
upload = await uploadRunEvidence({ endpoint: evidenceEndpoint, token, proof: result.proof, signedEvidence: result.signed_evidence });
|
|
69
|
+
}
|
|
70
|
+
result.upload = upload;
|
|
71
|
+
result.artifacts = { min_check: minPath, proof: proofPath, signed_evidence: evidencePath };
|
|
72
|
+
if (argv.includes("--json")) console.log(JSON.stringify(result, null, 2));
|
|
73
|
+
else printFull(result);
|
|
74
|
+
return result.proof.outcome.status === "failed" ? 1 : 0;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function printHelp() {
|
|
78
|
+
console.log(`Usage:
|
|
79
|
+
simy sqm min-check [options]
|
|
80
|
+
simy sqm check [options]
|
|
81
|
+
|
|
82
|
+
Two-stage SQM flow:
|
|
83
|
+
min-check Fast deterministic diff preflight and full-check execution plan.
|
|
84
|
+
check Full state/invariant/stress/strength/scenario verification. It
|
|
85
|
+
verifies the pinned min-check identity, writes proof.json, and
|
|
86
|
+
produces independent per-run signed evidence.
|
|
87
|
+
|
|
88
|
+
Options:
|
|
89
|
+
--base <branch> Compare against this base branch (default: dev)
|
|
90
|
+
--host <origin> SIMY Web origin used for the existing device session
|
|
91
|
+
--offline Use only the last verified cached knowledge bundle
|
|
92
|
+
--refresh Revalidate the signed bundle
|
|
93
|
+
--no-open Print the sign-in URL instead of opening a browser
|
|
94
|
+
--output <path> min-check result path
|
|
95
|
+
--min-result <path> full check input (default: the last local min-check)
|
|
96
|
+
--proof <path> proof.json output (default: ./proof.json)
|
|
97
|
+
--signed-evidence <path> per-run signature output
|
|
98
|
+
--no-upload keep proof local instead of uploading hashes/results
|
|
99
|
+
--json print machine-readable output
|
|
100
|
+
|
|
101
|
+
Neither phase executes Cloud-provided code or calls a local LLM.`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function explicitSqmEnvironment(argv) {
|
|
105
|
+
const organization = process.env.SIMY_SQM_ORGANIZATION_ID?.trim();
|
|
106
|
+
const account = process.env.SIMY_SQM_ACCOUNT_ID?.trim();
|
|
107
|
+
if (argv.includes("--offline")) return Boolean(organization && account);
|
|
108
|
+
const token = (process.env.SIMY_SQM_TOKEN || process.env.SIMY_ACCESS_TOKEN)?.trim();
|
|
109
|
+
const endpoint = process.env.SIMY_SQM_BUNDLE_URL?.trim();
|
|
110
|
+
return Boolean(organization && token && endpoint);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function printMin(result, output) {
|
|
114
|
+
for (const warning of result.warnings || []) console.warn(`warning: ${warning}`);
|
|
115
|
+
if (result.status === "unavailable") return;
|
|
116
|
+
console.log(`SQM min-check: ${result.status} · ${result.preliminary_findings.length} preliminary finding(s)`);
|
|
117
|
+
console.log(`Pinned bundle ${result.knowledge_bundle.id}@${result.knowledge_bundle.version} (${result.knowledge_bundle.digest})`);
|
|
118
|
+
console.log(`Full-check plan: ${result.full_check_plan.length} scenario(s)`);
|
|
119
|
+
console.log(`Result: ${output}`);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function printFull(result) {
|
|
123
|
+
console.log(`SQM full check: ${result.proof.outcome.status} · ${result.findings.length} finding(s)`);
|
|
124
|
+
console.log(`Proof ${result.proof.proof_id} (${result.proof.integrity.digest})`);
|
|
125
|
+
console.log(`Per-run signature: valid · ${result.signature_verification.key_id}`);
|
|
126
|
+
console.log(`Artifacts: ${result.artifacts.proof} · ${result.artifacts.signed_evidence}`);
|
|
127
|
+
if (result.upload.uploaded) console.log(`Cloud evidence: stored · ${result.upload.evidence_id || result.signed_evidence.evidence_id}`);
|
|
128
|
+
else console.log(`Cloud evidence: local only (${result.upload.reason})`);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function defaultMinResultPath() {
|
|
132
|
+
return path.join(process.env.SIMY_HOME?.trim() || path.join(homedir(), ".simy"), "sqm", "min-check.json");
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function atomicJson(target, value) {
|
|
136
|
+
const resolved = path.resolve(target);
|
|
137
|
+
await mkdir(path.dirname(resolved), { recursive: true, mode: 0o700 });
|
|
138
|
+
const temporary = `${resolved}.${process.pid}.tmp`;
|
|
139
|
+
await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
|
|
140
|
+
await rename(temporary, resolved);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function option(argv, name) {
|
|
144
|
+
const index = argv.indexOf(name);
|
|
145
|
+
if (index < 0) return null;
|
|
146
|
+
const value = argv[index + 1];
|
|
147
|
+
if (!value || value.startsWith("--")) throw new Error(`${name} requires a value.`);
|
|
148
|
+
return value;
|
|
149
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export async function uploadRunEvidence({ endpoint, token, proof, signedEvidence, fetchImpl = globalThis.fetch }) {
|
|
2
|
+
if (!endpoint || !token) return { uploaded: false, reason: "authenticated_cloud_session_unavailable" };
|
|
3
|
+
const response = await fetchImpl(endpoint, {
|
|
4
|
+
method: "POST",
|
|
5
|
+
headers: { accept: "application/json", authorization: `Bearer ${token}`, "content-type": "application/json" },
|
|
6
|
+
body: JSON.stringify({ proof, signed_evidence: signedEvidence }),
|
|
7
|
+
signal: AbortSignal.timeout(10_000),
|
|
8
|
+
});
|
|
9
|
+
const payload = await response.json().catch(() => null);
|
|
10
|
+
if (!response.ok) throw new Error(`SQM run evidence upload failed with HTTP ${response.status}${payload?.error ? `: ${typeof payload.error === "string" ? payload.error : payload.error.message || "unknown error"}` : ""}.`);
|
|
11
|
+
return { uploaded: true, ...payload };
|
|
12
|
+
}
|
package/src/sqm/index.js
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { evaluateKnowledgeBundle } from "./checkers.js";
|
|
2
|
+
import { loadKnowledgeBundle } from "./bundle-store.js";
|
|
3
|
+
import { uploadRunEvidence } from "./evidence-client.js";
|
|
4
|
+
import { buildProof, finalizeMinCheck, repositoryIdentity, sameRepositoryIdentity, signProof, verifyMinCheck, verifySignedEvidence } from "./proof.js";
|
|
5
|
+
import { inspectRepository } from "./repository.js";
|
|
6
|
+
|
|
7
|
+
export function createSqmChecker({
|
|
8
|
+
cwd = process.cwd(),
|
|
9
|
+
baseBranch = "dev",
|
|
10
|
+
cliVersion,
|
|
11
|
+
offline = false,
|
|
12
|
+
refresh = false,
|
|
13
|
+
token,
|
|
14
|
+
endpoint,
|
|
15
|
+
accountId,
|
|
16
|
+
organizationId,
|
|
17
|
+
deviceId,
|
|
18
|
+
evidenceEndpoint,
|
|
19
|
+
uploadRunEvidenceImpl = uploadRunEvidence,
|
|
20
|
+
inspectRepositoryImpl = inspectRepository,
|
|
21
|
+
loadKnowledgeBundleImpl = loadKnowledgeBundle,
|
|
22
|
+
evaluateKnowledgeBundleImpl = evaluateKnowledgeBundle,
|
|
23
|
+
executeKnowledgeBundleImpl,
|
|
24
|
+
...dependencies
|
|
25
|
+
}) {
|
|
26
|
+
let pinnedLoad = null;
|
|
27
|
+
let pinnedRepository = null;
|
|
28
|
+
let latestMinCheck = null;
|
|
29
|
+
|
|
30
|
+
return async function checkCurrentRepository(_attempt = null, { phase = "min" } = {}) {
|
|
31
|
+
let repository;
|
|
32
|
+
try {
|
|
33
|
+
repository = await inspectRepositoryImpl(cwd, { baseBranch, runCommand: dependencies.runCommand });
|
|
34
|
+
} catch (error) {
|
|
35
|
+
return unavailable(`SQM repository inspection failed: ${message(error)}`);
|
|
36
|
+
}
|
|
37
|
+
if (pinnedRepository && pinnedRepository !== repository.repository) return unavailable(`SQM repository changed during a pinned run (${pinnedRepository} to ${repository.repository}).`, repository.repository);
|
|
38
|
+
if (!pinnedLoad) {
|
|
39
|
+
pinnedRepository = repository.repository;
|
|
40
|
+
pinnedLoad = loadKnowledgeBundleImpl({ repository: repository.repository, cliVersion, offline, refresh, token, endpoint, accountId, organizationId, ...dependencies });
|
|
41
|
+
}
|
|
42
|
+
const loaded = await pinnedLoad;
|
|
43
|
+
if (!loaded.bundle) return { ...unavailable(loaded.warnings[0], repository.repository), warnings: loaded.warnings };
|
|
44
|
+
try {
|
|
45
|
+
const evaluate = executeKnowledgeBundleImpl
|
|
46
|
+
? async (bundle, currentRepository) => ({ findings: await executeKnowledgeBundleImpl(bundle, currentRepository), modules: [] })
|
|
47
|
+
: evaluateKnowledgeBundleImpl;
|
|
48
|
+
if (phase === "full") {
|
|
49
|
+
if (!latestMinCheck || !sameRepositoryIdentity(latestMinCheck.repository_identity, repositoryIdentity(repository))) {
|
|
50
|
+
latestMinCheck = await buildMinCheck({ repository, bundle: loaded.bundle, evaluation: await evaluate(loaded.bundle, repository), organizationId: organizationId || loaded.bundle.organization_id, cliVersion });
|
|
51
|
+
}
|
|
52
|
+
const full = await buildFullCheck({ repository, bundle: loaded.bundle, minCheck: latestMinCheck, evaluation: await evaluate(loaded.bundle, repository), organizationId: organizationId || loaded.bundle.organization_id, cliVersion, deviceId, accountId });
|
|
53
|
+
try {
|
|
54
|
+
full.upload = await uploadRunEvidenceImpl({ endpoint: evidenceEndpoint, token, proof: full.proof, signedEvidence: full.signed_evidence });
|
|
55
|
+
} catch (error) {
|
|
56
|
+
full.upload = { uploaded: false, reason: `upload_failed: ${message(error)}` };
|
|
57
|
+
full.warnings.push(`SQM signed evidence remains local because Cloud upload failed: ${message(error)}`);
|
|
58
|
+
}
|
|
59
|
+
return full;
|
|
60
|
+
}
|
|
61
|
+
latestMinCheck = await buildMinCheck({ repository, bundle: loaded.bundle, evaluation: await evaluate(loaded.bundle, repository), organizationId: organizationId || loaded.bundle.organization_id, cliVersion });
|
|
62
|
+
return toLegacyCheck(latestMinCheck, loaded);
|
|
63
|
+
} catch (error) {
|
|
64
|
+
return { ...unavailable(`SQM ${phase} execution failed: ${message(error)}`, repository.repository), bundle: bundleIdentity(loaded.bundle), warnings: [...loaded.warnings, `SQM ${phase} execution failed: ${message(error)}`] };
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function runSqmMinCheck(options) {
|
|
70
|
+
const context = await loadContext(options);
|
|
71
|
+
if (!context.loaded.bundle) return { ...unavailable(context.loaded.warnings[0], context.repository.repository), warnings: context.loaded.warnings };
|
|
72
|
+
const evaluation = await (options.evaluateKnowledgeBundleImpl || evaluateKnowledgeBundle)(context.loaded.bundle, context.repository);
|
|
73
|
+
return buildMinCheck({ repository: context.repository, bundle: context.loaded.bundle, evaluation, organizationId: options.organizationId, cliVersion: options.cliVersion });
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export async function runSqmCheck(options) {
|
|
77
|
+
const context = await loadContext(options);
|
|
78
|
+
if (!context.loaded.bundle) return { ...unavailable(context.loaded.warnings[0], context.repository.repository), warnings: context.loaded.warnings };
|
|
79
|
+
verifyMinCheck(options.minCheck);
|
|
80
|
+
if (!sameRepositoryIdentity(options.minCheck.repository_identity, repositoryIdentity(context.repository))) throw new Error("Repository identity changed after min-check; run `simy sqm min-check` again.");
|
|
81
|
+
if (options.minCheck.knowledge_bundle.digest !== context.loaded.bundle.integrity.digest) throw new Error("Knowledge bundle changed after min-check; run `simy sqm min-check` again so both phases use one pinned bundle.");
|
|
82
|
+
const evaluation = await (options.evaluateKnowledgeBundleImpl || evaluateKnowledgeBundle)(context.loaded.bundle, context.repository);
|
|
83
|
+
return buildFullCheck({ repository: context.repository, bundle: context.loaded.bundle, minCheck: options.minCheck, evaluation, organizationId: options.organizationId, cliVersion: options.cliVersion, deviceId: options.deviceId, accountId: options.accountId, signingKeyPath: options.signingKeyPath });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function loadContext(options) {
|
|
87
|
+
const repository = await (options.inspectRepositoryImpl || inspectRepository)(options.cwd || process.cwd(), { baseBranch: options.baseBranch || "dev", runCommand: options.runCommand });
|
|
88
|
+
const loaded = await (options.loadKnowledgeBundleImpl || loadKnowledgeBundle)({ repository: repository.repository, cliVersion: options.cliVersion, offline: options.offline, refresh: options.refresh, token: options.token, endpoint: options.endpoint, accountId: options.accountId, organizationId: options.organizationId, ...(options.bundleDependencies || {}) });
|
|
89
|
+
return { repository, loaded };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function buildMinCheck({ repository, bundle, evaluation, organizationId, cliVersion }) {
|
|
93
|
+
const plan = evaluation.modules.flatMap((module) => module.scenarios.map((scenario) => ({ module_id: module.module_id, scenario_id: scenario.id, state_model_id: scenario.state_model_id, transition_id: scenario.transition_id, stressor_refs: scenario.stressor_refs, expected_strength_refs: scenario.expected_strength_refs, execution_requirement: scenario.status === "requires_human" ? "requires_human" : scenario.status === "requires_post_deploy" ? "requires_post_deploy" : "local" })));
|
|
94
|
+
return finalizeMinCheck({
|
|
95
|
+
schema_version: "1.0.0",
|
|
96
|
+
kind: "sqm_min_check",
|
|
97
|
+
organization_id: organizationId,
|
|
98
|
+
repository_identity: repositoryIdentity(repository),
|
|
99
|
+
knowledge_bundle: bundleIdentity(bundle),
|
|
100
|
+
executed_at: new Date().toISOString(),
|
|
101
|
+
cli_version: cliVersion,
|
|
102
|
+
status: evaluation.findings.length ? "preliminary_failed" : "passed",
|
|
103
|
+
preliminary_findings: evaluation.findings,
|
|
104
|
+
matched_modules: evaluation.modules.map((module) => ({ module_id: module.module_id, module_version: module.module_version, state_model_ids: module.state_models.map((model) => model.id), transition_ids: module.state_models.flatMap((model) => model.transitions.map((transition) => transition.id)), stressor_ids: module.stressors.map((stressor) => stressor.id), scenario_ids: module.scenarios.map((scenario) => scenario.id) })),
|
|
105
|
+
full_check_plan: plan,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function buildFullCheck({ repository, bundle, minCheck, evaluation, organizationId, cliVersion, deviceId, accountId, signingKeyPath }) {
|
|
110
|
+
const proof = buildProof({ organizationId, repository, bundle, minCheck, evaluation, findings: evaluation.findings, cliVersion });
|
|
111
|
+
const signedEvidence = await signProof(proof, { deviceId, accountId, keyPath: signingKeyPath });
|
|
112
|
+
const verification = verifySignedEvidence(proof, signedEvidence);
|
|
113
|
+
return {
|
|
114
|
+
status: "checked",
|
|
115
|
+
mode: "full",
|
|
116
|
+
repository: repository.repository,
|
|
117
|
+
base_ref: repository.base_ref,
|
|
118
|
+
bundle: bundleIdentity(bundle),
|
|
119
|
+
min_check: minCheck,
|
|
120
|
+
findings: evaluation.findings,
|
|
121
|
+
executions: evaluation.modules,
|
|
122
|
+
proof,
|
|
123
|
+
signed_evidence: signedEvidence,
|
|
124
|
+
signature_verification: verification,
|
|
125
|
+
warnings: [],
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function toLegacyCheck(minCheck, loaded) {
|
|
130
|
+
return { status: "checked", mode: "min", repository: minCheck.repository_identity.repository, base_ref: minCheck.repository_identity.base_ref, source: loaded.source, warnings: loaded.warnings, bundle: minCheck.knowledge_bundle, findings: minCheck.preliminary_findings, min_check: minCheck };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function bundleIdentity(bundle) {
|
|
134
|
+
return { id: bundle.bundle_id, version: bundle.version, digest: bundle.integrity.digest, signing_key_id: bundle.integrity.key_id };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function unavailable(warning, repository = null) {
|
|
138
|
+
return { status: "unavailable", mode: "shadow", repository, bundle: null, findings: [], warnings: [warning] };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function message(error) { return error instanceof Error ? error.message : String(error); }
|
package/src/sqm/proof.js
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { createHash, createPrivateKey, createPublicKey, generateKeyPairSync, randomUUID, sign, verify } from "node:crypto";
|
|
2
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
|
|
6
|
+
import { canonicalize } from "./canonical.js";
|
|
7
|
+
|
|
8
|
+
export function repositoryIdentity(repository) {
|
|
9
|
+
return {
|
|
10
|
+
repository: repository.repository,
|
|
11
|
+
base_ref: repository.base_ref,
|
|
12
|
+
base_commit: repository.base_commit ?? null,
|
|
13
|
+
head_commit: repository.head_commit ?? null,
|
|
14
|
+
committed_diff_digest: repository.committed_diff_digest ?? null,
|
|
15
|
+
working_tree_digest: repository.working_tree_digest ?? null,
|
|
16
|
+
diff_digest: repository.diff_digest ?? null,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function sameRepositoryIdentity(left, right) {
|
|
21
|
+
return ["repository", "base_ref", "base_commit", "head_commit", "committed_diff_digest", "working_tree_digest", "diff_digest"]
|
|
22
|
+
.every((key) => left?.[key] === right?.[key]);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function finalizeMinCheck(value) {
|
|
26
|
+
const result = structuredClone(value);
|
|
27
|
+
delete result.result_digest;
|
|
28
|
+
return { ...result, result_digest: taggedDigest(result) };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function verifyMinCheck(value) {
|
|
32
|
+
if (!value || value.schema_version !== "1.0.0" || value.kind !== "sqm_min_check") throw new Error("SQM min-check result is invalid.");
|
|
33
|
+
const copy = structuredClone(value);
|
|
34
|
+
const claimed = copy.result_digest;
|
|
35
|
+
delete copy.result_digest;
|
|
36
|
+
if (claimed !== taggedDigest(copy)) throw new Error("SQM min-check result digest does not match its content.");
|
|
37
|
+
return value;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function buildProof({ organizationId, repository, bundle, minCheck, evaluation, findings, cliVersion, executedAt = new Date().toISOString() }) {
|
|
41
|
+
const statuses = evaluation.modules.flatMap((module) => module.scenarios.map((scenario) => scenario.status));
|
|
42
|
+
const failedModules = evaluation.modules.filter((module) => module.scenarios.some((scenario) => scenario.status === "failed"));
|
|
43
|
+
const hasFailure = findings.length > 0 || failedModules.length > 0;
|
|
44
|
+
const shadowOnly = hasFailure && findings.every((finding) => finding.lifecycle === "shadow") && failedModules.every((module) => module.lifecycle === "shadow");
|
|
45
|
+
const requiresHuman = statuses.includes("requires_human");
|
|
46
|
+
const requiresPostDeploy = statuses.includes("requires_post_deploy");
|
|
47
|
+
const status = shadowOnly ? "shadow" : hasFailure ? "failed" : requiresHuman ? "requires_human" : requiresPostDeploy ? "requires_post_deploy" : evaluation.modules.length === 0 ? "skipped" : "passed";
|
|
48
|
+
const proof = {
|
|
49
|
+
schema_version: "1.0.0",
|
|
50
|
+
proof_id: `SQM-PROOF-${randomUUID()}`,
|
|
51
|
+
organization_id: organizationId,
|
|
52
|
+
repository_identity: repositoryIdentity(repository),
|
|
53
|
+
knowledge_bundle: {
|
|
54
|
+
id: bundle.bundle_id,
|
|
55
|
+
version: bundle.version,
|
|
56
|
+
digest: bundle.integrity.digest,
|
|
57
|
+
signing_key_id: bundle.integrity.key_id,
|
|
58
|
+
},
|
|
59
|
+
min_check: {
|
|
60
|
+
result_digest: minCheck.result_digest,
|
|
61
|
+
executed_at: minCheck.executed_at,
|
|
62
|
+
status: minCheck.status,
|
|
63
|
+
preliminary_finding_count: minCheck.preliminary_findings.length,
|
|
64
|
+
},
|
|
65
|
+
executions: evaluation.modules,
|
|
66
|
+
findings,
|
|
67
|
+
outcome: {
|
|
68
|
+
status,
|
|
69
|
+
reasons: [
|
|
70
|
+
...(hasFailure && !shadowOnly ? ["one_or_more_declarative_checks_failed"] : []),
|
|
71
|
+
...(shadowOnly ? ["declarative_checks_failed_in_shadow_mode"] : []),
|
|
72
|
+
...(evaluation.modules.length === 0 ? ["no_applicable_knowledge_module"] : []),
|
|
73
|
+
...(requiresHuman ? ["human_evidence_required"] : []),
|
|
74
|
+
...(requiresPostDeploy ? ["post_deploy_evidence_required"] : []),
|
|
75
|
+
],
|
|
76
|
+
},
|
|
77
|
+
provenance: { cli_version: cliVersion, executed_at: executedAt, executor: "simy-cli" },
|
|
78
|
+
};
|
|
79
|
+
return { ...proof, integrity: { algorithm: "sha256", digest: taggedDigest(proof) } };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function verifyProof(proof) {
|
|
83
|
+
const copy = structuredClone(proof);
|
|
84
|
+
const integrity = copy.integrity;
|
|
85
|
+
delete copy.integrity;
|
|
86
|
+
if (integrity?.algorithm !== "sha256" || integrity.digest !== taggedDigest(copy)) throw new Error("SQM proof digest verification failed.");
|
|
87
|
+
return integrity.digest;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function signProof(proof, {
|
|
91
|
+
deviceId,
|
|
92
|
+
accountId = null,
|
|
93
|
+
keyPath = process.env.SIMY_SQM_SIGNING_KEY_PATH || path.join(process.env.SIMY_HOME?.trim() || path.join(homedir(), ".simy"), "sqm", "run-signing-key.json"),
|
|
94
|
+
issuedAt = new Date().toISOString(),
|
|
95
|
+
} = {}) {
|
|
96
|
+
if (!deviceId) throw new Error("A trusted SIMY CLI device identity is required to sign SQM proof.");
|
|
97
|
+
const identity = await loadOrCreateSigningIdentity(keyPath);
|
|
98
|
+
const proofDigest = verifyProof(proof);
|
|
99
|
+
const signature = sign(null, Buffer.from(proofDigest.slice("sha256:".length), "hex"), identity.privateKey).toString("base64");
|
|
100
|
+
return {
|
|
101
|
+
schema_version: "1.0.0",
|
|
102
|
+
evidence_id: `SQM-EVIDENCE-${randomUUID()}`,
|
|
103
|
+
proof_id: proof.proof_id,
|
|
104
|
+
organization_id: proof.organization_id,
|
|
105
|
+
repository: proof.repository_identity.repository,
|
|
106
|
+
proof_digest: proofDigest,
|
|
107
|
+
signer: {
|
|
108
|
+
type: "simy_cli_device",
|
|
109
|
+
device_id: deviceId,
|
|
110
|
+
account_id: accountId,
|
|
111
|
+
key_id: identity.keyId,
|
|
112
|
+
public_key: identity.publicKeyDer.toString("base64"),
|
|
113
|
+
},
|
|
114
|
+
issued_at: issuedAt,
|
|
115
|
+
integrity: { algorithm: "ed25519", signature },
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function verifySignedEvidence(proof, evidence) {
|
|
120
|
+
const proofDigest = verifyProof(proof);
|
|
121
|
+
if (evidence?.proof_id !== proof.proof_id || evidence?.proof_digest !== proofDigest || evidence?.organization_id !== proof.organization_id || evidence?.repository !== proof.repository_identity.repository) throw new Error("SQM signed evidence is not bound to this proof.");
|
|
122
|
+
if (evidence?.integrity?.algorithm !== "ed25519") throw new Error("SQM signed evidence uses an unsupported algorithm.");
|
|
123
|
+
const publicKeyDer = Buffer.from(String(evidence?.signer?.public_key || ""), "base64");
|
|
124
|
+
const keyId = `sha256:${createHash("sha256").update(publicKeyDer).digest("hex")}`;
|
|
125
|
+
if (evidence?.signer?.key_id !== keyId) throw new Error("SQM signed evidence key ID does not match its public key.");
|
|
126
|
+
const publicKey = createPublicKey({ key: publicKeyDer, format: "der", type: "spki" });
|
|
127
|
+
const signature = Buffer.from(String(evidence?.integrity?.signature || ""), "base64");
|
|
128
|
+
if (signature.length !== 64 || !verify(null, Buffer.from(proofDigest.slice("sha256:".length), "hex"), publicKey, signature)) throw new Error("SQM per-run signature verification failed.");
|
|
129
|
+
return { proof_digest: proofDigest, key_id: keyId, valid: true };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function loadOrCreateSigningIdentity(location) {
|
|
133
|
+
try {
|
|
134
|
+
const parsed = JSON.parse(await readFile(location, "utf8"));
|
|
135
|
+
const privateKey = createPrivateKey({ key: Buffer.from(parsed.private_key, "base64"), format: "der", type: "pkcs8" });
|
|
136
|
+
const publicKeyDer = Buffer.from(parsed.public_key, "base64");
|
|
137
|
+
const keyId = `sha256:${createHash("sha256").update(publicKeyDer).digest("hex")}`;
|
|
138
|
+
if (parsed.key_id !== keyId) throw new Error("stored key ID mismatch");
|
|
139
|
+
return { privateKey, publicKeyDer, keyId };
|
|
140
|
+
} catch (error) {
|
|
141
|
+
if (error?.code !== "ENOENT") throw new Error(`SQM signing identity is invalid: ${error.message}`);
|
|
142
|
+
}
|
|
143
|
+
const pair = generateKeyPairSync("ed25519");
|
|
144
|
+
const privateKeyDer = pair.privateKey.export({ format: "der", type: "pkcs8" });
|
|
145
|
+
const publicKeyDer = pair.publicKey.export({ format: "der", type: "spki" });
|
|
146
|
+
const keyId = `sha256:${createHash("sha256").update(publicKeyDer).digest("hex")}`;
|
|
147
|
+
await mkdir(path.dirname(location), { recursive: true, mode: 0o700 });
|
|
148
|
+
await writeFile(location, `${JSON.stringify({ algorithm: "ed25519", key_id: keyId, private_key: privateKeyDer.toString("base64"), public_key: publicKeyDer.toString("base64"), created_at: new Date().toISOString() }, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
|
|
149
|
+
return { privateKey: pair.privateKey, publicKeyDer, keyId };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function taggedDigest(value) {
|
|
153
|
+
return `sha256:${createHash("sha256").update(canonicalize(value), "utf8").digest("hex")}`;
|
|
154
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { lstat, readFile } from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { promisify } from "node:util";
|
|
6
|
+
|
|
7
|
+
import { normalizeGitHubRemote } from "../workspace-context.js";
|
|
8
|
+
|
|
9
|
+
const execFileAsync = promisify(execFile);
|
|
10
|
+
const MAX_UNTRACKED_FILE_BYTES = 1024 * 1024;
|
|
11
|
+
const MAX_UNTRACKED_TOTAL_BYTES = 10 * 1024 * 1024;
|
|
12
|
+
const MAX_UNTRACKED_FILES = 1_000;
|
|
13
|
+
const MAX_UNTRACKED_LINES = 100_000;
|
|
14
|
+
const UTF8_DECODER = new TextDecoder("utf-8", { fatal: true });
|
|
15
|
+
|
|
16
|
+
export async function inspectRepository(cwd, { baseBranch = "dev", runCommand = execFileAsync } = {}) {
|
|
17
|
+
const root = await git(runCommand, ["rev-parse", "--show-toplevel"], cwd);
|
|
18
|
+
const remote = await git(runCommand, ["remote", "get-url", "origin"], root);
|
|
19
|
+
const repository = normalizeGitHubRemote(remote);
|
|
20
|
+
if (!repository) throw new Error("SQM requires a GitHub origin remote in owner/repository form.");
|
|
21
|
+
const baseRef = await resolveBaseRef(runCommand, root, baseBranch);
|
|
22
|
+
const baseCommit = await git(runCommand, ["merge-base", baseRef, "HEAD"], root);
|
|
23
|
+
const headCommit = await git(runCommand, ["rev-parse", "HEAD"], root);
|
|
24
|
+
const committed = await git(runCommand, ["diff", "--unified=0", "--no-color", `${baseRef}...HEAD`], root);
|
|
25
|
+
const working = await git(runCommand, ["diff", "--unified=0", "--no-color", "HEAD"], root);
|
|
26
|
+
const untracked = zeroSeparated(
|
|
27
|
+
await git(runCommand, ["ls-files", "-z", "--others", "--exclude-standard"], root),
|
|
28
|
+
);
|
|
29
|
+
const files = zeroSeparated(
|
|
30
|
+
await git(
|
|
31
|
+
runCommand,
|
|
32
|
+
["ls-files", "-z", "--cached", "--others", "--exclude-standard"],
|
|
33
|
+
root,
|
|
34
|
+
),
|
|
35
|
+
);
|
|
36
|
+
const trackedDiff = parseUnifiedDiff(`${committed}\n${working}`);
|
|
37
|
+
const untrackedDiff = await untrackedAddedDiff(root, untracked);
|
|
38
|
+
const diff = [...trackedDiff, ...untrackedDiff];
|
|
39
|
+
const workingTreeDigest = digest({ working, untracked: untrackedDiff });
|
|
40
|
+
return {
|
|
41
|
+
root,
|
|
42
|
+
repository,
|
|
43
|
+
base_ref: baseRef,
|
|
44
|
+
base_commit: baseCommit,
|
|
45
|
+
head_commit: headCommit,
|
|
46
|
+
committed_diff_digest: digest(committed),
|
|
47
|
+
working_tree_digest: workingTreeDigest,
|
|
48
|
+
diff_digest: digest(diff),
|
|
49
|
+
diff,
|
|
50
|
+
paths: [...new Set(files)],
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function digest(value) {
|
|
55
|
+
const encoded = typeof value === "string" ? value : JSON.stringify(value);
|
|
56
|
+
return `sha256:${createHash("sha256").update(encoded).digest("hex")}`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function untrackedAddedDiff(root, files) {
|
|
60
|
+
const result = [];
|
|
61
|
+
let totalBytes = 0;
|
|
62
|
+
for (const relative of files.slice(0, MAX_UNTRACKED_FILES)) {
|
|
63
|
+
const resolved = pathInsideRoot(root, relative);
|
|
64
|
+
if (!resolved) continue;
|
|
65
|
+
let fileStat;
|
|
66
|
+
try {
|
|
67
|
+
fileStat = await lstat(resolved);
|
|
68
|
+
} catch {
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
if (
|
|
72
|
+
!fileStat.isFile() ||
|
|
73
|
+
fileStat.isSymbolicLink() ||
|
|
74
|
+
fileStat.size > MAX_UNTRACKED_FILE_BYTES ||
|
|
75
|
+
totalBytes + fileStat.size > MAX_UNTRACKED_TOTAL_BYTES
|
|
76
|
+
) {
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
let buffer;
|
|
80
|
+
let content;
|
|
81
|
+
try {
|
|
82
|
+
buffer = await readFile(resolved);
|
|
83
|
+
if (buffer.includes(0)) continue;
|
|
84
|
+
content = UTF8_DECODER.decode(buffer);
|
|
85
|
+
} catch {
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
const textLines = content.split(/\r?\n/);
|
|
89
|
+
if (textLines.length > MAX_UNTRACKED_LINES) continue;
|
|
90
|
+
if (textLines.at(-1) === "") textLines.pop();
|
|
91
|
+
totalBytes += buffer.length;
|
|
92
|
+
result.push({
|
|
93
|
+
path: relative,
|
|
94
|
+
lines: textLines.map((text, index) => ({ side: "added", line: index + 1, text })),
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
return result;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function parseUnifiedDiff(raw) {
|
|
101
|
+
const files = new Map();
|
|
102
|
+
let current = null;
|
|
103
|
+
let oldPath = null;
|
|
104
|
+
let oldLine = 0;
|
|
105
|
+
let newLine = 0;
|
|
106
|
+
for (const line of String(raw || "").split(/\r?\n/)) {
|
|
107
|
+
if (line.startsWith("--- ")) {
|
|
108
|
+
const candidate = line.slice(4).replace(/^a\//, "");
|
|
109
|
+
oldPath = candidate === "/dev/null" ? null : candidate;
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
if (line.startsWith("+++ ")) {
|
|
113
|
+
const candidate = line.slice(4).replace(/^b\//, "");
|
|
114
|
+
current = candidate === "/dev/null" ? oldPath : candidate;
|
|
115
|
+
if (current && !files.has(current)) files.set(current, []);
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
const hunk = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(line);
|
|
119
|
+
if (hunk) {
|
|
120
|
+
oldLine = Number(hunk[1]);
|
|
121
|
+
newLine = Number(hunk[2]);
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
if (!current || line.startsWith("diff --git ")) continue;
|
|
125
|
+
if (line.startsWith("+") && !line.startsWith("+++")) {
|
|
126
|
+
files.get(current).push({ side: "added", line: newLine, text: line.slice(1) });
|
|
127
|
+
newLine += 1;
|
|
128
|
+
} else if (line.startsWith("-") && !line.startsWith("---")) {
|
|
129
|
+
files.get(current).push({ side: "removed", line: oldLine, text: line.slice(1) });
|
|
130
|
+
oldLine += 1;
|
|
131
|
+
} else if (!line.startsWith("\\")) {
|
|
132
|
+
oldLine += 1;
|
|
133
|
+
newLine += 1;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return [...files.entries()].map(([path, lines]) => ({ path, lines }));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function resolveBaseRef(runCommand, cwd, baseBranch) {
|
|
140
|
+
for (const ref of [`origin/${baseBranch}`, baseBranch]) {
|
|
141
|
+
try {
|
|
142
|
+
await git(runCommand, ["rev-parse", "--verify", ref], cwd);
|
|
143
|
+
return ref;
|
|
144
|
+
} catch {
|
|
145
|
+
// Try the local branch after origin/<branch>.
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
throw new Error(`SQM could not resolve base branch ${baseBranch}.`);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function git(runCommand, args, cwd) {
|
|
152
|
+
const result = await runCommand("git", args, { cwd, maxBuffer: 10 * 1024 * 1024 });
|
|
153
|
+
return String(result?.stdout || "").trimEnd();
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function zeroSeparated(value) {
|
|
157
|
+
return String(value || "").split("\0").filter(Boolean);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function pathInsideRoot(root, relative) {
|
|
161
|
+
const resolved = path.resolve(root, relative);
|
|
162
|
+
return resolved !== root && resolved.startsWith(`${root}${path.sep}`) ? resolved : null;
|
|
163
|
+
}
|