Skip to content

Commit 4bc4910

Browse files
committed
fix(approvals): wire trusted admission lifecycle
1 parent 1b5b3ef commit 4bc4910

20 files changed

Lines changed: 1329 additions & 195 deletions
Lines changed: 143 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,24 @@
11
#!/usr/bin/env node
2+
import { execFile } from 'node:child_process';
23
import { createHash } from 'node:crypto';
34
import { createReadStream } from 'node:fs';
45
import { access, readFile } from 'node:fs/promises';
5-
import { dirname, resolve } from 'node:path';
6+
import { basename, dirname, resolve } from 'node:path';
7+
import { promisify } from 'node:util';
68
import { fileURLToPath } from 'node:url';
79

10+
const run = promisify(execFile);
811
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
912
const lock = JSON.parse(await readFile(resolve(root, 'opencode-hosted-runtime.lock.json'), 'utf8'));
10-
const provenance = JSON.parse(
11-
await readFile(resolve(root, 'opencode-hosted-runtime.provenance.json'), 'utf8')
12-
);
13-
const candidatePath = process.argv[2] ? resolve(process.argv[2]) : null;
13+
const candidateManifestPath = process.argv[2] ? resolve(process.argv[2]) : null;
14+
const failures = [];
15+
16+
function check(value, message) {
17+
if (!value) failures.push(message);
18+
}
1419

