@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,213 @@
|
|
|
1
|
+
name: Guard Trusted PR Attestation
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
workflow_run:
|
|
5
|
+
workflows: [Guard]
|
|
6
|
+
types: [completed]
|
|
7
|
+
|
|
8
|
+
permissions: {}
|
|
9
|
+
|
|
10
|
+
jobs:
|
|
11
|
+
inspect:
|
|
12
|
+
if: github.event.workflow_run.event == 'pull_request'
|
|
13
|
+
runs-on: ubuntu-latest
|
|
14
|
+
outputs:
|
|
15
|
+
number: ${{ steps.pr.outputs.number }}
|
|
16
|
+
head_sha: ${{ steps.pr.outputs.head_sha }}
|
|
17
|
+
base_sha: ${{ steps.pr.outputs.base_sha }}
|
|
18
|
+
fork: ${{ steps.pr.outputs.fork }}
|
|
19
|
+
steps:
|
|
20
|
+
- name: Read the associated pull request context
|
|
21
|
+
id: pr
|
|
22
|
+
env:
|
|
23
|
+
EVENT_PATH: ${{ github.event_path }}
|
|
24
|
+
run: |
|
|
25
|
+
node --input-type=module <<'NODE'
|
|
26
|
+
import { appendFileSync, readFileSync } from 'node:fs';
|
|
27
|
+
const event = JSON.parse(readFileSync(process.env.EVENT_PATH, 'utf8'));
|
|
28
|
+
const pullRequests = event.workflow_run?.pull_requests ?? [];
|
|
29
|
+
const isRepository = (value) => typeof value === 'string' && /^[^/\s]+\/[^/\s]+$/.test(value);
|
|
30
|
+
if (pullRequests.length === 0) {
|
|
31
|
+
const runHeadRepository = event.workflow_run?.head_repository?.full_name;
|
|
32
|
+
const eventBaseRepository = event.repository?.full_name;
|
|
33
|
+
if (!isRepository(runHeadRepository) || !isRepository(eventBaseRepository) || runHeadRepository.toLowerCase() === eventBaseRepository.toLowerCase()) {
|
|
34
|
+
throw new Error('Expected exactly one associated pull request; could not safely identify an unattached fork run.');
|
|
35
|
+
}
|
|
36
|
+
appendFileSync(process.env.GITHUB_OUTPUT, 'fork=true\n');
|
|
37
|
+
} else {
|
|
38
|
+
if (pullRequests.length !== 1) throw new Error('Expected exactly one associated pull request.');
|
|
39
|
+
const pullRequest = pullRequests[0];
|
|
40
|
+
const number = pullRequest.number;
|
|
41
|
+
const headSha = pullRequest.head?.sha;
|
|
42
|
+
const baseSha = pullRequest.base?.sha;
|
|
43
|
+
const headRepository = pullRequest.head?.repo?.full_name;
|
|
44
|
+
const baseRepository = pullRequest.base?.repo?.full_name;
|
|
45
|
+
if (!Number.isSafeInteger(number) || number < 1 || !/^[a-f0-9]{40}$/i.test(headSha ?? '') || !/^[a-f0-9]{40}$/i.test(baseSha ?? '') || !isRepository(headRepository) || !isRepository(baseRepository)) {
|
|
46
|
+
throw new Error('Pull request number, repository, head SHA, or base SHA is missing or invalid.');
|
|
47
|
+
}
|
|
48
|
+
const fork = headRepository.toLowerCase() !== baseRepository.toLowerCase();
|
|
49
|
+
appendFileSync(process.env.GITHUB_OUTPUT, `number=${number}\nhead_sha=${headSha}\nbase_sha=${baseSha}\nfork=${fork}\n`);
|
|
50
|
+
}
|
|
51
|
+
NODE
|
|
52
|
+
|
|
53
|
+
verify:
|
|
54
|
+
needs: inspect
|
|
55
|
+
if: needs.inspect.result == 'success' && needs.inspect.outputs.fork != 'true'
|
|
56
|
+
runs-on: ubuntu-latest
|
|
57
|
+
permissions:
|
|
58
|
+
contents: read
|
|
59
|
+
outputs:
|
|
60
|
+
attestation: ${{ steps.result.outputs.attestation }}
|
|
61
|
+
steps:
|
|
62
|
+
- name: Check out the PR head from the base repository
|
|
63
|
+
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
64
|
+
with:
|
|
65
|
+
ref: refs/pull/${{ needs.inspect.outputs.number }}/head
|
|
66
|
+
fetch-depth: 0
|
|
67
|
+
|
|
68
|
+
- name: Fetch the exact base commit and verify the checkout
|
|
69
|
+
env:
|
|
70
|
+
BASE_SHA: ${{ needs.inspect.outputs.base_sha }}
|
|
71
|
+
HEAD_SHA: ${{ needs.inspect.outputs.head_sha }}
|
|
72
|
+
run: |
|
|
73
|
+
git fetch --no-tags origin "$BASE_SHA"
|
|
74
|
+
actual_head=$(git rev-parse HEAD)
|
|
75
|
+
if [ "$actual_head" != "$HEAD_SHA" ]; then
|
|
76
|
+
echo "Checked out $actual_head, expected PR head $HEAD_SHA" >&2
|
|
77
|
+
exit 1
|
|
78
|
+
fi
|
|
79
|
+
|
|
80
|
+
- name: Set up Node.js
|
|
81
|
+
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
|
|
82
|
+
with:
|
|
83
|
+
node-version: 22
|
|
84
|
+
|
|
85
|
+
- name: Fetch the pinned Guard source revision
|
|
86
|
+
working-directory: ${{ runner.temp }}
|
|
87
|
+
env:
|
|
88
|
+
GUARD_VERSION: ${{ vars.CODAPULT_GUARD_VERSION }}
|
|
89
|
+
GUARD_COMMIT: ${{ vars.CODAPULT_GUARD_COMMIT }}
|
|
90
|
+
run: |
|
|
91
|
+
if ! [[ "$GUARD_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then
|
|
92
|
+
echo "Set CODAPULT_GUARD_VERSION to an exact Guard release version." >&2
|
|
93
|
+
exit 1
|
|
94
|
+
fi
|
|
95
|
+
if ! [[ "$GUARD_COMMIT" =~ ^[a-f0-9]{40}$ ]]; then
|
|
96
|
+
echo "Set CODAPULT_GUARD_COMMIT to the full 40-character commit SHA for that Guard release tag." >&2
|
|
97
|
+
exit 1
|
|
98
|
+
fi
|
|
99
|
+
git init codapult-guard-source
|
|
100
|
+
git -C codapult-guard-source remote add origin https://github.com/codapult/codapult-guard.git
|
|
101
|
+
git -C codapult-guard-source fetch --depth=1 origin "refs/tags/v$GUARD_VERSION:refs/tags/guard-release"
|
|
102
|
+
fetched_commit=$(git -C codapult-guard-source rev-parse 'refs/tags/guard-release^{commit}')
|
|
103
|
+
if [ "$fetched_commit" != "$GUARD_COMMIT" ]; then
|
|
104
|
+
echo "Guard release tag resolved to $fetched_commit, expected $GUARD_COMMIT." >&2
|
|
105
|
+
exit 1
|
|
106
|
+
fi
|
|
107
|
+
git -C codapult-guard-source checkout --detach "$GUARD_COMMIT"
|
|
108
|
+
if [ "$(node -p "require('./codapult-guard-source/package.json').version")" != "$GUARD_VERSION" ]; then
|
|
109
|
+
echo "Guard source package version does not match CODAPULT_GUARD_VERSION." >&2
|
|
110
|
+
exit 1
|
|
111
|
+
fi
|
|
112
|
+
|
|
113
|
+
- name: Build pinned Guard with its frozen dependency graph
|
|
114
|
+
working-directory: ${{ runner.temp }}/codapult-guard-source
|
|
115
|
+
run: |
|
|
116
|
+
corepack enable
|
|
117
|
+
corepack pnpm install --frozen-lockfile --ignore-scripts
|
|
118
|
+
corepack pnpm --filter @codapult/guard build
|
|
119
|
+
|
|
120
|
+
- name: Verify PR with trusted Guard and base policy
|
|
121
|
+
env:
|
|
122
|
+
BASE_SHA: ${{ needs.inspect.outputs.base_sha }}
|
|
123
|
+
GUARD_RESULT_FILE: ${{ runner.temp }}/guard-verification.json
|
|
124
|
+
GUARD_STATEMENT_FILE: ${{ runner.temp }}/guard-verification.intoto.json
|
|
125
|
+
run: |
|
|
126
|
+
node "$RUNNER_TEMP/codapult-guard-source/dist/cli/index.js" verify \
|
|
127
|
+
--no-project-checks \
|
|
128
|
+
--tools off \
|
|
129
|
+
--policy-base "$BASE_SHA" \
|
|
130
|
+
--fail-on-policy-change \
|
|
131
|
+
--fail-on-protected-changes \
|
|
132
|
+
--json \
|
|
133
|
+
--attestation-out "$GUARD_STATEMENT_FILE" \
|
|
134
|
+
> "$GUARD_RESULT_FILE"
|
|
135
|
+
|
|
136
|
+
- name: Confirm the receipt matches this PR
|
|
137
|
+
id: result
|
|
138
|
+
env:
|
|
139
|
+
BASE_SHA: ${{ needs.inspect.outputs.base_sha }}
|
|
140
|
+
HEAD_SHA: ${{ needs.inspect.outputs.head_sha }}
|
|
141
|
+
GUARD_RESULT_FILE: ${{ runner.temp }}/guard-verification.json
|
|
142
|
+
GUARD_STATEMENT_FILE: ${{ runner.temp }}/guard-verification.intoto.json
|
|
143
|
+
run: |
|
|
144
|
+
node --input-type=module <<'NODE'
|
|
145
|
+
import { appendFileSync, readFileSync } from 'node:fs';
|
|
146
|
+
const result = JSON.parse(readFileSync(process.env.GUARD_RESULT_FILE, 'utf8'));
|
|
147
|
+
const statement = JSON.parse(readFileSync(process.env.GUARD_STATEMENT_FILE, 'utf8'));
|
|
148
|
+
const receipt = result.run?.receipt;
|
|
149
|
+
const scope = receipt?.verificationScope;
|
|
150
|
+
const statementReceipt = statement?.predicate?.receipt;
|
|
151
|
+
if (result.outcome !== 'pass' || receipt?.outcome !== 'pass') throw new Error('Guard did not pass.');
|
|
152
|
+
if (receipt.version !== 1 || receipt.kind !== 'verification-receipt' || typeof receipt.runId !== 'string' || receipt.runId.length === 0) {
|
|
153
|
+
throw new Error('Guard receipt has an unsupported schema or no run ID.');
|
|
154
|
+
}
|
|
155
|
+
if (receipt.subject?.headSha !== process.env.HEAD_SHA || receipt.subject?.baseSha !== process.env.BASE_SHA) {
|
|
156
|
+
throw new Error('Guard receipt does not match the triggering PR head and base SHAs.');
|
|
157
|
+
}
|
|
158
|
+
if (scope?.algorithm !== 'sha256' || scope.complete !== true || scope.stable !== true || !/^[a-f0-9]{64}$/.test(scope.fingerprint ?? '')) {
|
|
159
|
+
throw new Error('Guard receipt has no complete, stable verification scope.');
|
|
160
|
+
}
|
|
161
|
+
if (statement?._type !== 'https://in-toto.io/Statement/v1' || statement.predicateType !== 'https://codapult.dev/attestations/guard-verification/v1') {
|
|
162
|
+
throw new Error('in-toto statement or Guard predicate type is invalid.');
|
|
163
|
+
}
|
|
164
|
+
if (!Array.isArray(statement.subject) || statement.subject.length !== 1 || statement.subject[0]?.name !== 'codapult-guard:verification-context' || statement.subject[0]?.digest?.sha256 !== scope.fingerprint) {
|
|
165
|
+
throw new Error('in-toto statement subject does not match the verified scope.');
|
|
166
|
+
}
|
|
167
|
+
if (JSON.stringify(statementReceipt) !== JSON.stringify(receipt)) {
|
|
168
|
+
throw new Error('in-toto statement does not match the verified receipt and scope.');
|
|
169
|
+
}
|
|
170
|
+
appendFileSync(process.env.GITHUB_OUTPUT, 'attestation=true\n');
|
|
171
|
+
NODE
|
|
172
|
+
|
|
173
|
+
- name: Upload the independently verified statement
|
|
174
|
+
if: steps.result.outcome == 'success'
|
|
175
|
+
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
|
176
|
+
with:
|
|
177
|
+
name: guard-trusted-pr-attestation
|
|
178
|
+
path: ${{ runner.temp }}/guard-verification.intoto.json
|
|
179
|
+
if-no-files-found: error
|
|
180
|
+
|
|
181
|
+
fork-pr-summary:
|
|
182
|
+
needs: inspect
|
|
183
|
+
if: needs.inspect.result == 'success' && needs.inspect.outputs.fork == 'true'
|
|
184
|
+
runs-on: ubuntu-latest
|
|
185
|
+
permissions: {}
|
|
186
|
+
steps:
|
|
187
|
+
- name: Explain why this fork PR has no attestation
|
|
188
|
+
run: |
|
|
189
|
+
cat >> "$GITHUB_STEP_SUMMARY" <<'SUMMARY'
|
|
190
|
+
## Guard attestation skipped
|
|
191
|
+
|
|
192
|
+
This pull request comes from a fork. The trusted workflow did not check out or inspect fork-controlled code, and it did not create a signed attestation. Checkout's fork protection remains enabled. The ordinary Guard pull request check still reports its own result.
|
|
193
|
+
SUMMARY
|
|
194
|
+
|
|
195
|
+
attest:
|
|
196
|
+
needs: [inspect, verify]
|
|
197
|
+
if: needs.inspect.result == 'success' && needs.inspect.outputs.fork != 'true' && needs.verify.result == 'success' && needs.verify.outputs.attestation == 'true'
|
|
198
|
+
runs-on: ubuntu-latest
|
|
199
|
+
permissions:
|
|
200
|
+
contents: read
|
|
201
|
+
id-token: write
|
|
202
|
+
attestations: write
|
|
203
|
+
artifact-metadata: write
|
|
204
|
+
steps:
|
|
205
|
+
- name: Download the trusted verifier output
|
|
206
|
+
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
|
207
|
+
with:
|
|
208
|
+
name: guard-trusted-pr-attestation
|
|
209
|
+
|
|
210
|
+
- name: Sign the in-toto statement file
|
|
211
|
+
uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
|
|
212
|
+
with:
|
|
213
|
+
subject-path: guard-verification.intoto.json
|