forgeos 0.1.0-alpha.63 → 0.1.0-alpha.65
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/AGENTS.md +1 -1
- package/CHANGELOG.md +29 -0
- package/docs/agent-fabric.md +416 -0
- package/package.json +7 -4
- package/schemas/agent-fabric/v0.1/control-event.schema.json +281 -0
- package/scripts/field-test-forgeos.mjs +11 -2
- package/src/forge/_generated/releaseManifest.json +1 -1
- package/src/forge/_generated/releaseManifest.ts +3 -3
- package/src/forge/agent-adapters/index.ts +18 -14
- package/src/forge/agent-adapters/types.ts +1 -1
- package/src/forge/agent-fabric/adapter.ts +131 -0
- package/src/forge/agent-fabric/authority.ts +298 -0
- package/src/forge/agent-fabric/canonical.ts +135 -0
- package/src/forge/agent-fabric/conductor.ts +754 -0
- package/src/forge/agent-fabric/dictionary.ts +12 -0
- package/src/forge/agent-fabric/errors.ts +32 -0
- package/src/forge/agent-fabric/hardened-conductor.ts +485 -0
- package/src/forge/agent-fabric/hardened-reducer.ts +211 -0
- package/src/forge/agent-fabric/index.ts +22 -0
- package/src/forge/agent-fabric/journal.ts +116 -0
- package/src/forge/agent-fabric/local-adaptive-digest-worker.mjs +26 -0
- package/src/forge/agent-fabric/local-adaptive-harness.ts +343 -0
- package/src/forge/agent-fabric/local-adaptive-service.ts +398 -0
- package/src/forge/agent-fabric/local-adaptive-worker.ts +146 -0
- package/src/forge/agent-fabric/local-approval-window.ts +211 -0
- package/src/forge/agent-fabric/local-coding-worker.ts +285 -0
- package/src/forge/agent-fabric/local-control-store.ts +239 -0
- package/src/forge/agent-fabric/local-effect-broker.ts +348 -0
- package/src/forge/agent-fabric/local-evolution-approval.ts +81 -0
- package/src/forge/agent-fabric/local-evolution-profile.ts +87 -0
- package/src/forge/agent-fabric/local-evolution-registry.ts +427 -0
- package/src/forge/agent-fabric/local-evolution-service.ts +200 -0
- package/src/forge/agent-fabric/local-intelligence.ts +281 -0
- package/src/forge/agent-fabric/local-paths.ts +18 -0
- package/src/forge/agent-fabric/local-task-contract.ts +203 -0
- package/src/forge/agent-fabric/local-task-inbox.ts +543 -0
- package/src/forge/agent-fabric/local-task-server.ts +249 -0
- package/src/forge/agent-fabric/local-task-service.ts +982 -0
- package/src/forge/agent-fabric/local-verification.ts +551 -0
- package/src/forge/agent-fabric/p0a.ts +104 -0
- package/src/forge/agent-fabric/p0b-model-adapter.ts +434 -0
- package/src/forge/agent-fabric/planning.ts +155 -0
- package/src/forge/agent-fabric/reducer.ts +863 -0
- package/src/forge/agent-fabric/resource-ledger.ts +266 -0
- package/src/forge/agent-fabric/serialized-local-adapter.ts +60 -0
- package/src/forge/agent-fabric/types.ts +424 -0
- package/src/forge/agent-fabric/validation.ts +500 -0
- package/src/forge/agent-memory/bridge.ts +223 -90
- package/src/forge/agent-memory/mcp.ts +71 -9
- package/src/forge/agent-memory/sources/codex-hook-runner.mjs +77 -116
- package/src/forge/cli/adaptive.ts +51 -0
- package/src/forge/cli/changed.ts +13 -32
- package/src/forge/cli/commands.ts +26 -4
- package/src/forge/cli/evolution.ts +40 -0
- package/src/forge/cli/fabric.ts +131 -0
- package/src/forge/cli/main.ts +24 -0
- package/src/forge/cli/new.ts +16 -4
- package/src/forge/cli/parse.ts +61 -0
- package/src/forge/cli/studio.ts +141 -77
- package/src/forge/cli/verify.ts +4 -1
- package/src/forge/compiler/app-graph/tsconfig-hash.ts +41 -4
- package/src/forge/compiler/package-graph/dts-extractor.ts +14 -1
- package/src/forge/dev-console/cycle.ts +6 -1
- package/src/forge/impact/index.ts +18 -21
- package/src/forge/review/index.ts +6 -2
- package/src/forge/runtime/ai/providers.ts +19 -4
- package/src/forge/version.ts +1 -1
- package/src/forge/workspace/change-summary.ts +37 -7
- package/src/forge/workspace/git-summary.ts +27 -25
- package/templates/agent-workroom/package.json +1 -0
- package/templates/agent-workroom/pnpm-workspace.yaml +2 -0
- package/templates/b2b-support-web/package.json +1 -0
- package/templates/b2b-support-web/pnpm-workspace.yaml +2 -0
- package/templates/b2b-support-web/web/package.json +1 -1
- package/templates/minimal-web/package.json +1 -0
- package/templates/minimal-web/pnpm-workspace.yaml +2 -0
- package/templates/nuxt-web/.yarnrc.yml +1 -0
- package/templates/nuxt-web/package.json +1 -0
- package/templates/nuxt-web/pnpm-workspace.yaml +2 -0
- package/templates/nuxt-web/web/package.json +1 -0
- package/templates/vendor-access/package.json +1 -0
- package/templates/vendor-access/pnpm-workspace.yaml +2 -0
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import { getOwn, setOwn } from "./dictionary.ts";
|
|
2
|
+
import { AgentFabricError } from "./errors.ts";
|
|
3
|
+
import { ResourceLedger } from "./resource-ledger.ts";
|
|
4
|
+
import type {
|
|
5
|
+
AuthorityResolution,
|
|
6
|
+
ControlState,
|
|
7
|
+
DerivedGrantRequest,
|
|
8
|
+
EffectClass,
|
|
9
|
+
ExecutionGrant,
|
|
10
|
+
OwnerAuthorization,
|
|
11
|
+
} from "./types.ts";
|
|
12
|
+
|
|
13
|
+
function isSubset<T>(child: readonly T[], parent: readonly T[]): boolean {
|
|
14
|
+
const allowed = new Set(parent);
|
|
15
|
+
return child.every((value) => allowed.has(value));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function reject(...reasonCodes: string[]): AuthorityResolution {
|
|
19
|
+
return { outcome: "rejected", reasonCodes, limitations: [] };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function rootGrantAuthorizationViolations(
|
|
23
|
+
authorization: OwnerAuthorization,
|
|
24
|
+
grant: ExecutionGrant,
|
|
25
|
+
): readonly string[] {
|
|
26
|
+
const violations: string[] = [];
|
|
27
|
+
if (grant.parentGrantId !== null) violations.push("root_grant_has_parent");
|
|
28
|
+
if (grant.rootAuthorizationId !== authorization.authorizationId) {
|
|
29
|
+
violations.push("root_authorization_mismatch");
|
|
30
|
+
}
|
|
31
|
+
if (authorization.rootExecutionId.length === 0) violations.push("authorization_missing_execution");
|
|
32
|
+
if (!authorization.subjectIds.includes(grant.subjectId)) violations.push("subject_not_authorized");
|
|
33
|
+
if (!isSubset(grant.capabilities, authorization.capabilities)) violations.push("capability_scope_expanded");
|
|
34
|
+
if (!isSubset(grant.sourceIds, authorization.sourceIds)) violations.push("source_scope_expanded");
|
|
35
|
+
if (!isSubset(grant.targetIds, authorization.targetIds)) violations.push("target_scope_expanded");
|
|
36
|
+
if (!isSubset<EffectClass>(grant.effectClasses, authorization.effectClasses)) {
|
|
37
|
+
violations.push("effect_scope_expanded");
|
|
38
|
+
}
|
|
39
|
+
if (grant.notBefore < authorization.notBefore || grant.expiresAt > authorization.expiresAt) {
|
|
40
|
+
violations.push("time_scope_expanded");
|
|
41
|
+
}
|
|
42
|
+
if (grant.expiresAt <= grant.notBefore) violations.push("invalid_time_window");
|
|
43
|
+
if (grant.maximumAttempts <= 0 || grant.maximumAttempts > authorization.maximumAttempts) {
|
|
44
|
+
violations.push("attempt_limit_expanded");
|
|
45
|
+
}
|
|
46
|
+
if (
|
|
47
|
+
grant.delegationDepthRemaining < 0 ||
|
|
48
|
+
grant.delegationDepthRemaining > authorization.maximumDelegationDepth
|
|
49
|
+
) {
|
|
50
|
+
violations.push("delegation_depth_expanded");
|
|
51
|
+
}
|
|
52
|
+
for (const [resource, amount] of Object.entries(grant.resourceCeilings)) {
|
|
53
|
+
const ceiling = getOwn(authorization.resourceCeilings, resource);
|
|
54
|
+
if (!Number.isFinite(amount) || amount <= 0 || ceiling === undefined || amount > ceiling) {
|
|
55
|
+
violations.push(`resource_ceiling_expanded:${resource}`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return violations;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function assertRootGrantAuthorized(
|
|
62
|
+
authorization: OwnerAuthorization,
|
|
63
|
+
grant: ExecutionGrant,
|
|
64
|
+
): void {
|
|
65
|
+
const violations = rootGrantAuthorizationViolations(authorization, grant);
|
|
66
|
+
if (violations.length > 0) {
|
|
67
|
+
throw new AgentFabricError(
|
|
68
|
+
"AF_GRANT_REJECTED",
|
|
69
|
+
`Root grant ${grant.grantId} exceeds authorization ${authorization.authorizationId}`,
|
|
70
|
+
{ violations },
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function grantAttenuationViolations(
|
|
76
|
+
parent: ExecutionGrant,
|
|
77
|
+
child: ExecutionGrant,
|
|
78
|
+
): readonly string[] {
|
|
79
|
+
const violations: string[] = [];
|
|
80
|
+
if (child.parentGrantId !== parent.grantId) violations.push("parent_grant_mismatch");
|
|
81
|
+
if (child.rootAuthorizationId !== parent.rootAuthorizationId) {
|
|
82
|
+
violations.push("root_authorization_changed");
|
|
83
|
+
}
|
|
84
|
+
if (!child.reservationId) violations.push("derived_grant_missing_reservation");
|
|
85
|
+
if (!isSubset(child.capabilities, parent.capabilities)) violations.push("capability_scope_expanded");
|
|
86
|
+
if (!isSubset(child.sourceIds, parent.sourceIds)) violations.push("source_scope_expanded");
|
|
87
|
+
if (!isSubset(child.targetIds, parent.targetIds)) violations.push("target_scope_expanded");
|
|
88
|
+
if (!isSubset<EffectClass>(child.effectClasses, parent.effectClasses)) {
|
|
89
|
+
violations.push("effect_scope_expanded");
|
|
90
|
+
}
|
|
91
|
+
if (child.notBefore < parent.notBefore || child.expiresAt > parent.expiresAt) {
|
|
92
|
+
violations.push("time_scope_expanded");
|
|
93
|
+
}
|
|
94
|
+
if (child.expiresAt <= child.notBefore) violations.push("invalid_time_window");
|
|
95
|
+
if (child.maximumAttempts <= 0 || child.maximumAttempts > parent.maximumAttempts) {
|
|
96
|
+
violations.push("attempt_limit_expanded");
|
|
97
|
+
}
|
|
98
|
+
if (
|
|
99
|
+
child.delegationDepthRemaining < 0 ||
|
|
100
|
+
child.delegationDepthRemaining >= parent.delegationDepthRemaining
|
|
101
|
+
) {
|
|
102
|
+
violations.push("delegation_depth_not_attenuated");
|
|
103
|
+
}
|
|
104
|
+
for (const [resource, amount] of Object.entries(child.resourceCeilings)) {
|
|
105
|
+
const ceiling = getOwn(parent.resourceCeilings, resource);
|
|
106
|
+
if (!Number.isFinite(amount) || amount <= 0 || ceiling === undefined || amount > ceiling) {
|
|
107
|
+
violations.push(`resource_ceiling_expanded:${resource}`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return violations;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function assertGrantAttenuated(
|
|
114
|
+
parent: ExecutionGrant,
|
|
115
|
+
child: ExecutionGrant,
|
|
116
|
+
): void {
|
|
117
|
+
const violations = grantAttenuationViolations(parent, child);
|
|
118
|
+
if (violations.length > 0) {
|
|
119
|
+
throw new AgentFabricError(
|
|
120
|
+
"AF_GRANT_REJECTED",
|
|
121
|
+
`Derived grant ${child.grantId} is not attenuated from ${parent.grantId}`,
|
|
122
|
+
{ violations },
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function deriveExecutionGrant(
|
|
128
|
+
parent: ExecutionGrant,
|
|
129
|
+
request: DerivedGrantRequest,
|
|
130
|
+
ledger: ResourceLedger,
|
|
131
|
+
now: number,
|
|
132
|
+
): AuthorityResolution {
|
|
133
|
+
if (now < parent.notBefore || now >= parent.expiresAt) {
|
|
134
|
+
return reject("parent_grant_not_current");
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const aggregatedResourceRequests = Object.entries(
|
|
138
|
+
request.resourceRequests.reduce<Record<string, number>>((totals, resourceRequest) => {
|
|
139
|
+
setOwn(totals, resourceRequest.resource, (getOwn(totals, resourceRequest.resource) ?? 0) + resourceRequest.amount);
|
|
140
|
+
return totals;
|
|
141
|
+
}, {}),
|
|
142
|
+
).map(([resource, amount]) => ({ resource, amount }));
|
|
143
|
+
|
|
144
|
+
const candidate: ExecutionGrant = {
|
|
145
|
+
grantId: request.grantId,
|
|
146
|
+
rootAuthorizationId: parent.rootAuthorizationId,
|
|
147
|
+
subjectId: request.subjectId,
|
|
148
|
+
parentGrantId: parent.grantId,
|
|
149
|
+
capabilities: [...request.capabilities],
|
|
150
|
+
sourceIds: [...request.sourceIds],
|
|
151
|
+
targetIds: [...request.targetIds],
|
|
152
|
+
effectClasses: [...request.effectClasses],
|
|
153
|
+
notBefore: request.notBefore,
|
|
154
|
+
expiresAt: request.expiresAt,
|
|
155
|
+
maximumAttempts: request.maximumAttempts,
|
|
156
|
+
delegationDepthRemaining: request.delegationDepthRemaining,
|
|
157
|
+
resourceCeilings: Object.fromEntries(
|
|
158
|
+
aggregatedResourceRequests.map((item) => [item.resource, item.amount]),
|
|
159
|
+
),
|
|
160
|
+
reservationId: request.reservationId,
|
|
161
|
+
};
|
|
162
|
+
const violations = grantAttenuationViolations(parent, candidate);
|
|
163
|
+
if (violations.length > 0) return reject(...violations);
|
|
164
|
+
|
|
165
|
+
try {
|
|
166
|
+
const reserved = ledger.reserve(
|
|
167
|
+
request.reservationId,
|
|
168
|
+
parent.grantId,
|
|
169
|
+
aggregatedResourceRequests,
|
|
170
|
+
parent.resourceCeilings,
|
|
171
|
+
);
|
|
172
|
+
return {
|
|
173
|
+
outcome: "allowed",
|
|
174
|
+
reasonCodes: [],
|
|
175
|
+
limitations: [],
|
|
176
|
+
grant: {
|
|
177
|
+
...candidate,
|
|
178
|
+
resourceCeilings: Object.fromEntries(
|
|
179
|
+
reserved.requests.map((item) => [item.resource, item.amount]),
|
|
180
|
+
),
|
|
181
|
+
},
|
|
182
|
+
};
|
|
183
|
+
} catch (error) {
|
|
184
|
+
if (error instanceof AgentFabricError) {
|
|
185
|
+
return reject(error.code.toLowerCase());
|
|
186
|
+
}
|
|
187
|
+
return { outcome: "unknown", reasonCodes: ["resource_reservation_unknown"], limitations: [] };
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export function assertAuthorizationCurrent(
|
|
192
|
+
authorization: OwnerAuthorization,
|
|
193
|
+
now: number,
|
|
194
|
+
revokedReason?: string,
|
|
195
|
+
): void {
|
|
196
|
+
if (revokedReason) {
|
|
197
|
+
throw new AgentFabricError(
|
|
198
|
+
"AF_GRANT_REJECTED",
|
|
199
|
+
`Owner authorization ${authorization.authorizationId} is revoked`,
|
|
200
|
+
{ revokedReason },
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
if (now < authorization.notBefore || now >= authorization.expiresAt) {
|
|
204
|
+
throw new AgentFabricError(
|
|
205
|
+
"AF_GRANT_REJECTED",
|
|
206
|
+
`Owner authorization ${authorization.authorizationId} is outside its validity window`,
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export function assertGrantCurrent(
|
|
212
|
+
grant: ExecutionGrant,
|
|
213
|
+
now: number,
|
|
214
|
+
revokedReason?: string,
|
|
215
|
+
): void {
|
|
216
|
+
if (revokedReason) {
|
|
217
|
+
throw new AgentFabricError(
|
|
218
|
+
"AF_GRANT_REJECTED",
|
|
219
|
+
`Grant ${grant.grantId} is revoked`,
|
|
220
|
+
{ revokedReason },
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
if (now < grant.notBefore || now >= grant.expiresAt) {
|
|
224
|
+
throw new AgentFabricError(
|
|
225
|
+
"AF_GRANT_REJECTED",
|
|
226
|
+
`Grant ${grant.grantId} is outside its validity window`,
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export function assertGrantLineageCurrent(
|
|
232
|
+
state: Pick<
|
|
233
|
+
ControlState,
|
|
234
|
+
| "authorizations"
|
|
235
|
+
| "revokedAuthorizations"
|
|
236
|
+
| "grants"
|
|
237
|
+
| "revokedGrants"
|
|
238
|
+
| "resourceReservations"
|
|
239
|
+
>,
|
|
240
|
+
grantId: string,
|
|
241
|
+
now: number,
|
|
242
|
+
): void {
|
|
243
|
+
const seen = new Set<string>();
|
|
244
|
+
const selected = getOwn(state.grants, grantId);
|
|
245
|
+
if (!selected) throw new AgentFabricError("AF_NOT_FOUND", `Unknown grant: ${grantId}`);
|
|
246
|
+
let current: ExecutionGrant = selected;
|
|
247
|
+
|
|
248
|
+
while (true) {
|
|
249
|
+
if (seen.has(current.grantId)) {
|
|
250
|
+
throw new AgentFabricError("AF_GRANT_REJECTED", "Grant ancestry contains a cycle");
|
|
251
|
+
}
|
|
252
|
+
seen.add(current.grantId);
|
|
253
|
+
assertGrantCurrent(current, now, getOwn(state.revokedGrants, current.grantId));
|
|
254
|
+
|
|
255
|
+
if (current.parentGrantId) {
|
|
256
|
+
if (!current.reservationId) {
|
|
257
|
+
throw new AgentFabricError(
|
|
258
|
+
"AF_GRANT_REJECTED",
|
|
259
|
+
`Derived grant ${current.grantId} has no resource reservation`,
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
const reservation = getOwn(state.resourceReservations, current.reservationId);
|
|
263
|
+
if (!reservation || reservation.status === "released") {
|
|
264
|
+
throw new AgentFabricError(
|
|
265
|
+
"AF_GRANT_REJECTED",
|
|
266
|
+
`Derived grant ${current.grantId} has no current resource reservation`,
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (!current.parentGrantId) {
|
|
272
|
+
const authorization = getOwn(state.authorizations, current.rootAuthorizationId);
|
|
273
|
+
if (!authorization) {
|
|
274
|
+
throw new AgentFabricError(
|
|
275
|
+
"AF_GRANT_REJECTED",
|
|
276
|
+
`Root grant ${current.grantId} has no registered owner authorization`,
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
assertAuthorizationCurrent(
|
|
280
|
+
authorization,
|
|
281
|
+
now,
|
|
282
|
+
getOwn(state.revokedAuthorizations, authorization.authorizationId),
|
|
283
|
+
);
|
|
284
|
+
assertRootGrantAuthorized(authorization, current);
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const parent: ExecutionGrant | undefined = getOwn(state.grants, current.parentGrantId);
|
|
289
|
+
if (!parent) {
|
|
290
|
+
throw new AgentFabricError(
|
|
291
|
+
"AF_GRANT_REJECTED",
|
|
292
|
+
`Grant ${current.grantId} references missing parent ${current.parentGrantId}`,
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
assertGrantAttenuated(parent, current);
|
|
296
|
+
current = parent;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { AgentFabricError } from "./errors.ts";
|
|
3
|
+
import type { Digest, DigestFunction } from "./types.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* P0a canonical JSON profile.
|
|
7
|
+
*
|
|
8
|
+
* This profile is intentionally explicit and deterministic for the current
|
|
9
|
+
* TypeScript/JavaScript reference implementation. Cross-language JCS
|
|
10
|
+
* conformance is a separate protocol profile and is not claimed here.
|
|
11
|
+
*/
|
|
12
|
+
export const P0A_CANONICALIZATION_PROFILE = "forge-canonical-json/v0.1" as const;
|
|
13
|
+
|
|
14
|
+
function canonicalizationFailure(message: string): never {
|
|
15
|
+
throw new AgentFabricError("AF_CANONICALIZATION_FAILED", message);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function assertJsonObjectShape(value: object): void {
|
|
19
|
+
const prototype = Object.getPrototypeOf(value);
|
|
20
|
+
if (prototype !== Object.prototype && prototype !== null) {
|
|
21
|
+
canonicalizationFailure("Canonical objects must have Object.prototype or null prototype");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
for (const key of Reflect.ownKeys(value)) {
|
|
25
|
+
if (typeof key !== "string") {
|
|
26
|
+
canonicalizationFailure("Canonical objects cannot contain symbol properties");
|
|
27
|
+
}
|
|
28
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
|
29
|
+
if (!descriptor?.enumerable) {
|
|
30
|
+
canonicalizationFailure(`Canonical object property ${key} must be enumerable`);
|
|
31
|
+
}
|
|
32
|
+
if (!("value" in descriptor)) {
|
|
33
|
+
canonicalizationFailure(`Canonical object property ${key} cannot be an accessor`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function assertJsonArrayShape(value: readonly unknown[]): void {
|
|
39
|
+
if (Object.getPrototypeOf(value) !== Array.prototype) {
|
|
40
|
+
canonicalizationFailure("Canonical arrays must have Array.prototype");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const ownKeys = Reflect.ownKeys(value);
|
|
44
|
+
const expectedIndexKeys = new Set(Array.from({ length: value.length }, (_, index) => String(index)));
|
|
45
|
+
|
|
46
|
+
for (const key of ownKeys) {
|
|
47
|
+
if (key === "length") continue;
|
|
48
|
+
if (typeof key !== "string" || !expectedIndexKeys.has(key)) {
|
|
49
|
+
canonicalizationFailure("Canonical arrays cannot contain extra or symbol properties");
|
|
50
|
+
}
|
|
51
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
|
52
|
+
if (!descriptor?.enumerable || !("value" in descriptor)) {
|
|
53
|
+
canonicalizationFailure(`Canonical array index ${key} must be an enumerable data property`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
58
|
+
if (!Object.prototype.hasOwnProperty.call(value, index)) {
|
|
59
|
+
canonicalizationFailure("Canonical arrays cannot be sparse");
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function normalize(value: unknown, seen: Set<object>): unknown {
|
|
65
|
+
if (value === null || typeof value === "string" || typeof value === "boolean") {
|
|
66
|
+
return value;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (typeof value === "number") {
|
|
70
|
+
if (!Number.isFinite(value)) {
|
|
71
|
+
canonicalizationFailure("Canonical values cannot contain non-finite numbers");
|
|
72
|
+
}
|
|
73
|
+
return Object.is(value, -0) ? 0 : value;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (typeof value === "undefined" || typeof value === "function" || typeof value === "symbol") {
|
|
77
|
+
canonicalizationFailure(`Unsupported canonical value type: ${typeof value}`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (typeof value === "bigint") {
|
|
81
|
+
canonicalizationFailure("BigInt values must be encoded as strings before canonicalization");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (Array.isArray(value)) {
|
|
85
|
+
if (seen.has(value)) canonicalizationFailure("Canonical values cannot be cyclic");
|
|
86
|
+
assertJsonArrayShape(value);
|
|
87
|
+
seen.add(value);
|
|
88
|
+
const normalized: unknown[] = [];
|
|
89
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
90
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, String(index));
|
|
91
|
+
if (!descriptor || !("value" in descriptor)) {
|
|
92
|
+
seen.delete(value);
|
|
93
|
+
canonicalizationFailure(`Canonical array index ${index} must be an own data property`);
|
|
94
|
+
}
|
|
95
|
+
normalized[index] = normalize(descriptor.value, seen);
|
|
96
|
+
}
|
|
97
|
+
seen.delete(value);
|
|
98
|
+
return normalized;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (typeof value === "object") {
|
|
102
|
+
if (seen.has(value)) canonicalizationFailure("Canonical values cannot be cyclic");
|
|
103
|
+
assertJsonObjectShape(value);
|
|
104
|
+
seen.add(value);
|
|
105
|
+
const record = value as Record<string, unknown>;
|
|
106
|
+
// A null-prototype accumulator is required so an own `__proto__` key is
|
|
107
|
+
// preserved as data rather than invoking Object.prototype's legacy setter.
|
|
108
|
+
const normalized = Object.create(null) as Record<string, unknown>;
|
|
109
|
+
for (const key of Object.keys(record).sort()) {
|
|
110
|
+
if (record[key] === undefined) {
|
|
111
|
+
canonicalizationFailure(`Canonical object property ${key} is undefined`);
|
|
112
|
+
}
|
|
113
|
+
normalized[key] = normalize(record[key], seen);
|
|
114
|
+
}
|
|
115
|
+
seen.delete(value);
|
|
116
|
+
return normalized;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
canonicalizationFailure("Unsupported canonical value");
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function stableStringify(value: unknown): string {
|
|
123
|
+
return JSON.stringify(normalize(value, new Set<object>()));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function sha256Digest(canonicalValue: string): Digest {
|
|
127
|
+
return `sha256:${createHash("sha256").update(canonicalValue).digest("hex")}`;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function digestCanonical(
|
|
131
|
+
value: unknown,
|
|
132
|
+
digest: DigestFunction,
|
|
133
|
+
): Digest {
|
|
134
|
+
return digest(stableStringify(value));
|
|
135
|
+
}
|