@codapult/guard 0.10.0 → 0.11.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 +144 -4
- package/dist/adapters/agents/agent-integration.js +26 -3
- package/dist/cli/commands/guard.d.ts +2 -0
- package/dist/cli/commands/guard.js +77 -11
- package/dist/cli/index.js +2 -0
- package/dist/core/guard.d.ts +28 -1
- package/dist/core/guard.js +37 -0
- package/dist/core/history/runs.d.ts +64 -1
- package/dist/core/history/runs.js +109 -0
- package/dist/core/output/attestation.d.ts +17 -0
- package/dist/core/output/attestation.js +22 -0
- package/dist/core/policy/schemas.d.ts +47 -0
- package/dist/core/policy/schemas.js +67 -0
- package/dist/core/verification/github-actions.d.ts +25 -0
- package/dist/core/verification/github-actions.js +397 -0
- package/dist/core/verification/github-context.d.ts +8 -0
- package/dist/core/verification/github-context.js +41 -0
- package/dist/core/verification/verify.d.ts +23 -4
- package/dist/core/verification/verify.js +601 -47
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/mcp/prompts.js +7 -4
- package/dist/mcp/tools/guard.js +9 -3
- package/docs/extensions.md +79 -0
- package/docs/guard-agent-integration.md +51 -0
- package/docs/guard-ci.yml +202 -0
- package/docs/guard-fixtures.md +127 -0
- package/docs/guard-trusted-pr-attestation.yml +213 -0
- package/docs/guard.md +1125 -0
- package/docs/integrations/README.md +38 -0
- package/docs/integrations/claude-code.md +92 -0
- package/docs/integrations/codex.md +58 -0
- package/docs/integrations/cursor.md +116 -0
- package/docs/integrations/gemini-cli.md +47 -0
- package/docs/integrations/generic.md +41 -0
- package/docs/integrations/github-copilot.md +72 -0
- package/docs/policy-mutations.md +75 -0
- package/docs/releasing.md +58 -0
- package/docs/verify-guard-attestation.mjs +166 -0
- package/package.json +4 -2
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { execFileSync } from 'node:child_process';
|
|
3
|
+
import { lstatSync, readFileSync, realpathSync } from 'node:fs';
|
|
4
|
+
import { relative, resolve, sep } from 'node:path';
|
|
5
|
+
import { parseDocument } from 'yaml';
|
|
6
|
+
function isRecord(value) {
|
|
7
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
8
|
+
}
|
|
9
|
+
function list(value) {
|
|
10
|
+
return Array.isArray(value) ? value : [];
|
|
11
|
+
}
|
|
12
|
+
function getEventConfig(value, event) {
|
|
13
|
+
if (typeof value === 'string')
|
|
14
|
+
return value === event ? null : undefined;
|
|
15
|
+
if (Array.isArray(value))
|
|
16
|
+
return value.includes(event) ? null : undefined;
|
|
17
|
+
if (isRecord(value) && Object.hasOwn(value, event))
|
|
18
|
+
return value[event];
|
|
19
|
+
return undefined;
|
|
20
|
+
}
|
|
21
|
+
function usesFullShaPin(reference) {
|
|
22
|
+
// Local actions are mutable repository code and cannot satisfy immutable-ref
|
|
23
|
+
// requirements. Protect their source paths separately when using them.
|
|
24
|
+
if (reference.startsWith('./'))
|
|
25
|
+
return false;
|
|
26
|
+
const separator = reference.lastIndexOf('@');
|
|
27
|
+
if (separator < 1)
|
|
28
|
+
return false;
|
|
29
|
+
return /^[a-f0-9]{40}$/i.test(reference.slice(separator + 1));
|
|
30
|
+
}
|
|
31
|
+
function parseActions(source) {
|
|
32
|
+
const document = parseDocument(source, { uniqueKeys: true, schema: 'core' });
|
|
33
|
+
if (document.errors.length > 0)
|
|
34
|
+
return undefined;
|
|
35
|
+
let value;
|
|
36
|
+
try {
|
|
37
|
+
value = document.toJS({ maxAliasCount: 100 });
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
if (!isRecord(value) || !isRecord(value.jobs))
|
|
43
|
+
return undefined;
|
|
44
|
+
const actions = [
|
|
45
|
+
...new Set(Object.values(value.jobs).flatMap((candidate) => {
|
|
46
|
+
if (!isRecord(candidate))
|
|
47
|
+
return [];
|
|
48
|
+
return [
|
|
49
|
+
...(typeof candidate.uses === 'string' ? [candidate.uses] : []),
|
|
50
|
+
...list(candidate.steps)
|
|
51
|
+
.filter(isRecord)
|
|
52
|
+
.flatMap((step) => (typeof step.uses === 'string' ? [step.uses] : [])),
|
|
53
|
+
];
|
|
54
|
+
})),
|
|
55
|
+
].sort();
|
|
56
|
+
return { digest: createHash('sha256').update(source).digest('hex'), actions };
|
|
57
|
+
}
|
|
58
|
+
function actionChanges(baseActions, currentActions) {
|
|
59
|
+
const byName = (actions) => {
|
|
60
|
+
const result = new Map();
|
|
61
|
+
for (const action of actions) {
|
|
62
|
+
const at = action.lastIndexOf('@');
|
|
63
|
+
const name = at > 0 ? action.slice(0, at) : action;
|
|
64
|
+
result.set(name, [...(result.get(name) ?? []), action]);
|
|
65
|
+
}
|
|
66
|
+
return result;
|
|
67
|
+
};
|
|
68
|
+
const base = byName(baseActions);
|
|
69
|
+
const current = byName(currentActions);
|
|
70
|
+
const names = [...new Set([...base.keys(), ...current.keys()])].sort();
|
|
71
|
+
return names
|
|
72
|
+
.map((name) => ({
|
|
73
|
+
name,
|
|
74
|
+
base: [...(base.get(name) ?? [])].sort(),
|
|
75
|
+
current: [...(current.get(name) ?? [])].sort(),
|
|
76
|
+
}))
|
|
77
|
+
.filter((change) => change.base.join('\n') !== change.current.join('\n'));
|
|
78
|
+
}
|
|
79
|
+
function isContinueOnError(value) {
|
|
80
|
+
if (value === true || value === 'true')
|
|
81
|
+
return true;
|
|
82
|
+
return typeof value === 'string' && value.includes('${{');
|
|
83
|
+
}
|
|
84
|
+
function analyzeWorkflow(root, contract, baseRef) {
|
|
85
|
+
const absolutePath = resolve(root, contract.path);
|
|
86
|
+
const relativePath = relative(resolve(root), absolutePath);
|
|
87
|
+
if (relativePath === '..' || relativePath.startsWith(`..${sep}`)) {
|
|
88
|
+
return {
|
|
89
|
+
path: contract.path,
|
|
90
|
+
job: contract.job,
|
|
91
|
+
status: 'incomplete',
|
|
92
|
+
actions: [],
|
|
93
|
+
issues: [{ code: 'workflow-unreadable', message: 'Workflow path escaped the project root.' }],
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
let source;
|
|
97
|
+
try {
|
|
98
|
+
const realRoot = realpathSync(root);
|
|
99
|
+
const realWorkflowPath = realpathSync(absolutePath);
|
|
100
|
+
const resolvedRelativePath = relative(realRoot, realWorkflowPath);
|
|
101
|
+
const workflowStats = lstatSync(absolutePath);
|
|
102
|
+
if (resolvedRelativePath === '..' ||
|
|
103
|
+
resolvedRelativePath.startsWith(`..${sep}`) ||
|
|
104
|
+
workflowStats.isSymbolicLink() ||
|
|
105
|
+
!workflowStats.isFile() ||
|
|
106
|
+
workflowStats.size > 2_000_000) {
|
|
107
|
+
return {
|
|
108
|
+
path: contract.path,
|
|
109
|
+
job: contract.job,
|
|
110
|
+
status: 'incomplete',
|
|
111
|
+
actions: [],
|
|
112
|
+
issues: [
|
|
113
|
+
{
|
|
114
|
+
code: 'workflow-unreadable',
|
|
115
|
+
message: 'Workflow path is symlinked, outside the project root, not a regular file, or exceeds the 2 MB limit.',
|
|
116
|
+
},
|
|
117
|
+
],
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
source = readFileSync(absolutePath, 'utf8');
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
return {
|
|
124
|
+
path: contract.path,
|
|
125
|
+
job: contract.job,
|
|
126
|
+
status: 'policy-weakened',
|
|
127
|
+
actions: [],
|
|
128
|
+
issues: [
|
|
129
|
+
{
|
|
130
|
+
code: 'workflow-unreadable',
|
|
131
|
+
message: 'Required workflow file is missing or unreadable.',
|
|
132
|
+
},
|
|
133
|
+
],
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
const digest = createHash('sha256').update(source).digest('hex');
|
|
137
|
+
let baseWorkflow;
|
|
138
|
+
let baseWorkflowUnavailable = false;
|
|
139
|
+
if (baseRef) {
|
|
140
|
+
try {
|
|
141
|
+
const baseSource = execFileSync('git', ['show', `${baseRef}:${contract.path}`], {
|
|
142
|
+
cwd: root,
|
|
143
|
+
stdio: 'pipe',
|
|
144
|
+
timeout: 5_000,
|
|
145
|
+
maxBuffer: 2_000_000,
|
|
146
|
+
}).toString();
|
|
147
|
+
baseWorkflow = parseActions(baseSource);
|
|
148
|
+
baseWorkflowUnavailable = baseWorkflow === undefined;
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
baseWorkflowUnavailable = true;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
const document = parseDocument(source, { uniqueKeys: true, schema: 'core' });
|
|
155
|
+
if (document.errors.length > 0) {
|
|
156
|
+
return {
|
|
157
|
+
path: contract.path,
|
|
158
|
+
job: contract.job,
|
|
159
|
+
digest,
|
|
160
|
+
status: 'incomplete',
|
|
161
|
+
actions: [],
|
|
162
|
+
issues: document.errors.map((error) => ({
|
|
163
|
+
code: 'workflow-invalid',
|
|
164
|
+
message: `Workflow YAML could not be parsed: ${error.message}`,
|
|
165
|
+
})),
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
let workflow;
|
|
169
|
+
try {
|
|
170
|
+
workflow = document.toJS({ maxAliasCount: 100 });
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
return {
|
|
174
|
+
path: contract.path,
|
|
175
|
+
job: contract.job,
|
|
176
|
+
digest,
|
|
177
|
+
status: 'incomplete',
|
|
178
|
+
actions: [],
|
|
179
|
+
issues: [
|
|
180
|
+
{ code: 'workflow-invalid', message: 'Workflow YAML aliases could not be resolved.' },
|
|
181
|
+
],
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
if (!isRecord(workflow)) {
|
|
185
|
+
return {
|
|
186
|
+
path: contract.path,
|
|
187
|
+
job: contract.job,
|
|
188
|
+
digest,
|
|
189
|
+
status: 'incomplete',
|
|
190
|
+
actions: [],
|
|
191
|
+
issues: [{ code: 'workflow-invalid', message: 'Workflow root must be a YAML mapping.' }],
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
const issues = [];
|
|
195
|
+
if (baseWorkflowUnavailable) {
|
|
196
|
+
issues.push({
|
|
197
|
+
code: 'base-workflow-unavailable',
|
|
198
|
+
message: 'Could not read and parse the contracted workflow from the trusted base revision.',
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
const pullRequestEventConfig = getEventConfig(workflow.on, 'pull_request');
|
|
202
|
+
const pullRequestTargetEventConfig = getEventConfig(workflow.on, 'pull_request_target');
|
|
203
|
+
const pullRequestTypes = isRecord(pullRequestEventConfig)
|
|
204
|
+
? pullRequestEventConfig.types
|
|
205
|
+
: undefined;
|
|
206
|
+
if (contract.requirePullRequest !== false && pullRequestEventConfig === undefined) {
|
|
207
|
+
issues.push({
|
|
208
|
+
code: 'pull-request-trigger-missing',
|
|
209
|
+
message: 'Workflow is not configured for pull_request events.',
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
if (contract.requireUnfilteredPullRequest !== false &&
|
|
213
|
+
isRecord(pullRequestEventConfig) &&
|
|
214
|
+
(['paths', 'paths-ignore', 'branches', 'branches-ignore'].some((key) => Object.hasOwn(pullRequestEventConfig, key)) ||
|
|
215
|
+
(Array.isArray(pullRequestTypes) &&
|
|
216
|
+
['opened', 'synchronize', 'reopened'].some((type) => !pullRequestTypes.includes(type))))) {
|
|
217
|
+
issues.push({
|
|
218
|
+
code: 'pull-request-filtered',
|
|
219
|
+
message: 'Pull request trigger filters may prevent this workflow from running for some changes.',
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
if (pullRequestTargetEventConfig !== undefined) {
|
|
223
|
+
issues.push({
|
|
224
|
+
code: 'pull-request-target-present',
|
|
225
|
+
message: 'Workflow also listens to pull_request_target; Guard cannot prove that untrusted PR content is never executed with the elevated event context.',
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
const jobs = isRecord(workflow.jobs) ? workflow.jobs : {};
|
|
229
|
+
const actions = [
|
|
230
|
+
...new Set(Object.values(jobs).flatMap((candidate) => {
|
|
231
|
+
if (!isRecord(candidate))
|
|
232
|
+
return [];
|
|
233
|
+
return [
|
|
234
|
+
...(typeof candidate.uses === 'string' ? [candidate.uses] : []),
|
|
235
|
+
...list(candidate.steps)
|
|
236
|
+
.filter(isRecord)
|
|
237
|
+
.flatMap((step) => (typeof step.uses === 'string' ? [step.uses] : [])),
|
|
238
|
+
];
|
|
239
|
+
})),
|
|
240
|
+
].sort();
|
|
241
|
+
const changedActionReferences = baseWorkflow ? actionChanges(baseWorkflow.actions, actions) : [];
|
|
242
|
+
for (const change of changedActionReferences) {
|
|
243
|
+
issues.push({
|
|
244
|
+
code: 'action-reference-changed',
|
|
245
|
+
message: `Action references changed for "${change.name}"; review the base and proposed refs.`,
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
const job = jobs[contract.job];
|
|
249
|
+
if (!isRecord(job)) {
|
|
250
|
+
issues.push({
|
|
251
|
+
code: 'job-missing',
|
|
252
|
+
message: `Required workflow job "${contract.job}" is missing.`,
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
else {
|
|
256
|
+
if (contract.maxPermissions) {
|
|
257
|
+
const permissions = isRecord(job.permissions) ? job.permissions : workflow.permissions;
|
|
258
|
+
if (!isRecord(permissions)) {
|
|
259
|
+
issues.push({
|
|
260
|
+
code: 'permission-expansion',
|
|
261
|
+
message: 'Explicit workflow or job permissions are required by the CI integrity contract.',
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
else {
|
|
265
|
+
const levels = { none: 0, read: 1, write: 2 };
|
|
266
|
+
for (const [scope, value] of Object.entries(permissions)) {
|
|
267
|
+
if (value !== 'none' && value !== 'read' && value !== 'write') {
|
|
268
|
+
issues.push({
|
|
269
|
+
code: 'permission-expansion',
|
|
270
|
+
message: `Permission "${scope}" has an unsupported or broad value.`,
|
|
271
|
+
});
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
const ceiling = contract.maxPermissions[scope] ?? contract.maxPermissions['*'] ?? 'none';
|
|
275
|
+
if (levels[value] > levels[ceiling]) {
|
|
276
|
+
issues.push({
|
|
277
|
+
code: 'permission-expansion',
|
|
278
|
+
message: `Permission "${scope}: ${value}" exceeds the configured maximum "${ceiling}".`,
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
if (contract.requireUnconditionalJob !== false && Object.hasOwn(job, 'if')) {
|
|
285
|
+
issues.push({
|
|
286
|
+
code: 'job-conditional',
|
|
287
|
+
message: `Required workflow job "${contract.job}" has a condition that may skip it.`,
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
const runSteps = list(job.steps)
|
|
291
|
+
.filter(isRecord)
|
|
292
|
+
.filter((step) => typeof step.run === 'string');
|
|
293
|
+
const commandAnchor = contract.requiredRunIncludes[0] ?? '';
|
|
294
|
+
const invocationSteps = runSteps.filter((step) => typeof step.run === 'string' && step.run.includes(commandAnchor));
|
|
295
|
+
const invocationText = invocationSteps.map((step) => step.run).join('\n');
|
|
296
|
+
for (const fragment of contract.requiredRunIncludes) {
|
|
297
|
+
if (!invocationText.includes(fragment)) {
|
|
298
|
+
issues.push({
|
|
299
|
+
code: 'command-missing',
|
|
300
|
+
message: `Required command fragment is missing: ${fragment}`,
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
if (contract.requireUnconditionalJob !== false &&
|
|
305
|
+
invocationSteps.some((step) => Object.hasOwn(step, 'if'))) {
|
|
306
|
+
issues.push({
|
|
307
|
+
code: 'step-conditional',
|
|
308
|
+
message: `A command step in required job "${contract.job}" has a condition that may skip it.`,
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
if (contract.disallowContinueOnError !== false) {
|
|
312
|
+
if (isContinueOnError(job['continue-on-error'])) {
|
|
313
|
+
issues.push({
|
|
314
|
+
code: 'continue-on-error',
|
|
315
|
+
message: `Required workflow job "${contract.job}" allows errors to continue.`,
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
for (const step of invocationSteps) {
|
|
319
|
+
if (isContinueOnError(step['continue-on-error'])) {
|
|
320
|
+
issues.push({
|
|
321
|
+
code: 'continue-on-error',
|
|
322
|
+
message: `A step in required job "${contract.job}" allows errors to continue.`,
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
const needs = Array.isArray(job.needs)
|
|
328
|
+
? job.needs
|
|
329
|
+
: typeof job.needs === 'string'
|
|
330
|
+
? [job.needs]
|
|
331
|
+
: [];
|
|
332
|
+
for (const dependency of contract.requiredNeeds ?? []) {
|
|
333
|
+
if (!needs.includes(dependency)) {
|
|
334
|
+
issues.push({
|
|
335
|
+
code: 'dependency-missing',
|
|
336
|
+
message: `Required job dependency "${dependency}" is missing from "${contract.job}".`,
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
const dependencyJob = jobs[dependency];
|
|
340
|
+
if (isRecord(dependencyJob)) {
|
|
341
|
+
if (Object.hasOwn(dependencyJob, 'if')) {
|
|
342
|
+
issues.push({
|
|
343
|
+
code: 'dependency-conditional',
|
|
344
|
+
message: `Required dependency job "${dependency}" has a condition that may skip it.`,
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
if (isContinueOnError(dependencyJob['continue-on-error'])) {
|
|
348
|
+
issues.push({
|
|
349
|
+
code: 'dependency-continue-on-error',
|
|
350
|
+
message: `Required dependency job "${dependency}" allows errors to continue.`,
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
if (contract.requireFullShaActions !== false) {
|
|
357
|
+
for (const [jobId, candidate] of Object.entries(jobs)) {
|
|
358
|
+
if (!isRecord(candidate))
|
|
359
|
+
continue;
|
|
360
|
+
if (typeof candidate.uses === 'string' && !usesFullShaPin(candidate.uses)) {
|
|
361
|
+
issues.push({
|
|
362
|
+
code: 'action-not-sha-pinned',
|
|
363
|
+
message: `Reusable workflow in job "${jobId}" is not pinned to an immutable full commit SHA.`,
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
for (const step of list(candidate.steps).filter(isRecord)) {
|
|
367
|
+
if (typeof step.uses === 'string' && !usesFullShaPin(step.uses)) {
|
|
368
|
+
issues.push({
|
|
369
|
+
code: 'action-not-sha-pinned',
|
|
370
|
+
message: `Action "${step.uses}" in job "${jobId}" is not pinned to an immutable full commit SHA.`,
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
return {
|
|
377
|
+
path: contract.path,
|
|
378
|
+
job: contract.job,
|
|
379
|
+
digest,
|
|
380
|
+
...(baseWorkflow ? { baseDigest: baseWorkflow.digest, baseActions: baseWorkflow.actions } : {}),
|
|
381
|
+
actions,
|
|
382
|
+
...(baseWorkflow ? { actionChanges: changedActionReferences } : {}),
|
|
383
|
+
status: baseWorkflowUnavailable ? 'incomplete' : issues.length === 0 ? 'ok' : 'policy-weakened',
|
|
384
|
+
issues,
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
export function auditGitHubActions(root, policy, baseRef) {
|
|
388
|
+
if (!policy)
|
|
389
|
+
return undefined;
|
|
390
|
+
const workflows = policy.workflows.map((contract) => analyzeWorkflow(root, contract, baseRef));
|
|
391
|
+
const status = workflows.some((workflow) => workflow.status === 'incomplete')
|
|
392
|
+
? 'incomplete'
|
|
393
|
+
: workflows.some((workflow) => workflow.status === 'policy-weakened')
|
|
394
|
+
? 'policy-weakened'
|
|
395
|
+
: 'ok';
|
|
396
|
+
return { status, workflows };
|
|
397
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export interface GuardGitHubContext {
|
|
2
|
+
event: string;
|
|
3
|
+
headSha?: string | undefined;
|
|
4
|
+
baseSha?: string | undefined;
|
|
5
|
+
mergeSha?: string | undefined;
|
|
6
|
+
}
|
|
7
|
+
/** Read GitHub's event payload and bind receipts to the code context actually checked out. */
|
|
8
|
+
export declare function readGuardGitHubContext(eventName: string | undefined, eventPath: string | undefined, checkedOutSha: string | undefined, eventSha: string | undefined): GuardGitHubContext | undefined;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
const GIT_SHA_PATTERN = /^[a-f0-9]{40}$/i;
|
|
3
|
+
function validSha(value) {
|
|
4
|
+
return typeof value === 'string' && GIT_SHA_PATTERN.test(value) ? value.toLowerCase() : undefined;
|
|
5
|
+
}
|
|
6
|
+
/** Read GitHub's event payload and bind receipts to the code context actually checked out. */
|
|
7
|
+
export function readGuardGitHubContext(eventName, eventPath, checkedOutSha, eventSha) {
|
|
8
|
+
if (!eventName)
|
|
9
|
+
return undefined;
|
|
10
|
+
let payload;
|
|
11
|
+
if (eventPath &&
|
|
12
|
+
(eventName === 'pull_request' ||
|
|
13
|
+
eventName === 'pull_request_target' ||
|
|
14
|
+
eventName === 'workflow_run')) {
|
|
15
|
+
try {
|
|
16
|
+
payload = JSON.parse(readFileSync(eventPath, 'utf8'));
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
// Preserve available context; missing event data is represented by absent SHAs.
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
const pullRequest = payload?.pull_request ??
|
|
23
|
+
(eventName === 'workflow_run' ? payload?.workflow_run?.pull_requests?.[0] : undefined);
|
|
24
|
+
const headSha = validSha(pullRequest?.head?.sha);
|
|
25
|
+
const baseSha = validSha(pullRequest?.base?.sha);
|
|
26
|
+
const currentSha = validSha(checkedOutSha);
|
|
27
|
+
const githubSha = validSha(eventSha);
|
|
28
|
+
const context = { event: eventName };
|
|
29
|
+
if (headSha)
|
|
30
|
+
context.headSha = headSha;
|
|
31
|
+
if (baseSha)
|
|
32
|
+
context.baseSha = baseSha;
|
|
33
|
+
if (eventName !== 'workflow_run' &&
|
|
34
|
+
currentSha &&
|
|
35
|
+
headSha &&
|
|
36
|
+
currentSha !== headSha &&
|
|
37
|
+
currentSha === githubSha) {
|
|
38
|
+
context.mergeSha = currentSha;
|
|
39
|
+
}
|
|
40
|
+
return context;
|
|
41
|
+
}
|
|
@@ -3,7 +3,7 @@ import type { ProjectCheck } from '../model/types.js';
|
|
|
3
3
|
import { type GuardAdapter, type ProjectCheckResults, type ProjectRuntimeDiagnostics } from '../../adapters/project-checks.js';
|
|
4
4
|
import { type GuardToolMode, type GuardContractIssue, type GuardReport } from '../guard.js';
|
|
5
5
|
import { type GuardImpactAnalysis } from '../analysis/impact.js';
|
|
6
|
-
import { type GuardRunManifest, type GuardRunPolicy } from '../history/runs.js';
|
|
6
|
+
import { type GuardRunManifest, type GuardRunPolicy, type GuardVerificationCiIntegrity } from '../history/runs.js';
|
|
7
7
|
export type GuardVerificationCheck = ProjectCheck;
|
|
8
8
|
export interface GuardVerificationOptions {
|
|
9
9
|
checks?: GuardVerificationCheck[] | undefined;
|
|
@@ -15,11 +15,12 @@ export interface GuardVerificationOptions {
|
|
|
15
15
|
projectChecks?: boolean | undefined;
|
|
16
16
|
policyBase?: string | undefined;
|
|
17
17
|
failOnPolicyChange?: boolean | undefined;
|
|
18
|
+
failOnProtectedChanges?: boolean | undefined;
|
|
18
19
|
}
|
|
19
20
|
export interface GuardVerificationResult {
|
|
20
|
-
status: 'ok' | 'fail' | 'not-configured';
|
|
21
|
-
outcome: 'pass' | 'fail' | 'warning' | 'needs-review' | 'not-configured';
|
|
22
|
-
errorCode?: 'GUARD_NOT_CONFIGURED' | 'GUARD_CONFIG_INVALID' | 'GUARD_POLICY_BASE_INVALID' | 'GUARD_POLICY_CHANGED' | undefined;
|
|
21
|
+
status: 'ok' | 'fail' | 'incomplete' | 'policy-weakened' | 'not-configured';
|
|
22
|
+
outcome: 'pass' | 'fail' | 'incomplete' | 'policy-weakened' | 'warning' | 'needs-review' | 'not-configured';
|
|
23
|
+
errorCode?: 'GUARD_NOT_CONFIGURED' | 'GUARD_CONFIG_INVALID' | 'GUARD_POLICY_BASE_INVALID' | 'GUARD_POLICY_CHANGED' | 'GUARD_PROTECTED_PATH_CHANGED' | 'GUARD_CI_POLICY_WEAKENED' | 'GUARD_CI_POLICY_INCOMPLETE' | 'GUARD_REQUIRED_STAGE_INCOMPLETE' | 'GUARD_POLICY_SURFACE_INCOMPLETE' | undefined;
|
|
23
24
|
recoverable?: boolean | undefined;
|
|
24
25
|
tools?: GuardToolMode | undefined;
|
|
25
26
|
checks: ProjectCheckResults;
|
|
@@ -38,5 +39,23 @@ export interface GuardVerificationResult {
|
|
|
38
39
|
run: GuardRunManifest;
|
|
39
40
|
policy?: GuardRunPolicy | undefined;
|
|
40
41
|
policyChanged?: boolean | undefined;
|
|
42
|
+
policySurface?: GuardPolicySurface | undefined;
|
|
43
|
+
ciIntegrity?: GuardVerificationCiIntegrity | undefined;
|
|
44
|
+
requiredStages?: string[] | undefined;
|
|
45
|
+
incompleteStages?: string[] | undefined;
|
|
46
|
+
}
|
|
47
|
+
interface GuardPolicySurface {
|
|
48
|
+
protectedPaths: string[];
|
|
49
|
+
changedPaths: string[];
|
|
50
|
+
changes: {
|
|
51
|
+
path: string;
|
|
52
|
+
pattern: string;
|
|
53
|
+
kind: string;
|
|
54
|
+
severity: 'warning' | 'error';
|
|
55
|
+
}[];
|
|
56
|
+
suppressionMarkers: string[];
|
|
57
|
+
workflowChanged: boolean;
|
|
58
|
+
complete: boolean;
|
|
41
59
|
}
|
|
42
60
|
export declare function runGuardVerification(root: string, options?: GuardVerificationOptions): GuardVerificationResult;
|
|
61
|
+
export {};
|