15-
function invariant(value, message) {
16-
if (!value) throw new Error(`hosted-opencode-provenance-invalid:${message}`);
20+
async function exists(path) {
21+
return access(path).then(() => true, () => false);
1722
}
1823

1924
async function sha256File(path) {
@@ -22,94 +27,149 @@ async function sha256File(path) {
2227
return hash.digest('hex');
2328
}
2429

25-
invariant(provenance.schemaVersion === 1, 'schema');
26-
invariant(provenance.assets.length === 5, 'asset-count');
27-
invariant(lock.productionEligible === false, 'eligibility');
28-
invariant(provenance.release.productionEligible === false, 'provenance-eligibility');
29-
invariant(lock.releaseRepository === provenance.release.repository, 'repository');
30-
invariant(lock.version === provenance.release.version, 'version');
31-
invariant(lock.tag === provenance.release.tag && lock.tag === `v${lock.version}`, 'tag');
32-
invariant(lock.source.commit === provenance.release.sourceCommit, 'source-commit');
33-
invariant(lock.source.baseCommit === provenance.release.baseCommit, 'base-commit');
34-
invariant(lock.source.reviewedPatchSha256 === provenance.release.patchSha256, 'patch');
30+
async function sha256ArchiveBinary(path, asset) {
31+
const archiveKind = path.endsWith('.tar.gz') ? 'tar.gz' : path.endsWith('.zip') ? 'zip' : null;
32+
if (archiveKind === null) throw new Error('archive-kind');
33+
const executable = archiveKind === 'tar.gz' ? '/usr/bin/tar' : '/usr/bin/unzip';
34+
const args =
35+
archiveKind === 'tar.gz'
36+
? ['-xOzf', path, asset.binaryPath]
37+
: ['-p', path, asset.binaryPath];
38+
const child = await run(executable, args, {
39+
encoding: 'buffer',
40+
maxBuffer: Math.max(asset.binarySize + 1024, 256 * 1024 * 1024),
41+
});
42+
return createHash('sha256').update(child.stdout).digest('hex');
43+
}
3544

36-
for (const asset of provenance.assets) {
37-
const locked = lock.platforms[asset.platform];
38-
invariant(locked?.status === 'available', `lock-platform:${asset.platform}`);
39-
invariant(locked.file === asset.archive, `archive-name:${asset.platform}`);
40-
invariant(locked.archiveSha256 === asset.archiveSha256, `archive-hash:${asset.platform}`);
41-
invariant(locked.binaryName === asset.binary, `binary-name:${asset.platform}`);
42-
invariant(locked.binarySha256 === asset.binarySha256, `binary-hash:${asset.platform}`);
43-
invariant(
44-
locked.assetUrl ===
45-
`https://github.com/${provenance.release.repository}/releases/download/${provenance.release.tag}/${asset.archive}`,
46-
`tag-url:${asset.platform}`
47-
);
45+
async function verifyAttestation(subjectPath, repository, platform) {
46+
const attestationPath = `${subjectPath}.intoto.jsonl`;
47+
if (!(await exists(attestationPath))) {
48+
failures.push(`materialized-attestation-missing:${platform}`);
49+
return;
50+
}
51+
try {
52+
await run('/usr/bin/gh', [
53+
'attestation',
54+
'verify',
55+
subjectPath,
56+
'--repo',
57+
repository,
58+
'--bundle',
59+
attestationPath,
60+
]);
61+
} catch {
62+
failures.push(`materialized-attestation-invalid:${platform}`);
63+
}
4864
}
4965

50-
if (candidatePath) {
51-
const candidateBytes = await readFile(candidatePath);
52-
invariant(
53-
createHash('sha256').update(candidateBytes).digest('hex') ===
54-
provenance.candidateManifestSha256,
55-
'candidate-manifest-hash'
56-
);
57-
const candidate = JSON.parse(candidateBytes.toString('utf8'));
58-
invariant(candidate.release.sourceCommit === provenance.release.sourceCommit, 'candidate-commit');
59-
invariant(candidate.release.sourceTree === provenance.release.sourceTree, 'candidate-tree');
60-
invariant(candidate.release.baseCommit === provenance.release.baseCommit, 'candidate-base');
61-
invariant(candidate.release.patchSha256 === provenance.release.patchSha256, 'candidate-patch');
62-
invariant(candidate.release.tag === provenance.release.tag, 'candidate-tag');
63-
invariant(candidate.release.productionEligible === false, 'candidate-eligibility');
64-
const candidateAssets = new Map(
65-
candidate.assets.map((asset) => [
66-
`${asset.os === 'windows' ? 'win32' : asset.os}-${asset.arch}`,
67-
asset,
68-
])
66+
check(candidateManifestPath !== null, 'candidate-manifest-required');
67+
if (candidateManifestPath === null) {
68+
throw new Error(`hosted-opencode-provenance-invalid:${failures.join(',')}`);
69+
}
70+
71+
const candidateBytes = await readFile(candidateManifestPath);
72+
const candidate = JSON.parse(candidateBytes.toString('utf8'));
73+
const candidateDirectory = dirname(candidateManifestPath);
74+
const manifestDigest = createHash('sha256').update(candidateBytes).digest('hex');
75+
76+
check(
77+
manifestDigest === '608adf3705a367415e0811469bedd41f388034b7e2ea4e42bfa36895593a8486',
78+
'candidate-manifest-hash'
79+
);
80+
check(candidate.schemaVersion === 1, 'candidate-schema');
81+
check(candidate.release?.productionEligible === false, 'candidate-eligibility');
82+
check(candidate.workflow?.repository === '777genius/opencode-anomaly', 'candidate-repository');
83+
check(candidate.workflow?.workflow === 'hardened CLI prerelease', 'candidate-workflow');
84+
check(/^31824308795$/.test(candidate.workflow?.runId ?? ''), 'candidate-workflow-run');
85+
check(candidate.workflow?.runAttempt === '1', 'candidate-workflow-attempt');
86+
check(candidate.workflow?.actor === '777genius', 'candidate-workflow-actor');
87+
check(candidate.workflow?.ref === 'refs/pull/2/merge', 'candidate-workflow-ref');
88+
check(
89+
candidate.workflow?.sha === 'a9145f4407abe4cbfefe6703cb53389a56293844',
90+
'candidate-workflow-sha'
91+
);
92+
check(candidate.release?.tag === `v${candidate.release?.version}`, 'candidate-tag');
93+
check(
94+
candidate.release?.sourceCommit === '476b667c385210b19fbd15bcb57456cacb0ae9e7',
95+
'candidate-source-commit'
96+
);
97+
check(
98+
candidate.release?.sourceTree === '122dd7d77fd01f1a054ac52666a1e9a8a5529dcb',
99+
'candidate-source-tree'
100+
);
101+
check(
102+
candidate.release?.baseCommit === '49c69c5ed3ccf706b61b3febb43c8aaff7f8325e',
103+
'candidate-base-commit'
104+
);
105+
check(
106+
candidate.release?.patchSha256 ===
107+
'dbd8b2c1eda38043e3bfc9e2b809f4ef393fa075349ed219109a7deaca0c590e',
108+
'candidate-patch'
109+
);
110+
check(Array.isArray(candidate.assets) && candidate.assets.length === 5, 'candidate-assets');
111+
await verifyAttestation(candidateManifestPath, candidate.workflow?.repository, 'release-manifest');
112+
const patchPath = resolve(candidateDirectory, 'reviewed.patch');
113+
if (!(await exists(patchPath))) {
114+
failures.push('materialized-reviewed-patch-missing');
115+
} else {
116+
check((await sha256File(patchPath)) === candidate.release?.patchSha256, 'materialized-patch-hash');
117+
}
118+
119+
check(lock.productionEligible === false, 'lock-eligibility');
120+
check(lock.releaseRepository === candidate.workflow?.repository, 'lock-repository');
121+
check(lock.version === candidate.release?.version, 'lock-version');
122+
check(lock.tag === candidate.release?.tag, 'lock-tag');
123+
check(lock.source?.commit === candidate.release?.sourceCommit, 'lock-source-commit');
124+
check(lock.source?.baseCommit === candidate.release?.baseCommit, 'lock-base-commit');
125+
check(lock.source?.reviewedPatchSha256 === candidate.release?.patchSha256, 'lock-patch');
126+
127+
const platforms = new Set();
128+
129+
for (const asset of candidate.assets ?? []) {
130+
const platform = `${asset.os === 'windows' ? 'win32' : asset.os}-${asset.arch}`;
131+
check(!platforms.has(platform), `candidate-platform-duplicate:${platform}`);
132+
platforms.add(platform);
133+
const locked = lock.platforms?.[platform];
134+
check(locked?.status === 'available', `lock-platform:${platform}`);
135+
check(locked?.file === asset.archive, `lock-archive:${platform}`);
136+
check(locked?.archiveSha256 === asset.archiveSha256, `lock-archive-hash:${platform}`);
137+
check(locked?.binaryName === basename(asset.binaryPath), `lock-binary:${platform}`);
138+
check(locked?.binarySha256 === asset.binarySha256, `lock-binary-hash:${platform}`);
139+
check(
140+
locked?.assetUrl ===
141+
`https://github.com/${candidate.workflow.repository}/releases/download/${candidate.release.tag}/${asset.archive}`,
142+
`lock-tag-url:${platform}`
69143
);
70-
for (const expected of provenance.assets) {
71-
const actual = candidateAssets.get(expected.platform);
72-
invariant(actual?.archive === expected.archive, `candidate-archive:${expected.platform}`);
73-
invariant(
74-
actual.archiveSha256 === expected.archiveSha256,
75-
`candidate-archive-hash:${expected.platform}`
76-
);
77-
invariant(actual.binaryPath === expected.binary, `candidate-binary:${expected.platform}`);
78-
invariant(
79-
actual.binarySha256 === expected.binarySha256,
80-
`candidate-binary-hash:${expected.platform}`
81-
);
82-
}
83-
const candidateDirectory = dirname(candidatePath);
84-
for (const expected of provenance.assets) {
85-
const archivePath = resolve(candidateDirectory, expected.archive);
86-
try {
87-
await access(archivePath);
88-
invariant(
89-
(await sha256File(archivePath)) === expected.archiveSha256,
90-
`materialized-archive:${expected.platform}`
91-
);
92-
} catch (error) {
93-
if (error?.code !== 'ENOENT') throw error;
94-
}
144+
const archivePath = resolve(candidateDirectory, asset.archive);
145+
if (!(await exists(archivePath))) {
146+
failures.push(`materialized-archive-missing:${platform}`);
147+
continue;
95148
}
96-
const linuxBinary = resolve(candidateDirectory, 'opencode');
149+
check((await sha256File(archivePath)) === asset.archiveSha256, `materialized-archive-hash:${platform}`);
97150
try {
98-
await access(linuxBinary);
99-
const expected = provenance.assets.find((asset) => asset.platform === 'linux-x64');
100-
invariant(
101-
(await sha256File(linuxBinary)) === expected.binarySha256,
102-
'materialized-linux-x64-binary'
151+
check(
152+
(await sha256ArchiveBinary(archivePath, asset)) === asset.binarySha256,
153+
`materialized-binary-hash:${platform}`
103154
);
104-
} catch (error) {
105-
if (error?.code !== 'ENOENT') throw error;
155+
} catch {
156+
failures.push(`materialized-binary-unverifiable:${platform}`);
106157
}
158+
await verifyAttestation(archivePath, candidate.workflow.repository, platform);
107159
}
108160

161+
if (failures.length > 0) {
162+
throw new Error(`hosted-opencode-provenance-invalid:${failures.join(',')}`);
163+
}
109164
process.stdout.write(
110165
`${JSON.stringify({
111166
verified: true,
112-
assets: provenance.assets.length,
113-
candidate: candidatePath !== null,
167+
manifestSha256: manifestDigest,
168+
repository: candidate.workflow.repository,
169+
sourceCommit: candidate.release.sourceCommit,
170+
sourceTree: candidate.release.sourceTree,
171+
workflowRunId: candidate.workflow.runId,
172+
tag: candidate.release.tag,
173+
assets: candidate.assets.length,
114174
})}\n`
115175
);
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import { createHash } from 'node:crypto';
2+
3+
import { parseRuntimePermissionApprovalIngressAuthority } from '@features/team-runtime-control/contracts';
4+
5+
import { readHostedAdmissionExactRecord as readExactRecord } from './hostedAdmissionExactRecord';
6+
7+
import type { HostedApprovalAdmissionPin } from './hostedApprovalAdmissionPin';
8+
import type { HostedApprovalOwnerRoute } from './hostedApprovalOwnerRouteCatalog';
9+
10+
export function validateHostedApprovalAdmissionSnapshotPin(
11+
pin: HostedApprovalAdmissionPin,
12+
value: unknown,
13+
routes?: readonly HostedApprovalOwnerRoute[]
14+
): void {
15+
if (pin.state !== 'active') {
16+
if (value !== null) {
17+
throw new TypeError('hosted-lifecycle-approval-snapshot-unexpected');
18+
}
19+
return;
20+
}
21+
const snapshot = readExactRecord(value, [
22+
'schemaVersion',
23+
'approvalGeneration',
24+
'authorities',
25+
]);
26+
if (
27+
snapshot.schemaVersion !== 1 ||
28+
snapshot.approvalGeneration !== pin.approvalGeneration ||
29+
!Array.isArray(snapshot.authorities) ||
30+
snapshot.authorities.length === 0 ||
31+
snapshot.authorities.length > 256
32+
) {
33+
throw new TypeError('hosted-lifecycle-approval-snapshot-invalid');
34+
}
35+
const authorities = snapshot.authorities.map(parseRuntimePermissionApprovalIngressAuthority);
36+
const identities = authorities.map(
37+
(authority) =>
38+
`${authority.teamId}\0${authority.runId}\0${authority.laneId}\0${authority.sessionId}`
39+
);
40+
if (new Set(identities).size !== identities.length) {
41+
throw new TypeError('hosted-lifecycle-approval-snapshot-invalid');
42+
}
43+
const canonical = JSON.stringify({
44+
schemaVersion: 1,
45+
approvalGeneration: pin.approvalGeneration,
46+
authorities,
47+
});
48+
const digest = `sha256:${createHash('sha256').update(canonical).digest('hex')}`;
49+
if (digest !== pin.approvalDigest) {
50+
throw new TypeError('hosted-lifecycle-approval-snapshot-invalid');
51+
}
52+
if (routes) validateHostedApprovalSnapshotRoutes(value, routes);
53+
}
54+
55+
function validateHostedApprovalSnapshotRoutes(
56+
snapshot: unknown,
57+
routes: readonly HostedApprovalOwnerRoute[]
58+
): void {
59+
const record = readExactRecord(snapshot, [
60+
'schemaVersion',
61+
'approvalGeneration',
62+
'authorities',
63+
]);
64+
if (!Array.isArray(record.authorities)) {
65+
throw new TypeError('hosted-lifecycle-approval-snapshot-invalid');
66+
}
67+
const snapshotTeams = [
68+
...new Set(
69+
record.authorities.map(
70+
(authority) => parseRuntimePermissionApprovalIngressAuthority(authority).teamId
71+
)
72+
),
73+
].toSorted();
74+
const routeTeams = routes.map((route) => route.teamId).toSorted();
75+
if (
76+
snapshotTeams.length !== routeTeams.length ||
77+
snapshotTeams.some((teamId, index) => teamId !== routeTeams[index])
78+
) {
79+
throw new TypeError('hosted-lifecycle-owner-approval-route-snapshot-mismatch');
80+
}
81+
}

src/main/composition/hosted/hostedLifecycleProductionOwnerAdmission.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
type HostedApprovalAdmissionPin,
2222
parseHostedApprovalAdmissionPin,
2323
} from './hostedApprovalAdmissionPin';
24+
import { validateHostedApprovalAdmissionSnapshotPin } from './hostedApprovalAdmissionSnapshot';
2425
import {
2526
type HostedApprovalOwnerRoute,
2627
parseHostedApprovalOwnerRoutes,
@@ -588,6 +589,7 @@ function parseAdmissionPayload(
588589
approvalAdmission,
589590
})
590591
: Object.freeze([]);
592+
validateHostedApprovalAdmissionSnapshotPin(approvalAdmission, approvalSnapshot, version === 4 ? approvalRoutes : undefined);
591593
if (bootstrapBinding.ownerArtifactDigest !== artifact.artifactDigest) {
592594
throw new TypeError('hosted-lifecycle-owner-admission-artifact-binding-invalid');
593595
}

src/main/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -288,7 +288,7 @@ import {
288288
BranchStatusService,
289289
ClaudeBinaryResolver,
290290
CliInstallerService,
291-
configManager,
291+
configManager, createProductOwnedTeamProvisioningService,
292292
LocalFileSystemProvider,
293293
MemberStatsComputer,
294294
NotificationManager,
@@ -2085,7 +2085,7 @@ async function initializeServices(): Promise<void> {
20852085
teamDataService.setTaskCommentNotificationJournalStore(
20862086
internalStorageFeature.taskCommentNotificationJournalStore
20872087
);
2088-
teamProvisioningService = new TeamProvisioningService();
2088+
teamProvisioningService = createProductOwnedTeamProvisioningService(getTeamsBasePath(), getAppDataPath());
20892089
const teamFeatureCapabilitySources =
20902090
createDesktopTeamFeatureCapabilitySources(teamProvisioningService);
20912091
const teamDiagnosticsApi = teamFeatureCapabilitySources.diagnostics;

0 commit comments

Comments
 (0)