@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.
Files changed (40) hide show
  1. package/README.md +144 -4
  2. package/dist/adapters/agents/agent-integration.js +26 -3
  3. package/dist/cli/commands/guard.d.ts +2 -0
  4. package/dist/cli/commands/guard.js +77 -11
  5. package/dist/cli/index.js +2 -0
  6. package/dist/core/guard.d.ts +28 -1
  7. package/dist/core/guard.js +37 -0
  8. package/dist/core/history/runs.d.ts +64 -1
  9. package/dist/core/history/runs.js +109 -0
  10. package/dist/core/output/attestation.d.ts +17 -0
  11. package/dist/core/output/attestation.js +22 -0
  12. package/dist/core/policy/schemas.d.ts +47 -0
  13. package/dist/core/policy/schemas.js +67 -0
  14. package/dist/core/verification/github-actions.d.ts +25 -0
  15. package/dist/core/verification/github-actions.js +397 -0
  16. package/dist/core/verification/github-context.d.ts +8 -0
  17. package/dist/core/verification/github-context.js +41 -0
  18. package/dist/core/verification/verify.d.ts +23 -4
  19. package/dist/core/verification/verify.js +601 -47
  20. package/dist/index.d.ts +1 -0
  21. package/dist/index.js +1 -0
  22. package/dist/mcp/prompts.js +7 -4
  23. package/dist/mcp/tools/guard.js +9 -3
  24. package/docs/extensions.md +79 -0
  25. package/docs/guard-agent-integration.md +51 -0
  26. package/docs/guard-ci.yml +202 -0
  27. package/docs/guard-fixtures.md +127 -0
  28. package/docs/guard-trusted-pr-attestation.yml +213 -0
  29. package/docs/guard.md +1125 -0
  30. package/docs/integrations/README.md +38 -0
  31. package/docs/integrations/claude-code.md +92 -0
  32. package/docs/integrations/codex.md +58 -0
  33. package/docs/integrations/cursor.md +116 -0
  34. package/docs/integrations/gemini-cli.md +47 -0
  35. package/docs/integrations/generic.md +41 -0
  36. package/docs/integrations/github-copilot.md +72 -0
  37. package/docs/policy-mutations.md +75 -0
  38. package/docs/releasing.md +58 -0
  39. package/docs/verify-guard-attestation.mjs +166 -0
  40. 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