From 29b0c5617e2e85b85e0659a553dabb77efa53ef6 Mon Sep 17 00:00:00 2001 From: Joe Date: Tue, 2 Jun 2026 22:18:52 -0400 Subject: [PATCH 1/6] feat: expose commit signature and payload on getCommit The GET /repos/commit endpoint now returns optional `signature` (armored OpenPGP/SSH block from the commit's gpgsig header) and `payload` (the exact signed bytes) fields for signed commits. Surface them across all three SDKs via a new CommitInfoWithSignature type; both are absent for unsigned commits. Bumps minor versions: Go 0.9.0 -> 0.10.0, Python 1.10.0 -> 1.11.0, TypeScript 1.9.0 -> 1.10.0. --- packages/code-storage-go/repo.go | 22 ++++++---- packages/code-storage-go/repo_test.go | 30 ++++++++++++- packages/code-storage-go/responses.go | 11 ++++- packages/code-storage-go/types.go | 14 +++++- packages/code-storage-go/version.go | 2 +- packages/code-storage-python/README.md | 5 +++ .../pierre_storage/__init__.py | 2 + .../pierre_storage/repo.py | 11 ++++- .../pierre_storage/types.py | 17 ++++++- .../pierre_storage/version.py | 2 +- packages/code-storage-python/pyproject.toml | 2 +- .../code-storage-python/tests/test_repo.py | 44 +++++++++++++++++++ packages/code-storage-python/uv.lock | 2 +- packages/code-storage-typescript/README.md | 17 ++++++- packages/code-storage-typescript/package.json | 2 +- packages/code-storage-typescript/src/index.ts | 10 ++++- .../code-storage-typescript/src/schemas.ts | 10 ++++- packages/code-storage-typescript/src/types.ts | 15 ++++++- .../tests/index.test.ts | 35 +++++++++++++++ skills/code-storage/SKILL.md | 6 ++- 20 files changed, 235 insertions(+), 24 deletions(-) diff --git a/packages/code-storage-go/repo.go b/packages/code-storage-go/repo.go index b7aa25a..fdadf90 100644 --- a/packages/code-storage-go/repo.go +++ b/packages/code-storage-go/repo.go @@ -555,15 +555,19 @@ func (r *Repo) GetCommit(ctx context.Context, options GetCommitOptions) (GetComm } 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, + Commit: CommitInfoWithSignature{ + CommitInfo: 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, + }, + Signature: payload.Commit.Signature, + Payload: payload.Commit.Payload, }, }, nil } diff --git a/packages/code-storage-go/repo_test.go b/packages/code-storage-go/repo_test.go index d89d2cb..ade2517 100644 --- a/packages/code-storage-go/repo_test.go +++ b/packages/code-storage-go/repo_test.go @@ -1938,7 +1938,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 +1964,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..ce7bd16 100644 --- a/packages/code-storage-go/responses.go +++ b/packages/code-storage-go/responses.go @@ -56,7 +56,7 @@ type listCommitsResponse struct { } type getCommitResponse struct { - Commit commitInfoRaw `json:"commit"` + Commit commitInfoWithSignatureRaw `json:"commit"` } type blameResponse struct { @@ -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,omitempty"` + Payload string `json:"payload,omitempty"` +} + type listReposResponse struct { Repos []repoInfoRaw `json:"repos"` NextCursor string `json:"next_cursor"` diff --git a/packages/code-storage-go/types.go b/packages/code-storage-go/types.go index 0f18e8d..7781648 100644 --- a/packages/code-storage-go/types.go +++ b/packages/code-storage-go/types.go @@ -566,9 +566,21 @@ type GetCommitOptions struct { SHA string } +// CommitInfoWithSignature extends CommitInfo with signature details that are +// only surfaced on the single-commit endpoint. The shape mirrors GitHub's +// commit verification object: Signature is the armored block (from the +// commit's gpgsig header, OpenPGP or SSH) and Payload is the exact bytes the +// signature is computed over (the raw commit object with the gpgsig header +// removed). Both are empty for unsigned commits. +type CommitInfoWithSignature struct { + CommitInfo + Signature string + Payload string +} + // GetCommitResult is the result returned by Repo.GetCommit. type GetCommitResult struct { - Commit CommitInfo + Commit CommitInfoWithSignature } // BlameOptions configures a per-line blame lookup. 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..8394492 100644 --- a/packages/code-storage-python/pierre_storage/__init__.py +++ b/packages/code-storage-python/pierre_storage/__init__.py @@ -14,6 +14,7 @@ BlameResult, BranchInfo, CommitInfo, + CommitInfoWithSignature, CommitMetadata, CommitResult, CommitSignature, @@ -81,6 +82,7 @@ "CreateBranchResult", "CreateTagResult", "CommitInfo", + "CommitInfoWithSignature", "CommitResult", "CommitSignature", "DeleteBranchResult", diff --git a/packages/code-storage-python/pierre_storage/repo.py b/packages/code-storage-python/pierre_storage/repo.py index f03fd57..909c5dd 100644 --- a/packages/code-storage-python/pierre_storage/repo.py +++ b/packages/code-storage-python/pierre_storage/repo.py @@ -22,6 +22,7 @@ BranchInfo, CommitBuilder, CommitInfo, + CommitInfoWithSignature, CommitMetadata, CommitResult, CommitSignature, @@ -1380,7 +1381,7 @@ async def get_commit( commit_raw = data["commit"] date = datetime.fromisoformat(commit_raw["date"].replace("Z", "+00:00")) - commit: CommitInfo = { + commit: CommitInfoWithSignature = { "sha": commit_raw["sha"], "message": commit_raw["message"], "author_name": commit_raw["author_name"], @@ -1390,6 +1391,14 @@ async def get_commit( "date": date, "raw_date": commit_raw["date"], } + # Only present for signed commits, matching the server which omits + # both fields when a commit is unsigned. + signature = commit_raw.get("signature") + if signature is not None: + commit["signature"] = signature + payload = commit_raw.get("payload") + if payload is not None: + 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..01d4c82 100644 --- a/packages/code-storage-python/pierre_storage/types.py +++ b/packages/code-storage-python/pierre_storage/types.py @@ -325,10 +325,25 @@ class ListCommitsResult(TypedDict): has_more: bool +class CommitInfoWithSignature(CommitInfo, total=False): + """Commit metadata for a single resolved revision. + + Extends :class:`CommitInfo` with signature details surfaced only by the + single-commit endpoint. Mirrors GitHub's commit verification object: + ``signature`` is the armored block from the commit's gpgsig header (OpenPGP + or SSH) and ``payload`` is the exact bytes the signature is computed over + (the raw commit object with the gpgsig header removed). Both keys are + omitted for unsigned commits. + """ + + signature: str + payload: str + + class GetCommitResult(TypedDict): """Result from fetching metadata for a single commit.""" - commit: CommitInfo + commit: CommitInfoWithSignature class BlameLine(TypedDict): 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_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..94c4a43 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 @@ -797,8 +803,17 @@ interface GetCommitOptions { ttl?: number; } +interface CommitInfoWithSignature extends CommitInfo { + // Armored OpenPGP/SSH signature from the commit's gpgsig header. Present + // only for signed commits. + signature?: string; + // The exact signed bytes: the raw commit object with the gpgsig header + // removed. Present only for signed commits. + payload?: string; +} + interface GetCommitResult { - commit: CommitInfo; + commit: CommitInfoWithSignature; } interface BlameOptions { 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..8fdcc2a 100644 --- a/packages/code-storage-typescript/src/index.ts +++ b/packages/code-storage-typescript/src/index.ts @@ -368,7 +368,15 @@ function transformListCommitsResult( function transformGetCommitResult(raw: GetCommitResponse): GetCommitResult { return { - commit: transformCommitInfo(raw.commit), + commit: { + ...transformCommitInfo(raw.commit), + ...(raw.commit.signature !== undefined + ? { signature: raw.commit.signature } + : {}), + ...(raw.commit.payload !== undefined + ? { 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..b1b44f9 100644 --- a/packages/code-storage-typescript/src/types.ts +++ b/packages/code-storage-typescript/src/types.ts @@ -527,8 +527,21 @@ export interface GetCommitOptions extends GitStorageInvocationOptions { export type GetCommitResponse = GetCommitResponseRaw; +/** + * Commit metadata for a single resolved revision, extending {@link CommitInfo} + * with signature details surfaced only by the single-commit endpoint. Mirrors + * GitHub's commit verification object: `signature` is the armored block from + * the commit's gpgsig header (OpenPGP or SSH) and `payload` is the exact bytes + * the signature is computed over (the raw commit object with the gpgsig header + * removed). Both are absent for unsigned commits. + */ +export interface CommitInfoWithSignature extends CommitInfo { + signature?: string; + payload?: string; +} + export interface GetCommitResult { - commit: CommitInfo; + commit: CommitInfoWithSignature; } // Blame API types diff --git a/packages/code-storage-typescript/tests/index.test.ts b/packages/code-storage-typescript/tests/index.test.ts index fbe9c67..f6c05b6 100644 --- a/packages/code-storage-typescript/tests/index.test.ts +++ b/packages/code-storage-typescript/tests/index.test.ts @@ -284,6 +284,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 +302,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 () => { diff --git a/skills/code-storage/SKILL.md b/skills/code-storage/SKILL.md index 27ce91d..474a4ec 100644 --- a/skills/code-storage/SKILL.md +++ b/skills/code-storage/SKILL.md @@ -437,7 +437,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 From 59da6cb0cc94c7d4ffa454e76a3a5e575370a09c Mon Sep 17 00:00:00 2001 From: Joe Date: Wed, 3 Jun 2026 15:21:11 -0400 Subject: [PATCH 2/6] refactor: keep getCommit non-breaking by adding signature/payload to CommitInfo Avoid the source-breaking change of swapping GetCommitResult.Commit's type (Go consumers using the field as a CommitInfo value would no longer compile). Instead add optional signature/payload directly onto CommitInfo and revert GetCommitResult to hold a plain CommitInfo, dropping CommitInfoWithSignature. The fields are populated only by getCommit for signed commits and remain empty/undefined/absent everywhere else, so the change is purely additive in all three SDKs. --- packages/code-storage-go/repo.go | 24 +++++++------- packages/code-storage-go/types.go | 25 +++++++-------- .../pierre_storage/__init__.py | 2 -- .../pierre_storage/repo.py | 3 +- .../pierre_storage/types.py | 31 +++++++++---------- packages/code-storage-typescript/README.md | 17 +++++----- packages/code-storage-typescript/src/types.ts | 26 +++++++--------- 7 files changed, 56 insertions(+), 72 deletions(-) diff --git a/packages/code-storage-go/repo.go b/packages/code-storage-go/repo.go index fdadf90..1dbeb7b 100644 --- a/packages/code-storage-go/repo.go +++ b/packages/code-storage-go/repo.go @@ -555,19 +555,17 @@ func (r *Repo) GetCommit(ctx context.Context, options GetCommitOptions) (GetComm } return GetCommitResult{ - Commit: CommitInfoWithSignature{ - CommitInfo: 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, - }, - Signature: payload.Commit.Signature, - Payload: payload.Commit.Payload, + 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, + Signature: payload.Commit.Signature, + Payload: payload.Commit.Payload, }, }, nil } diff --git a/packages/code-storage-go/types.go b/packages/code-storage-go/types.go index 7781648..fd2d4f1 100644 --- a/packages/code-storage-go/types.go +++ b/packages/code-storage-go/types.go @@ -551,6 +551,14 @@ 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 for 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 otherwise. + Payload string } // ListCommitsResult describes commits list. @@ -566,21 +574,10 @@ type GetCommitOptions struct { SHA string } -// CommitInfoWithSignature extends CommitInfo with signature details that are -// only surfaced on the single-commit endpoint. The shape mirrors GitHub's -// commit verification object: Signature is the armored block (from the -// commit's gpgsig header, OpenPGP or SSH) and Payload is the exact bytes the -// signature is computed over (the raw commit object with the gpgsig header -// removed). Both are empty for unsigned commits. -type CommitInfoWithSignature struct { - CommitInfo - Signature string - Payload 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 CommitInfoWithSignature + Commit CommitInfo } // BlameOptions configures a per-line blame lookup. diff --git a/packages/code-storage-python/pierre_storage/__init__.py b/packages/code-storage-python/pierre_storage/__init__.py index 8394492..35a2c89 100644 --- a/packages/code-storage-python/pierre_storage/__init__.py +++ b/packages/code-storage-python/pierre_storage/__init__.py @@ -14,7 +14,6 @@ BlameResult, BranchInfo, CommitInfo, - CommitInfoWithSignature, CommitMetadata, CommitResult, CommitSignature, @@ -82,7 +81,6 @@ "CreateBranchResult", "CreateTagResult", "CommitInfo", - "CommitInfoWithSignature", "CommitResult", "CommitSignature", "DeleteBranchResult", diff --git a/packages/code-storage-python/pierre_storage/repo.py b/packages/code-storage-python/pierre_storage/repo.py index 909c5dd..da1ea77 100644 --- a/packages/code-storage-python/pierre_storage/repo.py +++ b/packages/code-storage-python/pierre_storage/repo.py @@ -22,7 +22,6 @@ BranchInfo, CommitBuilder, CommitInfo, - CommitInfoWithSignature, CommitMetadata, CommitResult, CommitSignature, @@ -1381,7 +1380,7 @@ async def get_commit( commit_raw = data["commit"] date = datetime.fromisoformat(commit_raw["date"].replace("Z", "+00:00")) - commit: CommitInfoWithSignature = { + commit: CommitInfo = { "sha": commit_raw["sha"], "message": commit_raw["message"], "author_name": commit_raw["author_name"], diff --git a/packages/code-storage-python/pierre_storage/types.py b/packages/code-storage-python/pierre_storage/types.py index 01d4c82..f16e5bb 100644 --- a/packages/code-storage-python/pierre_storage/types.py +++ b/packages/code-storage-python/pierre_storage/types.py @@ -305,7 +305,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 +322,8 @@ class CommitInfo(TypedDict): committer_email: str date: datetime raw_date: str + signature: NotRequired[str] + payload: NotRequired[str] class ListCommitsResult(TypedDict): @@ -325,25 +334,13 @@ class ListCommitsResult(TypedDict): has_more: bool -class CommitInfoWithSignature(CommitInfo, total=False): - """Commit metadata for a single resolved revision. +class GetCommitResult(TypedDict): + """Result from fetching metadata for a single commit. - Extends :class:`CommitInfo` with signature details surfaced only by the - single-commit endpoint. Mirrors GitHub's commit verification object: - ``signature`` is the armored block from the commit's gpgsig header (OpenPGP - or SSH) and ``payload`` is the exact bytes the signature is computed over - (the raw commit object with the gpgsig header removed). Both keys are - omitted for unsigned commits. + For signed commits, ``commit`` carries ``signature`` and ``payload``. """ - signature: str - payload: str - - -class GetCommitResult(TypedDict): - """Result from fetching metadata for a single commit.""" - - commit: CommitInfoWithSignature + commit: CommitInfo class BlameLine(TypedDict): diff --git a/packages/code-storage-typescript/README.md b/packages/code-storage-typescript/README.md index 94c4a43..4912d03 100644 --- a/packages/code-storage-typescript/README.md +++ b/packages/code-storage-typescript/README.md @@ -796,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 { @@ -803,17 +806,11 @@ interface GetCommitOptions { ttl?: number; } -interface CommitInfoWithSignature extends CommitInfo { - // Armored OpenPGP/SSH signature from the commit's gpgsig header. Present - // only for signed commits. - signature?: string; - // The exact signed bytes: the raw commit object with the gpgsig header - // removed. Present only for signed commits. - payload?: string; -} - interface GetCommitResult { - commit: CommitInfoWithSignature; + // 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; } interface BlameOptions { diff --git a/packages/code-storage-typescript/src/types.ts b/packages/code-storage-typescript/src/types.ts index b1b44f9..72ad5fa 100644 --- a/packages/code-storage-typescript/src/types.ts +++ b/packages/code-storage-typescript/src/types.ts @@ -510,6 +510,17 @@ 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. + */ + payload?: string; } export type ListCommitsResponse = ListCommitsResponseRaw; @@ -527,21 +538,8 @@ export interface GetCommitOptions extends GitStorageInvocationOptions { export type GetCommitResponse = GetCommitResponseRaw; -/** - * Commit metadata for a single resolved revision, extending {@link CommitInfo} - * with signature details surfaced only by the single-commit endpoint. Mirrors - * GitHub's commit verification object: `signature` is the armored block from - * the commit's gpgsig header (OpenPGP or SSH) and `payload` is the exact bytes - * the signature is computed over (the raw commit object with the gpgsig header - * removed). Both are absent for unsigned commits. - */ -export interface CommitInfoWithSignature extends CommitInfo { - signature?: string; - payload?: string; -} - export interface GetCommitResult { - commit: CommitInfoWithSignature; + commit: CommitInfo; } // Blame API types From 9e34bcc80c7567d59feeb65372294b73f5551ff7 Mon Sep 17 00:00:00 2001 From: Joe Date: Wed, 3 Jun 2026 15:29:25 -0400 Subject: [PATCH 3/6] 3123 --- packages/code-storage-go/responses.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/code-storage-go/responses.go b/packages/code-storage-go/responses.go index ce7bd16..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 { @@ -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"` @@ -96,8 +96,8 @@ type commitInfoRaw struct { // omitted for unsigned commits. type commitInfoWithSignatureRaw struct { commitInfoRaw - Signature string `json:"signature,omitempty"` - Payload string `json:"payload,omitempty"` + Signature string `json:"signature"` + Payload string `json:"payload"` } type listReposResponse struct { @@ -194,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"` } From 32c369b18f4e81513d68beb4a66faf60fbbd7b82 Mon Sep 17 00:00:00 2001 From: Joe Date: Wed, 3 Jun 2026 15:40:59 -0400 Subject: [PATCH 4/6] feat: add verify-sig ref-policy op to all three SDKs Expose the new `verify-sig` push policy op (OpVerifySig / OP_VERIFY_SIG) alongside no-push / no-force-push so callers can require signed commits on matching refs when minting JWTs / remote URLs. Ops pass through to the refs claim unchanged. Adds per-language tests and documents the op in SKILL.md. --- packages/code-storage-go/repo_test.go | 24 +++++++++++++++++++ packages/code-storage-go/types.go | 3 +++ .../pierre_storage/__init__.py | 2 ++ .../pierre_storage/types.py | 3 +++ .../code-storage-python/tests/test_client.py | 14 +++++++++++ packages/code-storage-typescript/src/types.ts | 6 +++++ .../tests/index.test.ts | 23 +++++++++++++++++- skills/code-storage/SKILL.md | 3 ++- 8 files changed, 76 insertions(+), 2 deletions(-) diff --git a/packages/code-storage-go/repo_test.go b/packages/code-storage-go/repo_test.go index ade2517..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 { diff --git a/packages/code-storage-go/types.go b/packages/code-storage-go/types.go index fd2d4f1..0023b6d 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 signing key registered for the tenant. + OpVerifySig Op = "verify-sig" ) // Ops is a list of policy operations. 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/types.py b/packages/code-storage-python/pierre_storage/types.py index f16e5bb..e7f527f 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 signing key registered for the tenant. +OP_VERIFY_SIG: Op = "verify-sig" # Ops is a list of policy operations. Ops = List[Op] 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-typescript/src/types.ts b/packages/code-storage-typescript/src/types.ts index 72ad5fa..ee4d599 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 signing key registered for the tenant. + */ +export const OP_VERIFY_SIG: Op = "verify-sig"; + /** A list of policy operations. */ export type Ops = Op[]; diff --git a/packages/code-storage-typescript/tests/index.test.ts b/packages/code-storage-typescript/tests/index.test.ts index f6c05b6..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; @@ -2993,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 474a4ec..a56b45b 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 tenant signing key. | #### Per-ref policies (preferred, use this for new code) @@ -1031,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 tenant 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. | From 8ff73fc06da5e86e6edfbc2ba43c330678d43163 Mon Sep 17 00:00:00 2001 From: Joe Date: Wed, 3 Jun 2026 16:29:49 -0400 Subject: [PATCH 5/6] 231231 --- packages/code-storage-go/repo.go | 33 +++++++++++-------- packages/code-storage-go/types.go | 9 ++--- .../pierre_storage/repo.py | 11 ++++--- .../pierre_storage/types.py | 2 +- packages/code-storage-typescript/src/index.ts | 11 ++++--- 5 files changed, 37 insertions(+), 29 deletions(-) diff --git a/packages/code-storage-go/repo.go b/packages/code-storage-go/repo.go index 1dbeb7b..723b99c 100644 --- a/packages/code-storage-go/repo.go +++ b/packages/code-storage-go/repo.go @@ -554,20 +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, - Signature: payload.Commit.Signature, - Payload: payload.Commit.Payload, - }, - }, 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/types.go b/packages/code-storage-go/types.go index 0023b6d..30f7cb8 100644 --- a/packages/code-storage-go/types.go +++ b/packages/code-storage-go/types.go @@ -37,7 +37,7 @@ 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 signing key registered for the tenant. + // to carry a valid signature from a registered signing key. OpVerifySig Op = "verify-sig" ) @@ -555,12 +555,13 @@ type CommitInfo struct { 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 for unsigned commits. + // 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 otherwise. + // signed commits. Always empty for ListCommits entries and unsigned + // commits. Payload string } diff --git a/packages/code-storage-python/pierre_storage/repo.py b/packages/code-storage-python/pierre_storage/repo.py index da1ea77..c6cc7e7 100644 --- a/packages/code-storage-python/pierre_storage/repo.py +++ b/packages/code-storage-python/pierre_storage/repo.py @@ -1390,13 +1390,14 @@ async def get_commit( "date": date, "raw_date": commit_raw["date"], } - # Only present for signed commits, matching the server which omits - # both fields when a commit is unsigned. + # 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") - if signature is not None: - commit["signature"] = signature payload = commit_raw.get("payload") - if payload is not None: + if signature and payload: + commit["signature"] = signature commit["payload"] = payload return {"commit": commit} diff --git a/packages/code-storage-python/pierre_storage/types.py b/packages/code-storage-python/pierre_storage/types.py index e7f527f..9a32f00 100644 --- a/packages/code-storage-python/pierre_storage/types.py +++ b/packages/code-storage-python/pierre_storage/types.py @@ -47,7 +47,7 @@ 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 signing key registered for the tenant. +# signature from a registered signing key. OP_VERIFY_SIG: Op = "verify-sig" # Ops is a list of policy operations. diff --git a/packages/code-storage-typescript/src/index.ts b/packages/code-storage-typescript/src/index.ts index 8fdcc2a..6bfb0d8 100644 --- a/packages/code-storage-typescript/src/index.ts +++ b/packages/code-storage-typescript/src/index.ts @@ -367,14 +367,15 @@ 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), - ...(raw.commit.signature !== undefined - ? { signature: raw.commit.signature } - : {}), - ...(raw.commit.payload !== undefined - ? { payload: raw.commit.payload } + ...(signed + ? { signature: raw.commit.signature, payload: raw.commit.payload } : {}), }, }; From 0f6d012b7de7d516d21bcbc8004a23b75ef94efe Mon Sep 17 00:00:00 2001 From: Joe Date: Wed, 3 Jun 2026 16:32:16 -0400 Subject: [PATCH 6/6] 312 --- packages/code-storage-typescript/src/types.ts | 5 +++-- skills/code-storage/SKILL.md | 4 ++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/code-storage-typescript/src/types.ts b/packages/code-storage-typescript/src/types.ts index ee4d599..16dba47 100644 --- a/packages/code-storage-typescript/src/types.ts +++ b/packages/code-storage-typescript/src/types.ts @@ -56,7 +56,7 @@ 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 signing key registered for the tenant. + * valid signature from a registered signing key. */ export const OP_VERIFY_SIG: Op = "verify-sig"; @@ -518,13 +518,14 @@ export interface CommitInfo { 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 + * `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; } diff --git a/skills/code-storage/SKILL.md b/skills/code-storage/SKILL.md index a56b45b..4393856 100644 --- a/skills/code-storage/SKILL.md +++ b/skills/code-storage/SKILL.md @@ -61,7 +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 tenant signing key. | +| `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) @@ -1032,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. `verify-sig` (`OP_VERIFY_SIG`/`OpVerifySig`) blocks pushes introducing commits not signed by a registered tenant signing key. 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. |