@cassiomc1/forgeloop 1.6.2 → 1.6.4
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/DOCS_INDEX.md +26 -4
- package/EXECUTION_STATE.md +7 -0
- package/LOOP_ENGINEERING.md +58 -0
- package/LOOP_SYSTEM_DESIGN.md +54 -0
- package/ORCHESTRATOR_INTEGRATION.md +23 -0
- package/PROTOCOL_INTEGRATION.md +36 -0
- package/QUALITY_SCORECARD.md +21 -0
- package/README.md +57 -2
- package/TERMINOLOGY.md +11 -0
- package/THREAT_MODEL.md +7 -0
- package/completions/_forgeloop +85 -0
- package/completions/forgeloop.bash +173 -0
- package/completions/forgeloop.fish +544 -0
- package/docs/AGENT_PROTOCOL_SUMMARY.md +281 -0
- package/docs/ARTIFACT_REFERENCE.md +226 -0
- package/docs/CLI_REFERENCE.md +308 -2
- package/docs/CODE_ATTESTATION.md +141 -0
- package/docs/CROSS_HARNESS_CONTINUITY.md +22 -2
- package/docs/DOCUMENTATION_GUIDE.md +13 -11
- package/docs/GETTING_STARTED.md +98 -1
- package/docs/MCP.md +8 -0
- package/docs/PLATFORM_ADAPTERS.md +69 -0
- package/docs/RECIPES.md +138 -0
- package/docs/RELEASE_CHECKLIST.md +54 -0
- package/docs/REVISION_PROVIDERS.md +136 -0
- package/docs/SIGNING_PROVIDERS.md +83 -0
- package/docs/TROUBLESHOOTING.md +184 -0
- package/docs/UNIVERSAL_INTEGRATION.md +11 -0
- package/docs/assets/diagrams/forgeloop-code-attestation-flow.html +13841 -0
- package/docs/assets/diagrams/forgeloop-code-attestation-flow.receipt.json +37 -0
- package/docs/assets/diagrams/forgeloop-code-attestation-flow.svg +5046 -0
- package/docs/assets/diagrams/forgeloop-engineering-flow.html +46 -43
- package/docs/assets/diagrams/forgeloop-engineering-flow.receipt.json +6 -6
- package/docs/assets/diagrams/forgeloop-engineering-flow.svg +41 -41
- package/docs/assets/diagrams/forgeloop-verification-trust-flow.html +13783 -0
- package/docs/assets/diagrams/forgeloop-verification-trust-flow.receipt.json +37 -0
- package/docs/assets/diagrams/forgeloop-verification-trust-flow.svg +4988 -0
- package/docs/diagrams/README.md +30 -22
- package/docs/diagrams/forgeloop-code-attestation-flow.workflow.json +125 -0
- package/docs/diagrams/forgeloop-engineering-flow.workflow.json +11 -8
- package/docs/diagrams/forgeloop-verification-trust-flow.workflow.json +116 -0
- package/docs/diagrams/manifest.json +42 -0
- package/docs/diagrams/reviews/forgeloop-code-attestation-flow.review.json +20 -0
- package/docs/diagrams/reviews/forgeloop-engineering-flow.review.json +3 -3
- package/docs/diagrams/reviews/forgeloop-verification-trust-flow.review.json +20 -0
- package/integrations/generic-ci/verify.sh +14 -0
- package/package.json +31 -9
- package/schemas/attestation-verification-result.schema.json +34 -0
- package/schemas/code-attestation.schema.json +61 -0
- package/schemas/code-manifest.schema.json +57 -0
- package/schemas/config.schema.json +57 -1
- package/schemas/handoff-envelope.schema.json +57 -0
- package/schemas/in-toto-statement.schema.json +32 -0
- package/schemas/responsibility.schema.json +37 -0
- package/schemas/verification-scope.schema.json +26 -0
- package/schemas/workspace-binding.schema.json +20 -0
- package/scripts/CI_VALIDATORS.md +3 -0
- package/scripts/benchmark-cli-startup.mjs +60 -0
- package/scripts/check-changelog-freshness.mjs +88 -0
- package/scripts/check-critical-coverage.mjs +58 -0
- package/scripts/generate-agent-protocol-summary.mjs +141 -0
- package/scripts/generate-shell-completions.mjs +132 -0
- package/scripts/write-forgeloop-attestation-summary.mjs +93 -0
- package/src/cli.js +114 -6
- package/src/commands/attestation-create.js +30 -0
- package/src/commands/attestation-status.js +12 -0
- package/src/commands/attestation-verify-range.js +32 -0
- package/src/commands/attestation-verify.js +23 -0
- package/src/commands/handoff-create.js +9 -0
- package/src/commands/handoff-list.js +12 -0
- package/src/commands/handoff-show.js +10 -0
- package/src/commands/responsibility-set.js +20 -0
- package/src/commands/responsibility-status.js +12 -0
- package/src/commands/run-check.js +43 -3
- package/src/commands/task-repair-legacy-recovery.js +1 -2
- package/src/commands/verify-scope.js +9 -0
- package/src/commands/workspace-bind.js +17 -0
- package/src/commands/workspace-status.js +19 -0
- package/src/core/artifact-registry.js +84 -0
- package/src/core/attestation-coverage.js +134 -0
- package/src/core/attestation-verifier.js +227 -0
- package/src/core/attestation.js +263 -0
- package/src/core/audit.js +67 -1
- package/src/core/bundles.js +210 -9
- package/src/core/cli-command-definitions.js +196 -0
- package/src/core/code-manifest.js +293 -0
- package/src/core/command-executors.js +79 -0
- package/src/core/command-input.js +69 -18
- package/src/core/command-runtime.js +2 -1
- package/src/core/completion.js +170 -8
- package/src/core/config.js +60 -2
- package/src/core/continuity-cli-options.js +9 -3
- package/src/core/continuity-reconciliation.js +20 -0
- package/src/core/diagnostic-projection.js +3 -1
- package/src/core/error-codes.js +146 -0
- package/src/core/events.js +109 -13
- package/src/core/exit-codes.js +22 -0
- package/src/core/handoff.js +208 -0
- package/src/core/information-gain-projection.js +3 -3
- package/src/core/integration-invocation-policy.js +40 -0
- package/src/core/integration-resources.js +54 -0
- package/src/core/next-action-phases.js +968 -0
- package/src/core/next-action.js +32 -951
- package/src/core/phase.js +29 -1
- package/src/core/protocol-info.js +41 -1
- package/src/core/repository.js +256 -11
- package/src/core/responsibility.js +265 -0
- package/src/core/revision/git.js +207 -0
- package/src/core/revision/provider.js +90 -0
- package/src/core/revision/registry.js +5 -0
- package/src/core/route-artifact.js +1 -1
- package/src/core/schema-validation.js +8 -0
- package/src/core/signing/none.js +23 -0
- package/src/core/signing/provider.js +21 -0
- package/src/core/signing/registry.js +7 -0
- package/src/core/signing/sigstore.js +158 -0
- package/src/core/task-command.js +9 -1
- package/src/core/task-paths.js +72 -0
- package/src/core/templates.js +8 -0
- package/src/core/verification-scope-capability.js +179 -0
- package/src/core/verification-scope.js +271 -0
- package/src/core/workspace-binding.js +211 -0
- package/src/integration.d.ts +91 -0
- package/AGENT_COMPATIBILITY.md +0 -11
- package/docs/RELEASE_CHECKLIST_1_4.md +0 -38
- package/docs/RELEASE_CHECKLIST_1_5_MCP.md +0 -78
- package/docs/RELEASE_CHECKLIST_1_6_1.md +0 -121
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { lstat, readFile, readlink } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
import { canonicalFingerprint } from "../artifacts.js";
|
|
5
|
+
import { assertSafePath, ensureWithin } from "../filesystem.js";
|
|
6
|
+
import {
|
|
7
|
+
currentRepositoryFingerprint,
|
|
8
|
+
expectedGitBlobOid,
|
|
9
|
+
repositoryBlobAtRef,
|
|
10
|
+
repositoryBlobOidAtRef,
|
|
11
|
+
repositoryDiffEntries,
|
|
12
|
+
repositoryIndexEntries,
|
|
13
|
+
repositoryObjectFormat,
|
|
14
|
+
repositoryRemoteUrl,
|
|
15
|
+
repositoryStatusEntries,
|
|
16
|
+
repositoryTreeEntry,
|
|
17
|
+
repositoryWorktreeMetadata,
|
|
18
|
+
} from "../repository.js";
|
|
19
|
+
import { normalizeRevisionEntry } from "./provider.js";
|
|
20
|
+
|
|
21
|
+
function revisionError(code, message, cause = null) {
|
|
22
|
+
const error = new Error(message);
|
|
23
|
+
error.code = code;
|
|
24
|
+
if (cause) error.cause = cause;
|
|
25
|
+
return error;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function normalizeGitPath(value) {
|
|
29
|
+
return String(value).replaceAll("\\", "/").replace(/^\.\//u, "");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function operationFromStatus(status) {
|
|
33
|
+
const raw = String(status ?? "");
|
|
34
|
+
if (raw.includes("?")) return "ADDED";
|
|
35
|
+
const code = raw.replace(/[ ]/gu, "").slice(0, 1);
|
|
36
|
+
return {
|
|
37
|
+
A: "ADDED",
|
|
38
|
+
M: "MODIFIED",
|
|
39
|
+
D: "DELETED",
|
|
40
|
+
R: "RENAMED",
|
|
41
|
+
C: "COPIED",
|
|
42
|
+
T: "TYPE_CHANGED",
|
|
43
|
+
U: "TYPE_CHANGED",
|
|
44
|
+
"?": "ADDED",
|
|
45
|
+
}[code] ?? "MODIFIED";
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function kindFromMode(mode) {
|
|
49
|
+
if (mode === "160000") return "GITLINK";
|
|
50
|
+
if (mode === "120000") return "SYMLINK";
|
|
51
|
+
return "FILE";
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function worktreeEntry(target, entry) {
|
|
55
|
+
const relativePath = normalizeGitPath(entry.path);
|
|
56
|
+
await assertSafePath(target, relativePath);
|
|
57
|
+
const absolutePath = ensureWithin(target, relativePath);
|
|
58
|
+
try {
|
|
59
|
+
const info = await lstat(absolutePath);
|
|
60
|
+
if (info.isSymbolicLink()) {
|
|
61
|
+
const linkTarget = await readlink(absolutePath, { encoding: "buffer" });
|
|
62
|
+
return { ...entry, kind: "SYMLINK", bytes: Buffer.from(linkTarget) };
|
|
63
|
+
}
|
|
64
|
+
if (info.isDirectory()) {
|
|
65
|
+
return { ...entry, kind: "GITLINK", bytes: null };
|
|
66
|
+
}
|
|
67
|
+
return { ...entry, kind: "FILE", bytes: await readFile(absolutePath) };
|
|
68
|
+
} catch (error) {
|
|
69
|
+
if (entry.operation === "DELETED" || error.code === "ENOENT") {
|
|
70
|
+
return { ...entry, operation: entry.operation === "TYPE_CHANGED" ? "TYPE_CHANGED" : "DELETED", kind: "DELETED", bytes: null };
|
|
71
|
+
}
|
|
72
|
+
throw revisionError("E_REVISION_CONTENT_UNAVAILABLE", `Cannot read worktree path ${relativePath}`, error);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function normalizeDiffEntry(entry) {
|
|
77
|
+
return {
|
|
78
|
+
path: normalizeGitPath(entry.path),
|
|
79
|
+
sourcePath: entry.sourcePath ? normalizeGitPath(entry.sourcePath) : null,
|
|
80
|
+
operation: operationFromStatus(entry.status),
|
|
81
|
+
kind: "FILE",
|
|
82
|
+
bytes: null,
|
|
83
|
+
providerContentId: null,
|
|
84
|
+
providerMetadata: { score: entry.score ?? null },
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function createGitRevisionProvider() {
|
|
89
|
+
return {
|
|
90
|
+
name: "git",
|
|
91
|
+
async detect(target) {
|
|
92
|
+
try {
|
|
93
|
+
await repositoryWorktreeMetadata(target);
|
|
94
|
+
return true;
|
|
95
|
+
} catch {
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
async getCurrentRevision(target) {
|
|
100
|
+
try {
|
|
101
|
+
return (await currentRepositoryFingerprint(target)).head;
|
|
102
|
+
} catch (error) {
|
|
103
|
+
throw revisionError("E_REVISION_NOT_FOUND", "Current Git revision is unavailable", error);
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
async getRepositoryIdentity(target) {
|
|
107
|
+
try {
|
|
108
|
+
const metadata = await repositoryWorktreeMetadata(target);
|
|
109
|
+
return canonicalFingerprint({
|
|
110
|
+
commonDirectory: path.normalize(metadata.commonDirectory).replaceAll("\\", "/"),
|
|
111
|
+
objectFormat: metadata.objectFormat,
|
|
112
|
+
});
|
|
113
|
+
} catch (error) {
|
|
114
|
+
throw revisionError("E_REVISION_PROVIDER_UNAVAILABLE", "Git repository identity is unavailable", error);
|
|
115
|
+
}
|
|
116
|
+
},
|
|
117
|
+
async getChangedEntries({ target, baseRevision = null, headRevision = "WORKTREE", paths = null } = {}) {
|
|
118
|
+
try {
|
|
119
|
+
const rawEntries = headRevision === "WORKTREE" || headRevision === "working-tree"
|
|
120
|
+
? await repositoryStatusEntries(target, { paths })
|
|
121
|
+
: await repositoryDiffEntries(target, { baseRevision, headRevision, paths });
|
|
122
|
+
const normalized = rawEntries
|
|
123
|
+
.map(normalizeDiffEntry)
|
|
124
|
+
.filter((entry) => !entry.path.startsWith(".forgeloop/"));
|
|
125
|
+
const complete = [];
|
|
126
|
+
for (const entry of normalized) {
|
|
127
|
+
const materialized = (headRevision === "WORKTREE" || headRevision === "working-tree")
|
|
128
|
+
? await worktreeEntry(target, entry)
|
|
129
|
+
: entry.operation === "DELETED"
|
|
130
|
+
? { ...entry, kind: "DELETED", bytes: null }
|
|
131
|
+
: { ...entry, bytes: await this.readContent({ target, revision: headRevision, path: entry.path }) };
|
|
132
|
+
let kind = materialized.kind;
|
|
133
|
+
let providerContentId = materialized.providerContentId;
|
|
134
|
+
if (headRevision !== "WORKTREE" && materialized.operation !== "DELETED") {
|
|
135
|
+
try {
|
|
136
|
+
const treeEntry = await repositoryTreeEntry(target, { revision: headRevision, path: entry.path });
|
|
137
|
+
kind = kindFromMode(treeEntry?.mode);
|
|
138
|
+
providerContentId = treeEntry?.objectId ?? providerContentId;
|
|
139
|
+
} catch {
|
|
140
|
+
// A provider may still return content for a mode-less fixture.
|
|
141
|
+
}
|
|
142
|
+
} else if (headRevision === "WORKTREE" && materialized.operation !== "DELETED" && kind === "FILE") {
|
|
143
|
+
try { providerContentId = await expectedGitBlobOid(target, { path: entry.path }); } catch { /* untracked files have no Git identity */ }
|
|
144
|
+
} else if (headRevision === "WORKTREE" && materialized.operation !== "DELETED" && kind === "GITLINK") {
|
|
145
|
+
try {
|
|
146
|
+
const indexEntry = (await repositoryIndexEntries(target, { paths: [entry.path] }))
|
|
147
|
+
.find((candidate) => candidate.path === entry.path);
|
|
148
|
+
providerContentId = indexEntry?.objectId ?? null;
|
|
149
|
+
} catch {
|
|
150
|
+
// A provider must reject an unresolved gitlink during manifest validation.
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
complete.push(normalizeRevisionEntry({
|
|
154
|
+
...materialized,
|
|
155
|
+
kind,
|
|
156
|
+
providerContentId,
|
|
157
|
+
providerMetadata: { ...materialized.providerMetadata, objectFormat: await repositoryObjectFormat(target) },
|
|
158
|
+
}));
|
|
159
|
+
}
|
|
160
|
+
return complete.sort((left, right) => left.path.localeCompare(right.path));
|
|
161
|
+
} catch (error) {
|
|
162
|
+
if (error.code?.startsWith("E_REVISION_")) throw error;
|
|
163
|
+
throw revisionError("E_REVISION_PROVIDER_INVALID", `Git changed-entry discovery failed: ${error.message}`, error);
|
|
164
|
+
}
|
|
165
|
+
},
|
|
166
|
+
async readContent({ target, revision, path: repositoryPath } = {}) {
|
|
167
|
+
const safePath = normalizeGitPath(repositoryPath);
|
|
168
|
+
if (revision === "WORKTREE" || revision === "working-tree") {
|
|
169
|
+
await assertSafePath(target, safePath);
|
|
170
|
+
try { return await readFile(ensureWithin(target, safePath)); } catch (error) {
|
|
171
|
+
const wrapped = revisionError("E_REVISION_CONTENT_UNAVAILABLE", `Cannot read ${safePath} from the worktree`, error);
|
|
172
|
+
wrapped.notFound = error.code === "ENOENT";
|
|
173
|
+
throw wrapped;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
try {
|
|
177
|
+
return await repositoryBlobAtRef(target, { ref: revision, path: safePath });
|
|
178
|
+
} catch (error) {
|
|
179
|
+
const wrapped = revisionError("E_REVISION_CONTENT_UNAVAILABLE", `Cannot read ${safePath} at ${revision}`, error);
|
|
180
|
+
wrapped.notFound = error.notFound === true;
|
|
181
|
+
throw wrapped;
|
|
182
|
+
}
|
|
183
|
+
},
|
|
184
|
+
async getContentIdentity({ target, revision, path: repositoryPath } = {}) {
|
|
185
|
+
try {
|
|
186
|
+
if (revision === "WORKTREE" || revision === "working-tree") {
|
|
187
|
+
return await expectedGitBlobOid(target, { path: repositoryPath });
|
|
188
|
+
}
|
|
189
|
+
return await repositoryBlobOidAtRef(target, { ref: revision, path: repositoryPath });
|
|
190
|
+
} catch (error) {
|
|
191
|
+
const wrapped = revisionError("E_REVISION_CONTENT_UNAVAILABLE", `Cannot resolve content identity for ${repositoryPath}`, error);
|
|
192
|
+
wrapped.notFound = error.notFound === true;
|
|
193
|
+
throw wrapped;
|
|
194
|
+
}
|
|
195
|
+
},
|
|
196
|
+
async getProviderMetadata(target) {
|
|
197
|
+
const [objectFormat, remote, fingerprint] = await Promise.all([
|
|
198
|
+
repositoryObjectFormat(target),
|
|
199
|
+
repositoryRemoteUrl(target),
|
|
200
|
+
currentRepositoryFingerprint(target),
|
|
201
|
+
]);
|
|
202
|
+
return { objectFormat, remote, branch: fingerprint.branch, head: fingerprint.head };
|
|
203
|
+
},
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export const GitRevisionProvider = createGitRevisionProvider;
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Transport- and SCM-neutral revision provider contract.
|
|
5
|
+
*
|
|
6
|
+
* Providers expose opaque revision identifiers and normalized changed entries.
|
|
7
|
+
* The attestation core deliberately knows nothing about Git command syntax.
|
|
8
|
+
*/
|
|
9
|
+
export function assertRevisionProvider(provider) {
|
|
10
|
+
const required = [
|
|
11
|
+
"detect",
|
|
12
|
+
"getCurrentRevision",
|
|
13
|
+
"getChangedEntries",
|
|
14
|
+
"readContent",
|
|
15
|
+
"getContentIdentity",
|
|
16
|
+
"getRepositoryIdentity",
|
|
17
|
+
];
|
|
18
|
+
if (!provider || typeof provider !== "object"
|
|
19
|
+
|| required.some((method) => typeof provider[method] !== "function")) {
|
|
20
|
+
const error = new Error(`Revision provider must implement ${required.join(", ")}`);
|
|
21
|
+
error.code = "E_REVISION_PROVIDER_INVALID";
|
|
22
|
+
throw error;
|
|
23
|
+
}
|
|
24
|
+
return provider;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function normalizeRevisionEntry(entry) {
|
|
28
|
+
if (!entry || typeof entry !== "object" || typeof entry.path !== "string" || !entry.path) {
|
|
29
|
+
const error = new Error("Revision entry requires a path");
|
|
30
|
+
error.code = "E_REVISION_PROVIDER_INVALID";
|
|
31
|
+
throw error;
|
|
32
|
+
}
|
|
33
|
+
const pathValue = entry.path.replaceAll("\\", "/");
|
|
34
|
+
const sourcePath = entry.sourcePath ? entry.sourcePath.replaceAll("\\", "/") : null;
|
|
35
|
+
const isSafe = (value) => typeof value === "string"
|
|
36
|
+
&& value.length > 0
|
|
37
|
+
&& !/[\u0000-\u001f\u007f]/u.test(value)
|
|
38
|
+
&& !value.startsWith("/")
|
|
39
|
+
&& path.posix.normalize(value) === value
|
|
40
|
+
&& value !== "."
|
|
41
|
+
&& value !== ".."
|
|
42
|
+
&& !value.startsWith("../")
|
|
43
|
+
&& !value.startsWith(".forgeloop/");
|
|
44
|
+
if (!isSafe(pathValue) || (sourcePath !== null && !isSafe(sourcePath))) {
|
|
45
|
+
const error = new Error(`Revision entry path is unsafe or reserved: ${pathValue}`);
|
|
46
|
+
error.code = "E_REVISION_PROVIDER_INVALID";
|
|
47
|
+
throw error;
|
|
48
|
+
}
|
|
49
|
+
return {
|
|
50
|
+
path: pathValue,
|
|
51
|
+
sourcePath,
|
|
52
|
+
operation: entry.operation,
|
|
53
|
+
kind: entry.kind,
|
|
54
|
+
bytes: entry.bytes ?? null,
|
|
55
|
+
providerContentId: entry.providerContentId ?? null,
|
|
56
|
+
providerMetadata: entry.providerMetadata ?? {},
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function resolveRevisionProvider({ target, providerName = null, registry } = {}) {
|
|
61
|
+
const providers = registry ?? {};
|
|
62
|
+
if (providerName) {
|
|
63
|
+
const factory = providers[providerName];
|
|
64
|
+
if (typeof factory !== "function") {
|
|
65
|
+
const error = new Error(`Revision provider is unavailable: ${providerName}`);
|
|
66
|
+
error.code = "E_REVISION_PROVIDER_UNAVAILABLE";
|
|
67
|
+
throw error;
|
|
68
|
+
}
|
|
69
|
+
return assertRevisionProvider(await factory({ target }));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const available = [];
|
|
73
|
+
for (const [name, factory] of Object.entries(providers)) {
|
|
74
|
+
try {
|
|
75
|
+
const provider = assertRevisionProvider(await factory({ target }));
|
|
76
|
+
if (await provider.detect(target)) available.push({ name, provider });
|
|
77
|
+
} catch {
|
|
78
|
+
// Auto-detection ignores providers that cannot inspect this target.
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if (available.length === 1) return available[0].provider;
|
|
82
|
+
if (available.length === 0) {
|
|
83
|
+
const error = new Error("No revision provider can resolve the target repository");
|
|
84
|
+
error.code = "E_REVISION_PROVIDER_UNAVAILABLE";
|
|
85
|
+
throw error;
|
|
86
|
+
}
|
|
87
|
+
const error = new Error(`Multiple revision providers match the target: ${available.map(({ name }) => name).join(", ")}`);
|
|
88
|
+
error.code = "E_REVISION_PROVIDER_AMBIGUOUS";
|
|
89
|
+
throw error;
|
|
90
|
+
}
|
|
@@ -6,7 +6,7 @@ import { taskArtifactPath } from "./task-paths.js";
|
|
|
6
6
|
|
|
7
7
|
export async function persistRoute(target, route, packageRoot, options = {}) {
|
|
8
8
|
assertRouteInvariants(route);
|
|
9
|
-
|
|
9
|
+
const { contractFingerprint, ...writeOptions } = options;
|
|
10
10
|
let contractArtifact = null;
|
|
11
11
|
if (contractFingerprint === undefined) {
|
|
12
12
|
try {
|
|
@@ -42,6 +42,14 @@ export const SHIPPED_SCHEMA_NAMES = Object.freeze([
|
|
|
42
42
|
"capability-policy",
|
|
43
43
|
"trajectory-evaluation",
|
|
44
44
|
"trajectory-scenario",
|
|
45
|
+
"workspace-binding",
|
|
46
|
+
"handoff-envelope",
|
|
47
|
+
"responsibility",
|
|
48
|
+
"verification-scope",
|
|
49
|
+
"code-manifest",
|
|
50
|
+
"code-attestation",
|
|
51
|
+
"attestation-verification-result",
|
|
52
|
+
"in-toto-statement",
|
|
45
53
|
]);
|
|
46
54
|
|
|
47
55
|
export class SchemaValidationError extends Error {
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export function createNoneSigningProvider() {
|
|
2
|
+
return {
|
|
3
|
+
name: "none",
|
|
4
|
+
async detect() { return true; },
|
|
5
|
+
async sign() {
|
|
6
|
+
return {
|
|
7
|
+
status: "UNAVAILABLE",
|
|
8
|
+
code: "E_ATTESTATION_SIGNER_UNAVAILABLE",
|
|
9
|
+
message: "The none signing provider cannot create an external signature",
|
|
10
|
+
};
|
|
11
|
+
},
|
|
12
|
+
async verify({ bundlePath } = {}) {
|
|
13
|
+
if (bundlePath) {
|
|
14
|
+
return {
|
|
15
|
+
status: "INVALID",
|
|
16
|
+
code: "E_ATTESTATION_SIGNER_UNAVAILABLE",
|
|
17
|
+
message: "The none signing provider cannot verify an external signature bundle",
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
return { status: "UNSIGNED", signer: null };
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export function assertSigningProvider(provider) {
|
|
2
|
+
if (!provider || typeof provider !== "object"
|
|
3
|
+
|| typeof provider.detect !== "function"
|
|
4
|
+
|| typeof provider.sign !== "function"
|
|
5
|
+
|| typeof provider.verify !== "function") {
|
|
6
|
+
const error = new Error("Signing provider must implement detect, sign, and verify");
|
|
7
|
+
error.code = "E_ATTESTATION_SIGNER_UNAVAILABLE";
|
|
8
|
+
throw error;
|
|
9
|
+
}
|
|
10
|
+
return provider;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function resolveSigningProvider({ providerName = "none", registry = {} } = {}) {
|
|
14
|
+
const factory = registry[providerName];
|
|
15
|
+
if (typeof factory !== "function") {
|
|
16
|
+
const error = new Error(`Signing provider is unavailable: ${providerName}`);
|
|
17
|
+
error.code = "E_ATTESTATION_SIGNER_UNAVAILABLE";
|
|
18
|
+
throw error;
|
|
19
|
+
}
|
|
20
|
+
return assertSigningProvider(await factory());
|
|
21
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { ensureWithin, assertSafePath } from "../filesystem.js";
|
|
6
|
+
|
|
7
|
+
const execFileAsync = promisify(execFile);
|
|
8
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
9
|
+
const MAX_OUTPUT_BYTES = 8 * 1024 * 1024;
|
|
10
|
+
|
|
11
|
+
export function createSigstoreSigningProvider({
|
|
12
|
+
command = "cosign",
|
|
13
|
+
execFileImpl = execFileAsync,
|
|
14
|
+
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
15
|
+
} = {}) {
|
|
16
|
+
const run = (args, options = {}) => execFileImpl(command, args, {
|
|
17
|
+
windowsHide: true,
|
|
18
|
+
shell: false,
|
|
19
|
+
cwd: options.cwd,
|
|
20
|
+
timeout: timeoutMs,
|
|
21
|
+
killSignal: "SIGTERM",
|
|
22
|
+
maxBuffer: MAX_OUTPUT_BYTES,
|
|
23
|
+
});
|
|
24
|
+
return {
|
|
25
|
+
name: "sigstore",
|
|
26
|
+
async detect() {
|
|
27
|
+
try {
|
|
28
|
+
await run(["version"]);
|
|
29
|
+
return true;
|
|
30
|
+
} catch {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
async sign({ target, statementPath, outputPath, bundlePath } = {}) {
|
|
35
|
+
const destinationPath = outputPath ?? bundlePath;
|
|
36
|
+
if (!destinationPath) {
|
|
37
|
+
return {
|
|
38
|
+
status: "INVALID",
|
|
39
|
+
code: "E_ATTESTATION_SIGNATURE_INVALID",
|
|
40
|
+
message: "A Sigstore bundle output path is required",
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
try {
|
|
44
|
+
await assertSafePath(target, statementPath);
|
|
45
|
+
await assertSafePath(target, destinationPath);
|
|
46
|
+
await run([
|
|
47
|
+
"attest-blob",
|
|
48
|
+
"--statement", ensureWithin(target, statementPath),
|
|
49
|
+
"--bundle", ensureWithin(target, destinationPath),
|
|
50
|
+
"--yes",
|
|
51
|
+
], { cwd: path.resolve(target) });
|
|
52
|
+
return { status: "VALID", path: destinationPath };
|
|
53
|
+
} catch (error) {
|
|
54
|
+
const unavailable = error.code === "ENOENT" || error.cause?.code === "ENOENT";
|
|
55
|
+
const timedOut = error.code === "ETIMEDOUT" || error.killed === true || error.signal === "SIGTERM";
|
|
56
|
+
return {
|
|
57
|
+
status: "INVALID",
|
|
58
|
+
code: unavailable || timedOut ? "E_ATTESTATION_SIGNER_UNAVAILABLE" : "E_ATTESTATION_SIGNATURE_INVALID",
|
|
59
|
+
message: unavailable
|
|
60
|
+
? "cosign is unavailable for Sigstore signing"
|
|
61
|
+
: timedOut
|
|
62
|
+
? "Sigstore signing timed out"
|
|
63
|
+
: "Sigstore signing failed",
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
async verify({ target, statementPath, bundlePath, policy = {} } = {}) {
|
|
68
|
+
if (!bundlePath) return { status: "UNSIGNED", signer: null };
|
|
69
|
+
try {
|
|
70
|
+
await assertSafePath(target, statementPath);
|
|
71
|
+
await assertSafePath(target, bundlePath);
|
|
72
|
+
const statementAbsolute = ensureWithin(target, statementPath);
|
|
73
|
+
const bundleAbsolute = ensureWithin(target, bundlePath);
|
|
74
|
+
const args = [
|
|
75
|
+
"verify-blob-attestation",
|
|
76
|
+
"--statement", statementAbsolute,
|
|
77
|
+
"--bundle", bundleAbsolute,
|
|
78
|
+
];
|
|
79
|
+
const identities = Array.isArray(policy.identities)
|
|
80
|
+
? [...new Set(policy.identities.filter((value) => typeof value === "string" && value.length > 0))]
|
|
81
|
+
: [];
|
|
82
|
+
if (policy.identity) args.push("--certificate-identity", policy.identity);
|
|
83
|
+
else if (identities.length === 1) args.push("--certificate-identity", identities[0]);
|
|
84
|
+
else if (identities.length > 1) {
|
|
85
|
+
const exactAlternatives = identities
|
|
86
|
+
.map((value) => value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"))
|
|
87
|
+
.join("|");
|
|
88
|
+
args.push("--certificate-identity-regexp", `^(?:${exactAlternatives})$`);
|
|
89
|
+
}
|
|
90
|
+
if (policy.issuer) args.push("--certificate-oidc-issuer", policy.issuer);
|
|
91
|
+
if (policy.trustedRoot) {
|
|
92
|
+
await assertSafePath(target, policy.trustedRoot);
|
|
93
|
+
args.push("--trusted-root", ensureWithin(target, policy.trustedRoot));
|
|
94
|
+
}
|
|
95
|
+
await run(args, { cwd: path.resolve(target) });
|
|
96
|
+
return { status: "VALID", signer: policy.identity ?? (identities.length === 1 ? identities[0] : null) };
|
|
97
|
+
} catch (error) {
|
|
98
|
+
if (error.code === "E_ATTESTATION_SIGNER_UNAVAILABLE") throw error;
|
|
99
|
+
const unavailable = error.code === "ENOENT" || error.cause?.code === "ENOENT";
|
|
100
|
+
const timedOut = error.code === "ETIMEDOUT" || error.killed === true || error.signal === "SIGTERM";
|
|
101
|
+
const outputLimit = error.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER";
|
|
102
|
+
const diagnostic = `${error.stderr ?? error.cause?.stderr ?? ""}`.toLowerCase();
|
|
103
|
+
const identityUntrusted = Boolean(policy.identity) && /identity|certificate-identity|subject/iu.test(diagnostic);
|
|
104
|
+
const issuerUntrusted = Boolean(policy.issuer) && /issuer|oidc/iu.test(diagnostic);
|
|
105
|
+
return {
|
|
106
|
+
status: "INVALID",
|
|
107
|
+
code: unavailable || timedOut || outputLimit
|
|
108
|
+
? "E_ATTESTATION_SIGNER_UNAVAILABLE"
|
|
109
|
+
: identityUntrusted
|
|
110
|
+
? "E_ATTESTATION_IDENTITY_UNTRUSTED"
|
|
111
|
+
: issuerUntrusted
|
|
112
|
+
? "E_ATTESTATION_ISSUER_UNTRUSTED"
|
|
113
|
+
: "E_ATTESTATION_SIGNATURE_INVALID",
|
|
114
|
+
message: unavailable
|
|
115
|
+
? "cosign is unavailable for Sigstore verification"
|
|
116
|
+
: timedOut
|
|
117
|
+
? "Sigstore verification timed out"
|
|
118
|
+
: outputLimit
|
|
119
|
+
? "Sigstore verification exceeded the output limit"
|
|
120
|
+
: identityUntrusted
|
|
121
|
+
? "Sigstore signer identity is not trusted"
|
|
122
|
+
: issuerUntrusted
|
|
123
|
+
? "Sigstore certificate issuer is not trusted"
|
|
124
|
+
: "Sigstore signature verification failed",
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Convenience wrapper for callers that only need to verify one bundle.
|
|
133
|
+
* All process, path, timeout, and output-boundary rules remain owned by the
|
|
134
|
+
* canonical signing provider.
|
|
135
|
+
*/
|
|
136
|
+
export async function verifySigstoreBundle({
|
|
137
|
+
target,
|
|
138
|
+
statementPath,
|
|
139
|
+
bundlePath,
|
|
140
|
+
identity = null,
|
|
141
|
+
issuer = null,
|
|
142
|
+
trustedRootPath = null,
|
|
143
|
+
command = "cosign",
|
|
144
|
+
execFileImpl = execFileAsync,
|
|
145
|
+
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
146
|
+
} = {}) {
|
|
147
|
+
const provider = createSigstoreSigningProvider({ command, execFileImpl, timeoutMs });
|
|
148
|
+
return provider.verify({
|
|
149
|
+
target,
|
|
150
|
+
statementPath,
|
|
151
|
+
bundlePath,
|
|
152
|
+
policy: {
|
|
153
|
+
...(identity ? { identity } : {}),
|
|
154
|
+
...(issuer ? { issuer } : {}),
|
|
155
|
+
...(trustedRootPath ? { trustedRoot: trustedRootPath } : {}),
|
|
156
|
+
},
|
|
157
|
+
});
|
|
158
|
+
}
|
package/src/core/task-command.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { resolveTaskContext, TASK_SELECTION_MODES } from "./task-context.js";
|
|
2
2
|
import { withTaskTransaction } from "./transaction.js";
|
|
3
3
|
import { assertTaskMutationAllowed } from "./task-claim-state.js";
|
|
4
|
+
import { assertWorkspaceBinding } from "./workspace-binding.js";
|
|
4
5
|
|
|
5
6
|
export async function withResolvedTask(
|
|
6
7
|
target,
|
|
@@ -24,7 +25,7 @@ export async function withTaskMutation(
|
|
|
24
25
|
options = {},
|
|
25
26
|
operation = "mutation",
|
|
26
27
|
callback,
|
|
27
|
-
{ explicitRequired = false } = {},
|
|
28
|
+
{ explicitRequired = false, skipWorkspaceBinding = false } = {},
|
|
28
29
|
) {
|
|
29
30
|
const taskOption = options.taskId ?? options.task ?? null;
|
|
30
31
|
const taskContext = await resolveTaskContext(target, {
|
|
@@ -37,6 +38,13 @@ export async function withTaskMutation(
|
|
|
37
38
|
if (taskContext) {
|
|
38
39
|
return withTaskTransaction({ target, taskId: taskContext.taskId, operation, packageRoot: options.packageRoot, recordCommitEvent: true }, async (transaction) => {
|
|
39
40
|
await assertTaskMutationAllowed(target, { taskId: taskContext.taskId, packageRoot: options.packageRoot });
|
|
41
|
+
if (!skipWorkspaceBinding) {
|
|
42
|
+
await assertWorkspaceBinding(target, {
|
|
43
|
+
taskId: taskContext.taskId,
|
|
44
|
+
packageRoot: options.packageRoot,
|
|
45
|
+
operation,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
40
48
|
return callback({ ...taskContext, transaction });
|
|
41
49
|
});
|
|
42
50
|
}
|
package/src/core/task-paths.js
CHANGED
|
@@ -20,6 +20,11 @@ export const TASK_ARTIFACT_FILES = Object.freeze({
|
|
|
20
20
|
evaluations: "evaluations",
|
|
21
21
|
policySnapshot: "policy-snapshot.json",
|
|
22
22
|
recovery: "recovery.json",
|
|
23
|
+
workspaceBinding: "workspace-binding.json",
|
|
24
|
+
handoffs: "handoffs",
|
|
25
|
+
responsibility: "responsibility.json",
|
|
26
|
+
verificationScope: "verification-scope.json",
|
|
27
|
+
attestations: "attestations",
|
|
23
28
|
});
|
|
24
29
|
|
|
25
30
|
export const POLICY_ROOT = ".forgeloop/policy";
|
|
@@ -99,6 +104,65 @@ export function taskEvaluationPath(taskId, evaluationId) {
|
|
|
99
104
|
return `${taskDirectory(taskId)}/${TASK_ARTIFACT_FILES.evaluations}/${evaluationId}.json`;
|
|
100
105
|
}
|
|
101
106
|
|
|
107
|
+
function assertArtifactId(value, pattern, label) {
|
|
108
|
+
if (typeof value !== "string" || !pattern.test(value)) {
|
|
109
|
+
throw new Error(`Invalid ${label}: ${value}`);
|
|
110
|
+
}
|
|
111
|
+
return value;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function taskWorkspaceBindingPath(taskId) {
|
|
115
|
+
return taskArtifactPath(taskId, "workspaceBinding");
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function taskResponsibilityPath(taskId) {
|
|
119
|
+
return taskArtifactPath(taskId, "responsibility");
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function taskVerificationScopePath(taskId) {
|
|
123
|
+
return taskArtifactPath(taskId, "verificationScope");
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function taskHandoffDirectory(taskId) {
|
|
127
|
+
return taskArtifactPath(taskId, "handoffs");
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function taskHandoffPath(taskId, handoffId) {
|
|
131
|
+
assertArtifactId(handoffId, /^handoff-[A-Za-z0-9_-]+$/, "handoff ID");
|
|
132
|
+
return `${taskHandoffDirectory(taskId)}/${handoffId}.json`;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function taskAttestationDirectory(taskId) {
|
|
136
|
+
return taskArtifactPath(taskId, "attestations");
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function taskCodeManifestPath(taskId) {
|
|
140
|
+
return `${taskAttestationDirectory(taskId)}/code-manifest.json`;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function taskAttestationStatementPath(taskId) {
|
|
144
|
+
return `${taskAttestationDirectory(taskId)}/statement.json`;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function taskAttestationBundlePath(taskId) {
|
|
148
|
+
return `${taskAttestationDirectory(taskId)}/statement.sigstore.json`;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function taskAttestationHistoryDirectory(taskId, verificationCycle) {
|
|
152
|
+
if (!Number.isInteger(verificationCycle) || verificationCycle < 1) {
|
|
153
|
+
throw new Error(`Invalid attestation verification cycle: ${verificationCycle}`);
|
|
154
|
+
}
|
|
155
|
+
return `${taskAttestationDirectory(taskId)}/history/cycle-${verificationCycle}`;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function taskCodeManifestHistoryPath(taskId, verificationCycle) {
|
|
159
|
+
return `${taskAttestationHistoryDirectory(taskId, verificationCycle)}/code-manifest.json`;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function taskAttestationStatementHistoryPath(taskId, verificationCycle) {
|
|
163
|
+
return `${taskAttestationHistoryDirectory(taskId, verificationCycle)}/statement.json`;
|
|
164
|
+
}
|
|
165
|
+
|
|
102
166
|
export function taskLockPath(taskId) {
|
|
103
167
|
assertTaskId(taskId);
|
|
104
168
|
return `${TASK_LOCK_ROOT}/${taskStorageKey(taskId)}.lock`;
|
|
@@ -132,5 +196,13 @@ export function buildTaskArtifactPaths(taskId) {
|
|
|
132
196
|
lock: taskLockPath(taskId),
|
|
133
197
|
policySnapshot: `${dir}/${TASK_ARTIFACT_FILES.policySnapshot}`,
|
|
134
198
|
recovery: `${dir}/${TASK_ARTIFACT_FILES.recovery}`,
|
|
199
|
+
workspaceBinding: `${dir}/${TASK_ARTIFACT_FILES.workspaceBinding}`,
|
|
200
|
+
handoffs: `${dir}/${TASK_ARTIFACT_FILES.handoffs}`,
|
|
201
|
+
responsibility: `${dir}/${TASK_ARTIFACT_FILES.responsibility}`,
|
|
202
|
+
verificationScope: `${dir}/${TASK_ARTIFACT_FILES.verificationScope}`,
|
|
203
|
+
attestations: `${dir}/${TASK_ARTIFACT_FILES.attestations}`,
|
|
204
|
+
codeManifest: taskCodeManifestPath(taskId),
|
|
205
|
+
attestationStatement: taskAttestationStatementPath(taskId),
|
|
206
|
+
attestationBundle: taskAttestationBundlePath(taskId),
|
|
135
207
|
});
|
|
136
208
|
}
|
package/src/core/templates.js
CHANGED
|
@@ -75,6 +75,14 @@ export const TEMPLATE_PATHS = [
|
|
|
75
75
|
"schemas/capability-policy.schema.json",
|
|
76
76
|
"schemas/trajectory-evaluation.schema.json",
|
|
77
77
|
"schemas/trajectory-scenario.schema.json",
|
|
78
|
+
"schemas/workspace-binding.schema.json",
|
|
79
|
+
"schemas/handoff-envelope.schema.json",
|
|
80
|
+
"schemas/responsibility.schema.json",
|
|
81
|
+
"schemas/verification-scope.schema.json",
|
|
82
|
+
"schemas/code-manifest.schema.json",
|
|
83
|
+
"schemas/code-attestation.schema.json",
|
|
84
|
+
"schemas/attestation-verification-result.schema.json",
|
|
85
|
+
"schemas/in-toto-statement.schema.json",
|
|
78
86
|
];
|
|
79
87
|
|
|
80
88
|
export function getPackageRoot() {
|