diff --git a/packages/code-storage-go/repo.go b/packages/code-storage-go/repo.go index b7aa25a..723b99c 100644 --- a/packages/code-storage-go/repo.go +++ b/packages/code-storage-go/repo.go @@ -554,18 +554,25 @@ func (r *Repo) GetCommit(ctx context.Context, options GetCommitOptions) (GetComm return GetCommitResult{}, err } - return GetCommitResult{ - Commit: CommitInfo{ - SHA: payload.Commit.SHA, - Message: payload.Commit.Message, - AuthorName: payload.Commit.AuthorName, - AuthorEmail: payload.Commit.AuthorEmail, - CommitterName: payload.Commit.CommitterName, - CommitterEmail: payload.Commit.CommitterEmail, - Date: parseTime(payload.Commit.Date), - RawDate: payload.Commit.Date, - }, - }, nil + commit := CommitInfo{ + SHA: payload.Commit.SHA, + Message: payload.Commit.Message, + AuthorName: payload.Commit.AuthorName, + AuthorEmail: payload.Commit.AuthorEmail, + CommitterName: payload.Commit.CommitterName, + CommitterEmail: payload.Commit.CommitterEmail, + Date: parseTime(payload.Commit.Date), + RawDate: payload.Commit.Date, + } + // Only surface these for signed commits, which carry both the armored + // signature and the signed payload. If either is missing the commit is + // treated as unsigned and neither field is set. + if payload.Commit.Signature != "" && payload.Commit.Payload != "" { + commit.Signature = payload.Commit.Signature + commit.Payload = payload.Commit.Payload + } + + return GetCommitResult{Commit: commit}, nil } // GetBlame returns per-line authorship for a file at a ref. diff --git a/packages/code-storage-go/repo_test.go b/packages/code-storage-go/repo_test.go index d89d2cb..4651c9d 100644 --- a/packages/code-storage-go/repo_test.go +++ b/packages/code-storage-go/repo_test.go @@ -142,6 +142,30 @@ func TestRemoteURLRefs(t *testing.T) { } }) + t.Run("includes verify-sig op in refs claim", func(t *testing.T) { + remote, err := repo.RemoteURL(nil, RemoteURLOptions{ + RefPolicies: RefPolicyList{ + {Pattern: "refs/heads/main", Ops: Ops{OpVerifySig}}, + }, + }) + if err != nil { + t.Fatalf("remote url error: %v", err) + } + claims := parseJWTFromURL(t, remote) + refs, ok := claims["refs"].([]interface{}) + if !ok || len(refs) != 1 { + t.Fatalf("expected 1 ref rule, got %v", claims["refs"]) + } + rule, ok := refs[0].([]interface{}) + if !ok || len(rule) != 2 { + t.Fatalf("unexpected rule shape: %v", refs[0]) + } + ops, ok := rule[1].([]interface{}) + if !ok || len(ops) != 1 || ops[0] != "verify-sig" { + t.Fatalf("unexpected ops: %v", rule[1]) + } + }) + t.Run("omits refs from JWT when not provided", func(t *testing.T) { remote, err := repo.RemoteURL(nil, RemoteURLOptions{}) if err != nil { @@ -1938,7 +1962,7 @@ func TestGetCommit(t *testing.T) { t.Fatalf("unexpected sha query: %q", got) } w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"commit":{"sha":"abc123","message":"feat: add endpoint","author_name":"Jane Doe","author_email":"jane@example.com","committer_name":"Jane Doe","committer_email":"jane@example.com","date":"2024-01-15T14:32:18Z"}}`)) + _, _ = w.Write([]byte(`{"commit":{"sha":"abc123","message":"feat: add endpoint","author_name":"Jane Doe","author_email":"jane@example.com","committer_name":"Jane Doe","committer_email":"jane@example.com","date":"2024-01-15T14:32:18Z","signature":"-----BEGIN PGP SIGNATURE-----\nABC\n-----END PGP SIGNATURE-----\n","payload":"tree deadbeef\nauthor Jane Doe 1700000000 +0000\n"}}`)) })) defer server.Close() @@ -1964,6 +1988,34 @@ func TestGetCommit(t *testing.T) { if result.Commit.RawDate != "2024-01-15T14:32:18Z" || result.Commit.Date.IsZero() { t.Fatalf("unexpected date: %+v", result.Commit) } + if !strings.Contains(result.Commit.Signature, "BEGIN PGP SIGNATURE") { + t.Fatalf("unexpected signature: %q", result.Commit.Signature) + } + if !strings.HasPrefix(result.Commit.Payload, "tree deadbeef") { + t.Fatalf("unexpected payload: %q", result.Commit.Payload) + } +} + +func TestGetCommitUnsigned(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"commit":{"sha":"abc123","message":"chore: noop","author_name":"Jane Doe","author_email":"jane@example.com","committer_name":"Jane Doe","committer_email":"jane@example.com","date":"2024-01-15T14:32:18Z"}}`)) + })) + defer server.Close() + + client, err := NewClient(Options{Name: "acme", Key: testKey, APIBaseURL: server.URL}) + if err != nil { + t.Fatalf("client error: %v", err) + } + repo := &Repo{ID: "repo", DefaultBranch: "main", client: client} + + result, err := repo.GetCommit(nil, GetCommitOptions{SHA: "abc123"}) + if err != nil { + t.Fatalf("get commit error: %v", err) + } + if result.Commit.Signature != "" || result.Commit.Payload != "" { + t.Fatalf("expected empty signature/payload for unsigned commit, got %+v", result.Commit) + } } func TestGetCommitRequiresSHA(t *testing.T) { diff --git a/packages/code-storage-go/responses.go b/packages/code-storage-go/responses.go index f4bf33a..f3e8577 100644 --- a/packages/code-storage-go/responses.go +++ b/packages/code-storage-go/responses.go @@ -27,7 +27,7 @@ type fileWithMetadataRaw struct { Mode string `json:"mode"` Size int64 `json:"size"` LastCommitSHA string `json:"last_commit_sha"` - Type string `json:"type,omitempty"` + Type string `json:"type"` } type commitMetadataRaw struct { @@ -56,7 +56,7 @@ type listCommitsResponse struct { } type getCommitResponse struct { - Commit commitInfoRaw `json:"commit"` + Commit commitInfoWithSignatureRaw `json:"commit"` } type blameResponse struct { @@ -71,7 +71,7 @@ type blameLineRaw struct { CommitSHA string `json:"commit_sha"` OriginalLineNumber int32 `json:"original_line_number"` OriginalPath string `json:"original_path"` - PreviousCommitSHA string `json:"previous_commit_sha,omitempty"` + PreviousCommitSHA string `json:"previous_commit_sha"` AuthorName string `json:"author_name"` AuthorEmail string `json:"author_email"` AuthorTime string `json:"author_time"` @@ -91,6 +91,15 @@ type commitInfoRaw struct { Date string `json:"date"` } +// commitInfoWithSignatureRaw extends commitInfoRaw with the signature details +// the single-commit endpoint returns for signed commits. Both fields are +// omitted for unsigned commits. +type commitInfoWithSignatureRaw struct { + commitInfoRaw + Signature string `json:"signature"` + Payload string `json:"payload"` +} + type listReposResponse struct { Repos []repoInfoRaw `json:"repos"` NextCursor string `json:"next_cursor"` @@ -185,7 +194,7 @@ type mergeResponse struct { TreeSHA string `json:"tree_sha"` Source mergeSourceRaw `json:"source"` Target mergeTargetRaw `json:"target"` - MergeBaseSHA string `json:"merge_base_sha,omitempty"` + MergeBaseSHA string `json:"merge_base_sha"` PromotedCommits int `json:"promoted_commits"` } diff --git a/packages/code-storage-go/types.go b/packages/code-storage-go/types.go index 0f18e8d..30f7cb8 100644 --- a/packages/code-storage-go/types.go +++ b/packages/code-storage-go/types.go @@ -36,6 +36,9 @@ type Op = string const ( OpNoForcePush Op = "no-force-push" OpNoPush Op = "no-push" + // OpVerifySig requires every commit introduced by a push to a matching ref + // to carry a valid signature from a registered signing key. + OpVerifySig Op = "verify-sig" ) // Ops is a list of policy operations. @@ -551,6 +554,15 @@ type CommitInfo struct { CommitterEmail string Date time.Time RawDate string + // Signature is the armored OpenPGP/SSH signature from the commit's gpgsig + // header. Only populated by GetCommit for signed commits. Always empty for + // ListCommits entries and unsigned commits. + Signature string + // Payload is the exact bytes the signature is computed over (the raw commit + // object with the gpgsig header removed). Only populated by GetCommit for + // signed commits. Always empty for ListCommits entries and unsigned + // commits. + Payload string } // ListCommitsResult describes commits list. @@ -566,7 +578,8 @@ type GetCommitOptions struct { SHA string } -// GetCommitResult is the result returned by Repo.GetCommit. +// GetCommitResult is the result returned by Repo.GetCommit. For signed commits +// the returned CommitInfo carries the armored Signature and signed Payload. type GetCommitResult struct { Commit CommitInfo } diff --git a/packages/code-storage-go/version.go b/packages/code-storage-go/version.go index e00ce03..4f73482 100644 --- a/packages/code-storage-go/version.go +++ b/packages/code-storage-go/version.go @@ -2,7 +2,7 @@ package storage const ( PackageName = "code-storage-go-sdk" - PackageVersion = "0.9.0" + PackageVersion = "0.10.0" ) func userAgent() string { diff --git a/packages/code-storage-python/README.md b/packages/code-storage-python/README.md index d466d46..2bccc6d 100644 --- a/packages/code-storage-python/README.md +++ b/packages/code-storage-python/README.md @@ -273,6 +273,11 @@ print(commits["commits"]) # Get a single commit's metadata (no diff) result = await repo.get_commit(sha="abc123...") print(result["commit"]["message"], result["commit"]["author_name"]) +# Signed commits also include the armored signature plus the exact signed +# payload (raw commit object minus the gpgsig header) so you can verify it +# yourself. Both keys are absent for unsigned commits. +if "signature" in result["commit"]: + print(result["commit"]["signature"], result["commit"]["payload"]) # Blame a file (per-line authorship). The top-level "commit_sha" is the SHA the # `ref` resolved to; each entry in "lines" carries its own author/committer diff --git a/packages/code-storage-python/pierre_storage/__init__.py b/packages/code-storage-python/pierre_storage/__init__.py index 35a2c89..ad92b6c 100644 --- a/packages/code-storage-python/pierre_storage/__init__.py +++ b/packages/code-storage-python/pierre_storage/__init__.py @@ -9,6 +9,7 @@ from pierre_storage.types import ( OP_NO_FORCE_PUSH, OP_NO_PUSH, + OP_VERIFY_SIG, BaseRepo, BlameLine, BlameResult, @@ -101,6 +102,7 @@ "Op", "OP_NO_FORCE_PUSH", "OP_NO_PUSH", + "OP_VERIFY_SIG", "Ops", "RefPolicy", "Refs", diff --git a/packages/code-storage-python/pierre_storage/repo.py b/packages/code-storage-python/pierre_storage/repo.py index f03fd57..c6cc7e7 100644 --- a/packages/code-storage-python/pierre_storage/repo.py +++ b/packages/code-storage-python/pierre_storage/repo.py @@ -1390,6 +1390,15 @@ async def get_commit( "date": date, "raw_date": commit_raw["date"], } + # Only surface these for signed commits, which carry both the + # armored signature and the signed payload. If either is missing + # (absent/null/empty) the commit is treated as unsigned and + # neither key is attached. + signature = commit_raw.get("signature") + payload = commit_raw.get("payload") + if signature and payload: + commit["signature"] = signature + commit["payload"] = payload return {"commit": commit} async def get_blame( diff --git a/packages/code-storage-python/pierre_storage/types.py b/packages/code-storage-python/pierre_storage/types.py index 9094e2e..9a32f00 100644 --- a/packages/code-storage-python/pierre_storage/types.py +++ b/packages/code-storage-python/pierre_storage/types.py @@ -46,6 +46,9 @@ class GitStorageOptions(TypedDict, total=False): OP_NO_FORCE_PUSH: Op = "no-force-push" OP_NO_PUSH: Op = "no-push" +# Requires every commit introduced by a push to a matching ref to carry a valid +# signature from a registered signing key. +OP_VERIFY_SIG: Op = "verify-sig" # Ops is a list of policy operations. Ops = List[Op] @@ -305,7 +308,14 @@ class DeleteBranchResult(TypedDict): class CommitInfo(TypedDict): - """Information about a commit.""" + """Information about a commit. + + ``signature`` and ``payload`` are populated only by ``get_commit`` for + signed commits (``signature`` is the armored OpenPGP/SSH block from the + commit's gpgsig header; ``payload`` is the exact bytes the signature is + computed over). Both keys are absent for list-commits entries and for + unsigned commits. + """ sha: str message: str @@ -315,6 +325,8 @@ class CommitInfo(TypedDict): committer_email: str date: datetime raw_date: str + signature: NotRequired[str] + payload: NotRequired[str] class ListCommitsResult(TypedDict): @@ -326,7 +338,10 @@ class ListCommitsResult(TypedDict): class GetCommitResult(TypedDict): - """Result from fetching metadata for a single commit.""" + """Result from fetching metadata for a single commit. + + For signed commits, ``commit`` carries ``signature`` and ``payload``. + """ commit: CommitInfo diff --git a/packages/code-storage-python/pierre_storage/version.py b/packages/code-storage-python/pierre_storage/version.py index ac3cde2..accb437 100644 --- a/packages/code-storage-python/pierre_storage/version.py +++ b/packages/code-storage-python/pierre_storage/version.py @@ -1,7 +1,7 @@ """Version information for Pierre Storage SDK.""" PACKAGE_NAME = "code-storage-py-sdk" -PACKAGE_VERSION = "1.10.0" +PACKAGE_VERSION = "1.11.0" def get_user_agent() -> str: diff --git a/packages/code-storage-python/pyproject.toml b/packages/code-storage-python/pyproject.toml index 1eab32e..5edc200 100644 --- a/packages/code-storage-python/pyproject.toml +++ b/packages/code-storage-python/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "pierre-storage" -version = "1.10.0" +version = "1.11.0" description = "Pierre Git Storage SDK for Python" readme = "README.md" license = "MIT" diff --git a/packages/code-storage-python/tests/test_client.py b/packages/code-storage-python/tests/test_client.py index 2434aa2..69fabaf 100644 --- a/packages/code-storage-python/tests/test_client.py +++ b/packages/code-storage-python/tests/test_client.py @@ -1152,6 +1152,20 @@ def test_generate_jwt_with_refs(self, test_key: str) -> None: ] assert "ops" not in payload + def test_generate_jwt_with_verify_sig_ref(self, test_key: str) -> None: + """Test JWT generation with the verify-sig ref policy op.""" + from pierre_storage import OP_VERIFY_SIG + + token = generate_jwt( + key_pem=test_key, + issuer="test-customer", + repo_id="test-repo", + refs=[{"pattern": "refs/heads/main", "ops": [OP_VERIFY_SIG]}], + ) + + payload = jwt.decode(token, options={"verify_signature": False}) + assert payload["refs"] == [["refs/heads/main", ["verify-sig"]]] + def test_generate_jwt_without_refs(self, test_key: str) -> None: """Test JWT generation omits refs when not provided.""" token = generate_jwt( diff --git a/packages/code-storage-python/tests/test_repo.py b/packages/code-storage-python/tests/test_repo.py index 35f2cde..f4bbd92 100644 --- a/packages/code-storage-python/tests/test_repo.py +++ b/packages/code-storage-python/tests/test_repo.py @@ -1805,6 +1805,8 @@ async def test_get_commit(self, git_storage_options: dict) -> None: "committer_name": "Jane Doe", "committer_email": "jane@example.com", "date": "2024-01-15T14:32:18Z", + "signature": "-----BEGIN PGP SIGNATURE-----\nABC\n-----END PGP SIGNATURE-----\n", + "payload": "tree deadbeef\n", }, } @@ -1826,6 +1828,10 @@ async def test_get_commit(self, git_storage_options: dict) -> None: assert commit["raw_date"] == "2024-01-15T14:32:18Z" assert isinstance(commit["date"], datetime) assert commit["date"] == datetime(2024, 1, 15, 14, 32, 18, tzinfo=timezone.utc) + assert commit["signature"] == ( + "-----BEGIN PGP SIGNATURE-----\nABC\n-----END PGP SIGNATURE-----\n" + ) + assert commit["payload"] == "tree deadbeef\n" called_url = client_instance.get.call_args.args[0] parsed = urlparse(called_url) @@ -1839,6 +1845,44 @@ async def test_get_commit(self, git_storage_options: dict) -> None: assert payload["scopes"] == ["git:read"] assert payload["repo"] == "test-repo" + @pytest.mark.asyncio + async def test_get_commit_unsigned_omits_signature(self, git_storage_options: dict) -> None: + """get_commit should omit signature/payload keys for unsigned commits.""" + storage = GitStorage(git_storage_options) + + create_response = MagicMock() + create_response.status_code = 200 + create_response.is_success = True + create_response.json.return_value = {"repo_id": "test-repo"} + + commit_response = MagicMock() + commit_response.status_code = 200 + commit_response.is_success = True + commit_response.raise_for_status = MagicMock() + commit_response.json.return_value = { + "commit": { + "sha": "abc123", + "message": "chore: noop", + "author_name": "Jane Doe", + "author_email": "jane@example.com", + "committer_name": "Jane Doe", + "committer_email": "jane@example.com", + "date": "2024-01-15T14:32:18Z", + }, + } + + with patch("httpx.AsyncClient") as mock_client: + client_instance = mock_client.return_value.__aenter__.return_value + client_instance.post = AsyncMock(return_value=create_response) + client_instance.get = AsyncMock(return_value=commit_response) + + repo = await storage.create_repo(id="test-repo") + result = await repo.get_commit(sha="abc123") + + commit = result["commit"] + assert "signature" not in commit + assert "payload" not in commit + @pytest.mark.asyncio async def test_get_commit_trims_sha_and_honors_ttl(self, git_storage_options: dict) -> None: """get_commit should strip surrounding whitespace and apply ttl override.""" diff --git a/packages/code-storage-python/uv.lock b/packages/code-storage-python/uv.lock index 11127c7..126db4b 100644 --- a/packages/code-storage-python/uv.lock +++ b/packages/code-storage-python/uv.lock @@ -915,7 +915,7 @@ wheels = [ [[package]] name = "pierre-storage" -version = "1.10.0" +version = "1.11.0" source = { editable = "." } dependencies = [ { name = "cryptography" }, diff --git a/packages/code-storage-typescript/README.md b/packages/code-storage-typescript/README.md index bb7b2c3..4912d03 100644 --- a/packages/code-storage-typescript/README.md +++ b/packages/code-storage-typescript/README.md @@ -254,6 +254,12 @@ console.log(commits.commits); // Get a single commit's metadata (no diff) const { commit } = await repo.getCommit({ sha: 'abc123...' }); console.log(commit.message, commit.authorName); +// Signed commits also expose the armored signature plus the exact signed +// payload (raw commit object minus the gpgsig header) so you can verify it +// yourself. Both are undefined for unsigned commits. +if (commit.signature) { + console.log(commit.signature, commit.payload); +} // Blame a file (per-line authorship). The top-level `commitSha` is the SHA the // `ref` resolved to; each entry in `lines` carries its own author/committer @@ -790,6 +796,9 @@ interface CommitInfo { committerEmail: string; date: Date; rawDate: string; + // Populated only by getCommit for signed commits; undefined otherwise. + signature?: string; + payload?: string; } interface GetCommitOptions { @@ -798,6 +807,9 @@ interface GetCommitOptions { } interface GetCommitResult { + // For signed commits, commit.signature (armored OpenPGP/SSH block) and + // commit.payload (the exact signed bytes) are populated. Both are undefined + // for unsigned commits. commit: CommitInfo; } diff --git a/packages/code-storage-typescript/package.json b/packages/code-storage-typescript/package.json index 897fb81..a251c4b 100644 --- a/packages/code-storage-typescript/package.json +++ b/packages/code-storage-typescript/package.json @@ -1,6 +1,6 @@ { "name": "@pierre/storage", - "version": "1.9.0", + "version": "1.10.0", "description": "Pierre Git Storage SDK", "repository": { "type": "git", diff --git a/packages/code-storage-typescript/src/index.ts b/packages/code-storage-typescript/src/index.ts index 2358266..6bfb0d8 100644 --- a/packages/code-storage-typescript/src/index.ts +++ b/packages/code-storage-typescript/src/index.ts @@ -367,8 +367,17 @@ function transformListCommitsResult( } function transformGetCommitResult(raw: GetCommitResponse): GetCommitResult { + // Only surface these for signed commits, which carry both the armored + // signature and the signed payload. If either is missing the commit is + // treated as unsigned and neither key is attached. + const signed = Boolean(raw.commit.signature) && Boolean(raw.commit.payload); return { - commit: transformCommitInfo(raw.commit), + commit: { + ...transformCommitInfo(raw.commit), + ...(signed + ? { signature: raw.commit.signature, payload: raw.commit.payload } + : {}), + }, }; } diff --git a/packages/code-storage-typescript/src/schemas.ts b/packages/code-storage-typescript/src/schemas.ts index 5372256..555918e 100644 --- a/packages/code-storage-typescript/src/schemas.ts +++ b/packages/code-storage-typescript/src/schemas.ts @@ -72,8 +72,13 @@ export const listCommitsResponseSchema = z.object({ has_more: z.boolean(), }); +export const commitInfoWithSignatureRawSchema = commitInfoRawSchema.extend({ + signature: z.string().optional(), + payload: z.string().optional(), +}); + export const getCommitResponseSchema = z.object({ - commit: commitInfoRawSchema, + commit: commitInfoWithSignatureRawSchema, }); export const blameLineRawSchema = z.object({ @@ -330,6 +335,9 @@ export type ListBranchesResponseRaw = z.infer< typeof listBranchesResponseSchema >; export type RawCommitInfo = z.infer; +export type RawCommitInfoWithSignature = z.infer< + typeof commitInfoWithSignatureRawSchema +>; export type ListCommitsResponseRaw = z.infer; export type GetCommitResponseRaw = z.infer; export type BlameLineRaw = z.infer; diff --git a/packages/code-storage-typescript/src/types.ts b/packages/code-storage-typescript/src/types.ts index 45e1b67..16dba47 100644 --- a/packages/code-storage-typescript/src/types.ts +++ b/packages/code-storage-typescript/src/types.ts @@ -54,6 +54,12 @@ export const OP_NO_FORCE_PUSH: Op = "no-force-push"; export const OP_NO_PUSH: Op = "no-push"; +/** + * Requires every commit introduced by a push to a matching ref to carry a + * valid signature from a registered signing key. + */ +export const OP_VERIFY_SIG: Op = "verify-sig"; + /** A list of policy operations. */ export type Ops = Op[]; @@ -510,6 +516,18 @@ export interface CommitInfo { committerEmail: string; date: Date; rawDate: string; + /** + * Armored OpenPGP/SSH signature from the commit's gpgsig header. Only set by + * `getCommit` for signed commits. Always undefined for list-commits entries + * and unsigned commits. + */ + signature?: string; + /** + * The exact bytes the signature is computed over (the raw commit object with + * the gpgsig header removed). Only set by `getCommit` for signed commits. + * Always undefined for list-commits entries and unsigned commits. + */ + payload?: string; } export type ListCommitsResponse = ListCommitsResponseRaw; diff --git a/packages/code-storage-typescript/tests/index.test.ts b/packages/code-storage-typescript/tests/index.test.ts index fbe9c67..fc9cf06 100644 --- a/packages/code-storage-typescript/tests/index.test.ts +++ b/packages/code-storage-typescript/tests/index.test.ts @@ -1,7 +1,12 @@ import { importPKCS8, jwtVerify } from 'jose'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { CodeStorage, GitStorage, createClient } from '../src/index'; +import { + CodeStorage, + GitStorage, + OP_VERIFY_SIG, + createClient, +} from '../src/index'; // Mock fetch globally if it is not already stubbed const existingFetch = globalThis.fetch as unknown; @@ -284,6 +289,9 @@ describe('GitStorage', () => { committer_name: 'Jane Doe', committer_email: 'jane@example.com', date: '2024-01-15T14:32:18Z', + signature: + '-----BEGIN PGP SIGNATURE-----\nABC\n-----END PGP SIGNATURE-----\n', + payload: 'tree deadbeef\n', }, }), } as any); @@ -299,6 +307,38 @@ describe('GitStorage', () => { expect(result.commit.rawDate).toBe('2024-01-15T14:32:18Z'); expect(result.commit.date).toBeInstanceOf(Date); expect(result.commit.date.toISOString()).toBe('2024-01-15T14:32:18.000Z'); + expect(result.commit.signature).toBe( + '-----BEGIN PGP SIGNATURE-----\nABC\n-----END PGP SIGNATURE-----\n' + ); + expect(result.commit.payload).toBe('tree deadbeef\n'); + }); + + it('omits signature and payload for unsigned commits', async () => { + const store = new GitStorage({ name: 'v0', key }); + const repo = await store.createRepo({ id: 'repo-get-commit-unsigned' }); + + mockFetch.mockImplementationOnce(() => + Promise.resolve({ + ok: true, + status: 200, + statusText: 'OK', + json: async () => ({ + commit: { + sha: 'abc123', + message: 'chore: noop', + author_name: 'Jane Doe', + author_email: 'jane@example.com', + committer_name: 'Jane Doe', + committer_email: 'jane@example.com', + date: '2024-01-15T14:32:18Z', + }, + }), + } as any) + ); + + const result = await repo.getCommit({ sha: 'abc123' }); + expect(result.commit.signature).toBeUndefined(); + expect(result.commit.payload).toBeUndefined(); }); it('trims sha and honors ttl override on getCommit', async () => { @@ -2958,6 +2998,22 @@ describe('GitStorage', () => { expect(payload).not.toHaveProperty('ops'); }); + it('should include the verify-sig op in the refs claim', async () => { + const store = new GitStorage({ name: 'v0', key }); + const repo = await store.createRepo({}); + + expect(OP_VERIFY_SIG).toBe('verify-sig'); + + const url = await repo.getRemoteURL({ + refPolicies: [{ pattern: 'refs/heads/main', ops: [OP_VERIFY_SIG] }], + }); + + const jwt = extractJWT(url); + const payload = decodeJwtPayload(jwt); + + expect(payload.refs).toEqual([['refs/heads/main', ['verify-sig']]]); + }); + it('should encode allow-rules with empty ops array in refs claim', async () => { const store = new GitStorage({ name: 'v0', key }); const repo = await store.createRepo({}); diff --git a/skills/code-storage/SKILL.md b/skills/code-storage/SKILL.md index 27ce91d..4393856 100644 --- a/skills/code-storage/SKILL.md +++ b/skills/code-storage/SKILL.md @@ -61,6 +61,7 @@ JWT header: `{ "alg": "ES256", "typ": "JWT" }` (RS256 and EdDSA also supported) |------------------|------------------------------------------------------------------------------|-------------------------------------------------------| | `no-force-push` | TS `OP_NO_FORCE_PUSH` / Py `OP_NO_FORCE_PUSH` / Go `storage.OpNoForcePush` | Rejects force pushes / non-fast-forward ref updates. | | `no-push` | TS `OP_NO_PUSH` / Py `OP_NO_PUSH` / Go `storage.OpNoPush` | Rejects any push to matching refs. | +| `verify-sig` | TS `OP_VERIFY_SIG` / Py `OP_VERIFY_SIG` / Go `storage.OpVerifySig` | Rejects pushes introducing commits without a valid signature from a registered signing key. | #### Per-ref policies (preferred, use this for new code) @@ -437,7 +438,11 @@ curl "$CODE_STORAGE_BASE_URL/repos/commit?sha=COMMIT_SHA" \ Params: `sha` (required — full SHA, short SHA, branch name, or any revision Git can resolve). Returns commit metadata only; use `/repos/diff` for the diff. -Response: `{ "commit": { "sha", "message", "author_name", "author_email", "committer_name", "committer_email", "date" } }` +Response: `{ "commit": { "sha", "message", "author_name", "author_email", "committer_name", "committer_email", "date", "signature"?, "payload"? } }` +`signature` (armored OpenPGP/SSH block from the commit's gpgsig header) and +`payload` (the exact signed bytes: the raw commit object with the gpgsig header +removed) are present only for signed commits and omitted otherwise. Together +they let callers verify the signature themselves, mirroring GitHub's `verification` object. Errors: `400` missing/blank `sha`, `404` commit not found. ## GET /repos/diff — Get Commit Diff @@ -1027,5 +1032,5 @@ git push origin feature-branch | Pagination | Cursor-based. Pass `next_cursor` as `cursor` param. Stop when `has_more: false`. | | Blob data encoding | Always base64. Max 4 MiB per chunk. Use multiple chunks for large files. | | `expected_head_sha` | Optimistic lock. Provide current branch tip SHA to enforce fast-forward semantics. | -| Policy ops | JWT-level guards via `refPolicies` (per-ref, first match wins, preferred). `no-force-push` (TS/Py `OP_NO_FORCE_PUSH`, Go `OpNoForcePush`) blocks non-FF updates. `no-push` (`OP_NO_PUSH`/`OpNoPush`) blocks pushes to matching refs. Top-level `ops` is a legacy alias on URL-minting methods only. | +| Policy ops | JWT-level guards via `refPolicies` (per-ref, first match wins, preferred). `no-force-push` (TS/Py `OP_NO_FORCE_PUSH`, Go `OpNoForcePush`) blocks non-FF updates. `no-push` (`OP_NO_PUSH`/`OpNoPush`) blocks pushes to matching refs. `verify-sig` (`OP_VERIFY_SIG`/`OpVerifySig`) blocks pushes introducing commits not signed by a registered signing key. Top-level `ops` is a legacy alias on URL-minting methods only. | | Merge endpoint | `POST /repos/merge`. Strategies: `merge`, `ff_only`, `ff_prefer`. Optional `squash` (not with `ff_only`). 409 on conflict. |